github.com/frankkopp/FrankyGo@v1.0.3/internal/openingbook/openingbook.go (about) 1 // 2 // FrankyGo - UCI chess engine in GO for learning purposes 3 // 4 // MIT License 5 // 6 // Copyright (c) 2018-2020 Frank Kopp 7 // 8 // Permission is hereby granted, free of charge, to any person obtaining a copy 9 // of this software and associated documentation files (the "Software"), to deal 10 // in the Software without restriction, including without limitation the rights 11 // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 // copies of the Software, and to permit persons to whom the Software is 13 // furnished to do so, subject to the following conditions: 14 // 15 // The above copyright notice and this permission notice shall be included in all 16 // copies or substantial portions of the Software. 17 // 18 // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 // SOFTWARE. 25 // 26 27 // Package openingbook 28 // The OpeningBook reads game databases of different formats into an internal 29 // data structure. It can then be queried for a book move on a certain position. 30 // 31 // Supported formats are currently: 32 // 33 // BookFormat::SIMPLE for files storing a game per line with from-square and 34 // to-square notation 35 // 36 // BookFormat::SAN for files with lines of moves in SAN notation 37 // 38 // BookFormat::PGN for PGN formatted games<br/> 39 package openingbook 40 41 import ( 42 "bufio" 43 "encoding/gob" 44 "errors" 45 "os" 46 "path/filepath" 47 "regexp" 48 "strings" 49 "sync" 50 "time" 51 52 "github.com/op/go-logging" 53 "golang.org/x/text/language" 54 "golang.org/x/text/message" 55 56 myLogging "github.com/frankkopp/FrankyGo/internal/logging" 57 "github.com/frankkopp/FrankyGo/internal/movegen" 58 "github.com/frankkopp/FrankyGo/internal/position" 59 "github.com/frankkopp/FrankyGo/internal/types" 60 "github.com/frankkopp/FrankyGo/internal/util" 61 ) 62 63 var out = message.NewPrinter(language.German) 64 65 // setting to use multiple goroutines or not - useful for debugging. 66 const parallel = true 67 68 // BookFormat represent the supported book formats defined as constants. 69 type BookFormat uint8 70 71 // Supported book formats. 72 const ( 73 Simple BookFormat = iota 74 San BookFormat = iota 75 Pgn BookFormat = iota 76 ) 77 78 var ( 79 FormatFromString = map[string]BookFormat{ 80 "Simple": Simple, 81 "San": San, 82 "Pgn": Pgn, 83 } 84 ) 85 86 // Successor represents a tuple of a move 87 // and a zobrist key of the position the 88 // move leads to. 89 type Successor struct { 90 Move uint32 91 NextEntry uint64 92 } 93 94 // BookEntry represents a data structure for a move in the opening book 95 // data structure. It describes exactly one position defined by a zobrist 96 // key and has links to other entries representing moves and successor 97 // positions. 98 type BookEntry struct { 99 ZobristKey uint64 100 Counter int 101 Moves []Successor 102 } 103 104 // Book represents a structure for chess opening books which can 105 // be read from different file formats into an internal data structure. 106 // Create new book instance with NewBook() 107 type Book struct { 108 log *logging.Logger 109 bookMap map[uint64]BookEntry 110 rootEntry uint64 111 initialized bool 112 } 113 114 // NewBook create as new opening book instance. 115 func NewBook() *Book { 116 return &Book{ 117 log: myLogging.GetLog(), 118 } 119 } 120 121 // mutex to support concurrent writing to the book data structure. 122 var bookLock sync.Mutex 123 124 // Initialize reads game data from the given file into the internal data structure. 125 // A binary cache file will be created in the same folder (postfix .cache) to 126 // speedup subsequent loading if the opening book. Initialization only occurs once 127 // multiple calls will be ignored. To re-initialize call Reset() first. 128 func (b *Book) Initialize(bookPath string, bookFile string, bookFormat BookFormat, useCache bool, recreateCache bool) error { 129 if b.initialized { 130 return nil 131 } 132 133 // make absolute path 134 resolveFile, _ := util.ResolveFile(filepath.Join(bookPath, bookFile)) 135 bookFilePath := filepath.Clean(resolveFile) 136 137 b.log.Infof("Initializing Opening Book [%s]", bookFilePath) 138 err := b.initialize(bookFilePath, bookFormat, useCache, recreateCache) 139 b.log.Debug(util.GcWithStats()) 140 141 return err 142 } 143 144 func (b *Book) initialize(bookFilePath string, bookFormat BookFormat, useCache bool, recreateCache bool) error { 145 startTotal := time.Now() 146 147 // check file path 148 if _, err := os.Stat(bookFilePath); err != nil { 149 b.log.Warningf("file \"%s\" not found\n", bookFilePath) 150 return err 151 } 152 153 b.log.Debugf("Memory statistics: %s", util.MemStat()) 154 155 // if cache enabled check if we have a cache file and load from cache 156 if useCache && !recreateCache { 157 startReading := time.Now() 158 hasCache, err := b.loadFromCache(bookFilePath) 159 elapsedReading := time.Since(startReading) 160 if err != nil { 161 b.log.Warningf("Cache could not be loaded. Reading original data from \"%s\"", bookFilePath) 162 } 163 if hasCache { 164 b.log.Debugf("Finished reading cache from file in: %d ms\n", elapsedReading.Milliseconds()) 165 b.log.Debugf("Book from cache file contains %d entries\n", len(b.bookMap)) 166 return nil 167 } // else no cache file just load the data from original file 168 } 169 170 b.log.Debugf("Memory statistics: %s", util.MemStat()) 171 172 // read book from file 173 b.log.Infof("Reading opening book file: %s\n", bookFilePath) 174 startReading := time.Now() 175 lines, err := b.readFile(bookFilePath) 176 if err != nil { 177 b.log.Errorf("File \"%s\" could not be read: %s\n", bookFilePath, err) 178 return err 179 } 180 elapsedReading := time.Since(startReading) 181 b.log.Infof("Finished reading %d lines from file in: %d ms\n", len(*lines), elapsedReading.Milliseconds()) 182 b.log.Debugf("Memory statistics: %s", util.MemStat()) 183 184 // add root position 185 startPosition := position.NewPosition() 186 b.bookMap = make(map[uint64]BookEntry) 187 b.rootEntry = uint64(startPosition.ZobristKey()) 188 b.bookMap[uint64(startPosition.ZobristKey())] = BookEntry{ZobristKey: uint64(startPosition.ZobristKey()), Counter: 0, Moves: []Successor{}} 189 b.log.Debugf("Memory statistics: %s", util.MemStat()) 190 191 // process lines 192 if parallel { 193 b.log.Infof("Processing %d lines in parallel with format: %v\n", len(*lines), bookFormat) 194 } else { 195 b.log.Infof("Processing %d lines sequential with format: %v\n", len(*lines), bookFormat) 196 } 197 startProcessing := time.Now() 198 err = b.process(lines, bookFormat) 199 if err != nil { 200 b.log.Errorf("Error while processing: %s\n", err) 201 return err 202 } 203 elapsedProcessing := time.Since(startProcessing) 204 b.log.Infof("Finished processing %d lines in: %d ms\n", len(*lines), elapsedProcessing.Milliseconds()) 205 b.log.Debugf("Memory statistics: %s", util.MemStat()) 206 207 // finished 208 elapsedTotal := time.Since(startTotal) 209 210 b.log.Infof("Book contains %d entries\n", len(b.bookMap)) 211 b.log.Infof("Total initialization time : %d ms\n", elapsedTotal.Milliseconds()) 212 213 // saving to cache 214 if useCache { 215 b.log.Infof("Saving to cache...") 216 startSave := time.Now() 217 cacheFile, nBytes, err := b.saveToCache(bookFilePath) 218 if err != nil { 219 b.log.Errorf("Error while saving to cache: %s\n", err) 220 } 221 elapsedSave := time.Since(startSave) 222 bytes := out.Sprintf("%d", nBytes/1_024) 223 b.log.Infof("Saved %s kB to cache %s in %d ms\n", bytes, cacheFile, elapsedSave.Milliseconds()) 224 } 225 b.log.Debugf("Memory statistics: %s", util.MemStat()) 226 227 b.initialized = true 228 return nil 229 } 230 231 // NumberOfEntries returns the number of entries in the opening book 232 func (b *Book) NumberOfEntries() int { 233 return len(b.bookMap) 234 } 235 236 // GetEntry returns a copy of the entry with the corresponding key 237 func (b *Book) GetEntry(key position.Key) (BookEntry, bool) { 238 entryPtr, ok := b.bookMap[uint64(key)] 239 if ok { 240 return entryPtr, true 241 } else { 242 return entryPtr, false 243 } 244 } 245 246 // Reset resets the opening book so it can/must be initialized again 247 func (b *Book) Reset() { 248 b.bookMap = map[uint64]BookEntry{} 249 b.rootEntry = 0 250 b.initialized = false 251 } 252 253 // ///////////////////////////////////////////////// 254 // Private 255 // ///////////////////////////////////////////////// 256 257 // reads a complete file into a slice of strings 258 func (b *Book) readFile(bookPath string) (*[]string, error) { 259 f, err := os.Open(bookPath) 260 if err != nil { 261 b.log.Errorf("File \"%s\" could not be read; %s\n", bookPath, err) 262 return nil, err 263 } 264 defer func() { 265 if err = f.Close(); err != nil { 266 b.log.Errorf("File \"%s\" could not be closed: %s\n", bookPath, err) 267 } 268 }() 269 var lines []string 270 s := bufio.NewScanner(f) 271 for s.Scan() { 272 lines = append(lines, s.Text()) 273 } 274 err = s.Err() 275 if err != nil { 276 b.log.Errorf("Error while reading file \"%s\": %s\n", bookPath, err) 277 return nil, err 278 } 279 return &lines, nil 280 } 281 282 // sends all lines to the correct processing depending on format 283 func (b *Book) process(lines *[]string, format BookFormat) error { 284 switch format { 285 case Simple: 286 b.processSimple(lines) 287 case San: 288 b.processSan(lines) 289 case Pgn: 290 b.processPgn(lines) 291 } 292 return nil 293 } 294 295 // processes all lines of Simple format 296 // uses goroutines in parallel if enabled 297 func (b *Book) processSimple(lines *[]string) { 298 if parallel { 299 sliceLength := len(*lines) 300 var wg sync.WaitGroup 301 wg.Add(sliceLength) 302 for _, line := range *lines { 303 go func(line string) { 304 defer wg.Done() 305 b.processSimpleLine(line) 306 }(line) 307 } 308 wg.Wait() 309 } else { 310 for _, line := range *lines { 311 b.processSimpleLine(line) 312 } 313 } 314 } 315 316 // regular expressions for detecting moves 317 var regexSimpleUciMove = regexp.MustCompile("([a-h][1-8][a-h][1-8])") 318 319 // processes one line of simple format and adds each move to book 320 func (b *Book) processSimpleLine(line string) { 321 line = strings.TrimSpace(line) 322 323 // find Uci moves 324 matches := regexSimpleUciMove.FindAllString(line, -1) 325 326 // skip lines without matches 327 if len(matches) == 0 { 328 return 329 } 330 331 // start with root position 332 pos := position.NewPosition() 333 334 // increase counter for root position 335 bookLock.Lock() 336 e, found := b.bookMap[b.rootEntry] 337 if found { 338 e.Counter++ 339 b.bookMap[b.rootEntry] = e 340 } else { 341 panic("root entry of book map not found") 342 } 343 bookLock.Unlock() 344 345 // move gen to check moves 346 // movegen is not thread safe therefore we create a new instance for every line 347 var mg = movegen.NewMoveGen() 348 349 // add all matches to book 350 for _, moveString := range matches { 351 err := b.processSingleMove(moveString, mg, pos) 352 // stop processing further matches when we had an error as it 353 // would probably be fruitless as position will be wrong 354 if err != nil { 355 break 356 } 357 } 358 } 359 360 // processes all lines of Simple format 361 // uses goroutines in parallel if enabled 362 func (b *Book) processSan(lines *[]string) { 363 if parallel { 364 sliceLength := len(*lines) 365 var wg sync.WaitGroup 366 wg.Add(sliceLength) 367 for _, line := range *lines { 368 go func(line string) { 369 defer wg.Done() 370 b.processSanLine(line) 371 }(line) 372 } 373 wg.Wait() 374 } else { 375 for _, line := range *lines { 376 b.processSanLine(line) 377 } 378 } 379 } 380 381 var regexResult = regexp.MustCompile("((1-0)|(0-1)|(1/2-1/2)|(\\*))$") 382 383 // Processes PGN formatted file. PGN file have additional 384 // metadata and spreads its move section over several lines. 385 // We ignore the metadata for now and only look at the 386 // move section. 387 func (b *Book) processPgn(lines *[]string) { 388 // bundle games in slices of lines by finding result patterns 389 var gamesSlices [][]string 390 391 // find slices for each game 392 startSlicing := time.Now() 393 start := 0 394 // end := len(*lines) 395 for i, l := range *lines { 396 l = strings.TrimSpace(l) 397 if regexResult.MatchString(l) { 398 end := i + 1 399 gamesSlices = append(gamesSlices, (*lines)[start:end]) 400 start = end 401 } 402 } 403 elapsedReading := time.Since(startSlicing) 404 b.log.Infof("Finished finding %d games from file in: %d ms\n", len(gamesSlices), elapsedReading.Milliseconds()) 405 406 // process each game 407 // uses goroutines in parallel if enabled 408 startProcessing := time.Now() 409 if parallel { 410 noOfSlices := len(gamesSlices) 411 var wg sync.WaitGroup 412 wg.Add(noOfSlices) 413 for _, gs := range gamesSlices { 414 go func(gs []string) { 415 defer wg.Done() 416 b.processPgnGame(gs) 417 }(gs) 418 } 419 wg.Wait() 420 } else { 421 for _, gs := range gamesSlices { 422 b.processPgnGame(gs) 423 } 424 425 } 426 elapsedProcessing := time.Since(startProcessing) 427 b.log.Infof("Finished processing %d games from file in: %d ms\n", len(gamesSlices), elapsedProcessing.Milliseconds()) 428 } 429 430 var regexTrailingComments = regexp.MustCompile(";.*$") 431 var regexTagPairs = regexp.MustCompile("\\[\\w+ +\".*?\"\\]") 432 var regexNagAnnotation = regexp.MustCompile("(\\$\\d{1,3})") // no NAG annotation supported 433 var regexBracketComments = regexp.MustCompile("{[^{}]*}") // bracket comments 434 var regexReservedSymbols = regexp.MustCompile("<[^<>]*>") // reserved symbols < > 435 var regexRavVariants = regexp.MustCompile("\\([^()]*\\)") // RAV variant comments < > 436 437 // processes one game comprising of several input lines 438 func (b *Book) processPgnGame(gameSlice []string) { 439 // build a cleaned up string of the move part of the PGN 440 var moveLine strings.Builder 441 442 // cleanup lines and concatenate move lines 443 for _, l := range gameSlice { 444 l = strings.TrimSpace(l) 445 if strings.HasPrefix(l, "%") { // skip comment lines 446 continue 447 } 448 // remove unnecessary parts and lines / order is important - comments last 449 l = regexTagPairs.ReplaceAllString(l, "") 450 l = regexResult.ReplaceAllString(l, "") 451 l = regexTrailingComments.ReplaceAllString(l, "") 452 l = strings.TrimSpace(l) 453 // after cleanup skip now empty lines 454 if len(l) == 0 { 455 continue 456 } 457 // add the rest to the moveLine 458 moveLine.WriteString(" ") 459 moveLine.WriteString(l) 460 } 461 line := moveLine.String() 462 463 // clean up move section of PGN 464 line = regexNagAnnotation.ReplaceAllString(line, " ") 465 line = regexBracketComments.ReplaceAllString(line, " ") 466 line = regexReservedSymbols.ReplaceAllString(line, " ") 467 // RAV variation comments can be nested - therefore loop 468 for regexRavVariants.MatchString(line) { 469 line = regexRavVariants.ReplaceAllString(line, " ") 470 } 471 472 // process as SAN line 473 b.processSanLine(line) 474 } 475 476 // regular expressions for handling SAN/UCI input lines 477 var regexSanLineStart = regexp.MustCompile("^\\d+\\. ?") 478 var regexSanLineCleanUpNumbers = regexp.MustCompile("(\\d+\\.{1,3} ?)") 479 var regexSanLineCleanUpResults = regexp.MustCompile("(1/2|1|0)-(1/2|1|0)") 480 var regexWhiteSpace = regexp.MustCompile("\\s+") 481 482 // processes one line of SAN format 483 func (b *Book) processSanLine(line string) { 484 line = strings.TrimSpace(line) 485 486 // check if line starts valid 487 found := regexSanLineStart.MatchString(line) 488 if !found { 489 return 490 } 491 492 /* 493 Iterate over all tokens, ignore move numbers and results 494 Example: 495 1. f4 d5 2. Nf3 Nf6 3. e3 g6 4. b3 Bg7 5. Bb2 O-O 6. Be2 c5 7. O-O Nc6 8. Ne5 Qc7 1/2-1/2 496 1. f4 d5 2. Nf3 Nf6 3. e3 Bg4 4. Be2 e6 5. O-O Bd6 6. b3 O-O 7. Bb2 c5 1/2-1/2 497 */ 498 499 // remove unnecessary parts 500 line = regexSanLineCleanUpNumbers.ReplaceAllString(line, "") 501 line = regexSanLineCleanUpResults.ReplaceAllString(line, "") 502 line = strings.TrimSpace(line) 503 504 // split at every whitespace and iterate through items 505 moveStrings := regexWhiteSpace.Split(line, -1) 506 // skip lines without matches 507 if len(moveStrings) == 0 { 508 return 509 } 510 511 // start with root position 512 pos := position.NewPosition() 513 514 // increase counter for root position 515 bookLock.Lock() 516 e, found := b.bookMap[b.rootEntry] 517 if found { 518 e.Counter++ 519 b.bookMap[b.rootEntry] = e 520 } else { 521 panic("root entry of book map not found") 522 } 523 bookLock.Unlock() 524 525 // move gen to check moves 526 // movegen is not thread safe therefore we create a new instance for every line 527 var mg = movegen.NewMoveGen() 528 529 for _, moveString := range moveStrings { 530 err := b.processSingleMove(moveString, mg, pos) 531 // stop processing further matches when we had an error as it 532 // would probably be fruitless as position will be wrong 533 if err != nil { 534 b.log.Warningf("Move not valid %s on %s", moveString, pos.StringFen()) 535 break 536 } 537 } 538 } 539 540 var regexUciMove = regexp.MustCompile("([a-h][1-8][a-h][1-8])([NBRQnbrq])?") 541 var regexSanMove = regexp.MustCompile("([NBRQK])?([a-h])?([1-8])?x?([a-h][1-8]|O-O-O|O-O)(=?([NBRQ]))?([!?+#]*)?") 542 543 // Process a single move as a string in either UCI or SAN format. 544 // Uses pattern matching to distinguish format 545 func (b *Book) processSingleMove(s string, mgPtr *movegen.Movegen, posPtr *position.Position) error { 546 // find move in the current position or stop processing 547 var move = types.MoveNone 548 if regexUciMove.MatchString(s) { 549 move = mgPtr.GetMoveFromUci(posPtr, s) 550 } else if regexSanMove.MatchString(s) { 551 move = mgPtr.GetMoveFromSan(posPtr, s) 552 } 553 // if move is invalid return stop processing further matches 554 if !move.IsValid() { 555 return errors.New("Invalid move " + s) 556 } 557 // execute move on position and store the keys for the positions 558 curPosKey := uint64(posPtr.ZobristKey()) 559 posPtr.DoMove(move) 560 nextPosKey := uint64(posPtr.ZobristKey()) 561 // add the move 562 b.addToBook(curPosKey, nextPosKey, uint32(move)) 563 // no error 564 return nil 565 } 566 567 // adds a move to the book 568 // this function is thread save to be used in parallel 569 func (b *Book) addToBook(curPosKey uint64, nextPosKey uint64, move uint32) { 570 // out.Printf("Add %s to position %s\n", move.StringUci(), p.StringFen()) 571 572 // mutex to synchronize parallel access 573 bookLock.Lock() 574 defer bookLock.Unlock() 575 576 // find the current position's entry 577 currentPosEntry, found := b.bookMap[curPosKey] 578 if !found { 579 b.log.Error("Could not find current position in book.") 580 return 581 } 582 583 // create or update book entry 584 nextPosEntry, found := b.bookMap[nextPosKey] 585 if found { // entry already exists - update 586 nextPosEntry.Counter++ 587 b.bookMap[nextPosKey] = nextPosEntry 588 return 589 } else { // new entry 590 b.bookMap[nextPosKey] = BookEntry{ 591 ZobristKey: nextPosKey, 592 Counter: 1, 593 Moves: nil} 594 nextPosEntry = b.bookMap[nextPosKey] 595 // add move and link to child position entry to current entry 596 currentPosEntry.Moves = append(currentPosEntry.Moves, Successor{move, nextPosEntry.ZobristKey}) 597 b.bookMap[curPosKey] = currentPosEntry 598 } 599 } 600 601 func (b *Book) loadFromCache(bookPath string) (bool, error) { 602 // determine cache file name 603 cachePath := bookPath + ".cache" 604 605 // Read cache file 606 // Open a RO file 607 decodeFile, err := os.Open(cachePath) 608 if err != nil { 609 return false, err 610 } 611 defer decodeFile.Close() 612 613 // Create a decoder 614 decoder := gob.NewDecoder(decodeFile) 615 616 // Decode -- We need to pass a pointer otherwise accounts2 isn't modified 617 bookLock.Lock() 618 err = decoder.Decode(&b.bookMap) 619 if err != nil { 620 return false, err 621 } 622 bookLock.Unlock() 623 624 // set root entry key 625 p := position.NewPosition() 626 b.rootEntry = uint64(p.ZobristKey()) 627 628 // no error 629 return true, nil 630 } 631 632 func (b *Book) saveToCache(bookPath string) (string, int64, error) { 633 // determine cache file name 634 cachePath := bookPath + ".cache" 635 636 // Create a file for IO 637 encodeFile, err := os.Create(cachePath) 638 if err != nil { 639 return cachePath, 0, err 640 } 641 // create binary encoder 642 enc := gob.NewEncoder(encodeFile) 643 644 // encode bookMap 645 bookLock.Lock() 646 if err = enc.Encode(b.bookMap); err != nil { 647 panic(err) 648 } 649 bookLock.Unlock() 650 651 // close and return 652 err = encodeFile.Close() 653 if err != nil { 654 return cachePath, 0, err 655 } 656 657 // no error 658 fileInfo, _ := os.Stat(cachePath) 659 return cachePath, fileInfo.Size(), nil 660 }