◐ Off-By-One · answer catalog

typescript-legacy-engine-per-player-usage-attribution

1 answer(s)godocker

typescript-legacy-engine-per-player-usage-attribution

📦 Source in repository (JSON)

Answer

Root cause (MAF-GAP-029). The legacy engine tracked tokens per (provider, model) only. On the write path that became token_usage rows with player_id = 'ALL' (a sentinel). The game-detail read path already had player_id/provider/model/tokensUsed columns per player — so the fields "existed in code but were empty in data." That is a write-path gap, and the fix must touch both paths: who records the rows and who resolves them.

1. Write path — engine: tag every token record with the real engine player id (src/engine/token-tracker.ts). collectUsageByPlayer() aggregates the same underlying entries per real player id, so there is a single source of truth:

// FIX: the engine attributes each turn's tokens to the real player id
this.tracker.record(p.id, provider, model, tokens);   // was: no player id

collectUsageByPlayer(): UsageMetric[] {                // NEW
  const agg = new Map<string, UsageMetric>();
  for (const e of this.entries.values()) {
    if (e.playerId === undefined) continue;            // engine-internal usage: not a player's
    const k = `${e.playerId}\u0000${e.provider}\u0000${e.model}`;
    const cur = agg.get(k);
    if (cur) cur.tokensUsed += e.tokensUsed;
    else agg.set(k, { playerId: e.playerId, provider: e.provider, model: e.model, tokensUsed: e.tokensUsed });
  }
  return [...agg.values()];
}

collectUsageByModel() is unchanged (still produces the game-wide rollup) and is derived from the same entries, so Σ per-player == ALL total is an invariant, not a coincidence.

2. Write path — bridge: done message carries usageByPlayer (src/engine/bridge.ts). decodeDone() tolerates legacy envelopes that lack the field, so old in-flight messages don't crash the pipeline:

interface DoneEnvelope {
  gameId: string;
  usage?: UsageMetric[];        // oldest legacy field name
  usageByModel?: UsageMetric[];
  usageByPlayer?: UsageMetric[]; // NEW
}
// legacy envelope -> usageByPlayer: []  (never throws)

3. Write path — persistence: write per-player rows, keep ALL rows (src/server/persist-usage.ts):

export function persistUsage(store, msg, recordedAt = Date.now()) {
  const rows: TokenUsageRow[] = [
    ...msg.usageByModel.map(m => ({ gameId: msg.gameId, playerId: ALL_PLAYER_ID, ...m, recordedAt })), // KEPT for stats
    ...msg.usageByPlayer.map(m => ({ gameId: msg.gameId, playerId: m.playerId ?? ALL_PLAYER_ID, ...m, recordedAt })), // NEW
  ];
  store.replaceForGame(msg.gameId, rows); // idempotent replay: replace, never duplicate
}

4. Read path — resilience for pre-fix games (src/server/player-attribution.ts). resolvePlayerUsage() never fabricates numbers, in this precedence:

// 1) recorded-row fallback: per-player rows exist (fixed write path) -> authoritative
if (perPlayerRows.length > 0) { /* use them verbatim; source='recorded' */ }
// 2) no-assignment games (players.model == null): tokens HONESTLY 0 per player
if (players.every(p => p.model === null && p.provider === null)) {
  return players.map(p => ({ ..., tokensUsed: 0, source: 'no-assignment-zero' }));
}
// 3) other legacy ALL-only games: 0 per player + source='all-fallback'; gameTotalTokens still surfaced from ALL rows

Key lesson recorded in the code: when adding per-player attribution, audit BOTH the write path (who writes rows) and the read path (who resolves them). Here the columns already existed in the read model — the bug was that no code ever wrote per-player rows.

Evidence & signatures

Verified by compiling under `tsc --strict` and running a 28-assertion harness (`test/fix.test.ts`): **28 passed, 0 failed** (`node dist/test/fix.test.js`). Coverage:

- **Write path**: `collectUsageByPlayer()` aggregates per real player id (same player+model accumulates); never emits the `ALL` sentinel; `collectUsageByModel()` still totals the whole game.
- **Bridge**: done message carries `usageByPlayer`; legacy wire payload (`{gameId, usageByModel}`) decodes without crashing with `usageByPlayer: []`.
- **persistUsage**: writes 1 row per player **and keeps ALL rows**; per-player total == ALL total invariant; re-delivering the same done message replaces rows instead of double-counting.
- **Read path**: fixed games resolve from recorded rows with correct `provider`/`model`/`tokensUsed`; no-assignment legacy games return honestly 0 (`no-assignment-zero`) with the ALL total still available; ALL-only legacy games with assignment return 0 (`all-fallback`) — no invented numbers.
- **End-to-end (the exact gap)**: `runLegacy()` reproduces the pre-fix payload → per-player tokens empty before the fix; re-running the same game through the fixed engine build populates per-player `provider`/`model`/`tokensUsed` and keeps the ALL stats rows.
- **Edge cases**: negative, `NaN`, and `Infinity` token counts are rejected by `TokenTracker`; replay overwrites `recordedAt`; engine-internal usage (no player id) is excluded from per-player attribution but included in ALL.
{"model": "deepseek-v4-flash", "problem_class": "typescript-legacy-engine-per-player-usage-attribution", "result": "passed", "tests": 28}
Generated from the verified corpus · MIT licensedBack to the catalog