Skip to content

B6/RAG-5: cross-game habit aggregation (the Pro story on every platform) - #136

Open
ber4444 wants to merge 2 commits into
mainfrom
b6-habit-aggregation
Open

B6/RAG-5: cross-game habit aggregation (the Pro story on every platform)#136
ber4444 wants to merge 2 commits into
mainfrom
b6-habit-aggregation

Conversation

@ber4444

@ber4444 ber4444 commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Summary

Cross-game habit aggregation (B6/RAG-5): the app already stores a
MoveAssessment on every ply of every saved game, and nothing has ever read
across them. This does — "You've hung a piece in 4 of your last 10 games"
and it is the sentence no single-move coach can produce, on the axis the field
is weakest on (DecodeChess and Dr. Wolf both lose the thread between sessions).

It is also what Pro adds now. The Move Coach is deterministic on every
platform (#137 measured both phone runtimes against the same 100 golden
positions; neither was more truthful than the computed line), so Pro can no
longer mean "a model phrases the coach" anywhere. This is the replacement, and a
better one: it is a surface the free tier genuinely doesn't have, rather than a
nicer rendering of one it does.

Nothing here runs a model or touches the network, on any platform.

What's in it

  • HabitAggregator — a pure function over GameHistoryRepository.games.
    Walks the player's assessed MISTAKE/BLUNDER moves across the most recent
    games, groups by a recurring costly motif (currently hangs-piece — the one
    motif DeterministicCoach treats as describing the player's own mistake
    rather than something going right), with a general-rate fallback when no
    specific tactic clears its own bar. Requires the pattern to recur across
    multiple games
    , not multiple times in one bad game — a single rough game is
    not a habit.
  • HabitNarrator — deterministic text from a HabitSummary. Code detects;
    nothing narrates it into being. A model, where one exists, could later rephrase
    the two strings this produces, but the counts and the motif are never its to
    invent. That contract binds harder here than on a single move: an aggregate
    carries more claims per sentence, and both failures ML Kit on Android: availability was a client-config bug, and the "repetition loop" was ours #137's device run found
    were a supplied fact reattached to the wrong subject.
  • Practice positions — each HabitOccurrence carries fenBefore (the exact
    position) and bestMoveSan (the engine's alternative), both already computed
    by MoveAssessor/MotifDetector when the ply was assessed. Nothing new is
    computed; data already persisted is reprojected.
  • HabitsScreen + HabitsManager, wired into AppRoot as Screen.HABITS
    with a "Habits" button next to History in the game screen's menu row.
    Pro-gated the way Rules Q&A is (an explicit branch rather than ProGate,
    since the screen supplies its own SubScreenScaffold), with available =
    gameHistory != null.
  • HabitsManager needs no platform runtime, model or engine, so unlike the
    AI managers AppRoot constructs it directly from the gameHistory it already
    has. No entry-point wiring changed on any platform.
  • The paywall lists it, keyed off a new LocalHabitsManager — the same
    signal Screen.HABITS branches on, so the advertised list cannot drift from
    what a purchase actually unlocks. PaywallScreen.proFeatures() already had to
    avoid selling a surface the build can't show (ML Kit on Android: availability was a client-config bug, and the "repetition loop" was ours #137's coach line); gating one
    the build can show while never listing it is the same bug pointing the other
    way, and both are refund-shaped. Availability is a property of the build, which
    LocalEntitlements cannot know, so it stays a separate signal.

A prerequisite bug found and fixed

GameActions hardcoded White as the human regardless of
AppSettings.playerSide
— a leftover from before the player-side setting
(#112) existed, never updated when it shipped. Two consequences:

  • Every saved PGN and SavedGame mislabelled the colours for a Black-side game
    ([White "Player"] even when the human played Black).
  • GameHistoryBackfiller had no way to know which plies were the player's,
    so on a Black-side game it silently backfilled assessments for the engine's
    moves — fatal for this feature, which would then have reported the engine's
    mistakes as the user's habits.

Fixed by adding SavedGame.playerSide (defaulting to "WHITE", which is correct
for data saved before the field existed — that was the only side selectable then)
and threading it through GameActions.pgnTags/toSavedGame and
GameHistoryBackfiller.

A second, independent bug surfaced while fixing the backfiller: its
evaluate() fallback path negated a value that ChessEngine.evaluate() already
returns from White's perspective (confirmed against both UCI transports —
UciEvaluation.toWhitePerspective is applied before the value ever reaches the
caller), silently inverting every assessment that hit it — any game played
without a live-attached engine, e.g. the CPU fallback.
GameHistoryBackfillerTest pins both fixes.

