weakness-report 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- weakness_report/__init__.py +3 -0
- weakness_report/aggregate.py +256 -0
- weakness_report/batch.py +303 -0
- weakness_report/board.py +78 -0
- weakness_report/bridge.py +203 -0
- weakness_report/buckets.py +288 -0
- weakness_report/cli.py +473 -0
- weakness_report/exportcsv.py +62 -0
- weakness_report/features.py +218 -0
- weakness_report/findings.py +218 -0
- weakness_report/jobs.py +153 -0
- weakness_report/paths.py +82 -0
- weakness_report/pdf.py +580 -0
- weakness_report/pipeline.py +160 -0
- weakness_report/report.py +142 -0
- weakness_report/server.py +380 -0
- weakness_report/sources.py +501 -0
- weakness_report/store.py +203 -0
- weakness_report/web/app.js +700 -0
- weakness_report/web/index.html +238 -0
- weakness_report/web/style.css +428 -0
- weakness_report-0.1.0.dist-info/METADATA +524 -0
- weakness_report-0.1.0.dist-info/RECORD +27 -0
- weakness_report-0.1.0.dist-info/WHEEL +5 -0
- weakness_report-0.1.0.dist-info/entry_points.txt +2 -0
- weakness_report-0.1.0.dist-info/licenses/LICENSE +21 -0
- weakness_report-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""The arithmetic: your moves, sliced, counted, and compared against yourself.
|
|
2
|
+
|
|
3
|
+
Every number here is defined to match ChessAnalyzer's, because the point of
|
|
4
|
+
this app is to aggregate that app's reviews rather than to have a second
|
|
5
|
+
opinion about them:
|
|
6
|
+
|
|
7
|
+
* **ACPL** is the mean centipawn loss over your moves, **excluding book moves**
|
|
8
|
+
and moves the engine could not score -- exactly as ``_side_summary`` does it.
|
|
9
|
+
Including opening theory would flatter anyone with preparation and tell them
|
|
10
|
+
nothing about their play.
|
|
11
|
+
* **Accuracy** is the mean of the arithmetic and harmonic means of your move
|
|
12
|
+
accuracies in the bucket, which is what ``phase_accuracy`` does. The
|
|
13
|
+
harmonic half is what stops one brilliant move from hiding three terrible
|
|
14
|
+
ones. The function itself is imported rather than copied.
|
|
15
|
+
|
|
16
|
+
The one number this app adds is **excess loss**, and it is the number the
|
|
17
|
+
whole report is built on:
|
|
18
|
+
|
|
19
|
+
excess = moves_in_bucket x (bucket ACPL - your overall ACPL)
|
|
20
|
+
|
|
21
|
+
which is "how many centipawns this kind of position has cost you *beyond what
|
|
22
|
+
you cost yourself anyway*". Divided by the number of games, it becomes the
|
|
23
|
+
sentence you actually want: *queenless middlegames cost you 0.4 pawns a game
|
|
24
|
+
more than your average*.
|
|
25
|
+
|
|
26
|
+
That formula is chosen over the two obvious alternatives on purpose. Ranking
|
|
27
|
+
by bucket ACPL alone crowns whichever bucket has eleven moves in it; ranking
|
|
28
|
+
by total centipawns lost crowns the middlegame every time, because that is
|
|
29
|
+
where most of your moves are. Excess loss is the one that answers "what should
|
|
30
|
+
I work on", because it multiplies how bad you are at something by how often it
|
|
31
|
+
happens to you.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from collections import defaultdict
|
|
37
|
+
|
|
38
|
+
from . import features as feat
|
|
39
|
+
from .bridge import analyzer
|
|
40
|
+
from .buckets import DIMENSIONS
|
|
41
|
+
|
|
42
|
+
#: A bucket smaller than this is not evidence. Both have to be met: forty
|
|
43
|
+
#: moves inside one game is one game's worth of noise.
|
|
44
|
+
MIN_MOVES = 40
|
|
45
|
+
MIN_GAMES = 5
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _harmonic_mean(values: list) -> float:
|
|
49
|
+
"""ChessAnalyzer's, when it can be found; the same formula when it cannot.
|
|
50
|
+
|
|
51
|
+
This is the only place in the app with a fallback, and it is four lines of
|
|
52
|
+
arithmetic with no judgement in it, so the two cannot drift in any way
|
|
53
|
+
that matters.
|
|
54
|
+
"""
|
|
55
|
+
accuracy_module = analyzer("accuracy")
|
|
56
|
+
if accuracy_module is not None:
|
|
57
|
+
return accuracy_module.harmonic_mean(values)
|
|
58
|
+
positive = [max(value, 0.01) for value in values]
|
|
59
|
+
if not positive:
|
|
60
|
+
return 0.0
|
|
61
|
+
return len(positive) / sum(1.0 / value for value in positive)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def bucket_accuracy(accuracies: list) -> float | None:
|
|
65
|
+
"""The accuracy of one bucket, by ChessAnalyzer's per-phase definition."""
|
|
66
|
+
if not accuracies:
|
|
67
|
+
return None
|
|
68
|
+
mean = sum(accuracies) / len(accuracies)
|
|
69
|
+
return round((mean + _harmonic_mean(accuracies)) / 2.0, 1)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def move_rows(games: list, reviews: dict) -> list:
|
|
73
|
+
"""Flatten reviews into one list of *your* moves, ready to slice.
|
|
74
|
+
|
|
75
|
+
``games`` are the records, ``reviews`` maps a game id to its review. A
|
|
76
|
+
game with no review is skipped silently; the caller counts those.
|
|
77
|
+
"""
|
|
78
|
+
rows = []
|
|
79
|
+
for game in games:
|
|
80
|
+
review = reviews.get(game.get("id"))
|
|
81
|
+
if not review:
|
|
82
|
+
continue
|
|
83
|
+
you = game.get("you")
|
|
84
|
+
if you not in ("white", "black"):
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
opening = review.get("opening") or {}
|
|
88
|
+
context = {
|
|
89
|
+
"id": game.get("id"),
|
|
90
|
+
"url": game.get("url", ""),
|
|
91
|
+
"you": you,
|
|
92
|
+
"them": game.get("them", ""),
|
|
93
|
+
"youElo": game.get("youElo", ""),
|
|
94
|
+
"themElo": game.get("themElo", ""),
|
|
95
|
+
"speed": game.get("speed", ""),
|
|
96
|
+
"result": game.get("result", "*"),
|
|
97
|
+
"date": game.get("date", ""),
|
|
98
|
+
"opening": opening,
|
|
99
|
+
}
|
|
100
|
+
me = you == "white"
|
|
101
|
+
|
|
102
|
+
for row in review.get("moves") or []:
|
|
103
|
+
if row.get("color") != you:
|
|
104
|
+
continue
|
|
105
|
+
rows.append({
|
|
106
|
+
"ply": row.get("ply"),
|
|
107
|
+
"moveNumber": row.get("moveNumber"),
|
|
108
|
+
"san": row.get("san", ""),
|
|
109
|
+
"phase": row.get("phase", ""),
|
|
110
|
+
"clock": row.get("clock"),
|
|
111
|
+
"cpLoss": row.get("cpLoss"),
|
|
112
|
+
"winLoss": row.get("winLoss"),
|
|
113
|
+
"accuracy": row.get("accuracy"),
|
|
114
|
+
"label": row.get("label", ""),
|
|
115
|
+
"judgment": row.get("judgment"),
|
|
116
|
+
"inBook": bool(row.get("inBook")),
|
|
117
|
+
"forced": bool(row.get("forced")),
|
|
118
|
+
"isBest": bool(row.get("isBest")),
|
|
119
|
+
"bestSan": row.get("bestSan"),
|
|
120
|
+
"fenBefore": row.get("fenBefore", ""),
|
|
121
|
+
"features": feat.describe(row.get("fenBefore", ""), me),
|
|
122
|
+
"game": context,
|
|
123
|
+
})
|
|
124
|
+
return rows
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _scored(move: dict) -> bool:
|
|
128
|
+
"""Does this move count towards ACPL? ChessAnalyzer's rule, unchanged."""
|
|
129
|
+
return not move["inBook"] and move.get("cpLoss") is not None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class Tally:
|
|
133
|
+
"""One bucket's running totals."""
|
|
134
|
+
|
|
135
|
+
__slots__ = ("moves", "scored", "cp", "accuracies", "games", "labels",
|
|
136
|
+
"judgments", "best", "forced")
|
|
137
|
+
|
|
138
|
+
def __init__(self):
|
|
139
|
+
self.moves = 0
|
|
140
|
+
self.scored = 0
|
|
141
|
+
self.cp = 0
|
|
142
|
+
self.accuracies = []
|
|
143
|
+
self.games = set()
|
|
144
|
+
self.labels = defaultdict(int)
|
|
145
|
+
self.judgments = defaultdict(int)
|
|
146
|
+
self.best = 0
|
|
147
|
+
self.forced = 0
|
|
148
|
+
|
|
149
|
+
def add(self, move: dict) -> None:
|
|
150
|
+
self.moves += 1
|
|
151
|
+
self.games.add((move.get("game") or {}).get("id"))
|
|
152
|
+
if move.get("label"):
|
|
153
|
+
self.labels[move["label"]] += 1
|
|
154
|
+
if move.get("judgment"):
|
|
155
|
+
self.judgments[move["judgment"]] += 1
|
|
156
|
+
if move.get("isBest"):
|
|
157
|
+
self.best += 1
|
|
158
|
+
if move.get("forced"):
|
|
159
|
+
self.forced += 1
|
|
160
|
+
if move.get("accuracy") is not None:
|
|
161
|
+
self.accuracies.append(move["accuracy"])
|
|
162
|
+
if _scored(move):
|
|
163
|
+
self.scored += 1
|
|
164
|
+
self.cp += move["cpLoss"]
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def acpl(self):
|
|
168
|
+
return self.cp / self.scored if self.scored else None
|
|
169
|
+
|
|
170
|
+
def to_json(self, name: str, *, baseline, total_games: int) -> dict:
|
|
171
|
+
acpl = self.acpl
|
|
172
|
+
excess = (self.scored * (acpl - baseline)
|
|
173
|
+
if acpl is not None and baseline is not None else None)
|
|
174
|
+
return {
|
|
175
|
+
"bucket": name,
|
|
176
|
+
"moves": self.moves,
|
|
177
|
+
"scored": self.scored,
|
|
178
|
+
"games": len(self.games),
|
|
179
|
+
"acpl": round(acpl, 1) if acpl is not None else None,
|
|
180
|
+
"accuracy": bucket_accuracy(self.accuracies),
|
|
181
|
+
"cpLost": self.cp,
|
|
182
|
+
"pawnsLost": round(self.cp / 100.0, 2),
|
|
183
|
+
"excessCp": round(excess, 1) if excess is not None else None,
|
|
184
|
+
"excessPawnsPerGame": (round(excess / total_games / 100.0, 3)
|
|
185
|
+
if excess is not None and total_games else None),
|
|
186
|
+
"pawnsPerGame": (round(self.cp / total_games / 100.0, 3)
|
|
187
|
+
if total_games else None),
|
|
188
|
+
"bestShare": (round(100.0 * self.best / self.moves, 1)
|
|
189
|
+
if self.moves else None),
|
|
190
|
+
"labels": dict(sorted(self.labels.items(), key=lambda kv: -kv[1])),
|
|
191
|
+
"judgments": dict(self.judgments),
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def overall(moves: list, total_games: int) -> dict:
|
|
196
|
+
"""Your baseline: the numbers every bucket is compared against."""
|
|
197
|
+
tally = Tally()
|
|
198
|
+
for move in moves:
|
|
199
|
+
tally.add(move)
|
|
200
|
+
data = tally.to_json("overall", baseline=None, total_games=total_games)
|
|
201
|
+
data["excessCp"] = 0.0
|
|
202
|
+
data["excessPawnsPerGame"] = 0.0
|
|
203
|
+
return data
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def slice_by(moves: list, dimension, *, baseline: float | None,
|
|
207
|
+
total_games: int) -> list:
|
|
208
|
+
"""Every bucket of one dimension, biggest share of your loss first."""
|
|
209
|
+
tallies: dict = {}
|
|
210
|
+
for move in moves:
|
|
211
|
+
for name in dimension.buckets_of(move):
|
|
212
|
+
tallies.setdefault(name, Tally()).add(move)
|
|
213
|
+
|
|
214
|
+
rows = [tally.to_json(name, baseline=baseline, total_games=total_games)
|
|
215
|
+
for name, tally in tallies.items()]
|
|
216
|
+
|
|
217
|
+
if dimension.order:
|
|
218
|
+
position = {name: index for index, name in enumerate(dimension.order)}
|
|
219
|
+
rows.sort(key=lambda row: (position.get(row["bucket"], 99),
|
|
220
|
+
-row["moves"]))
|
|
221
|
+
else:
|
|
222
|
+
rows.sort(key=lambda row: -row["moves"])
|
|
223
|
+
return rows
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def build(moves: list, *, total_games: int) -> dict:
|
|
227
|
+
"""Every dimension, sliced, with the baseline they are measured against."""
|
|
228
|
+
base = overall(moves, total_games)
|
|
229
|
+
baseline = base["acpl"]
|
|
230
|
+
|
|
231
|
+
slices = {}
|
|
232
|
+
for dimension in DIMENSIONS:
|
|
233
|
+
rows = slice_by(moves, dimension, baseline=baseline,
|
|
234
|
+
total_games=total_games)
|
|
235
|
+
if not rows:
|
|
236
|
+
continue
|
|
237
|
+
slices[dimension.key] = {
|
|
238
|
+
"key": dimension.key,
|
|
239
|
+
"label": dimension.label,
|
|
240
|
+
"note": dimension.note,
|
|
241
|
+
"buckets": rows,
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return {"overall": base, "baselineAcpl": baseline, "slices": slices}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
__all__ = [
|
|
248
|
+
"MIN_GAMES",
|
|
249
|
+
"MIN_MOVES",
|
|
250
|
+
"Tally",
|
|
251
|
+
"bucket_accuracy",
|
|
252
|
+
"build",
|
|
253
|
+
"move_rows",
|
|
254
|
+
"overall",
|
|
255
|
+
"slice_by",
|
|
256
|
+
]
|
weakness_report/batch.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Reviewing a few hundred games, once.
|
|
2
|
+
|
|
3
|
+
This is the expensive half of the app and everything here exists to make it
|
|
4
|
+
cost as little as possible and to make the result mean something.
|
|
5
|
+
|
|
6
|
+
**Reviews are kept per game, not per report.** Adding fifty games to a
|
|
7
|
+
history of four hundred costs fifty reviews. Re-running the same report costs
|
|
8
|
+
nothing at all.
|
|
9
|
+
|
|
10
|
+
**Batches are searched to a fixed depth, and with one thread.** This is the
|
|
11
|
+
one place where this app deliberately differs from ChessAnalyzer's defaults,
|
|
12
|
+
and the reason is worth stating:
|
|
13
|
+
|
|
14
|
+
* A **movetime** budget makes every number depend on how busy the machine was.
|
|
15
|
+
That is tolerable for one game you are reading move by move -- ChessAnalyzer
|
|
16
|
+
says as much about its own presets -- and it is not tolerable for a figure
|
|
17
|
+
averaged over four hundred games, because re-running the report next week
|
|
18
|
+
would move every number in it and you would have no way to tell a real
|
|
19
|
+
change from a noisy laptop.
|
|
20
|
+
* **Threads > 1** makes Stockfish non-deterministic even at a fixed depth: the
|
|
21
|
+
search is split across threads and the order they finish in changes the
|
|
22
|
+
result. One thread is slower and gives the same answer twice.
|
|
23
|
+
|
|
24
|
+
Together they make a report **reproducible**, which is what lets you run it
|
|
25
|
+
again in three months and believe the difference. Both are settings, so you
|
|
26
|
+
can trade either away for speed.
|
|
27
|
+
|
|
28
|
+
**Adopting other reviews.** ChessAnalyzer may already have reviewed some of
|
|
29
|
+
these games. Those are free -- but only comparable if they were searched the
|
|
30
|
+
same way, so by default a review is adopted when its engine settings match
|
|
31
|
+
this batch and re-done when they do not. ``adopt="any"`` takes them regardless
|
|
32
|
+
and the report says the mix out loud; ``adopt="none"`` ignores them.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import time
|
|
38
|
+
|
|
39
|
+
from .bridge import FeatureUnavailable, analyzer, require_analyzer
|
|
40
|
+
|
|
41
|
+
#: Fixed depths rather than movetimes. See the module docstring.
|
|
42
|
+
PRESETS = {
|
|
43
|
+
"sweep": {
|
|
44
|
+
"label": "Sweep",
|
|
45
|
+
"depth": 10,
|
|
46
|
+
"multipv": 2,
|
|
47
|
+
"detail": "Roughly a second a game per 10 moves. Finds blunders and "
|
|
48
|
+
"big patterns; too shallow to trust a 5-centipawn gap.",
|
|
49
|
+
},
|
|
50
|
+
"standard": {
|
|
51
|
+
"label": "Standard",
|
|
52
|
+
"depth": 14,
|
|
53
|
+
"multipv": 3,
|
|
54
|
+
"detail": "The sensible default for a few hundred games. Comparable "
|
|
55
|
+
"to ChessAnalyzer's Standard preset in strength.",
|
|
56
|
+
},
|
|
57
|
+
"deep": {
|
|
58
|
+
"label": "Deep",
|
|
59
|
+
"depth": 18,
|
|
60
|
+
"multipv": 3,
|
|
61
|
+
"detail": "Several times slower. Worth it for a history you intend to "
|
|
62
|
+
"keep and compare against later.",
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
DEFAULT_PRESET = "standard"
|
|
67
|
+
|
|
68
|
+
#: One thread by default, for reproducibility rather than speed.
|
|
69
|
+
DEFAULT_THREADS = 1
|
|
70
|
+
DEFAULT_HASH_MB = 256
|
|
71
|
+
|
|
72
|
+
ADOPT_MODES = ("matching", "any", "none")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class BatchError(RuntimeError):
|
|
76
|
+
"""A batch could not run, with a message worth showing."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def settings_for(preset: str = DEFAULT_PRESET, *, engine_id=None,
|
|
80
|
+
threads: int = DEFAULT_THREADS, hash_mb: int = DEFAULT_HASH_MB):
|
|
81
|
+
"""A ChessAnalyzer ``Settings`` pinned to a depth. Raises without the app."""
|
|
82
|
+
review = require_analyzer("review", "Reviewing games")
|
|
83
|
+
chosen = PRESETS.get(preset) or PRESETS[DEFAULT_PRESET]
|
|
84
|
+
return review.Settings(
|
|
85
|
+
engine_id=engine_id,
|
|
86
|
+
preset=preset if preset in PRESETS else DEFAULT_PRESET,
|
|
87
|
+
movetime=0.0, # unused: depth wins in EngineOptions
|
|
88
|
+
depth=chosen["depth"],
|
|
89
|
+
multipv=chosen["multipv"],
|
|
90
|
+
threads=max(1, int(threads)),
|
|
91
|
+
hash_mb=max(16, int(hash_mb)),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def signature(settings) -> str:
|
|
96
|
+
"""How a review made with these settings will identify itself."""
|
|
97
|
+
return settings.options().key()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def review_signature(review: dict) -> str:
|
|
101
|
+
"""The settings key a saved review was made with, or ``""``."""
|
|
102
|
+
key = ((review or {}).get("engine") or {}).get("settingsKey") or ""
|
|
103
|
+
# "Stockfish 18|t1_h256_pv3_d14" -- the half after the bar is the options.
|
|
104
|
+
return key.split("|", 1)[1] if "|" in key else key
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def acceptable(existing: dict | None, wanted: str, adopt: str) -> bool:
|
|
108
|
+
"""Can this saved review stand in for one made with the current settings?
|
|
109
|
+
|
|
110
|
+
Only if it was searched the same way. Getting this wrong is exactly the
|
|
111
|
+
quiet failure this app is built to avoid: a report mixing depth-10 and
|
|
112
|
+
depth-18 numbers looks no different from one that does not, and every
|
|
113
|
+
figure in it is wrong. So changing the preset re-reviews -- and because
|
|
114
|
+
the position cache is keyed by settings too, the second preset is the
|
|
115
|
+
only thing actually paid for.
|
|
116
|
+
|
|
117
|
+
``adopt="any"`` turns the check off for anyone who would rather have the
|
|
118
|
+
speed. The report then says out loud that its settings were mixed.
|
|
119
|
+
"""
|
|
120
|
+
if existing is None:
|
|
121
|
+
return False
|
|
122
|
+
if adopt == "any":
|
|
123
|
+
return True
|
|
124
|
+
return review_signature(existing) == wanted
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def outstanding(store, games: list, *, preset: str = DEFAULT_PRESET,
|
|
128
|
+
threads: int = DEFAULT_THREADS,
|
|
129
|
+
hash_mb: int = DEFAULT_HASH_MB, adopt: str = "matching") -> dict:
|
|
130
|
+
"""How many of these games actually need the engine at these settings."""
|
|
131
|
+
try:
|
|
132
|
+
wanted = signature(settings_for(preset, threads=threads, hash_mb=hash_mb))
|
|
133
|
+
except FeatureUnavailable:
|
|
134
|
+
wanted = ""
|
|
135
|
+
ready = sum(1 for game in games
|
|
136
|
+
if acceptable(store.load_review(game.get("id")), wanted, adopt))
|
|
137
|
+
return {"total": len(games), "ready": ready,
|
|
138
|
+
"outstanding": len(games) - ready, "signature": wanted}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _record_for(game: dict):
|
|
142
|
+
"""A ChessAnalyzer record for one of our games, keeping our own id."""
|
|
143
|
+
sources = require_analyzer("sources", "Reviewing games")
|
|
144
|
+
return sources.record_from_pgn(
|
|
145
|
+
game["pgn"], source=game.get("source", "pgn"),
|
|
146
|
+
game_id=game["id"], url=game.get("url", ""))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def eval_cache(store):
|
|
150
|
+
"""The position cache, ours rather than ChessAnalyzer's.
|
|
151
|
+
|
|
152
|
+
Sharing that app's cache file would be a write into another app's folder,
|
|
153
|
+
which this repository's apps do not do to each other. The cost is that the
|
|
154
|
+
first batch cannot reuse positions ChessAnalyzer has already seen; the
|
|
155
|
+
benefit is that neither app can corrupt the other's data.
|
|
156
|
+
"""
|
|
157
|
+
library = analyzer("library")
|
|
158
|
+
if library is None:
|
|
159
|
+
return None
|
|
160
|
+
path = store.cache_path()
|
|
161
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
return library.EvalCache(path)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def run(store, games: list, *, preset: str = DEFAULT_PRESET, engine_id=None,
|
|
166
|
+
threads: int = DEFAULT_THREADS, hash_mb: int = DEFAULT_HASH_MB,
|
|
167
|
+
adopt: str = "matching", progress=None, should_stop=None) -> dict:
|
|
168
|
+
"""Make sure every game has a review. Returns what it had to do.
|
|
169
|
+
|
|
170
|
+
``progress(done, total, message)`` is called per game, and ``should_stop``
|
|
171
|
+
is checked between games and inside each one, so a cancelled batch keeps
|
|
172
|
+
every review it has already finished.
|
|
173
|
+
"""
|
|
174
|
+
if adopt not in ADOPT_MODES:
|
|
175
|
+
raise BatchError(f"adopt must be one of: {', '.join(ADOPT_MODES)}")
|
|
176
|
+
|
|
177
|
+
review_module = require_analyzer("review", "Reviewing games")
|
|
178
|
+
engines = require_analyzer("engines", "Reviewing games")
|
|
179
|
+
|
|
180
|
+
settings = settings_for(preset, engine_id=engine_id, threads=threads,
|
|
181
|
+
hash_mb=hash_mb)
|
|
182
|
+
wanted = signature(settings)
|
|
183
|
+
cache = eval_cache(store)
|
|
184
|
+
|
|
185
|
+
counts = {"total": len(games), "already": 0, "adopted": 0, "reviewed": 0,
|
|
186
|
+
"failed": 0, "skipped": 0}
|
|
187
|
+
signatures: dict = {}
|
|
188
|
+
failures: list = []
|
|
189
|
+
started = time.time()
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
for index, game in enumerate(games):
|
|
193
|
+
if should_stop and should_stop():
|
|
194
|
+
counts["skipped"] = len(games) - index
|
|
195
|
+
break
|
|
196
|
+
|
|
197
|
+
game_id = game.get("id")
|
|
198
|
+
if progress:
|
|
199
|
+
progress(index, len(games),
|
|
200
|
+
f"{game.get('white', '?')} vs {game.get('black', '?')}")
|
|
201
|
+
|
|
202
|
+
existing = store.load_review(game_id)
|
|
203
|
+
if acceptable(existing, wanted, adopt):
|
|
204
|
+
counts["already"] += 1
|
|
205
|
+
found = review_signature(existing)
|
|
206
|
+
signatures[found] = signatures.get(found, 0) + 1
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
offered = game.get("existingReview")
|
|
210
|
+
if offered and adopt != "none" and acceptable(offered, wanted, adopt):
|
|
211
|
+
store.save_review(game_id, offered)
|
|
212
|
+
counts["adopted"] += 1
|
|
213
|
+
found = review_signature(offered)
|
|
214
|
+
signatures[found] = signatures.get(found, 0) + 1
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
result = review_module.review(
|
|
219
|
+
_record_for(game), settings, cache=cache,
|
|
220
|
+
should_stop=should_stop)
|
|
221
|
+
except review_module.ReviewCancelled:
|
|
222
|
+
counts["skipped"] = len(games) - index
|
|
223
|
+
break
|
|
224
|
+
except Exception as exc: # noqa: BLE001
|
|
225
|
+
# One unreviewable game must not lose the batch. Record it and
|
|
226
|
+
# carry on; the report counts what it actually has.
|
|
227
|
+
counts["failed"] += 1
|
|
228
|
+
failures.append({"id": game_id, "reason": str(exc)[:200]})
|
|
229
|
+
continue
|
|
230
|
+
|
|
231
|
+
store.save_review(game_id, result)
|
|
232
|
+
counts["reviewed"] += 1
|
|
233
|
+
signatures[wanted] = signatures.get(wanted, 0) + 1
|
|
234
|
+
|
|
235
|
+
if cache is not None:
|
|
236
|
+
cache.save()
|
|
237
|
+
finally:
|
|
238
|
+
# python-chess runs each engine on a non-daemon thread, and CPython
|
|
239
|
+
# joins those before atexit runs -- so a process that leaves one open
|
|
240
|
+
# prints its last line and then hangs for ever with no traceback.
|
|
241
|
+
# Every entry point must do this. See ChessAnalyzer's README.
|
|
242
|
+
try:
|
|
243
|
+
engines.close()
|
|
244
|
+
except Exception: # noqa: BLE001
|
|
245
|
+
pass
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
"counts": counts,
|
|
249
|
+
"settings": settings.json(),
|
|
250
|
+
"signature": wanted,
|
|
251
|
+
"signatures": signatures,
|
|
252
|
+
"uniform": len(signatures) <= 1,
|
|
253
|
+
"failures": failures[:20],
|
|
254
|
+
"elapsed": round(time.time() - started, 1),
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def load_reviews(store, games: list) -> dict:
|
|
259
|
+
"""``{game id: review}`` for every game that has one on disk."""
|
|
260
|
+
out = {}
|
|
261
|
+
for game in games:
|
|
262
|
+
review = store.load_review(game.get("id"))
|
|
263
|
+
if review is not None:
|
|
264
|
+
out[game["id"]] = review
|
|
265
|
+
return out
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def estimate(games: list, preset: str = DEFAULT_PRESET, *,
|
|
269
|
+
already: int = 0) -> dict:
|
|
270
|
+
"""A rough time for a batch, so the button can say what it will cost.
|
|
271
|
+
|
|
272
|
+
Deliberately crude and labelled as such: engine speed varies by an order
|
|
273
|
+
of magnitude across machines, and a number with a plus-or-minus on it is
|
|
274
|
+
more honest than a spinner with no number at all.
|
|
275
|
+
"""
|
|
276
|
+
chosen = PRESETS.get(preset) or PRESETS[DEFAULT_PRESET]
|
|
277
|
+
seconds_each = {10: 3.0, 14: 12.0, 18: 60.0}.get(chosen["depth"], 12.0)
|
|
278
|
+
outstanding = max(0, len(games) - already)
|
|
279
|
+
return {
|
|
280
|
+
"games": len(games),
|
|
281
|
+
"outstanding": outstanding,
|
|
282
|
+
"secondsPerGame": seconds_each,
|
|
283
|
+
"seconds": int(outstanding * seconds_each),
|
|
284
|
+
"rough": True,
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
__all__ = [
|
|
289
|
+
"ADOPT_MODES",
|
|
290
|
+
"acceptable",
|
|
291
|
+
"DEFAULT_PRESET",
|
|
292
|
+
"PRESETS",
|
|
293
|
+
"BatchError",
|
|
294
|
+
"FeatureUnavailable",
|
|
295
|
+
"estimate",
|
|
296
|
+
"eval_cache",
|
|
297
|
+
"load_reviews",
|
|
298
|
+
"outstanding",
|
|
299
|
+
"review_signature",
|
|
300
|
+
"run",
|
|
301
|
+
"settings_for",
|
|
302
|
+
"signature",
|
|
303
|
+
]
|
weakness_report/board.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Board diagrams for the browser, drawn server-side as SVG.
|
|
2
|
+
|
|
3
|
+
A worst moment is only understandable as a picture, and this app shows a dozen
|
|
4
|
+
of them. python-chess draws the board itself, so this needs nothing beyond
|
|
5
|
+
the core dependency.
|
|
6
|
+
|
|
7
|
+
Unlike the sibling apps there is nothing to play here: a weakness report is a
|
|
8
|
+
document about games already finished, so the board is a diagram and stays
|
|
9
|
+
one. The palette matches the rest of the repository so a position looks the
|
|
10
|
+
same on screen as it does in the printed report.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import chess
|
|
16
|
+
import chess.svg
|
|
17
|
+
|
|
18
|
+
BOARD_COLORS = {
|
|
19
|
+
"square light": "#f0d9b5",
|
|
20
|
+
"square dark": "#b58863",
|
|
21
|
+
"square light lastmove": "#cdd26a",
|
|
22
|
+
"square dark lastmove": "#aaa23a",
|
|
23
|
+
"margin": "#f7f2e8",
|
|
24
|
+
"coord": "#5c4a33",
|
|
25
|
+
"arrow green": "#15781baa",
|
|
26
|
+
"arrow red": "#882020aa",
|
|
27
|
+
"arrow yellow": "#e68f00aa",
|
|
28
|
+
"arrow blue": "#003088aa",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _arrow(uci: str, colour: str):
|
|
33
|
+
if not uci or len(uci) < 4:
|
|
34
|
+
return None
|
|
35
|
+
try:
|
|
36
|
+
move = chess.Move.from_uci(uci[:5])
|
|
37
|
+
except ValueError:
|
|
38
|
+
return None
|
|
39
|
+
return chess.svg.Arrow(move.from_square, move.to_square, color=colour)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def board_svg(fen: str, *, size: int = 320, flipped: bool = False,
|
|
43
|
+
arrows: str = "", coordinates: bool = True) -> str:
|
|
44
|
+
"""One position.
|
|
45
|
+
|
|
46
|
+
``arrows`` is the compact query form ``red:e2e4,green:g1f3``. Throughout
|
|
47
|
+
this app **red is the move you played** and **green is the move the engine
|
|
48
|
+
wanted**, which is the only pair of colours a reader needs to learn.
|
|
49
|
+
"""
|
|
50
|
+
board = chess.Board(fen)
|
|
51
|
+
|
|
52
|
+
shapes = []
|
|
53
|
+
for item in filter(None, (arrows or "").split(",")):
|
|
54
|
+
colour, _, uci = item.partition(":")
|
|
55
|
+
arrow = _arrow(uci, colour or "green")
|
|
56
|
+
if arrow is not None:
|
|
57
|
+
shapes.append(arrow)
|
|
58
|
+
|
|
59
|
+
return chess.svg.board(
|
|
60
|
+
board, size=size,
|
|
61
|
+
orientation=chess.BLACK if flipped else chess.WHITE,
|
|
62
|
+
arrows=shapes, coordinates=coordinates,
|
|
63
|
+
check=board.king(board.turn) if board.is_check() else None,
|
|
64
|
+
colors=BOARD_COLORS)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def uci_of(fen: str, san: str) -> str:
|
|
68
|
+
"""The UCI for a SAN move in a position, or ``""``. Used to draw arrows."""
|
|
69
|
+
if not san:
|
|
70
|
+
return ""
|
|
71
|
+
try:
|
|
72
|
+
board = chess.Board(fen)
|
|
73
|
+
return board.parse_san(san).uci()
|
|
74
|
+
except (ValueError, AssertionError):
|
|
75
|
+
return ""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
__all__ = ["BOARD_COLORS", "board_svg", "uci_of"]
|