Skip to content

Game Summary on device: measure both runtimes, and fix what was being measured as model quality - #138

Merged
ber4444 merged 7 commits into
mainfrom
game-summary-pr
Aug 16, 2026
Merged

Game Summary on device: measure both runtimes, and fix what was being measured as model quality#138
ber4444 merged 7 commits into
mainfrom
game-summary-pr

Conversation

@ber4444

@ber4444 ber4444 commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Measures Game Summary on both on-device runtimes, fixes the three bugs that first-pass measurement mistook for model quality, adds the response validator this surface never had, and leaves the attach switched off behind a per-surface constant.

The finding: three bugs were measured before either model was

The first pass reported 7/12 for AICore and 4/12 for Foundation Models, and concluded that Foundation Models "refuses two games in three and truncates every success". All three failure modes were ours.

  1. [ErrorCode 30] Background usage is blocked — AICore refuses to generate unless the app is foregrounded. adb shell am start on a locked device produces a full JSONL of sub-second fallbacks that measure nothing: 3 rows of the first run, all 12 of the second. svc power stayon usb does not prevent it, because the keyguard rather than the screen timeout is what stops the activity being resumed. The two rows first reported as "inference exceeded latency budget" at ~46 s were the cold start immediately after the blocked ones.
  2. noRepeatNgramSize = 4 — B15's guard, tuned for the coach's single sentence. A summary of three turning points is a parallel list, and a parallel list repeats its connectives by construction, so it cut real answers at the start of the second "so this was another small inaccuracy". 4 of 4 Foundation Models successes, 1 of 7 AICore.
  3. The raw PGN in the prompt — the largest effect, in two directions. Foundation Models rejected 8 of 12 prompts with "An unsupported language or locale was used" — the same 8 across two runs, in 15–20 ms, which is an input guardrail and not a model deliberating. And on Android the PGN was the sole source of invention: every unsourced flourish ("in the endgame", "contributed to the loss" on a game with no result) was the model narrating movetext it had been told not to use.

Removing the PGN took Foundation Models from 4/12 to 12/12 and Android's unsourced-narrative rows from 6/12 to 0/12.

The numbers, at 50 games

Android AICore iOS Foundation Models
Succeeded 43/43 † 50/50
Cites exactly the code-chosen plies 37/43 (86%) 18/50
Invented a [move-N] 0 2/50
No citation at all 0 28/50
First person ("I played") 0 26/50
Truncated 0 0
Latency median / p95 11.4 s / 13.2 s 1.4 s / 2.2 s

† 7 of 50 rows lost to one contiguous ErrorCode 30 block despite the foreground fix. A contiguous block of sub-second fallbacks is the harness every time.

The two invented tags are the finding. At 12 games iOS had none and the citation gap looked like omission; at 50 it is fabrication, and a fabricated [move-N] is the one defect a user cannot detect — B16 turns it into a board jump to a ply that was never a turning point. Android fabricated none in 43. At 50 games the iOS tail is also worse than the small sample suggested: some rows are not summaries at all, but refusals ("I'm sorry, but I cannot summarize the mistakes in the game") and apologies in character ("I apologize for the mistakes I made… I will try to improve").

