Girl Chess: architecture

A personal chess tutor I built for myself. I play an engine opponent called mallow; cookie, the coach, answers my questions during and after the game. One part of the system is not deterministic; everything else is arithmetic over stored facts.
The model is never asked to compute chess. It puts facts the code already computed into words, and checks compare what it says against those facts.
Each wrong answer I traced came from a fact the coach was not handed or a check that did not run. None was fixed by a bigger model.
False piece placements
0%
was 7.5%, same models, July 2026 eval
Tests
2,903
194 files; 45,419 test lines against 48,737 app¹
Parts that are not deterministic
1
of the whole system
Cost to run
$0
on an existing subscription

¹ Line counts sum every tracked .ts and .tsx file at the published commit, split into test and app lines.

01

The determinism boundary

One part of the entire system is not deterministic. Everything else is arithmetic over stored facts.
chess.js decides legality.
Stockfish decides truth: how much better a position is, whether a forced mate exists, and the best line.
Maia decides what a human of a given rating would actually play.
The annotator decides what mattered: verdict tiers, turning points, conversion failures, game phase.
Claude only puts it into words.

From the spec: code computes every chess fact, award and schedule; the model only phrases them.

Four texts reach me. Code writes two of them from templates filled with stored facts: the hint ladder under the board and the debrief after the game. The coach writes the other two: its chat replies and the one-line note under the hint ladder. Those are the only sentences a model composes.

DETERMINISTIC: arithmetic over stored factsP2 · StockfishSearches ahead and scores each position.Scores every move I consider in 350 ms, and powers each hint’s deeper search.P3 · Maia on lc0Plays as mallow: weights trained on human gamespick what a player at my chosen rating would play,sampled so games vary.P4 · the annotatorGrades each move by its gap to the best move,marks win-chance swings as turning points,and reads the game phase from the pieces left.Runs each hint’s verified search. No model calls.P1 · the boardDraws the board, takes your clicks, and animates and replays moves.chess.js, the rules library:legal moves, check and mateRuns in the browser and on the server.PS · the storeSQLite keeps every game, move, engine score, turning point and coach reply.scoresmallow’s movehints, verifiedverdicts, turning pointsNOT DETERMINISTICP5 · cookie, the coachWrites its chat replies and thenote under the hint ladder,from facts it is handed.The only part that is not deterministic.Recommended setting: Claude, throughthe Anthropic Agent SDK.Its words are checked before yousee them; which checks run dependson where it speaks (see §08).A failed reply is retried once,then replaced by a canned reply.a list of factssentences back, never facts
LESSON

If the debrief says something false, the bug is in my code, not in a model: the annotator never calls one, so it cannot make up a fact. Where the coach writes, its words are checked before I see them (§08).

02

Two engines: Stockfish for truth, Maia for human play

Stockfish scores positions. Maia plays mallow the way a person at my chosen rating would. They run side by side, neither reads the other, and each sits behind its own interface, so either can be swapped.

From the spec: opponents that play human-like at every level up to Maia's 1900 cap.

Stockfish

Stockfish searches ahead through the tree of possible moves and scores each position with a small neural network trained on millions of positions. It plays the strongest chess it can find and has no notion of what a person would do. Here it is the source of truth for the judge, the hints and the debrief.

Maia

lc0 plays the opponent using Maia weights, a trained weights file rather than a separate engine, across nine bands from 1100 to 1900. lc0 learned chess by playing itself. Maia uses the same kind of network, trained on human games from Lichess instead, one model per rating band, so it predicts the move a player at that rating would make, mistakes included. It runs with search off, one node per move.

Two settings that cost debugging

At lc0's default temperature of 0, Maia always plays its single most likely move, which plays stronger than its band; at 1.0 it samples moves the way players at that rating do. And every query starts with a new-game reset, because a warmed engine could otherwise return the same move call after call.

Search budgets

How long each search runs (milliseconds of engine time)

SearchTimeWhy
the judge, on every move I consider350 ms, run twice: before and after the moveholds the verdict under two seconds
a hint, when I ask for one1,500 ms search, then a 500 ms check that the move holds up; 3,000 ms retry if the check failsa hint must never be a bad move; hints from the judge's quick score nearly lost me a playtest game
03

