ADR-030: AI predictions cached per (session × user) for cross-client consistency¶
Status: Accepted
Date: 2026-04-23
Context: During the Apr 22 Beacon team playtest, players reported inconsistent DTA prediction results: Jonathan saw "DTA predicted correctly" for Amin, while Amin saw "DTA predicted wrong" on his own client for the same scenario. Root cause: each client called /api/ai/multiplayer-predict independently, and since the Claude prediction path is non-deterministic, the same input produced different predictions across calls. The ai_predictions table existed in the schema but was never wired up — predictions were generated on every fetch with no persistence.
Decision¶
Wire the existing ai_predictions table as a write-through cache for multiplayer predictions, keyed by (session_id, target_user_id) (the existing unique constraint). On request: check cache first, return persisted predictions; for cache misses, generate via the existing rule-based + Claude pipeline and upsert. All clients viewing the same session see identical predictions because they read from the same persisted row.
Rationale¶
The fix had to address three problems at once:
- Display consistency — players expect the same outcome to appear identically across clients.
- Cost reduction — without caching, every client triggered a Claude API call on the reveal screen. For a 4-player session, that's up to 4× the necessary cost just for displaying the same result to everyone.
- Race tolerance — multiple clients fetching simultaneously would each generate their own prediction; upsert with the existing unique constraint resolves this without a lock.
The ai_predictions table was already designed for exactly this use case (session_id, target_user_id, predicted_option_id, confidence_score, reasoning, model_version, prompt_version, scenario_context JSONB). It just wasn't wired up. The fix is therefore a write-through cache layer added inside the existing /api/ai/multiplayer-predict endpoint, no schema change required.
Alternatives Considered¶
- Server-side in-memory cache (Map keyed by session+user). Volatile across restarts and across serverless instances. Wouldn't survive a redeploy or a cold start. Rejected.
- Lock the prediction generation per session. Would serialize Claude calls and add latency. Doesn't help across instances. Rejected.
- Always run rule-based prediction, never Claude. Deterministic and free, but lower quality for new players (rule-based requires
games_analyzed >= 3). Rejected — would regress prediction quality. - Move the prediction generation to
/api/sessions/completeand persist there. Would tie predictions to session completion timing rather than first display, and requires changes to the session lifecycle. The cache-on-read approach is simpler and changes nothing about when predictions exist, only about whether they persist.
Discussion¶
The bug was reported as "DTA predictions wrong" but the actual user-visible problem was inconsistency — the predictions were arguably correct on each individual client; they just weren't the same prediction. This kind of distributed-systems consistency issue is invisible in single-player and only surfaces when multiple clients view the same data.
The schema was already correct because someone had previously planned this caching layer but never implemented the wiring. This is worth flagging as a pattern: tables that exist but are never written to are a signal of dropped intent, and the right action is usually to wire them up rather than tear them down.
The upsert with onConflict: 'session_id,target_user_id' lets multiple concurrent requests safely persist without explicit locking. The first writer wins; subsequent writes are no-ops on the conflict. Slightly wasteful in API cost (multiple Claude calls during the race), but the race window is small and the data is correct.
Consequences¶
- Cross-client consistency: all viewers of the same session see identical predictions. The Apr 22 bug is fixed at the source.
- Cost reduction: Claude calls happen at most once per (session × user) instead of once per client view.
- Cache invalidation considerations: if a player's profile changes mid-session and we want to re-predict, the cache currently doesn't invalidate. Acceptable for now since predictions are per-scenario and scenarios don't outlive their sessions.
- Telemetry implication: the
model_versionandprompt_versionare persisted with each cached prediction, so we can audit which model/prompt produced any historical result. Useful for ML rollback or A/B comparison. - Watch for: if we ever add prediction re-rolls (e.g., for a "regenerate prediction" admin action), we'll need to either invalidate the cache row or accept that the re-roll won't propagate to other clients viewing the same session.
Key files:
- src/app/api/ai/multiplayer-predict/route.ts — cache check + upsert wired into existing flow
- migrations/* — ai_predictions table (pre-existing, unchanged)