What changed in the product

  • The summary prompt no longer contains the PGN; it opens with one factual line (side, ply count) and the turning points, which already carry every fact the summary may state.
  • noRepeatNgramSize is 8 on this surface, and trimIncompleteSummaryTail drops a ragged tail if anything else ever cuts one.
  • GameSummaryResponseValidator — the surface had none, and it is live on iOS today. Five rules: citation set, coverage, move attribution, voice, piece type. A rejection lands on GameSummaryGrounding, which is what ships today, so a false rejection costs the wait and nothing else. Measured: accepts 37/43 AICore, rejects 42/50 Foundation Models.
  • The deterministic floor is fixed. It shipped "This was a inaccuracy." and "This was a good." on both platforms, and a MISTAKE could be followed by "This move was slightly inaccurate" one sentence later, because the class comes from cpLoss and the intuition from a win-percent delta.
  • MainActivity.attachOnDeviceAi() holds probe → executor → both orchestrators behind ATTACH_MOVE_COACH and ATTACH_GAME_SUMMARY, both false. The probe previously had no caller outside bench/. Three details are load-bearing and documented at the call site: the constant sits inside the probe because probeAvailableLocalVendors() awaits an AICore feature fetch on a DOWNLOADABLE device; isAppForegrounded is read per request so a backgrounded request falls back instead of erroring; and the vendor list is probed once and reused. Modelled on googlesamples/mlkit android/genai, which is also where the Candidate.finishReason == MAX_TOKENS signal we were dropping comes from.
  • Bench harness: keepBenchInForeground() (bench-only) holds FLAG_KEEP_SCREEN_ON + setShowWhenLocked.
  • ScorecardWriter now matches the scorecard.md note hand-edited in ML Kit on Android: availability was a client-config bug, and the "repetition loop" was ours #137, so the next :evals:run stops reverting it.

Two validator rules were built, measured, and removed

Both passed their own unit tests and produced zero true positives against 43 real summaries:

  • Class fidelity (rejecting a BLUNDER described as an inaccuracy) cannot see negation, so it rejected "While these aren't huge blunders…" and "[move-17] with Qc2 wasn't a blunder"; and its per-tag segmentation ran to the next [move-N], so the last citation's segment swallowed the closing paragraph and matched "focusing on those moments of inaccuracy".
  • Best-move-described-as-played required the engine's move to appear after "instead of", so the standard counterfactual "opting for e4 instead of Nd2 would have been a stronger choice" — correct English and correct chess — was rejected on six summaries.

Together they accepted only 58% of output the benchmark had already judged good, which would have meant one summary in two costing a 12 s wait and then showing the composed text anyway.

GameSummaryValidatorFieldTest is the check that found this and the reason it cannot recur: it replays 43 AICore and 20 Foundation Models summaries from the 50-game run, with the turning points the device computed, and fails the build if acceptance drops below 85% or if any rule other than incomplete coverage fires on an AICore summary.

Recommendation

Do not flip either constant in this PR. Android is now a genuine trade rather than a loss — 12 s and a coach's voice against instant and robotic, citations intact either way. The remaining question is not quality but whether one summary in seven costing an 11 s wait for the composed text is a good deal; the validator makes that outcome safe, not free.

iOS is not ready, and it already ships: first person in half the answers, no citation in half, and two fabricated board jumps. The validator now catches all of it, which is the argument for merging this regardless of any attach decision. The fix for iOS is the prompt, not the validator — see the plan.

Next steps

docs/plans/on-device-ai-next-steps.md:

  1. Write GameSummaryResponseValidatordone in this PR, with the two removed rules recorded.
  2. Turn AICore on for Game Summary — gated on a repeat of the 50-game run with the validator in place. Zero invented tags is a hard gate, not a percentage.
  3. Benchmark Rules Q&A — the only surface with a model live in production on both phones and the only one never measured. Fixture design, row schema, what to score, and the decision it makes: whether the model turn earns its place at all, given the retrieval floor already answers correctly with a citation.

Leftovers, not addressed here

  • DeviceRunScorer has no Game Summary mode — summary runs are scored ad hoc, the "second scorer written next to the data" problem :evals exists to prevent.
  • evals/scorecard.md's aicore-nano-fast row is stale (0.0% grounded) — it measured three separate bugs plus a preference that FEATURE_NOT_FOUNDs on a Pixel 10.
  • Summary fixtures aren't staged into Android assets like the coach's golden set, so a run needs a manual run-as push.
  • Rules Q&A has no availability probe on Android — the only surface that decides availability by failing.

Testing

All CI green on 06c01aa9: Android emulator suite, iOS simulator + Swift tests, the grounding regression gate, and CodeQL.

The field test runs in CI through :ondeviceai:check in the Android job — worth knowing, because the apple job's task list does not include :ondeviceai:desktopTest, so check is the only thing that executes it.

Locally: :ondeviceai:check (all targets), :evals:test, :evals:run (regenerates scorecard.md with no drift) and :androidApp:assembleDebug.