What the coach model reads and writes: Claude, through the Anthropic Agent SDK

This is everything the coach model gets: a typed fact list, not a game history. Two assembly paths build it, and both skip the live evaluator, so the coach never waits in the judge's Stockfish queue.

the move I picked upnot yet confirmedscored before I confirmreplayed on the server,dropped if illegalStockfishscores each positionClaudethrough the Anthropic Agent SDKthe annotatorverdicts, turning points, hintssentenceschat replies and the noteunder the hint ladderour code turns scoresinto feedbackgap to the best move, in centipawns(hundredths of a pawn)the checksup to ninethe fact listtyped fieldsyouon screenunder 35no real gap35-149slightly better150-299clearly better300+ or a matedecisively betterfails: one retry,then a canned reply
the move I picked up, not yet confirmedStockfish scores it before I confirm. The server replays it and drops it if illegal before it enters the fact list.
  1. 1
    Stockfishscores each position
  2. 2
    the annotatorverdicts, turning points, hints
  3. 3
    our code turns scores into feedbackgap to the best move, in centipawns (hundredths of a pawn)
    under 35no real gap
    35-149slightly better
    150-299clearly better
    300+ or a matedecisively better
  4. 4
    the fact listtyped fields
  5. 5
    Claudethrough the Anthropic Agent SDK
  6. 6
    sentenceschat replies and the note under the hint ladder
  7. 7
    the checksup to nine
    fails: one retry, then a canned reply
  8. 8
    youon screen

Position

  • currentFen the live board
  • toMove whose turn it is
  • occupancy every piece, every square
  • legalSans every legal move
  • contested attackers and defenders

Move

  • gameSans every move played
  • allowedSans what may be named
  • side whose move it was, written on the row when the move is made

Engine

  • perPlyAnalysis eval, best, line, phase
  • hintFindings what the hint search already found and verified for this position
  • read the eval, as words
  • gap played versus best, in words
  • contestedAfterBest which squares are attacked and defended once the best move is played
  • candidateLine a move I name in chat but have not played gets its own checked engine line, instead of a guess

UI state -- what I am looking at

  • pendingMove the piece I picked up
  • hintFocus the open hint, verbatim
  • turningPointFocus the card I clicked
  • highlightedPlies moves I flagged
  • recentHints the deep hints I was shown this game, oldest first, at most eight

Turning points

  • turningPoints ply, move, label
  • focusPosition the board as it was then

History

  • status live or finished
  • outcome result, winner, how
  • standingNotes the newest 10 notes I asked the coach to record

Fields added after launch, by the day each reached the code:

  • hintFindings Aug 2, 2026
  • recentHints Aug 29, 2026
  • side Sep 1, 2026
  • candidateLine Sep 8, 2026
  • contestedAfterBest Sep 21, 2026

Aug 2, 2026: gap words. One phrase, “a bit better”, covered every position edge from 60 to 299 centipawns, so the coach said a better move existed when the two were virtually the same.

IT WRITES

Chat replies and the note under the hint ladder.

IT NEVER WRITES

A fact, or anything back into the store.

Engine scores reach the model as feedback words, measured against the best move, never as numbers, because a coach that says "plus 144" to a beginner is useless.

04

One coach interface, four backends

The coach talks to one interface, and four backends sit behind it: Claude through the Anthropic Agent SDK by default, the Claude command-line tool, a local model through Ollama, and canned replies.

From the spec: the game never depends on a subscription to run; a local model sits behind the same interface.

I built the interface before there was a speed problem. By Jul 21, 2026, 13 of 14 live nudges had missed their 15-second budget on the Claude command-line tool, because starting a process ate most of each call. The fix, the same day, was another backend behind the same interface, Claude through the Anthropic Agent SDK, which keeps one process warm; nothing downstream changed. Every addition since has been an optional parameter, so the other backends needed no edits. The Agent SDK backend is the only one I have optimized: streaming, prompt caching and the thinking setting exist only there.

Reply time before and after the Agent SDK backend (seconds, one reply)

ReplyBeforeAfter
the note under the hint ladder9 s3.6 s
a chat replyabout 9 sabout 7 s
05

Two coach surfaces, one chess brain

The worst bug here was plumbing, not the model. Two surfaces search the same position: the judge, on every move I consider, and the hint, only when I ask (their search times are in §02).

