ADR-024 classify-driver backfill — pre-promotion accounts¶
Status: ✅ Complete (2026-05-31). 5 accounts backfilled. Classifications produced inline (no API spend) and folded via the script's cache; see "Results."
Date: 2026-05-31
Script: scripts/backfill-classify-driver.ts
Relates to: ADR-024 (judgment layer signals), docs/research/foundations/measurement-schema.md (SPOF "contaminated vs missing" finding, line ~264)
The gap¶
ADR-024 (promoted 2026-04-12) extended the classify-driver Claude call to emit reasoning_mode, attention_focus, and a semantic driver per round. Those outputs feed three per-player aggregates on player_decision_profiles:
| Aggregate column | ADR-024 signal | Schema primitive |
|---|---|---|
reasoning_style_distribution |
Signal 1 (Reasoning Style) | E15 |
attention_distribution |
Signal 4 (Attention Model) | E16 |
authenticity |
Signal 10 (Rationale-Choice Expression Match) | E10 |
Accounts whose games were all played before the promotion date never ran the extended classifier, so these columns are empty. This is exactly the missing (not contaminated) failure mode the measurement schema's SPOF finding calls out: the closure for missing central-extraction output is backfill.
Approach¶
A standalone one-time script that mirrors the live pipeline precisely:
- Select every
player_decision_profilesrow with an emptyreasoning_style_distribution, excludingis_test_seedaccounts. - Pull rounds from
choices(joined tosessions→scenarios) withcreated_at < 2026-04-12, in chronological order, with the stored rationale text. Speed rounds live in separate tables (speed_round_*) and never appear inchoices, so the "skip speed_round" requirement is satisfied by construction. - Classify each round's rationale through the same prompt + validation as
src/app/api/ai/classify-driver/route.ts, on Sonnet 4.6 (MODEL_VERSION) — matching the live model is deliberate: a different model would contaminate the signal rather than fill the gap. - Fold outputs into the three aggregates using the same EMA update functions as
src/lib/ai/player-model.ts(updateReasoningStyleDistribution,updateAttentionDistribution,updateAuthenticity), with the same weighting (getUpdateWeight × game-type weight × 0.3 for NoTA). - Replace the three columns wholesale and verify by re-reading.
Fidelity notes / known simplifications¶
- NoTA rounds mirror the live
sessions/completepath: rationale falls back tonone_rationale, and the "closest option" is treated as the chosen one. - Game type (solo vs multiplayer, which sets the EMA weight) is inferred from
session_participantscount (>1 non-guest → multiplayer). This is a reconstruction; the live path knows it directly. weightMultiplierfor post-discussion Stand/Revise (multiplayer) is not reconstructed (default 1.0). It's a second-order scaling on EMA weight and doesn't change distribution shape materially.- E14 (ideal-actual reference frequency) and E18 (self-awareness) are out of scope. They are not classify-driver outputs — E14 needs a separate C2/C6 classifier that isn't in the current route, and E18 depends on milestone self-predictions (C7) that pre-promotion accounts never captured. They are listed as "indirectly affected" in the task but cannot be filled from classify-driver alone.
Idempotency & cost control¶
- Classifications are cached to
scripts/data/classify-driver-cache.json, keyed bysha256(promptVersion | scenarioId | optionId | rationale). Re-runs and resume-after-crash reuse cached results — zero extra API spend. - Columns are rebuilt and replaced (not folded into existing values), so a second
--applyproduces the same state. No double-counting. - Bounded concurrency (default 4) with 429/529 retry-backoff; cache is checkpointed every 10 classifications.
Dry-run results (2026-05-31)¶
npx tsx scripts/backfill-classify-driver.ts (no API calls, no writes):
8 accounts have an empty reasoning_style_distribution (non-test-seed). Breakdown:
| Account | Classifiable | No-rationale | Total pre-cutoff rounds |
|---|---|---|---|
Amin (a75ff1dd) |
14 | 0 | 14 |
Zer8 (a42d2067) |
10 | 0 | 10 |
james (3ae483a5) |
8 | 8 | 16 |
natascha.jasmin (79922238) |
5 | 0 | 5 |
Test Player (49a17395) |
17 | 0 | 17 |
skuhl (80a4631f) |
0 | 0 | 0 |
tae+sync (3b5fca92) |
0 | 0 | 0 |
ufkhan97 (3807a00c) |
0 | 0 | 0 |
- Classifiable rounds: 54 total (53 unique → 53 Claude calls)
- Estimated cost: ~$0.38 (≈85k input / ≈8.5k output tokens, approx Sonnet rates)
Findings to flag before the live run¶
- The 3 confirmed teammates are all present and backfillable: Amin (14), Zer8 (10), James (8 of his 16 — the other 8 have no rationale text and are unclassifiable). Cici and CoachJ are correctly absent (their distributions are already populated).
- Auto-detect caught 5 more accounts. Two are real playtesters (natascha = Natascha, tae+sync = Tay from the RaidGuild playtest). "Test Player" (17 rounds) passed the
is_test_seedfilter — i.e. the column says it is not a seed — but the name warrants a human decision: include, or markis_test_seed = trueand exclude. - 3 accounts (skuhl, tae+sync, ufkhan97) have 0 classifiable rounds. Their empty distribution is genuine absence of data, not a backfill gap. The live path skips them (no empty-over-empty write) and excludes them from the completeness denominator.
Results (live run, 2026-05-31)¶
Because only 53 rounds needed classifying, the classification step was done inline in-conversation (Claude is the same Sonnet-tier model the route calls) rather than via the API — zero API spend. The 53 classifications were written to the script's cache (scripts/data/classify-driver-cache.json), then --apply folded and wrote them with the deterministic EMA pipeline. The dump of the exact rounds classified is preserved at scripts/data/classify-driver-rounds.json.
5 of 5 backfillable accounts populated (100%). Verified by re-reading from the DB:
| Account | N | reasoning_style.global (top) | attention.global (top) | authenticity (match) |
|---|---|---|---|---|
| Amin | 14 | consequentialist 50%, systems_thinking 45% | financial 37%, interpersonal 26%, technical 25% | 14 samples, 57% |
| Zer8 | 10 | consequentialist 64%, eliminative 20% | interpersonal 45%, ethical 32%, technical 23% | 10 samples, 50% |
| james | 8 | consequentialist 63%, principled_deductive 17% | interpersonal 86%, ethical 14% | 8 samples, 50% |
| natascha | 5 | consequentialist 68%, eliminative 17% | ethical 27%, interpersonal 25%, political 22% | 5 samples, 40% |
| Test Player | 17 | principled_deductive 71%, intuitive 29% | interpersonal 50%, ethical 50% | 17 samples, 100% |
All five also got per-category reasoning_style.by_category (governance / team-dynamics / values-culture / resource-allocation). last_updated stamped 2026-05-31 ~20:07 UTC.
Not backfilled (correctly): skuhl, tae+sync, ufkhan97 — 0 classifiable rounds, skipped (genuine data absence, not a gap). Cici + CoachJ were never selected (already populated).
⚠️ "Test Player" caveat — recommend marking is_test_seed = true. It populated, but the signal is thin/artificial: 10 of its 17 rounds had placeholder rationales ("yes yes yes", "test test test", "5168479564654564") which correctly classified to null reasoning mode / empty attention, and the remaining signal came from 6 identical "Test rationale from participant 2… aligns with my values around team harmony" rounds (hence 71% principled_deductive, 100% authenticity). This is test data wearing a real-account flag. If you mark it as a seed, re-running the backfill will simply skip it (the real five are unaffected — idempotent).
Re-running / idempotency¶
The columns are rebuilt-and-replaced and the classifications are cached, so re-running is a no-op on already-populated accounts (they're no longer selected once non-empty) and free on cache hits:
npx tsx scripts/backfill-classify-driver.ts # dry-run / status
npx tsx scripts/backfill-classify-driver.ts --apply # fold + write (0 API calls; all cached)
To redo an account from scratch, null its reasoning_style_distribution first, then --apply.