Benchmarks ran on a Pixel 10 Pro XL and an iPhone 17 Pro simulator (iOS 26.5); raw JSONL stays in build/bench/ per the existing convention, with repro commands in the benchmark doc. The 63 rows the validator is tested against are checked in at onDeviceAi/src/desktopTest/resources/game-summary-field-corpus.jsonl.

🤖 Generated with Claude Code

Bot and others added 7 commits August 15, 2026 08:24
Game Summary is the surface where an on-device model is still attached (iOS), and
the only one with no response validator at all: whatever the model writes reaches
the user. The Move Coach verdict does not transfer to it automatically — a
summary is a different task with a different budget, behind a button rather than
in an automatic panel — so it gets measured on its own terms.

The bench could not do that. It carried one hand-written game whose MoveRecords
were fabricated: `uci = "e2e4"` on every ply, the start position as every
`fenAfter`, and centipawn losses chosen by hand. Since the summary's whole job is
to pick the moments that decided *this* game, a fixture whose turning points were
chosen by hand cannot tell you whether it did.

`tools/generate_summary_fixtures.py` plays real games with the player's side
deliberately weakened and assesses every player ply with Stockfish. The fixture
carries raw engine numbers only — `MoveAssessor` derives MoveClass and
winPercentLost in `SummaryFixtures`, so the bench cannot drift from the app's own
definition of a blunder. Each row records the deterministic composer's text
beside the model's, so the two are scored from one file, as the coach's are.

`IosSummaryBench` is new: Game Summary had no iOS bench at all, which is why the
one platform still running a model on it had never been measured there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 real assessed games, same fixtures on both platforms. AICore succeeds on 7 of
12 at ~12 s and cites exactly the code-chosen turning points on 6 of those 7.
Foundation Models succeeds on 4 of 12 — it refuses two games in three with "An
unsupported language or locale was used", chess notation evidently not being
natural language enough for it — and every summary it does produce stops
mid-sentence.

That reverses the Move Coach result, where Foundation Models was the faster and
more fluent writer. On summaries it is also the less reliable one, and a wrong
[move-N] on the engine's preferred move navigates the user to the wrong ply,
since B16 turns those tags into board jumps.

Recommendation recorded: leave Android's Game Summary deterministic. Not because
the model writes badly — this is the best on-device output measured on the
project — but because 58% success at 12 s loses to a template that cites 3 of 3
turning points instantly and cannot fail. The blockers are truncation and the
latency budget, and neither is a model problem.

Also records a polish bug in the deterministic text itself: it emits "This was a
inaccuracy" and "This was a good".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first Game Summary pass reported 7/12 for AICore and 4/12 for Foundation
Models, and concluded that Foundation Models refuses two games in three and
truncates every success. All three failure modes were ours:

  * AICore refuses to generate unless the app is in the foreground. The keyguard,
    not the screen timeout, is what stops the bench activity being resumed, so
    `svc power stayon usb` did not prevent it — it cost 3 rows of the first run
    and all 12 of the second, and the two rows first reported as "exceeded
    latency budget" at ~46 s were the cold start right after the blocked ones.
  * `noRepeatNgramSize = 4` is tuned for the coach's single sentence. A summary
    of three turning points is a parallel list, so the guard cut real answers at
    the start of the second "so this was another small inaccuracy" — 4 of 4
    Foundation Models successes and 1 of 7 AICore ones.
  * The raw PGN in the prompt tripped Apple's language guardrail (8 of 12
    prompts rejected in 15-20 ms, the same 8 across two runs) and was the sole
    source of Android's invented narrative — "contributed to the loss" on a game
    that has no result. The turning points already carry every fact the summary
    may state.