From the spec: a chat answer is grounded in engine facts, and thumbs-down captures why it missed.

TimeGame 167, move 19, 2026-08-02 -- every row from the database
11:18The hint engine found a forced mate, verified it, and showed it to me on screen.
11:21I asked the coach about it. It timed out and served a canned template.
11:22I asked again. It replied: "no mate line for this position in the facts i've got."

"The hint said that there's a forced mate that's available if I move my knight to G5. When I tried to ask the coach about it, the coach couldn't figure it out and gave a timed-out answer. Now, even after feeding it to the coach and telling it to check the chess facts from stockfish, the coach is claiming that the chess brain has not found a forced mate, but I know that it has."

my playtest note, 2026-08-02

The cause? The hint computed the mate, used the score to pick which piece to point at, then threw it away at the return. The mate distance was never carried. The coach's facts had ten categories and zero mentions of mate. The mate checker's set of true mate distances was empty, so nothing even blocked a mate claim. The model correctly refused to invent one.

LESSON

The coach and the hint answer from one search. The hint's verified findings are kept per position and reach the coach only on an exact position match. For a position the hint has not searched, or a move I name in chat but have not played, the coach runs that same verified search first.

More detail on how the hint ladder and the coach work together: The hint ladder and the coach.

06

Routing: this board, or chess in general

Before any model call, code decides: this position, or chess in general?

From the spec: when I do not understand why a move was wrong, I ask in plain English and get an answer at my level.

It began as a thumbs-down: "will only answer about moves i already did, not general chess questions for strategy next game." The refusal came from a line in the prompt, not a check, so the decision moved into code, before the call. Letting the model route itself was the failure I had already hit.

Board is the default because 38 of the 65 board questions I asked carry no signal a router can detect: "is this ok", "should i take it", a bare "yes". Sent to general, each would be answered without the position.

When a hint is showing, an "ask about this" button opens the chat pinned to that hint. That pin is what the first question in the flowchart checks, and a yes goes straight to board.

Both routes think at the same low setting; they differ in what the coach is handed and what it may draw on.

a question in chatAm I pointing at something: an open hint,a turning-point card, or a move I picked up?noDoes it use a general-chess phrase?noDoes it ask about theory, with nothingpointing at this board?noNone of these: board, the defaultyesyesyesboard: every fact about this position;the answer stays inside those facts“Why is King to H1 a good idea?” (Aug 28)general: four facts and no position;the answer comes from chess knowledge,tied to this game only when the connection is true“What are good opening strategies?” (Sep 4)
a question in chat
  1. Am I pointing at something: an open hint, a turning-point card, or a move I picked up?yesboard
  2. no
    Does it use a general-chess phrase?yesgeneral
  3. no
    Does it ask about theory, with nothing pointing at this board?yesgeneral
  4. no
    None of these: board, the default

board: every fact about this position; the answer stays inside those facts

“Why is King to H1 a good idea?” (Aug 28)

general: four facts and no position; the answer comes from chess knowledge, tied to this game only when the connection is true

“What are good opening strategies?” (Sep 4)

The four tiers are read from the router’s code.

TESTED ON MY REAL MESSAGES

On Aug 3, 2026, before merging, a read-only tool replayed the old and new routing over 102 of my real messages. Zero board questions flipped to general.

07

Supplementing the coach with facts and checks

An iterative process. Each time the coach or the game review got something wrong, I handed the coach a fact the code already held, or added a check on what it said. The timeline starts in July, about when I launched the game.

Jul 17: a fact handed to the coachJul 18: a check addedJul 21: a fact handed to the coachJul 22: a fact handed to the coachsame day: a check addedJul 28: a fact handed to the coachsame day: a check addedJul 29: a check addedAug 2: a fact handed to the coachAug 3: a fact handed to the coachsame day: a check addedAug 29: a fact handed to the coachSep 8: a fact handed to the coachSep 21: a fact handed to the coachsame day: a check addedsame day: a check addedSep 22: a check addedJul 17hints get their own deeper engine search, apartfrom the quick check on every moveJul 18a check that the coach’s note under the hintladder names only the squares and moves itwas givenJul 21whose turn it isJul 22six facts: which pieces defend which, where everypiece stands, the move I am holding, the exactposition, the engine’s reading of each move, andhow good the position is, in wordschecks on where pieces stand, which pieces areprotected, whose piece it is, and whether itsounds like a coachJul 28each move marked as mine or mallow’s, what theengine expects to happen next, and the moves Ihighlighted to study latera check on how many moves it takes to mateJul 2919 rules check the game review against its owndata before I see itAug 2what the hint already found for this position,whether taking back a piece is safe, and how muchworse my move was than the best, in wordsAug 3whether the hint’s move was fully checked oronly a quick first reada correction when it calls mallow’s move a mistakebut the engine rated that move best or solidAug 29the hints I was shown this game, so it no longerguesses which ones I sawSep 8a move I name in chat but have not played getsits own checked engine lineSep 21which squares are attacked and defended oncethe best move is playeda check on what can capture or attack whatthe check on where pieces stand now reads everyboard I was shown, not only the current oneSep 22the sounds-like-a-coach check also catchesself-corrections and leaked internal labels
  1. Jul 17hints get their own deeper engine search, apart from the quick check on every move
  2. Jul 18a check that the coach’s note under the hint ladder names only the squares and moves it was given
  3. Jul 21whose turn it is
  4. Jul 22six facts: which pieces defend which, where every piece stands, the move I am holding, the exact position, the engine’s reading of each move, and how good the position is, in words
  5. checks on where pieces stand, which pieces are protected, whose piece it is, and whether it sounds like a coach
  6. Jul 28each move marked as mine or mallow’s, what the engine expects to happen next, and the moves I highlighted to study later
  7. a check on how many moves it takes to mate
  8. Jul 2919 rules check the game review against its own data before I see it
  9. Aug 2what the hint already found for this position, whether taking back a piece is safe, and how much worse my move was than the best, in words
  10. Aug 3whether the hint’s move was fully checked or only a quick first read
  11. a correction when it calls mallow’s move a mistake but the engine rated that move best or solid
  12. Aug 29the hints I was shown this game, so it no longer guesses which ones I saw
  13. Sep 8a move I name in chat but have not played gets its own checked engine line
  14. Sep 21which squares are attacked and defended once the best move is played
  15. a check on what can capture or attack what
  16. the check on where pieces stand now reads every board I was shown, not only the current one
  17. Sep 22the sounds-like-a-coach check also catches self-corrections and leaked internal labels
a fact handed to the coach
a check added
DateWhat it saidMissing fact or check
Jul 21Described my own pending move as mallow's, and told me I'd win my own queen for free.Whose turn it is
Jul 22Said my pawn on e4 did not guard f5. It does. That exact trade happened a few moves later.The attacked-and-defended map
Jul 227.5% of its statements about where pieces sat were false. Nothing checked them.Occupancy, and a checker
Sep 21Threw out 14 true answers in one game, because the check compared where a piece would stand after the move to the board from before it.The check reads every board the coach was shown, not only the current one
Sep 21Described a rook attacking a square it had no line to. Nothing checked attacks or captures.A check on what can capture or attack what
LESSON

None of the July additions needed a bigger model. They needed facts the code already had. With them, placement errors fell from 7.5% to zero and explanation latency from 13-15 seconds to about 4, on the small model and the large one alike. In September the gap was in the checks: one compared answers to the wrong board, and one kind of claim had no check. Both are fixed: after a move, true answers wrongly rejected went from 65 of 144 to 0.

08

Regression checks: what the coach can't say

Nine checks, and which run depends on where the coach writes: board chat, general strategy chat, or the note under the hint ladder.

From the spec's ship gates: zero invented moves; a failed fact-check pauses the coach, never the game.

Which checks run where

boardchatgeneralstrategychatthe coach’snote under thehint ladderwhere pieces standplacement checkWhere it says a piece stands must match the board.where pieces stand, board chat: runs; a failure blocks the replywhere pieces stand, general strategy chat: runs when the reply is about the boardwhere pieces stand, the coach’s note: covered by the square list: the note can only name squares from its factswhich pieces are protecteddefence checkA piece it calls guarded or hanging must be guarded or hanging.which pieces are protected, board chat: runs; a failure blocks the replywhich pieces are protected, general strategy chat: runs; a failure blocks the replywhich pieces are protected, the coach’s note: runs; a failure blocks the replywhose piece it isside checkA move it credits to me or to mallow must be that side’s move.whose piece it is, board chat: runs; a failure blocks the replywhose piece it is, general strategy chat: runs; a failure blocks the replywhose piece it is, the coach’s note: does not runwhat can capture or attack whatrelation checkA capture or attack it describes must be possible on the board.what can capture or attack what, board chat: runs; a failure blocks the replywhat can capture or attack what, general strategy chat: runs; a failure blocks the replywhat can capture or attack what, the coach’s note: does not runhow many moves to matemate-distance checkA “mate in 3” must match the engine’s count.how many moves to mate, board chat: runs; a failure blocks the replyhow many moves to mate, general strategy chat: does not runhow many moves to mate, the coach’s note: does not runsounds like a coachvoice checkNo chess notation, banned words, raw engine numbers, self-corrections or internal labels.sounds like a coach, board chat: runs; a failure blocks the replysounds like a coach, general strategy chat: runs; a failure blocks the replysounds like a coach, the coach’s note: does not runfair about mallow’s move qualityopponent-quality correctionFixes an unfair word about mallow’s move; never blocks a reply.fair about mallow’s move quality, board chat: corrects the wording in place, never blocksfair about mallow’s move quality, general strategy chat: corrects the wording in place, never blocksfair about mallow’s move quality, the coach’s note: does not runonly names moves it was givenallowed movesEvery move it names must be played, legal, or in its facts.only names moves it was given, board chat: runs; a failure blocks the replyonly names moves it was given, general strategy chat: does not runonly names moves it was given, the coach’s note: runs; a failure blocks the replyonly names squares it was givenallowed squaresEvery square it names must come from its facts.only names squares it was given, board chat: does not runonly names squares it was given, general strategy chat: does not runonly names squares it was given, the coach’s note: runs; a failure blocks the reply
board chatgeneral strategy chatthe coach’s note under the hint ladder
where pieces standplacement checkWhere it says a piece stands must match the board.board chat: runs; a failure blocks the replygeneral strategy chat: runs when the reply is about the boardthe coach’s note: covered by the square list: the note can only name squares from its facts
which pieces are protecteddefence checkA piece it calls guarded or hanging must be guarded or hanging.board chat: runs; a failure blocks the replygeneral strategy chat: runs; a failure blocks the replythe coach’s note: runs; a failure blocks the reply
whose piece it isside checkA move it credits to me or to mallow must be that side’s move.board chat: runs; a failure blocks the replygeneral strategy chat: runs; a failure blocks the replythe coach’s note: does not run
what can capture or attack whatrelation checkA capture or attack it describes must be possible on the board.board chat: runs; a failure blocks the replygeneral strategy chat: runs; a failure blocks the replythe coach’s note: does not run
how many moves to matemate-distance checkA “mate in 3” must match the engine’s count.board chat: runs; a failure blocks the replygeneral strategy chat: does not runthe coach’s note: does not run
sounds like a coachvoice checkNo chess notation, banned words, raw engine numbers, self-corrections or internal labels.board chat: runs; a failure blocks the replygeneral strategy chat: runs; a failure blocks the replythe coach’s note: does not run
fair about mallow’s move qualityopponent-quality correctionFixes an unfair word about mallow’s move; never blocks a reply.board chat: corrects the wording in place, never blocksgeneral strategy chat: corrects the wording in place, never blocksthe coach’s note: does not run
only names moves it was givenallowed movesEvery move it names must be played, legal, or in its facts.board chat: runs; a failure blocks the replygeneral strategy chat: does not runthe coach’s note: runs; a failure blocks the reply
only names squares it was givenallowed squaresEvery square it names must come from its facts.board chat: does not rungeneral strategy chat: does not runthe coach’s note: runs; a failure blocks the reply
runs; a failure blocks the reply
runs when the reply is about the board
covered by the square list: the note can only name squares from its facts
corrects the wording in place, never blocks
does not run

A failed check gets one corrective retry. A second failure serves a canned reply, labelled with the reason, that asks me to try again or put it another way.

The note under the hint ladder is about one thing: the move I have picked up and am deciding whether to confirm. So its square list is strict: every square it names must come from its facts, the threat and the recommended move, and it cannot drift to pieces outside that move. Chat is meant to range wider, into what happens next and what else to consider, so it runs the placement check instead of a closed list.

What the coach says when it cannot answer

  • a reply failed its check twice

    i couldn't get that one clean. ask me again and i'll come at it from a different angle.

    garbled
  • the reply ran out of time

    that one took me longer than i had. ask me again and i'll get you an answer.

    slow
  • the coach is set up but cannot be reached

    i can't reach my thinking right now. try me again in a moment.

    offline
  • Claude Code is missing or signed out

    cookie needs you signed in to Claude Code. you can still play, get hints, and read every debrief. run claude in Terminal, sign in, then restart with npm run dev.

    not set up
  • a message with no chess in it at all

    keep it on the board. ask me about a move from this game and i'll break it down.

What a check costs, and what a retry costs

the defence check, on the current boardthe defence check, on the current board: 0.013 ms0.013 msthe defence check, on the board after a movethe defence check, on the board after a move: 0.070 ms0.070 msone retry: a second full model callone retry: a second full model call: about 20 sabout 20 s9.66% of 290 model-written replies needed a retry9.66% of 290 model-written replies needed a retry

Check times from a July benchmark. Retry share from the 290 model-written replies stored from Jul 18 to Sep 22.

Limit: the check times were measured once, in July.

LESSON

So adding a check is free. The cost is the retry, which is why the correction for mallow's move quality fixes the wording in place instead of asking for one. A check that rejects true answers costs the same way: in one September game, 14 of the placement check's 16 rejections threw out true answers (§09).

09

Proving a fix on past games

I do not wait for new games to find out whether a fix worked. A fix runs against every position already stored, before and after the change, and then I break it on purpose to prove the harness can tell the difference.

From the spec: a red flag is something to fix, not a feature to cut, and diagnosis starts from my thumbs-down notes and the traces.

In game 198 the check on where pieces stand rejected 16 coach answers. 14 were true statements about the position after the move being explained; the check had compared them to the board before it. The easy fix was to delete the check. I kept it and taught it which boards the coach was talking about, and it still catches the 2 answers that were wrong.

The testing harness: checking a fix on past games

1Positions from past gamespositions from 201 of my coach conversations,and 34 equally fast mates from my games2Statements labelled from the board4,772 true and 2,243 false statements about thosepositions, each labelled by reading the board3The check, before and after the fixthe same statements run through both versions: truestatements wrongly rejected after a move went from 65 to 04The bug put back on purposereintroduce the bug: the harness must catch it,and taking it out again must pass
  1. 1Positions from past gamespositions from 201 of my coach conversations, and 34 equally fast mates from my games
  2. 2Statements labelled from the board4,772 true and 2,243 false statements about those positions, each labelled by reading the board
  3. 3The check, before and after the fixthe same statements run through both versions: true statements wrongly rejected after a move went from 65 to 0
  4. 4The bug put back on purposereintroduce the bug: the harness must catch it, and taking it out again must pass

A fix is checked on saved games in four steps before I play again, and the last step puts the bug back on purpose to prove the harness catches it.

The 201 coach conversations and 34 equally fast mates come from my games; the 4,772 true and 2,243 false statements about them are generated.

Limit: the labels follow the check’s own rules, so a pass shows the check does what it was built to do, not that those rules are complete.

The check on where pieces stand, before and after the fix

050%100%LOWER IS BETTERHIGHER IS BETTERWrongly rejectedtrue statements after a moveWrongly rejected, true statements after a move: before the fixWrongly rejected, true statements after a move: after the fix, 45% → 0%45% → 0%65 of 144 beforeWrongly rejectedall true statementsWrongly rejected, all true statements: before the fixWrongly rejected, all true statements: after the fix, 1.4% → 0%1.4% → 0%65 of 4,772 beforeFalse statements caughtgenerated false statementsFalse statements caught, generated false statements: before the fixFalse statements caught, generated false statements: after the fix, 100% → 100%100% → 100%of 2,243 generatedRejections that were rightone September game’s rejectionsRejections that were right, one September game’s rejections: before the fixRejections that were right, one September game’s rejections: after the fix, 2 of 16 → 2 of 22 of 16 → 2 of 2one September game
before the fix
after the fix

After the fix, the check on where pieces stand wrongly rejected 0 of 144 true statements about the board after a move, down from 65, and still caught all 2,243 false ones.

Positions come from 201 of my conversations with the coach; the statements about them are generated and labelled true or false by reading each board. The last row is the 16 answers the check rejected in game 198.

Limit: the labels follow the check’s own rules, so this shows the check does what it was built to do, not that those rules are complete.

measurewhat it countsmeasured onbeforeafter
false positive ratetrue statements wrongly rejected144 generated statements about the board after a move45% (65 of 144)0%
false positive ratetrue statements wrongly rejected4,772 generated true statements1.4% (65 of 4,772)0%
recallfalse statements caught2,243 generated false statements100%100%
precisionrejections that were rightone September game’s rejections2 of 162 of 2

A check only ever seen passing proves nothing. So I broke the fix on purpose: pulling the move lines back out brought the after-move rejections straight back, and restoring them cleared them again. Four such breaks ran, each built to move one named cell of the results and nothing else.

Equal mates count as the best move

The same method proved a second fix. In game 198 my move mated as fast as the engine's stored best move, and the review still marked it as a miss, because it compared the two by name rather than by how fast they mate. The fix treats an equally fast mate as the best move everywhere the review judges a move.

When my move mates as fast as the engine’s best, every place that judges a move counts it as the best move; the four review places were checked on all 34 such moves in my games:

  • the turning point in the debrief
  • the “you found the best move” line
  • the highlighted-moves drawer
  • the review arrows
  • the live nudge about a faster mate, which fires only when the mate I held was 8 moves away or fewer
WHAT IT CANNOT CATCH YET

The placement check accepts a claim that is true on any board the coach was shown, up to four moves down a line. A claim about the current board that only becomes true a few moves later would pass, and the "false statements caught" figures above are measured against that rule. Nothing yet measures how often the coach writes one in play.

10

How we set thinking and effort

Two independent controls, not one parameter. One turns adaptive thinking off. The other caps it at low and leaves it on.

Latency by arm

Latency, seconds012.52537.550median, default (unbounded): 14.514.5median, low, shipped: 6.26.2median, disabled: 5.65.6medianp90, default (unbounded): 47.447.4p90, low, shipped: 11.811.8p90, disabled: 10.510.5p90
default (unbounded)
low, shipped
disabled

Capping the coach at low thinking cut its median reply from 14.5 to 6.2 seconds and its p90 from 47.4 to 11.8.

From the Aug 3 eval: 119 questions asked 3 times each, 357 replies per setting; p90 is the time nine in ten replies beat.

Limit: disabled was faster still, 5.6 seconds at the median, but fell back to a canned reply most often on the hardest questions, 22.7% of the time.

Fallback by difficulty

Fell back to a canned reply, %, by question difficulty05101520250direct fact, low, shipped: 2.42.4direct fact, disabled: 9.59.5direct factneeds a line, default (unbounded): 3.83.8needs a line, low, shipped: 7.17.1needs a line, disabled: 7.57.5needs a linetactical / mate, default (unbounded): 18.718.7tactical / mate, low, shipped: 13.313.3tactical / mate, disabled: 22.722.7tactical / matelow is lowest on the hardest bucket
default (unbounded)
low, shipped
disabled

On the hardest questions, low fell back to a canned reply least: 13.3%, against 18.7% for default and 22.7% for disabled.

From the same Aug 3 eval: the 357 replies per setting, sorted into three buckets by how hard the question was.

Limit: on direct fact questions low fell back 2.4% of the time, where default never did.

So why low? Difficulty, not the average. On the hardest bucket it beats both. It beats disabled in every bucket at a speed cost inside the noise, and beats default on tactical questions while running 2.3x faster on a fifth of the tokens. A blinded human read of 26 questions, columns randomized and the key held separately, gave the bounded arms 18 of 25 non-tie picks.

11

Finding that root cause took four passes

The first three passes each found a cause, shipped a fix, watched the median improve, and filed the rest under model variance. The fourth pass zoomed out and ran a root cause analysis, a method from total quality management: it accounted for every second of one slow call, which surfaced what the first three passes were not measuring. The same investigation used a fishbone diagram to sort a failure with several causes, where a forced mate the hint knew about never reached the chat.

