diff --git a/.github/workflows/osxbuild.yaml b/.github/workflows/osxbuild.yaml index f4fcc0c..dfb4535 100644 --- a/.github/workflows/osxbuild.yaml +++ b/.github/workflows/osxbuild.yaml @@ -52,7 +52,7 @@ jobs: pushd KaTrain.app/Contents/Resources/ ln -s ./venv/bin/KaTrain yourapp popd - ./fix-bundle-metadata.sh KaTrain.app -n KaTrain -v "1.8.0" -a "Sander Land" -o "org.katrain.KaTrain" -i "../../katrain/katrain/img/icon.ico" + ./fix-bundle-metadata.sh KaTrain.app -n KaTrain -v "1.9.0" -a "Sander Land" -o "org.katrain.KaTrain" -i "../../katrain/katrain/img/icon.ico" ./cleanup-app.sh KaTrain.app -g 1 ./relocate.sh KaTrain.app - name: Create dmg @@ -67,12 +67,4 @@ jobs: with: name: KaTrainOSX path: osx_app - - name: Upload to GitHub Release - uses: softprops/action-gh-release@78c309ef59fdb9557cd6574f2e0be552936ed728 - if: startsWith(github.ref, 'refs/tags/') - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - files: KaTrainOSX/* - draft: true - fail_on_unmatched_files: true + diff --git a/.gitignore b/.gitignore index 066efeb..7661862 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ dist_sgf +sgftest # additions KataGoData diff --git a/CONTRIBUTIONS.md b/CONTRIBUTIONS.md index 7e832a7..9912902 100644 --- a/CONTRIBUTIONS.md +++ b/CONTRIBUTIONS.md @@ -52,7 +52,8 @@ Many thanks to these additional authors: * "blamarche" for adding the board coordinates toggle. * "pdeblanc" for adding the ancient chinese scoring option, fixing a bug in query termination * "LiamHz" for adding the 'back to main branch' keyboard shortcut. -* "xiaoyifang" for adding the reset analysis option, feature to save options on the loading screen. +* "xiaoyifang" for adding the reset analysis option, feature to save options on the loading screen, and scrolling through variations. +* "electricRGB" for help with adding configurable keyboard shortcuts. ## Translators @@ -65,7 +66,7 @@ Many thanks to the following contributors for translations. * Russian: Dmitry Ivankov and Alexander Kiselev * Simplified Chinese: Qing Mu with contributions from "Medwin" and Viktor Lin * Japanese: "kaorahi" -* Traditional Chinese: "Tony-Liou" +* Traditional Chinese: "Tony-Liou" with contributions from Ching-yu Lin ## Additional thanks to diff --git a/ENGINE.md b/ENGINE.md new file mode 100644 index 0000000..05f3b83 --- /dev/null +++ b/ENGINE.md @@ -0,0 +1,74 @@ +# KataGo troubleshooting + +This page lists common ways in which the provided KataGo fails to work out of the box, and how to resolve these issues. +If you find your problem is not in here, you can ask on the [Leela Zero & Friends Discord](http://discord.gg/AjTPFpN) (use the #gui channel), + providing detailed information about your error. + + +* [General](#General) + * [GPU vs CPU](#CPU) +* [Windows specific help](#Windows) +* [MacOS specific help](#Mac) +* [Linux specific help](#Linux) + + + +## General + +### GPU vs CPU + +The standard executables assume you have a compatible graphics card (GPU). +If you don't, KataGo will fail to start in ways that are difficult for KaTrain to pick up. + +On Windows and Linux, you should be able to resolve this by: + +* Going to general and engine settings (F8) +* Click 'download katago versions' and wait for downloads to finish. +* Select a CPU based KataGo version (named 'Eigen' after the library it uses). + +Keep in mind that a CPU based engine can be significantly slower, and you may want to set your maximum number of +visits to a lower number to compensate for this. + +### KataGo model versions + +KataGo models have changed over time, and selecting an older executable with a newer model can lead to errors. +Of the provided binaries, this is typically the case for the 1.6.1 'bigger boards' binary, which should + only be used with the standard 15/20/30/40 block models, and not the newer distributed training models. + + +## macOs For macOS users + +### Running from source + +Make sure you `brew install katago` or set the engine path to your own KataGo binary, as there is no executable included. + +### New Macs with M1 architecture + +Make sure you `brew install katago` as the provided executable does not work on rosetta. + +### Getting more information about errors + +On macOS, the .app distributable will not show a console, so you will need install using `pip` to see the console window. + +## Windows For Windows users + +### Getting more information about errors + +Run DebugKaTrain.exe, which is released in the .zip file distributable in releases. This will show a console window + which typically tells you more. + + +## Linux For Linux users + +### libzip compatibility + +The most common KataGo issue relates to different libzip versions in the provided executables. +Although the provided executables should work on the latest versions of Ubuntu, various other versions and distros differ in their libzip version. + +* First, try `sudo apt-get install libzip-dev` to +* Next, try installing all packages mentioned [here](INSTALL.md#LinuxTrouble). +* A final alternative is to go [here](https://github.com/lightvector/KataGo) and compile KataGo yourself. + +### Getting more information about errors + +Check the terminal output around startup time. \ No newline at end of file diff --git a/INSTALL.md b/INSTALL.md index 507857a..bbaf272 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -6,6 +6,8 @@ * [Troubleshooting and installation from sources](#WindowsSources) * [Quick install guide for Linux](#LinuxQuick) * [Troubleshooting and installation from sources](#LinuxSources) +* [Configuring Multiple GPUS](#GPU) +* [Troubleshooting KataGo](#KataGo) ## macOs Installation for macOS users @@ -15,8 +17,9 @@ The easiest way to install is probably [brew](https://brew.sh/). Simply run `bre You can also find downloadable .app files for macOS [here](https://github.com/sanderland/katrain/releases). Simply download, unzip the file, mount the .dmg and drag the .app file to your application folder, everything is included. +The first time launching the application you may need to [control-click in finder to give permission for the 'unidentified' app to launch](https://support.apple.com/guide/mac-help/open-a-mac-app-from-an-unidentified-developer-mh40616/mac). This is simply a result of Apple charging $99/year to developers to be 'identified'. -Users with the last generation M1 macs with different architecture should then `brew install katago` in addition to this. KaTrain will automatically detect this katago binary. +Users with the last generation M1 macs with different architecture should then `brew install katago` in addition to this. KaTrain will automatically detect this KataGo binary. ### Command line install guide @@ -99,7 +102,7 @@ It assumes you have a working Python 3.6+ installation. A binary for KataGo is included, but if you have compiled your own, press F8 to open general settings and change the KataGo executable path to the relevant KataGo v1.4+ binary. -### Troubleshooting and advanced installation from sources +### Troubleshooting and advanced installation from sources You can try to manually install dependencies to resolve some issues relating to missing dependencies, e.g. the binary 'wheel' is not provided, KataGo is not starting, or sounds are not working. @@ -128,7 +131,9 @@ You can now start KaTrain by running `python3 -m katrain` In case KataGo does not start, an alternative is to go [here](https://github.com/lightvector/KataGo) and compile KataGo yourself. -## Configuring the GPU(s) KataGo uses + + +## Configuring the GPU(s) KataGo uses In most cases KataGo detects your configuration correctly, automatically searching for OpenCL devices and select the highest scoring device. However, if you have multiple GPUs or want to force a specific device you will need to edit the 'analysis_config.cfg' file in the KataGo folder. @@ -148,8 +153,8 @@ As you can see it scores about twice as high as the Intel UHD chip and KataGo ha * Open the 'analysis_config.cfg' file in the `katrain/KataGo` folder in your python packages, or local sources. If you can't find it, turn on `debug_level=1` in general settings and look for the command that is used to start KataGo. -* Search for `numNNServerThreadsPerModel` (~line 75), uncomment the line by deleting the # and set the value to 2. The line should read `numNNServerThreadsPerModel = 2`. -* Search for `openclDeviceToUseThread` (~line 117), uncomment by deleting the # and set the values to the device ID numbers identified in the terminal. +* Search for `numNNServerThreadsPerModel` (~line 108), uncomment the line by deleting the # and set the value to 2. The line should read `numNNServerThreadsPerModel = 2`. +* Search for `openclDeviceToUseThread` (~line 164), uncomment by deleting the # and set the values to the device ID numbers identified in the terminal. From the example above, we would want to use devices 1 and 2, for the Intel and AMD GPUs, but not device 0 (the CPU). In our case, the lines should read: ``` openclDeviceToUseThread0 = 1 @@ -165,3 +170,8 @@ openclDeviceToUseThread1 = 2 Using OpenCL Device 1: Intel(R) UHD Graphics 630 (Intel Inc.) OpenCL 1.2 Using OpenCL Device 2: AMD Radeon Pro 5500M Compute Engine (AMD) OpenCL 1.2 ``` + + +## Troubleshooting and advanced KataGo settings + +See [here](ENGINE.md) for an overview of how to resolve various issues with KataGo. \ No newline at end of file diff --git a/THEMES.md b/THEMES.md index c798349..99cf491 100644 --- a/THEMES.md +++ b/THEMES.md @@ -1,21 +1,25 @@ # Themes -Version 1.7 brings basic support for themes. + +Version 1.7 brings basic support for themes, and 1.9 extends it to include keyboard shortcuts and support for multiple theme files. ## Creating and editing themes -* Look at the `Theme` class in `katrain/gui/theme.py`. -* Make a `theme.json` file in your `/.katrain` directory and specify any variables from the above class you want to override, e.g. +* Look at the `Theme` class in [`katrain/gui/theme.py`](https://github.com/sanderland/katrain/blob/master/katrain/gui/theme.py). +* Make a `theme.json` file in your `/.katrain` directory and specify any variables from the above class you want to override, e.g. ```json { - "BACKGROUND_COLOR": [1,0,0,1] + "BACKGROUND_COLOR": [1,0,0,1], + "KEY_STOP_ANALYSIS": "f10", + "MISTAKE_SOUNDS": ["jeff.wav","what.wav"] } ``` -* All resources (including icons which can not be renamed for now) will be looked up in `/.katrain` first, so files with identical names there can be used to override sounds and images. +* All resources (including icons, which can not be renamed for now) will be looked up in `/.katrain` first, so files with identical names there can be used to override sounds and images. +* If variables are specified in multiple theme files, the *latest* alphabetically takes precedence. That is, each later theme file overwrites the settings from any previous one. ## Installation * To install a theme, simply unzip the theme.zip to your .katrain folder. - * On windows you can find it in C:\Users\you\.katrain and on linux in ~/.katrain. + * On Windows you can find it in C:\Users\you\\.katrain and on linux in ~/.katrain. * When in doubt, the general settings dialog will also show the location. * To uninstall a theme, remove theme.json and all relevant images from that folder. @@ -37,3 +41,11 @@ Version 1.7 brings basic support for themes. ![Preview](https://raw.githubusercontent.com/sanderland/katrain/master/themes/eric-lizzie.png) + +### Jeff sounds + +* This theme makes Jeff comment `Ahhh?` and `What?!` when you make mistakes. +* Sounds provided by Mikkgo. + +[Download](https://github.com/sanderland/katrain/blob/master/themes/jeff-sounds.zip) + diff --git a/i18n.py b/i18n.py index 76fe841..e5a3115 100644 --- a/i18n.py +++ b/i18n.py @@ -32,9 +32,11 @@ for lang in locales: strings_to_langs[entry.msgid][lang] = entry strings_to_keys[entry.msgid][lang] = set(re.findall("{.*?}", entry.msgstr)) if entry.msgid in lang_to_strings[lang]: - print("duplicate", entry.msgid, "in", lang) + print("duplicate", entry.msgid, "in", lang, "--> deleting", entry.msgstr) errors = True - lang_to_strings[lang].add(entry.msgid) + po[lang].remove(entry) + else: + lang_to_strings[lang].add(entry.msgid) if todos[lang] and any("todo" in a for a in sys.argv): print(f"========== {lang} has {len(todos[lang])} TODO entries ========== ") for item in todos[lang]: @@ -76,24 +78,25 @@ for lang in locales: for msgid, lang_entries in strings_to_langs.items(): if lang in lang_entries and "TODO" in lang_entries[lang].comment: if any(e.msgstr == lang_entries[lang].msgstr for ll, e in lang_entries.items() if ll != lang): - todo_comment = ( - f"TODO - {lang_entries[DEFAULT_LANG].comment}" if lang_entries[DEFAULT_LANG].comment else "TODO" - ) # update todo - if ( - lang_entries[lang].msgstr != lang_entries[DEFAULT_LANG].msgstr - or lang_entries[lang].comment.replace("\n", " ") != todo_comment - ): - print( - [ - lang_entries[lang].msgstr, - lang_entries[DEFAULT_LANG].msgstr, - lang_entries[lang].comment, - todo_comment, - ] - ) - lang_entries[lang].msgstr = lang_entries[DEFAULT_LANG].msgstr # update - lang_entries[lang].comment = todo_comment - print(f"{lang}/{msgid} todo entry updated") + if lang_entries.get(DEFAULT_LANG): + todo_comment = ( + f"TODO - {lang_entries[DEFAULT_LANG].comment}" if lang_entries[DEFAULT_LANG].comment else "TODO" + ) # update todo + if ( + lang_entries[lang].msgstr != lang_entries[DEFAULT_LANG].msgstr + or lang_entries[lang].comment.replace("\n", " ") != todo_comment + ): + print( + [ + lang_entries[lang].msgstr, + lang_entries[DEFAULT_LANG].msgstr, + lang_entries[lang].comment, + todo_comment, + ] + ) + lang_entries[lang].msgstr = lang_entries[DEFAULT_LANG].msgstr # update + lang_entries[lang].comment = todo_comment + print(f"{lang}/{msgid} todo entry updated") po[lang].save(pofile[lang]) mofile = pofile[lang].replace(".po", ".mo") diff --git a/katrain/KataGo/katago b/katrain/KataGo/katago index bb9936b..4d18a0f 100755 Binary files a/katrain/KataGo/katago and b/katrain/KataGo/katago differ diff --git a/katrain/KataGo/katago.exe b/katrain/KataGo/katago.exe index 95e9e9e..a5adebd 100644 Binary files a/katrain/KataGo/katago.exe and b/katrain/KataGo/katago.exe differ diff --git a/katrain/__main__.py b/katrain/__main__.py index ff4a6d2..8298214 100644 --- a/katrain/__main__.py +++ b/katrain/__main__.py @@ -1,10 +1,10 @@ """isort:skip_file""" -# first, logging level lower and force audio framework +# first, logging level lower import os os.environ["KCFG_KIVY_LOG_LEVEL"] = os.environ.get("KCFG_KIVY_LOG_LEVEL", "warning") -if "KIVY_AUDIO" not in os.environ: - os.environ["KIVY_AUDIO"] = "sdl2" # some backends hard crash / this seems to be most stable +# if "KIVY_AUDIO" not in os.environ: # trying default again +# os.environ["KIVY_AUDIO"] = "sdl2" # some backends hard crash / this seems to be most stable import kivy @@ -30,6 +30,8 @@ from queue import Queue import urllib3 import webbrowser import time +import random +import glob from kivy.base import ExceptionHandler, ExceptionManager from kivy.app import App @@ -65,7 +67,15 @@ from katrain.core.constants import ( DATA_FOLDER, AI_DEFAULT, ) -from katrain.gui.popups import ConfigTeacherPopup, ConfigTimerPopup, I18NPopup, SaveSGFPopup, ContributePopup +from katrain.gui.popups import ( + ConfigTeacherPopup, + ConfigTimerPopup, + I18NPopup, + SaveSGFPopup, + ContributePopup, + EngineRecoveryPopup, +) +from katrain.gui.sound import play_sound from katrain.core.base_katrain import KaTrainBase from katrain.core.engine import KataGoEngine from katrain.core.contribute_engine import KataGoContributeEngine @@ -221,6 +231,14 @@ class KaTrainGui(Screen, KaTrainBase): if not self.contributing: last_player, next_player = self.players_info[cn.player], self.players_info[cn.next_player] if self.play_analyze_mode == MODE_PLAY and self.nav_drawer.state != "open" and self.popup_open is None: + points_lost = cn.points_lost + if ( + last_player.human + and cn.analysis_complete + and points_lost is not None + and points_lost > self.config("trainer/eval_thresholds")[-4] + ): + self.play_mistake_sound(cn) teaching_undo = cn.player and last_player.being_taught and cn.parent if ( teaching_undo @@ -437,6 +455,7 @@ class KaTrainGui(Screen, KaTrainBase): title_key="general settings title", size=[dp(1200), dp(950)], content=ConfigPopup(self) ).__self__ self.config_popup.content.popup = self.config_popup + self.config_popup.title += ": " + self.config_file self.config_popup.open() def _do_contribute_popup(self): @@ -456,10 +475,30 @@ class KaTrainGui(Screen, KaTrainBase): self.ai_settings_popup.content.popup = self.ai_settings_popup self.ai_settings_popup.open() + def _do_engine_recovery_popup(self, error_message, code): + current_open = self.popup_open + self.log(f"engine rec {current_open}", OUTPUT_ERROR) + if current_open and isinstance(current_open.content, EngineRecoveryPopup): + self.log(f"Not opening engine recovery popup with {error_message} as one is already open", OUTPUT_DEBUG) + return + popup = I18NPopup( + title_key="engine recovery", + size=[dp(600), dp(700)], + content=EngineRecoveryPopup(self, error_message=error_message, code=code), + ).__self__ + popup.content.popup = popup + popup.open() + + def play_mistake_sound(self, node): + if self.config("timer/sound") and node.played_sound is None and Theme.MISTAKE_SOUNDS: + node.played_sound = True + play_sound(random.choice(Theme.MISTAKE_SOUNDS)) + def load_sgf_file(self, file, fast=False, rewind=True): if self.contributing: return try: + file = os.path.abspath(file) move_tree = KaTrainSGF.parse_file(file) except (ParseError, FileNotFoundError) as e: self.log(i18n._("Failed to load SGF").format(error=e), OUTPUT_ERROR) @@ -561,40 +600,49 @@ class KaTrainGui(Screen, KaTrainBase): or self.controls.move_tree.collide_point(*touch.pos) ): if touch.is_mouse_scrolling: - if touch.button == "scrollup": - self("redo") - elif touch.button == "scrolldown": - self("undo") + if self.board_gui.animating_pv is None: + if touch.button == "scrollup": + self("redo") + elif touch.button == "scrolldown": + self("undo") + else: + if touch.button == "scrollup": + self.board_gui.adjust_animate_pv_index(1) + elif touch.button == "scrolldown": + self.board_gui.adjust_animate_pv_index(-1) return super().on_touch_up(touch) @property def shortcuts(self): return { - "q": self.analysis_controls.show_children, - "w": self.analysis_controls.eval, - "e": self.analysis_controls.hints, - "t": self.analysis_controls.ownership, - "r": self.analysis_controls.policy, - "enter": ("ai-move",), - "numpadenter": ("ai-move",), - "a": ("analyze-extra", "extra"), - "s": ("analyze-extra", "equalize"), - "d": ("analyze-extra", "sweep"), - "f": ("analyze-extra", "alternative"), - "g": ("select-box",), - "h": ("reset-analysis",), - "i": ("insert-mode",), - "p": ("play", None), - "l": ("selfplay-setup", "end", None), - "b": ("undo", "branch"), - "down": ("switch-branch", 1), - "up": ("switch-branch", -1), - "f5": ("timer-popup",), - "f6": ("teacher-popup",), - "f7": ("ai-popup",), - "f8": ("config-popup",), - "f9": ("contribute-popup",), - "escape": ("analyze-extra", "stop"), + k: v + for ks, v in [ + (Theme.KEY_ANALYSIS_CONTROLS_SHOW_CHILDREN, self.analysis_controls.show_children), + (Theme.KEY_ANALYSIS_CONTROLS_EVAL, self.analysis_controls.eval), + (Theme.KEY_ANALYSIS_CONTROLS_HINTS, self.analysis_controls.hints), + (Theme.KEY_ANALYSIS_CONTROLS_OWNERSHIP, self.analysis_controls.ownership), + (Theme.KEY_ANALYSIS_CONTROLS_POLICY, self.analysis_controls.policy), + (Theme.KEY_AI_MOVE, ("ai-move",)), + (Theme.KEY_ANALYZE_EXTRA_EXTRA, ("analyze-extra", "extra")), + (Theme.KEY_ANALYZE_EXTRA_EQUALIZE, ("analyze-extra", "equalize")), + (Theme.KEY_ANALYZE_EXTRA_SWEEP, ("analyze-extra", "sweep")), + (Theme.KEY_ANALYZE_EXTRA_ALTERNATIVE, ("analyze-extra", "alternative")), + (Theme.KEY_SELECT_BOX, ("select-box",)), + (Theme.KEY_RESET_ANALYSIS, ("reset-analysis",)), + (Theme.KEY_INSERT_MODE, ("insert-mode",)), + (Theme.KEY_PASS, ("play", None)), + (Theme.KEY_SELFPLAY_TO_END, ("selfplay-setup", "end", None)), + (Theme.KEY_NAV_PREV_BRANCH, ("undo", "branch")), + (Theme.KEY_NAV_BRANCH_DOWN, ("switch-branch", 1)), + (Theme.KEY_NAV_BRANCH_UP, ("switch-branch", -1)), + (Theme.KEY_TIMER_POPUP, ("timer-popup",)), + (Theme.KEY_TEACHER_POPUP, ("teacher-popup",)), + (Theme.KEY_AI_POPUP, ("ai-popup",)), + (Theme.KEY_CONFIG_POPUP, ("config-popup",)), + (Theme.KEY_CONTRIBUTE_POPUP, ("contribute-popup",)), + (Theme.KEY_STOP_ANALYSIS, ("analyze-extra", "stop")), + ] + for k in (ks if isinstance(ks, list) else [ks]) } @property @@ -607,67 +655,72 @@ class KaTrainGui(Screen, KaTrainBase): def _on_keyboard_down(self, _keyboard, keycode, _text, modifiers): self.last_key_down = keycode ctrl_pressed = "ctrl" in modifiers + shift_pressed = "shift" in modifiers if self.controls.note.focus: return # when making notes, don't allow keyboard shortcuts popup = self.popup_open if popup: - if keycode[1] in ["f5", "f6", "f7", "f8", "f9"]: # switch between popups + if keycode[1] in [ + Theme.KEY_DEEPERANALYSIS_POPUP, + Theme.KEY_REPORT_POPUP, + Theme.KEY_TIMER_POPUP, + Theme.KEY_TEACHER_POPUP, + Theme.KEY_AI_POPUP, + Theme.KEY_CONFIG_POPUP, + Theme.KEY_CONTRIBUTE_POPUP, + ]: # switch between popups popup.dismiss() return - elif keycode[1] in ["enter", "numpadenter"]: + elif keycode[1] in Theme.KEY_SUBMIT_POPUP: fn = getattr(popup.content, "on_submit", None) if fn: fn() return else: return - shift_pressed = "shift" in modifiers - shortcuts = self.shortcuts - if keycode[1] == "spacebar": + if keycode[1] == Theme.KEY_TOGGLE_CONTINUOUS_ANALYSIS: self.toggle_continuous_analysis() - elif keycode[1] == "k": + elif keycode[1] == Theme.KEY_TOGGLE_COORDINATES: self.board_gui.toggle_coordinates() - elif keycode[1] in ["pause", "break", "f15"] and not ctrl_pressed: + elif keycode[1] in Theme.KEY_PAUSE_TIMER and not ctrl_pressed: self.controls.timer.paused = not self.controls.timer.paused - elif keycode[1] in ["`", "~", "f12"]: + elif keycode[1] in Theme.KEY_ZEN: self.zen = (self.zen + 1) % 3 - elif keycode[1] in ["left", "z"]: + elif keycode[1] in Theme.KEY_NAV_PREV: self("undo", 1 + shift_pressed * 9 + ctrl_pressed * 9999) - elif keycode[1] in ["right", "x"]: + elif keycode[1] in Theme.KEY_NAV_NEXT: self("redo", 1 + shift_pressed * 9 + ctrl_pressed * 9999) - elif keycode[1] == "home": + elif keycode[1] == Theme.KEY_NAV_GAME_START: self("undo", 9999) - elif keycode[1] == "end": + elif keycode[1] == Theme.KEY_NAV_GAME_END: self("redo", 9999) - elif keycode[1] == "pageup": + elif keycode[1] == Theme.KEY_MOVE_TREE_MAKE_SELECTED_NODE_MAIN_BRANCH: self.controls.move_tree.make_selected_node_main_branch() - elif keycode[1] == "n" and not ctrl_pressed: + elif keycode[1] == Theme.KEY_NAV_MISTAKE and not ctrl_pressed: self("find-mistake", "undo" if shift_pressed else "redo") - elif keycode[1] == "delete" and ctrl_pressed: + elif keycode[1] == Theme.KEY_MOVE_TREE_DELETE_SELECTED_NODE and ctrl_pressed: self.controls.move_tree.delete_selected_node() - elif keycode[1] == "c" and not ctrl_pressed: + elif keycode[1] == Theme.KEY_MOVE_TREE_TOGGLE_SELECTED_NODE_COLLAPSE and not ctrl_pressed: self.controls.move_tree.toggle_selected_node_collapse() - elif keycode[1] == "n" and ctrl_pressed: + elif keycode[1] == Theme.KEY_NEW_GAME and ctrl_pressed: self("new-game-popup") - elif keycode[1] == "l" and ctrl_pressed: + elif keycode[1] == Theme.KEY_LOAD_GAME and ctrl_pressed: self("analyze-sgf-popup") - elif keycode[1] == "s" and ctrl_pressed: + elif keycode[1] == Theme.KEY_SAVE_GAME and ctrl_pressed: self("save-game") - elif keycode[1] == "d" and ctrl_pressed: + elif keycode[1] == Theme.KEY_SAVE_GAME_AS and ctrl_pressed: self("save-game-as-popup") - elif keycode[1] == "c" and ctrl_pressed: + elif keycode[1] == Theme.KEY_COPY and ctrl_pressed: Clipboard.copy(self.game.root.sgf()) self.controls.set_status(i18n._("Copied SGF to clipboard."), STATUS_INFO) - elif keycode[1] == "v" and ctrl_pressed: + elif keycode[1] == Theme.KEY_PASTE and ctrl_pressed: self.load_sgf_from_clipboard() - elif keycode[1] == "b" and shift_pressed: + elif keycode[1] == Theme.KEY_NAV_PREV_BRANCH and shift_pressed: self("undo", "main-branch") - elif keycode[1] in shortcuts.keys() and not ctrl_pressed: - shortcut = shortcuts[keycode[1]] - if isinstance(shortcut, Widget): - shortcut.trigger_action(duration=0) - else: - self(*shortcut) + elif keycode[1] == Theme.KEY_DEEPERANALYSIS_POPUP: + self.analysis_controls.dropdown.open_game_analysis_popup() + elif keycode[1] == Theme.KEY_REPORT_POPUP: + self.analysis_controls.dropdown.open_report_popup() elif keycode[1] == "f10" and self.debug_level >= OUTPUT_EXTRA_DEBUG: import yappi @@ -682,6 +735,13 @@ class KaTrainGui(Screen, KaTrainBase): filename = f"callgrind.{int(time.time())}.prof" stats.save(filename, type="callgrind") self.log(f"wrote profiling results to {filename}", OUTPUT_ERROR) + elif not ctrl_pressed: + shortcut = self.shortcuts.get(keycode[1]) + if shortcut is not None: + if isinstance(shortcut, Widget): + shortcut.trigger_action(duration=0) + else: + self(*shortcut) def _on_keyboard_up(self, _keyboard, keycode): if keycode[1] in ["alt", "tab"]: @@ -723,8 +783,8 @@ class KaTrainApp(MDApp): resource_add_path(PATHS["PACKAGE"] + "/img") resource_add_path(os.path.abspath(os.path.expanduser(DATA_FOLDER))) # prefer resources in .katrain - theme_file = resource_find("theme.json") - if theme_file: + theme_files = glob.glob(os.path.join(os.path.expanduser(DATA_FOLDER), "theme*.json")) + for theme_file in sorted(theme_files): try: with open(theme_file) as f: theme_overrides = json.load(f) @@ -742,9 +802,11 @@ class KaTrainApp(MDApp): self.gui = KaTrainGui() Builder.load_file(popup_kv_file) - win_size = self.gui.config("ui_state/size", []) - win_left = self.gui.config("ui_state/left", None) - win_top = self.gui.config("ui_state/top", None) + win_left = win_top = win_size = None + if self.gui.config("ui_state/restoresize", True): + win_size = self.gui.config("ui_state/size", []) + win_left = self.gui.config("ui_state/left", None) + win_top = self.gui.config("ui_state/top", None) if not win_size: window_scale_fac = 1 try: @@ -755,6 +817,7 @@ class KaTrainApp(MDApp): except Exception as e: window_scale_fac = 0.85 win_size = [1300 * window_scale_fac, 1000 * window_scale_fac] + self.gui.log(f"Setting window size to {win_size} and position to {[win_left, win_top]}", OUTPUT_DEBUG) Window.size = (win_size[0], win_size[1]) if win_left is not None and win_top is not None: Window.left = win_left @@ -776,6 +839,7 @@ class KaTrainApp(MDApp): "homepage": HOMEPAGE + "#manual", "support": HOMEPAGE + "#support", "contribute:signup": "http://katagotraining.org/accounts/signup/", + "engine:help": HOMEPAGE + "/blob/master/ENGINE.md", } if site_key in websites: webbrowser.open(websites[site_key]) @@ -814,6 +878,7 @@ def run_app(): ex_type, ex, tb = sys.exc_info() trace = "".join(traceback.format_tb(tb)) app = MDApp.get_running_app() + if app and app.gui: app.gui.log( f"Exception {inst.__class__.__name__}: {', '.join(repr(a) for a in inst.args)}\n{trace}", diff --git a/katrain/config.json b/katrain/config.json index 703f938..eb19ab6 100644 --- a/katrain/config.json +++ b/katrain/config.json @@ -8,7 +8,7 @@ "max_visits": 500, "fast_visits": 25, "max_time": 8.0, - "wide_root_noise": 0.0, + "wide_root_noise": 0.04, "_enable_ownership": true }, "contribute": { @@ -28,7 +28,7 @@ "anim_pv_time": 0.5, "debug_level": 0, "lang": "en", - "version": "1.8.0", + "version": "1.9.0", "load_fast_analysis": false, "load_sgf_rewind": true }, @@ -159,6 +159,7 @@ } }, "ui_state": { + "restoresize": true, "size": [], "play": { "analysis_controls": { diff --git a/katrain/core/ai.py b/katrain/core/ai.py index 8f199db..7c274e4 100644 --- a/katrain/core/ai.py +++ b/katrain/core/ai.py @@ -33,9 +33,11 @@ from katrain.core.constants import ( OUTPUT_DEBUG, OUTPUT_ERROR, OUTPUT_INFO, + PRIORITY_EXTRA_AI_QUERY, + ADDITIONAL_MOVE_ORDER, ) from katrain.core.game import Game, GameNode, Move -from katrain.core.utils import var_to_grid, weighted_selection_without_replacement +from katrain.core.utils import var_to_grid, weighted_selection_without_replacement, evaluation_class def interp_ix(lst, x): @@ -91,6 +93,69 @@ def ai_rank_estimation(strategy, settings) -> int: return AI_STRENGTH[strategy] +def game_report(game, thresholds, depth_filter=None): + cn = game.current_node + nodes = cn.nodes_from_root + while cn.children: # main branch + cn = cn.children[0] + nodes.append(cn) + + x, y = game.board_size + depth_filter = [math.ceil(board_frac * x * y) for board_frac in depth_filter or (0, 1e9)] + nodes = [n for n in nodes if n.move and not n.is_root and depth_filter[0] <= n.depth < depth_filter[1]] + histogram = [{"B": 0, "W": 0} for _ in thresholds] + ai_top_move_count = {"B": 0, "W": 0} + ai_approved_move_count = {"B": 0, "W": 0} + player_ptloss = {"B": [], "W": []} + weights = {"B": [], "W": []} + + for n in nodes: + points_lost = n.points_lost + if n.points_lost is None: + continue + else: + points_lost = max(0, points_lost) + bucket = len(thresholds) - 1 - evaluation_class(points_lost, thresholds) + player_ptloss[n.player].append(points_lost) + histogram[bucket][n.player] += 1 + cands = n.parent.candidate_moves + filtered_cands = [d for d in cands if d["order"] < ADDITIONAL_MOVE_ORDER and "prior" in d] + weight = min( + 1.0, + sum([max(d["pointsLost"], 0) * d["prior"] for d in filtered_cands]) + / (sum(d["prior"] for d in filtered_cands) or 1e-6), + ) # complexity capped at 1 + # adj_weight between 0.05 - 1, dependent on difficulty and points lost + adj_weight = max(0.05, min(1.0, max(weight, points_lost / 4))) + weights[n.player].append((weight, adj_weight)) + if n.parent.analysis_complete: + ai_top_move_count[n.player] += int(cands[0]["move"] == n.move.gtp()) + ai_approved_move_count[n.player] += int( + n.move.gtp() + in [d["move"] for d in filtered_cands if d["order"] == 0 or (d["pointsLost"] < 0.5 and d["order"] < 5)] + ) + + wt_loss = { + bw: sum(s * aw for s, (w, aw) in zip(player_ptloss[bw], weights[bw])) + / (sum(aw for _, aw in weights[bw]) or 1e-6) + for bw in "BW" + } + sum_stats = { + bw: { + "accuracy": 100 * 0.75 ** wt_loss[bw], + "complexity": sum(w for w, aw in weights[bw]) / len(player_ptloss[bw]), + "mean_ptloss": sum(player_ptloss[bw]) / len(player_ptloss[bw]), + "weighted_ptloss": wt_loss[bw], + "ai_top_move": ai_top_move_count[bw] / len(player_ptloss[bw]), + "ai_top5_move": ai_approved_move_count[bw] / len(player_ptloss[bw]), + } + if len(player_ptloss[bw]) > 0 + else {} + for bw in "BW" + } + return sum_stats, histogram, player_ptloss + + def dirichlet_noise(num, dir_alpha=0.3): sample = [random.gammavariate(dir_alpha, 1) for _ in range(num)] sum_sample = sum(sample) @@ -173,7 +238,7 @@ def request_ai_analysis(game: Game, cn: GameNode, extra_settings: Dict) -> Optio cn, callback=set_analysis, error_callback=set_error, - priority=1_000, + priority=PRIORITY_EXTRA_AI_QUERY, ownership=False, extra_settings=extra_settings, ) diff --git a/katrain/core/constants.py b/katrain/core/constants.py index f14ca02..5ccb8b1 100644 --- a/katrain/core/constants.py +++ b/katrain/core/constants.py @@ -1,21 +1,34 @@ PROGRAM_NAME = "KaTrain" -VERSION = "1.8.4" +VERSION = "1.9.0" HOMEPAGE = "https://github.com/sanderland/katrain" -CONFIG_MIN_VERSION = "1.8.0" # keep config files from this version +CONFIG_MIN_VERSION = "1.9.0" # keep config files from this version ANALYSIS_FORMAT_VERSION = "1.0" DATA_FOLDER = "~/.katrain" + OUTPUT_ERROR = -1 OUTPUT_KATAGO_STDERR = -0.5 OUTPUT_INFO = 0 OUTPUT_DEBUG = 1 OUTPUT_EXTRA_DEBUG = 2 +KATAGO_EXCEPTION = "KATAGO-INTERNAL-ERROR" + STATUS_ANALYSIS = 1.0 # same priority for analysis/info STATUS_INFO = 1.1 STATUS_TEACHING = 2.0 STATUS_ERROR = 1000.0 +ADDITIONAL_MOVE_ORDER = 999 + +PRIORITY_GAME_ANALYSIS = -100 +PRIORITY_SWEEP = -10 # sweep is live, but slow, so deprioritize +PRIORITY_ALTERNATIVES = 100 # extra analysis, live interaction +PRIORITY_EQUALIZE = 100 +PRIORITY_EXTRA_ANALYSIS = 100 +PRIORITY_DEFAULT = 1000 # new move, high pri +PRIORITY_EXTRA_AI_QUERY = 10_000 + PLAYER_HUMAN, PLAYER_AI = "player:human", "player:ai" PLAYER_TYPES = [PLAYER_HUMAN, PLAYER_AI] diff --git a/katrain/core/contribute_engine.py b/katrain/core/contribute_engine.py index 028bce7..cb68510 100644 --- a/katrain/core/contribute_engine.py +++ b/katrain/core/contribute_engine.py @@ -10,7 +10,7 @@ import traceback from collections import defaultdict from katrain.core.constants import OUTPUT_DEBUG, OUTPUT_ERROR, OUTPUT_INFO, OUTPUT_KATAGO_STDERR, DATA_FOLDER -from katrain.core.engine import EngineDiedException, BaseEngine +from katrain.core.engine import BaseEngine from katrain.core.game import BaseGame from katrain.core.lang import i18n from katrain.core.sgf_parser import Move @@ -162,9 +162,9 @@ class KataGoContributeEngine(BaseEngine): self.stdout_thread.start() self.stderr_thread.start() - def check_alive(self, os_error="", exception_if_dead=False): + def check_alive(self, os_error="", maybe_open_help=False): ok = self.katago_process and self.katago_process.poll() is None - if not ok and exception_if_dead: + if not ok: if self.katago_process: code = self.katago_process and self.katago_process.poll() if code == 3221225781: @@ -175,10 +175,6 @@ class KataGoContributeEngine(BaseEngine): if code != 1 and not self.server_error: # deliberate exit, already showed message? self.katrain.log(died_msg, OUTPUT_ERROR) self.katago_process = None - else: - died_msg = i18n._("Engine died unexpectedly").format(error=os_error) - if not self.server_error: # dont raise if already know what happened - raise EngineDiedException(died_msg) return ok def shutdown(self, finish=False): @@ -210,8 +206,8 @@ class KataGoContributeEngine(BaseEngine): self.katrain.log(message, OUTPUT_KATAGO_STDERR) except Exception as e: print("ERROR in processing KataGo stderr:", line, "Exception", e) - elif self.katago_process: - self.check_alive(exception_if_dead=True) + elif self.katago_process and not self.check_alive(): + return except Exception as e: self.katrain.log(f"Exception in reading stdout {e}", OUTPUT_DEBUG) return @@ -273,8 +269,8 @@ class KataGoContributeEngine(BaseEngine): self.uploaded_games_count += 1 else: self.katrain.log(line, OUTPUT_KATAGO_STDERR) - elif self.katago_process: - self.check_alive(exception_if_dead=False) # stderr will do this + elif self.katago_process and not self.check_alive(): # stderr will do this + return except Exception as e: self.katrain.log(f"Exception in reading stdout {e}", OUTPUT_DEBUG) return diff --git a/katrain/core/engine.py b/katrain/core/engine.py index 99def28..ec7e3ab 100644 --- a/katrain/core/engine.py +++ b/katrain/core/engine.py @@ -5,7 +5,6 @@ import platform import queue import shlex import subprocess -import sys import threading import time import traceback @@ -13,17 +12,20 @@ from typing import Callable, Dict, List, Optional from kivy.utils import platform as kivy_platform -from katrain.core.constants import OUTPUT_DEBUG, OUTPUT_ERROR, OUTPUT_EXTRA_DEBUG, OUTPUT_KATAGO_STDERR, DATA_FOLDER +from katrain.core.constants import ( + OUTPUT_DEBUG, + OUTPUT_ERROR, + OUTPUT_EXTRA_DEBUG, + OUTPUT_KATAGO_STDERR, + DATA_FOLDER, + KATAGO_EXCEPTION, +) from katrain.core.game_node import GameNode from katrain.core.lang import i18n from katrain.core.sgf_parser import Move from katrain.core.utils import find_package_resource, json_truncate_arrays -class EngineDiedException(Exception): - pass - - class BaseEngine: # some common elements between analysis and contribute engine RULESETS_ABBR = [ @@ -46,7 +48,7 @@ class BaseEngine: # some common elements between analysis and contribute engine if ruleset.strip().startswith("{"): try: ruleset = json.loads(ruleset) - except: + except json.JSONDecodeError: pass if isinstance(ruleset, dict): return ruleset @@ -73,17 +75,20 @@ class BaseEngine: # some common elements between analysis and contribute engine exepath, exename = os.path.split(exe) if exepath and not os.path.isfile(exe): - self.katrain.log(i18n._("Kata exe not found").format(exe=exe), OUTPUT_ERROR) + self.on_error(i18n._("Kata exe not found").format(exe=exe), "KATAGO-EXE") return None elif not exepath: paths = os.getenv("PATH", ".").split(os.pathsep) + ["/opt/homebrew/bin/"] exe_with_paths = [os.path.join(path, exe) for path in paths if os.path.isfile(os.path.join(path, exe))] if not exe_with_paths: - self.katrain.log(i18n._("Kata exe not found in path").format(exe=exe), OUTPUT_ERROR) + self.on_error(i18n._("Kata exe not found in path").format(exe=exe), "KATAGO-EXE") return None exe = exe_with_paths[0] return exe + def on_error(self, message, code, allow_popup): + print("ERROR", message, code) + class KataGoEngine(BaseEngine): """Starts and communicates with the KataGO analysis engine""" @@ -91,6 +96,7 @@ class KataGoEngine(BaseEngine): def __init__(self, katrain, config): super().__init__(katrain, config) + self.allow_recovery = self.config.get("allow_recovery", True) # if false, don't give popups self.queries = {} # outstanding query id -> start time and callback self.query_counter = 0 self.katago_process = None @@ -102,29 +108,32 @@ class KataGoEngine(BaseEngine): self.shell = False self.write_queue = queue.Queue() self.thread_lock = threading.Lock() - exe = config.get("katago", "").strip() if config.get("altcommand", ""): self.command = config["altcommand"] self.shell = True else: model = find_package_resource(config["model"]) cfg = find_package_resource(config["config"]) - exe = self.get_engine_path(config["katago"]) + exe = self.get_engine_path(config.get("katago", "").strip()) if not exe: return if not os.path.isfile(model): - self.katrain.log(i18n._("Kata model not found").format(model=model), OUTPUT_ERROR) + self.on_error(i18n._("Kata model not found").format(model=model), code="KATAGO-FILES") return # don't start if not os.path.isfile(cfg): - self.katrain.log(i18n._("Kata config not found").format(config=cfg), OUTPUT_ERROR) + self.on_error(i18n._("Kata config not found").format(config=cfg), code="KATAGO-FILES") return # don't start self.command = shlex.split( f'"{exe}" analysis -model "{model}" -config "{cfg}" -analysis-threads {config["threads"]} -override-config "homeDataDir={os.path.expanduser(DATA_FOLDER)}"' ) self.start() - def start(self): + def on_error(self, message, code=None, allow_popup=True): + self.katrain.log(message, OUTPUT_ERROR) + if self.allow_recovery and allow_popup: + self.katrain("engine_recovery_popup", message, code) + def start(self): with self.thread_lock: self.write_queue = queue.Queue() try: @@ -142,10 +151,7 @@ class KataGoEngine(BaseEngine): shell=self.shell, ) except (FileNotFoundError, PermissionError, OSError) as e: - self.katrain.log( - i18n._("Starting Kata failed").format(command=self.command, error=e), - OUTPUT_ERROR, - ) + self.on_error(i18n._("Starting Kata failed").format(command=self.command, error=e), code="c") return # don't start self.analysis_thread = threading.Thread(target=self._analysis_read_thread, daemon=True) self.stderr_thread = threading.Thread(target=self._read_stderr_thread, daemon=True) @@ -180,7 +186,7 @@ class KataGoEngine(BaseEngine): self.shutdown(finish=False) self.start() - def check_alive(self, os_error="", exception_if_dead=False): + def check_alive(self, os_error="", exception_if_dead=False, maybe_open_recovery=False): ok = self.katago_process and self.katago_process.poll() is None if not ok and exception_if_dead: if self.katago_process: @@ -188,14 +194,12 @@ class KataGoEngine(BaseEngine): if code == 3221225781: died_msg = i18n._("Engine missing DLL") else: - os_error += f"status {code}" - died_msg = i18n._("Engine died unexpectedly").format(error=os_error) - if code != 1: # deliberate exit, already showed message? - self.katrain.log(died_msg, OUTPUT_ERROR) - self.katago_process = None + died_msg = i18n._("Engine died unexpectedly").format(error=f"{os_error} status {code}") + if code != 1: # deliberate exit + self.on_error(died_msg, code, allow_popup=maybe_open_recovery) + self.katago_process = None # return from threads else: - died_msg = i18n._("Engine died unexpectedly").format(error=os_error) - raise EngineDiedException(died_msg) + self.katrain.log(i18n._("Engine died unexpectedly").format(error=os_error), OUTPUT_DEBUG) return ok def wait_to_finish(self): @@ -227,14 +231,18 @@ class KataGoEngine(BaseEngine): try: line = self.katago_process.stderr.readline() if line: + if b"Uncaught exception" in line or b"what()" in line: # linux=what + msg = f"KataGo Engine Failed: {line.decode(errors='ignore')[9:].strip()}" + self.on_error(msg, KATAGO_EXCEPTION) + return try: self.katrain.log(line.decode(errors="ignore").strip(), OUTPUT_KATAGO_STDERR) except Exception as e: print("ERROR in processing KataGo stderr:", line, "Exception", e) - elif self.katago_process: - self.check_alive(exception_if_dead=True) + elif not self.check_alive(exception_if_dead=True): + return except Exception as e: - self.katrain.log(f"Exception in reading stdout {e}", OUTPUT_DEBUG) + self.katrain.log(f"Exception in reading stderr: {e}", OUTPUT_DEBUG) return def _analysis_read_thread(self): @@ -242,13 +250,15 @@ class KataGoEngine(BaseEngine): try: line = self.katago_process.stdout.readline().strip() if self.katago_process and not line: - self.check_alive(exception_if_dead=True) + if not self.check_alive(exception_if_dead=True, maybe_open_recovery=True): + return except OSError as e: - self.check_alive(os_error=str(e), exception_if_dead=True) + self.check_alive(os_error=str(e), exception_if_dead=True, maybe_open_recovery=True) return if b"Uncaught exception" in line: - self.katrain.log(f"KataGo Engine Failed: {line.decode(errors='ignore')}", OUTPUT_ERROR) + msg = f"KataGo Engine Failed: {line.decode(errors='ignore')}" + self.on_error(msg, KATAGO_EXCEPTION) return if not line: continue @@ -314,7 +324,8 @@ class KataGoEngine(BaseEngine): self.katago_process.stdin.write((json.dumps(query) + "\n").encode()) self.katago_process.stdin.flush() except OSError as e: - self.check_alive(os_error=str(e), exception_if_dead=False) + self.katrain.log(f"Exception in writing to katago: {e}", OUTPUT_DEBUG) + return # some other thread will take care of this def send_query(self, query, callback, error_callback, next_move=None, node=None): self.write_queue.put((query, callback, error_callback, next_move, node)) diff --git a/katrain/core/game.py b/katrain/core/game.py index 37dd3ed..91dfcb5 100644 --- a/katrain/core/game.py +++ b/katrain/core/game.py @@ -1,7 +1,6 @@ import copy import math import os -import random import re import threading from datetime import datetime @@ -21,6 +20,12 @@ from katrain.core.constants import ( STATUS_ERROR, STATUS_INFO, STATUS_TEACHING, + PRIORITY_GAME_ANALYSIS, + PRIORITY_EXTRA_ANALYSIS, + PRIORITY_SWEEP, + PRIORITY_ALTERNATIVES, + PRIORITY_EQUALIZE, + PRIORITY_DEFAULT, ) from katrain.core.engine import KataGoEngine from katrain.core.game_node import GameNode @@ -92,7 +97,7 @@ class BaseGame: if handicap: self.root.place_handicap_stones(handicap) - if not self.root.get_property("RU"): + if not self.root.get_property("RU"): # if rules missing in sgf, inherit current self.root.set_property("RU", katrain.config("game/rules")) self.set_current_node(self.root) @@ -439,11 +444,11 @@ class Game(BaseGame): self.region_of_interest = None threading.Thread( - target=lambda: self.analyze_all_nodes(-1_000_000, analyze_fast=analyze_fast, even_if_present=False), + target=lambda: self.analyze_all_nodes(analyze_fast=analyze_fast, even_if_present=False), daemon=True, ).start() # return faster, but bypass Kivy Clock - def analyze_all_nodes(self, priority=0, analyze_fast=False, even_if_present=True): + def analyze_all_nodes(self, priority=PRIORITY_GAME_ANALYSIS, analyze_fast=False, even_if_present=True): for node in self.root.nodes_in_tree: # forced, or not present, or something went wrong in loading if even_if_present or not node.analysis_from_sgf or not node.load_analysis(): @@ -502,14 +507,14 @@ class Game(BaseGame): ] try: while True: - if copy_from_node.move not in already_inserted_moves: - for m in copy_from_node.move_with_placements: + for m in copy_from_node.move_with_placements: + if m not in already_inserted_moves: self._validate_move_and_update_chains(m, True) - # this inserts - copy_to_node = GameNode( - parent=copy_to_node, properties=copy.deepcopy(copy_from_node.properties) - ) - num_copied += 1 + # this inserts + copy_to_node = GameNode( + parent=copy_to_node, properties=copy.deepcopy(copy_from_node.properties) + ) + num_copied += 1 if not copy_from_node.children: break copy_from_node = copy_from_node.ordered_children[0] @@ -571,18 +576,26 @@ class Game(BaseGame): self.katrain.controls.set_status(i18n._("extra analysis").format(visits=visits), STATUS_ANALYSIS) self.katrain.controls.set_status(i18n._("extra analysis").format(visits=visits), STATUS_ANALYSIS) cn.analyze( - engine, visits=visits, priority=-1_000, region_of_interest=self.region_of_interest, time_limit=False + engine, + visits=visits, + priority=PRIORITY_EXTRA_ANALYSIS, + region_of_interest=self.region_of_interest, + time_limit=False, ) return if mode == "game": nodes = self.root.nodes_in_tree + only_mistakes = kwargs.get("mistakes_only", False) + threshold = self.katrain.config("trainer/eval_thresholds")[-4] if "visits" in kwargs: visits = kwargs["visits"] else: min_visits = min(node.analysis_visits_requested for node in nodes) visits = min_visits + engine.config["max_visits"] for node in nodes: - node.analyze(engine, visits=visits, priority=-1_000_000, time_limit=False, report_every=None) + max_point_loss = max(c.points_lost or 0 for c in [node] + node.children) + if not only_mistakes or max_point_loss > threshold: + node.analyze(engine, visits=visits, priority=-1_000_000, time_limit=False, report_every=None) self.katrain.controls.set_status(i18n._("game re-analysis").format(visits=visits), STATUS_ANALYSIS) return @@ -613,19 +626,19 @@ class Game(BaseGame): ] visits = engine.config["fast_visits"] self.katrain.controls.set_status(i18n._("sweep analysis").format(visits=visits), STATUS_ANALYSIS) - priority = -1_000_000_000 + priority = PRIORITY_SWEEP elif mode in ["equalize", "alternative", "local"]: if not cn.analysis_complete and mode != "local": self.katrain.controls.set_status(i18n._("wait-before-extra-analysis"), STATUS_INFO, self.current_node) return if mode == "alternative": # also do a quick update on current candidates so it doesn't look too weird self.katrain.controls.set_status(i18n._("alternative analysis"), STATUS_ANALYSIS) - cn.analyze(engine, priority=-500, time_limit=False, find_alternatives="alternative") + cn.analyze(engine, priority=PRIORITY_ALTERNATIVES, time_limit=False, find_alternatives="alternative") visits = engine.config["fast_visits"] else: # equalize visits = max(d["visits"] for d in cn.analysis["moves"].values()) self.katrain.controls.set_status(i18n._("equalizing analysis").format(visits=visits), STATUS_ANALYSIS) - priority = -1_000 + priority = PRIORITY_EQUALIZE analyze_moves = [Move.from_gtp(gtp, player=cn.next_player) for gtp, _ in cn.analysis["moves"].items()] else: raise ValueError("Invalid analysis mode") @@ -653,7 +666,7 @@ class Game(BaseGame): self.engines[node.player].request_analysis( node, callback=lambda result, _partial: set_analysis(node, result), - priority=-1000, + priority=PRIORITY_DEFAULT, analyze_fast=True, extra_settings=engine_settings, **analysis_kwargs, diff --git a/katrain/core/game_node.py b/katrain/core/game_node.py index 90e6fda..79e7bfb 100644 --- a/katrain/core/game_node.py +++ b/katrain/core/game_node.py @@ -12,6 +12,8 @@ from katrain.core.constants import ( SGF_INTERNAL_COMMENTS_MARKER, SGF_SEPARATOR_MARKER, VERSION, + PRIORITY_DEFAULT, + ADDITIONAL_MOVE_ORDER, ) from katrain.core.lang import i18n from katrain.core.sgf_parser import Move, SGFNode @@ -39,6 +41,7 @@ class GameNode(SGFNode): def __init__(self, parent=None, properties=None, move=None): super().__init__(parent=parent, properties=properties, move=move) self.auto_undo = None # None = not analyzed. False: not undone (good move). True: undone (bad move) + self.played_sound = None self.ai_thoughts = "" self.note = "" self.move_number = 0 @@ -182,7 +185,7 @@ class GameNode(SGFNode): def analyze( self, engine, - priority=0, + priority=PRIORITY_DEFAULT, visits=None, time_limit=True, refine_move=None, @@ -211,13 +214,17 @@ class GameNode(SGFNode): if cur is None: self.analysis["moves"][move_gtp] = { "move": move_gtp, - "order": 999, + "order": ADDITIONAL_MOVE_ORDER, **move_analysis, } # some default values for keys missing in rootInfo else: - cur["order"] = min(cur["order"], move_analysis.get("order", 999)) # parent arriving after child + cur["order"] = min( + cur["order"], move_analysis.get("order", ADDITIONAL_MOVE_ORDER) + ) # parent arriving after child if cur["visits"] < move_analysis["visits"]: cur.update(move_analysis) + else: # prior etc only + cur.update({k: v for k, v in move_analysis.items() if k not in cur}) def set_analysis( self, @@ -238,7 +245,7 @@ class GameNode(SGFNode): del m["order"] elif refine_move is None: # normal update: old moves to end, new order matters. also for region? for move_dict in self.analysis["moves"].values(): - move_dict["order"] = 999 # old moves to end + move_dict["order"] = ADDITIONAL_MOVE_ORDER # old moves to end for move_analysis in analysis_json["moveInfos"]: self.update_move_analysis(move_analysis, move_analysis["move"]) self.analysis["ownership"] = analysis_json.get("ownership") @@ -344,7 +351,6 @@ class GameNode(SGFNode): if previous_top_move.get("pv") and (sgf or details): pv = self.make_pv(single_move.player, previous_top_move["pv"], interactive) text += i18n._("Info:PV").format(pv=pv) + "\n" - if sgf or details or teach: currmove_pol_rank, currmove_pol_prob, policy_ranking = self.move_policy_stats() if currmove_pol_rank is not None: @@ -417,10 +423,13 @@ class GameNode(SGFNode): root_score = self.analysis["root"]["scoreLead"] root_winrate = self.analysis["root"]["winrate"] move_dicts = list(self.analysis["moves"].values()) # prevent incoming analysis from causing crash + top_move = [d for d in move_dicts if d["order"] == 0] + top_score_lead = top_move[0]["scoreLead"] if top_move else root_score return sorted( [ { "pointsLost": self.player_sign(self.next_player) * (root_score - d["scoreLead"]), + "relativePointsLost": self.player_sign(self.next_player) * (top_score_lead - d["scoreLead"]), "winrateLost": self.player_sign(self.next_player) * (root_winrate - d["winrate"]), **d, } diff --git a/katrain/core/sgf_parser.py b/katrain/core/sgf_parser.py index 9f80e4d..03abdd3 100644 --- a/katrain/core/sgf_parser.py +++ b/katrain/core/sgf_parser.py @@ -448,7 +448,7 @@ class SGF: try: self.ix = self.contents.index("(") + 1 except ValueError: - raise ParseError("Parse error: Expected '('") + raise ParseError(f"Parse error: Expected '(' at start, found {self.contents[:50]}") self.root = self._NODE_CLASS() self._parse_branch(self.root) @@ -465,7 +465,7 @@ class SGF: self._parse_branch(self._NODE_CLASS(parent=current_move)) elif matched_item == ";": # ignore ;) for old SGF - useless = self.ix < len(self.contents) and self.contents[self.ix] == ")" + useless = self.ix < len(self.contents) and self.contents[self.ix :].strip() == ")" # ignore ; that generate empty nodes if not (current_move.empty or useless): current_move = self._NODE_CLASS(parent=current_move) diff --git a/katrain/gui.kv b/katrain/gui.kv index 51dbd9c..b111a48 100644 --- a/katrain/gui.kv +++ b/katrain/gui.kv @@ -25,11 +25,13 @@ #