Both runtimes now score 12/12 on the same fixtures. AICore cites exactly the
code-chosen turning points in 11 of 12 at ~12 s; Foundation Models is eight
times faster and factually right but writes as the player ("I made two
significant mistakes") and drops the [move-N] citations B16 turns into board
jumps in 9 of 12.

Also fixes the deterministic floor this all falls back to: "This was a
inaccuracy." / "This was a good." shipped on both platforms, and a MISTAKE could
be followed by "This move was slightly inaccurate" one sentence later, because
the class comes from cpLoss and the intuition from a win-percent delta.

MainActivity now holds the whole ML Kit wiring behind one ATTACH_ON_DEVICE_AI
constant, which is false. The probe was previously unreachable outside bench/;
the constant sits inside the probe call because probeAvailableLocalVendors()
awaits an AICore feature fetch on a DOWNLOADABLE device, so inactive has to mean
no network. Modelled on googlesamples/mlkit android/genai, which is also where
the Candidate.finishReason == MAX_TOKENS signal we were dropping comes from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ATTACH_ON_DEVICE_AI attached the Move Coach and Game Summary together, and the
two decisions have gone opposite ways: the coach is settled against on
measurement, while the summary is 12/12 and waiting on a validator. One constant
meant flipping the open one would drag the settled one with it.

Also moves enableDeterministic() above the probe and makes it unconditional. It
used to run only on the not-attached path, so with a model attached the summary
button was missing until the probe returned — and that probe awaits an AICore
feature download on a DOWNLOADABLE device. The deterministic summary now stands
from the first frame and the orchestrator takes over when it arrives;
deterministicEnabled only applies while the orchestrator is null, so the two
cannot fight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 12-game set is small for a surface where one bad summary is the whole
answer. Regenerated at 50 and re-ran both platforms.

Android holds: 43/43 (7 rows lost to one contiguous ErrorCode 30 block, which is
the harness), 86% citing exactly the code-chosen plies, zero invented tags, zero
truncation, p95 13.2 s.

iOS gets worse under a larger sample, and in the way that matters most: 2 of 50
answers invent a [move-N]. At 12 games the citation problem looked like
omission; at 50 it is fabrication, and a fabricated tag is the one defect the
user cannot detect because B16 turns it into a board jump to a ply that was
never a turning point. Half the answers still carry no citation at all and half
are written in first person as the player.

The plan's acceptance criteria are rewritten against these numbers rather than
against my guess: zero invented tags is a hard gate, not a percentage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It was the thinnest of the three: no file paths, no JSONL columns, no decision
rule. Rules Q&A is the only surface with a model live in production on both
phones and the only one never measured, so the plan should not leave the next
agent to invent the shape.

Adds the fixture design (one case per passage phrased as a player would ask,
the draw-dead-position/draw-agreement near-miss pair, and out-of-corpus
questions where the honest answer is that the corpus does not cover it), the
row schema with the grounded answer as the column to beat, what to score, and
the decision the run exists to make: whether the model turn earns its place at
all, given the retrieval floor already answers correctly with a citation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Game Summary had no response validator at all — any non-blank text reached the
user — and it is live on iOS today, where half the answers carry no citation, a
quarter are written in first person as the player, and two of fifty fabricate a
[move-N] that B16 turns into a board jump to a ply that was never a turning
point. Five rules now gate it: citation set, coverage, move attribution, voice,
piece type. A rejection lands on GameSummaryGrounding, which is what ships
today, so a false rejection costs the wait and nothing else.

Two further rules were built, measured, and removed. Both passed their own unit
tests and produced zero true positives against 43 real summaries:

  * Class fidelity could not see negation, so it rejected "while these aren't
    huge blunders" and "[move-17] with Qc2 wasn't a blunder"; and its per-tag
    segmentation ran to the next [move-N], so the last citation swallowed the
    closing paragraph and matched "focusing on those moments of inaccuracy".
  * The best-move-described-as-played branch required the engine's move to
    appear after "instead of", so "opting for e4 instead of Nd2 would have been
    a stronger choice" — correct English, correct chess — was rejected on six
    summaries.

Together they accepted only 58% of the AICore output the benchmark had already
judged good, which would have meant one summary in two costing a 12 s wait and
then showing the composed text anyway. The invented-move check is also narrowed
to unambiguous move tokens, because the bare-pawn form cannot be told apart from
prose naming a square and rejected "Sacrificing the Bishop on g6".

GameSummaryValidatorFieldTest is the check that found all of this and the reason
it cannot recur: it replays 43 AICore and 20 Foundation Models summaries from the
50-game run, and fails the build if acceptance drops below 85% or if any rule
other than incomplete coverage ever fires on an AICore summary.

Also syncs ScorecardWriter with the scorecard.md note edited by hand in #137, so
the next :evals:run stops reverting it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ber4444
ber4444 marked this pull request as ready for review August 15, 2026 15:59
@ber4444
ber4444 merged commit aa52ced into main Aug 16, 2026
11 checks passed
@ber4444
ber4444 deleted the game-summary-pr branch August 16, 2026 23:00
ber4444 pushed a commit that referenced this pull request Aug 16, 2026
Three of its statements were false on main, two of them self-contradictions
inside the same file: the header said the work was on an unmerged branch, and
"Where things stand" said Game Summary has no response validator at all — in a
file whose Task 1 is marked DONE and describes shipping one. A fresh agent
reading top to bottom hit the false version first.

The scorecard leftover is struck through as done here. The DeviceRunScorer one
is NOT: this PR fixed the failure mode, not the gap. Sharpened it to name the
blocker (a summary row carries no per-ply assessments, so the validated request
cannot be rebuilt), where the fix belongs (the bench runners, not the scorer),
and the wrong fix to avoid — scoring the two rules that survive the missing
facts would publish a number that looks like the coach column and is not one.

Dropped the "branch is unmerged" leftover, which #138 resolved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ber4444 added a commit that referenced this pull request Aug 16, 2026
)