What I could not verify

  • No Compose UI test for HabitsScreen. Comparable coverage
    (GameHistoryScreen) lives in androidDeviceTest and needs an emulator this
    environment doesn't have running. The aggregation and narration logic — the
    part that can be wrong in a way that matters — is covered by
    HabitAggregatorTest/HabitNarratorTest in commonTest, which did run.
  • The paywall entry is hand-test-only by construction. Per CLAUDE.md,
    isProUnlocked() treats a null LocalEntitlements as unrestricted, so no
    Compose UI test can catch a paywall regression on any surface.
  • Practice positions are plain FEN text, matching GameHistoryDetail's
    existing PGN-block precedent, not loaded into an interactive practice board —
    there is no FEN-import/practice-mode flow to hand them to, and building one is
    a materially larger feature. That is the honest scope of "suggest a practice
    position" here.
  • No [move-N]-style citation for cross-game occurrences.
    ui/CitationSanitizer deliberately preserves that syntax for the current
    game's board-jump affordance (B16); a habit spans multiple saved games, so
    reusing it would be ambiguous — and this feature never routes through a model
    or the sanitizer at all, so nothing forced the question. Occurrences are listed
    in plain text with a recency label.

Composes with the Maia-3 spike (not wired here)

HabitSummary/HabitOccurrence are plain data so a future Maia-3 signal
("players at your level play X here") can sit alongside a detected habit without
a redesign, per that spike's own note that the two compose: habits say you keep
doing X
, Maia says players above you do Y. Nothing Maia-related is wired in.

Verification

./gradlew :chess-core:check :ondeviceai:check :app:check :androidApp:assembleDebug

Green, including :ondeviceai:iosSimulatorArm64Test,
:app:iosSimulatorArm64Test and :app:wasmJsBrowserTest. No :onDeviceAi
public API touched.

🤖 Generated with Claude Code

Bot and others added 2 commits August 15, 2026 07:52
…ds on

Adds cross-game habit aggregation for Android's Pro story now that Android
ships no on-device model: with no model, deterministic cross-game habit
detection is the only proposed Pro feature that means anything there, and
it inherits the always-correct/always-instant properties of the coach line
for the same reason DeterministicCoach does.

HabitAggregator (pure, :app) walks the player's assessed MISTAKE/BLUNDER
moves across the most recent games in GameHistoryRepository, grouped by a
recurring costly motif (currently hangs-piece, the one motif
DeterministicCoach treats as describing the player's own mistake) with a
general-rate fallback when no specific tactic recurs. Requires the pattern
to recur across multiple games, not multiple times in one bad game.
HabitNarrator turns that into deterministic text -- code detects the
habit, nothing narrates it into being. Practice positions are the FEN
before each flagged move plus the engine's alternative, both already
computed by MoveAssessor/MotifDetector when the ply was assessed -- nothing
new to compute. New Habits screen wired into AppRoot, Pro-gated like Rules
Q&A.

Prerequisite fix found while building this: GameActions hardcoded White as
the human regardless of AppSettings.playerSide (a leftover from before the
player-side setting existed), so a Black-side game's PGN mislabelled the
colours and GameHistoryBackfiller had no way to know which plies were the
player's -- it would have backfilled the engine's moves instead on any
Black-side game. Added SavedGame.playerSide (defaults to "WHITE", correct
for pre-existing data) and threaded it through GameActions and the
backfiller. Also fixed a second, independent bug found in the same
function: the backfiller's evaluate() fallback negated a value that
ChessEngine.evaluate() already returns from White's perspective, inverting
every assessment that hit it (any game played without a live-attached
engine).

Verified: chess-core:check, ondeviceai:check, app:check (including
iosSimulatorArm64Test and wasmJsBrowserTest), androidApp:assembleDebug --
all green. No :onDeviceAi public API touched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
#137 took the model-phrased coach off iOS as well as Android, so this feature
is no longer "the Android Pro story" — it is the Pro story everywhere, and for
a better reason than an absent runtime. Both phone runtimes were measured on
the same 100 golden positions and neither was more truthful than the
deterministic line, so "what Pro adds" had to stop meaning "a model phrases it"
and start meaning a surface the free tier genuinely does not have.

The gap that reframing exposed: Screen.HABITS was Pro-gated but
PaywallScreen.proFeatures() never listed it, so a purchase unlocked a surface
the paywall didn't sell. That is the same class of bug #137 fixed in the other
direction (selling a surface the build can't show), and it wants the same
shape of fix — the entry is keyed off LocalHabitsManager, the same signal
Screen.HABITS branches on, so the list cannot drift from what a purchase
actually unlocks. Adds LocalHabitsManager for that one reason; availability is
a property of the build (gameHistory != null per entry point), which
LocalEntitlements cannot know.

Also notes in HabitNarrator why the "code detects, the model narrates" contract
binds harder on an aggregate than on a single move: more claims per sentence,
and both failures the device run found were a supplied fact reattached to the
wrong subject.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ber4444
ber4444 force-pushed the b6-habit-aggregation branch from 165fa06 to df637f5 Compare August 15, 2026 14:55
@ber4444 ber4444 changed the title B6/RAG-5: cross-game habit aggregation (Android's Pro story) B6/RAG-5: cross-game habit aggregation (the Pro story on every platform) Aug 15, 2026
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