PassDiagnosisRight?
Jul 21Process startup, not thinking timeYes, and so was the fix
Jul 27Budgets are too tightA mitigation, not a diagnosis
Aug 1Stale served code, starved retry cap, prompt sizeTwo right. The third was a confound
Aug 2Unbounded adaptive thinking, 1,500-4,500 invisible tokensNamed, reproduced, toggled

Anatomy of one 20.3-second call

0 s5101520.3 sstart-up and first message0 to 1.7 s · first message at 1.3 sthinking, never shown1.7 to 17.7 s · 1,430 tokenswriting the answer17.7 to 20.1 s · 200 tokensWhat the stream showed, against what I waited0 s1020304050 sfirst token arrived1.3 to 3.7 sthe whole wait45 s

One instrumented call from the Aug 2 investigation, at the low end of the slow range; first-token and wait times from the same investigation.

It hid because thinking streams with empty text by default: no log, trace or stream ever showed a thinking token. Both tells were arithmetic and sat in every call: the gap between the first token and the whole wait, and billed output tokens far larger than the visible answer.

LESSON

A fix that measurably helps is the most dangerous stopping point. It validates the pass without validating the diagnosis. Zoom out and name the confounders. Build an eval for what was not being measured, so the cause that matters shows up. The obvious fix, prompt size, was already measured and would only have been a band-aid.

12

One SQLite file, ten tables, no ORM

199 games, 3,287 moves, integrity verified by counting rows.

From the spec's guardrails: trace completeness 100%.

TableRowsWhat it records
sessions1,438one per sitting
games199opponent, result, which colour I played (player_color)
moves3,287both sides, with the engine's eval attached and whose move it was (side)
verdicts4,078every judged move, including retracted ones
advice_traces377every coach call: prompt, output, the thinking setting it ran under, and which of its sentences a check looked at
turning_points165what actually swung each game
chat_messages377my questions, and only replies that passed their checks
game_events1,606hints and overrides
mode_timers426screen time
coach_notes6notes I ask the coach to record; the newest 10 go into every chat prompt

Verdicts outnumber moves: every move I considered and retracted was judged and recorded too. Of the chat messages, 201 are mine and 176 are the coach's. There is no migration folder and no version number: the code declares every column each table should have, diffs that against the live database at startup, and adds what is missing.

13

What an agent can read while I play

An agent debugging the coach reads the game's state as data, from three endpoints, four command-line tools and the page itself, without a screenshot.

From the spec: a red flag is diagnosed from the traces, not cut.

EndpointWhat it lets you see
/api/healthwhether the server is up, and when the process started, the one reliable sign of which code it is running
/api/game/:id/statethe live board, the move count, whose turn it is, the result, the last verdict, and whether the coach has been switched off for that game
/api/agent/manifestthis table, as JSON, so a tool reads the vocabulary rather than this page
CommandWhat it lets you see
npm run dossiereverything the coach saw and said for one reply, on one screen; with a date, how many sentences a check looked at across every reply since then
npm run taila read-only live feed of moves and coach calls as I play
npm run scoreboardfor one game: how many coach replies were model-written or canned, how many needed a retry, my thumbs, the thinking setting, and how many sentences no check looked at
tools/replay-trace.tsone stored coach reply run again through today's checks, to see whether it would still be rejected
On the page itselfWhat it lets you see
data-gc-fenthe board as it stands
data-gc-sidewhose turn it is
data-gc-pendingwhether a move is picked up, waiting
data-gc-arrowswhich arrows are drawn
data-gc-hint-levelhow far up the hint ladder I am
data-gc-hint-visiblewhether the hint is showing
data-gc-trace-idwhich coach reply is in view
data-gc-postgamewhether the game is over
data-gc-turning-counthow many turning-point cards there are

Every coach call also records its thinking setting and which sentences a check looked at. Older rows have no thinking setting, so their retries can't be compared on it. A chat reply's count covers every check it ran; a note's covers only the defence check, so compare counts within one place, never across.

LESSON

The surface grew one debugging session at a time. An agent needs what I see, what the coach was handed, and what each check decided, as data.

Girl Chess. Built 0-to-1 by directing Claude Code. Counts verified read-only against the live database, 2026-09-22. They are a snapshot: I still play into this database, so the live figures move with every game.