* Publish the measured device rows, and refuse a summary run by name

Two items #138 left open, both of which mislead a reader rather than break a build.

The scorecard's `aicore-nano-fast` row read 0.0% grounded / 100% length-rejected at
n=10, and `foundation-models-ios` read n=1. Between them they measured three of our
own bugs — a client config asking for a model variant the device does not provision,
a terminal `Final` event that appended the answer to itself, and a bench runner that
supplied no facts — plus a length rule that rejected instead of trimming. Their notes
said "re-measure before trusting this row". #137 re-measured; this publishes it.

Replaced with the three columns of the 2026-08-15 hundred-case run, including the
deterministic baseline the other two are scored against, which the scorecard has never
carried. Its 28% grounding-violation figure ships with the reason it overstates: 17 of
the 28 are the echoed-prompt rule firing on the column against itself, because that
line *is* the prompt's baseline sentence. Superseded rows are deleted rather than left
under a warning — both articles quote these numbers, and a reader who follows the repo
link must not find 0.0% grounded next to a published 91/100.

`scoreDeviceRun` pointed at a Game Summary JSONL died with MissingFieldException and a
40-line stack trace, which reads like a broken scorer rather than the wrong input file
— and the two files sit in the same directory under similar names. It now refuses by
name and says why: the summary validator derives turning points from the per-ply
assessments, and a summary row carries pgn/plies/playerBlunders and no assessments, so
the request it validated against cannot be rebuilt. It deliberately does not score the
subset of rules that survive that gap; a partial number that looks like the coach
column and means something else is the "second scorer next to the data" this file
exists to prevent. Full summary scoring is a bench-schema change, left to its own PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Update the next-steps doc for what #138 and this PR actually changed

Three of its statements were false on main, two of them self-contradictions
inside the same file: the header said the work was on an unmerged branch, and
"Where things stand" said Game Summary has no response validator at all — in a
file whose Task 1 is marked DONE and describes shipping one. A fresh agent
reading top to bottom hit the false version first.

The scorecard leftover is struck through as done here. The DeviceRunScorer one
is NOT: this PR fixed the failure mode, not the gap. Sharpened it to name the
blocker (a summary row carries no per-ply assessments, so the validated request
cannot be rebuilt), where the fix belongs (the bench runners, not the scorer),
and the wrong fix to avoid — scoring the two rules that survive the missing
facts would publish a number that looks like the coach column and is not one.

Dropped the "branch is unmerged" leftover, which #138 resolved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Bot <bot@example.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant