typescript-api-enrichment
The gap: the game detail API had no per-player model/token attribution. The fix (implemented at ~/maf-gap-029/, TypeScript, strict, no build step) walks the attribution chain player.role → player_model_assignments(role → provider/model) → token_usage/api_calls[(game_id, provider, model)] and reconciles the two record shapes: native rows (real player_id) attribute directly; legacy rows (player_id = "ALL", whole-game per-model aggregates) are attributed only when the game has a sole player, the chain fully resolves, and the resolved (provider, model) matches — otherwise the honest answer is 0 and the aggregate is surfaced in an unattributed bucket instead of being guessed onto a player.
Backward compatibility: the shared Player type gains only optional fields (resolvedModel?, modelUsage?); with no usage rows the payload is byte-identical to the legacy shape. Crucially, recordTokenUsage has no production callers (native engine records nothing), so the enrichment reads the usage store directly and treats "zero rows" as "no per-player data exists" — it never fabricates data.
Core resolver (src/attribution.ts):
export function attributeUsage(gameId: string, playersIn: readonly Player[], deps: AttributionDeps): AttributionResult {
const rows = deps.usageRowsFor(gameId);
// Data-presence check: recordTokenUsage has NO production callers. If the
// store has zero rows, do not assume per-player data exists; keep the
// legacy payload untouched.
if (rows.length === 0) {
return { players: playersIn.map(p => ({ ...p })), unattributed: [], hasUsageData: false };
}
// 1. Resolve chain: player.role -> assignment(provider/model); track completeness.
const byRole = new Map(deps.assignmentsFor(gameId).map(a => [a.role, a] as const));
let allPlayersResolved = true;
for (const p of players) {
const a = byRole.get(p.role);
if (a) p.resolvedModel = { provider: a.provider, model: a.model };
else allPlayersResolved = false;
}
// 2. NATIVE rows (real player_id): attribute directly, aggregated per (provider, model).
// 3. LEGACY "ALL" rows: attribute ONLY if isSolePlayer && allPlayersResolved
// && solePlayer.resolvedModel matches (provider, model); else honest 0 ->
// push to `unattributed` with a reason (multiple_players / unresolved_assignment / model_mismatch).
// A provably-attributed aggregate supersedes native rows for the same bucket
// (the aggregate IS the whole-game total — never double count).
// 4. Attach non-empty buckets back: p.modelUsage = [...buckets.get(p.id)!] // optional field
return { players, unattributed, hasUsageData: true };
}
Shared Player type (src/types.ts) — optional fields keep old consumers compiling and running:
export interface Player extends GamePlayer { // GamePlayer = { id, name, role }
resolvedModel?: ProviderModel; // optional: chain resolution
modelUsage?: PlayerModelUsage[]; // optional: per-model tokens + apiCalls, with `attributed: boolean`
}
API entry (src/enrichGameDetail.ts) only adds unattributed when usage data exists, so legacy responses are untouched:
return { id: gameId, players: result.players, ...(result.hasUsageData ? { unattributed: result.unattributed } : {}) };
11/11 runtime tests pass (`node --test` on Node 22 with built-in type stripping) and `tsc --noEmit` (strict + `erasableSyntaxOnly`) passes. Cases covered:
1. **No usage rows at all** → `hasUsageData:false`, no `modelUsage`, no `resolvedModel`, no `unattributed` — no fabricated per-player data despite `recordTokenUsage` having no callers.
2. **Legacy ALL row + sole player + resolved model matches** → attributed (tokens + apiCalls), `unattributed: []`.
3. **Legacy ALL row + sole player but assignment missing** → honest 0 → `unattributed[].reason === "unresolved_assignment"`.
4. **Legacy ALL row + multiple players (even if one's model matches)** → honest 0 → `reason === "multiple_players"`.
5. **Legacy ALL row + sole player + model mismatch** → honest 0 → `reason === "model_mismatch"`.
6. **Native rows** → attributed per player, summed per `(provider, model)` bucket, sorted.
7. **Native + legacy for same bucket** → attributed aggregate supersedes native (no double counting).
8. **Native row with unknown player** → surfaced as `unattributed` (`unknown_player`), never dropped.
9. **End-to-end** via `enrichGameDetail`: `role → assignment → usage`; two-player game leaves the legacy aggregate honest-0 on the matching player, single-player game attributes it.
10. **Backward compat** (runtime): legacy payload has no `modelUsage`/`resolvedModel` keys and no `unattributed` key; **compile-time**: `{ id, name, role } satisfies Player` typechecks.
```text
# tests 11 | # pass 11 | # fail 0
tsc --noEmit (strict) ......... PASS
```{"model": "deepseek-v4-flash", "problem_class": "typescript-api-enrichment", "result": "passed", "tests": 11}The gap: the game detail API had no per-player model/token attribution. The fix (implemented at ~/maf-gap-029/, TypeScript, strict, no build step) walks the attribution chain player.role → player_model_assignments(role → provider/model) → token_usage/api_calls[(game_id, provider, model)] and reconciles the two record shapes: native rows (real player_id) attribute directly; legacy rows (player_id = "ALL", whole-game per-model aggregates) are attributed only when the game has a sole player, the chain fully resolves, and the resolved (provider, model) matches — otherwise the honest answer is 0 and the aggregate is surfaced in an unattributed bucket instead of being guessed onto a player.
Backward compatibility: the shared Player type gains only optional fields (resolvedModel?, modelUsage?); with no usage rows the payload is byte-identical to the legacy shape. Crucially, recordTokenUsage has no production callers (native engine records nothing), so the enrichment reads the usage store directly and treats "zero rows" as "no per-player data exists" — it never fabricates data.
Core resolver (src/attribution.ts):
export function attributeUsage(gameId: string, playersIn: readonly Player[], deps: AttributionDeps): AttributionResult {
const rows = deps.usageRowsFor(gameId);
// Data-presence check: recordTokenUsage has NO production callers. If the
// store has zero rows, do not assume per-player data exists; keep the
// legacy payload untouched.
if (rows.length === 0) {
return { players: playersIn.map(p => ({ ...p })), unattributed: [], hasUsageData: false };
}
// 1. Resolve chain: player.role -> assignment(provider/model); track completeness.
const byRole = new Map(deps.assignmentsFor(gameId).map(a => [a.role, a] as const));
let allPlayersResolved = true;
for (const p of players) {
const a = byRole.get(p.role);
if (a) p.resolvedModel = { provider: a.provider, model: a.model };
else allPlayersResolved = false;
}
// 2. NATIVE rows (real player_id): attribute directly, aggregated per (provider, model).
// 3. LEGACY "ALL" rows: attribute ONLY if isSolePlayer && allPlayersResolved
// && solePlayer.resolvedModel matches (provider, model); else honest 0 ->
// push to `unattributed` with a reason (multiple_players / unresolved_assignment / model_mismatch).
// A provably-attributed aggregate supersedes native rows for the same bucket
// (the aggregate IS the whole-game total — never double count).
// 4. Attach non-empty buckets back: p.modelUsage = [...buckets.get(p.id)!] // optional field
return { players, unattributed, hasUsageData: true };
}
Shared Player type (src/types.ts) — optional fields keep old consumers compiling and running:
export interface Player extends GamePlayer { // GamePlayer = { id, name, role }
resolvedModel?: ProviderModel; // optional: chain resolution
modelUsage?: PlayerModelUsage[]; // optional: per-model tokens + apiCalls, with `attributed: boolean`
}
API entry (src/enrichGameDetail.ts) only adds unattributed when usage data exists, so legacy responses are untouched:
return { id: gameId, players: result.players, ...(result.hasUsageData ? { unattributed: result.unattributed } : {}) };
11/11 runtime tests pass (`node --test` on Node 22 with built-in type stripping) and `tsc --noEmit` (strict + `erasableSyntaxOnly`) passes. Cases covered:
1. **No usage rows at all** → `hasUsageData:false`, no `modelUsage`, no `resolvedModel`, no `unattributed` — no fabricated per-player data despite `recordTokenUsage` having no callers.
2. **Legacy ALL row + sole player + resolved model matches** → attributed (tokens + apiCalls), `unattributed: []`.
3. **Legacy ALL row + sole player but assignment missing** → honest 0 → `unattributed[].reason === "unresolved_assignment"`.
4. **Legacy ALL row + multiple players (even if one's model matches)** → honest 0 → `reason === "multiple_players"`.
5. **Legacy ALL row + sole player + model mismatch** → honest 0 → `reason === "model_mismatch"`.
6. **Native rows** → attributed per player, summed per `(provider, model)` bucket, sorted.
7. **Native + legacy for same bucket** → attributed aggregate supersedes native (no double counting).
8. **Native row with unknown player** → surfaced as `unattributed` (`unknown_player`), never dropped.
9. **End-to-end** via `enrichGameDetail`: `role → assignment → usage`; two-player game leaves the legacy aggregate honest-0 on the matching player, single-player game attributes it.
10. **Backward compat** (runtime): legacy payload has no `modelUsage`/`resolvedModel` keys and no `unattributed` key; **compile-time**: `{ id, name, role } satisfies Player` typechecks.
```text
# tests 11 | # pass 11 | # fail 0
tsc --noEmit (strict) ......... PASS
```{"model": "deepseek-v4-flash", "problem_class": "typescript-api-enrichment", "result": "passed", "tests": 11}