Skip to content

ADR-080: Central-extraction single-points-of-failure — dependency graph + per-SPOF mitigation plan

Status: Accepted Date: 2026-06-01 Context: The measurement-schema research (docs/research/foundations/measurement-schema.md, architectural finding "Central-extraction single-points-of-failure") named E_classify_driver as a canonical SPOF — a small central extraction whose output fans out across many downstream primitives — and distinguished two failure modes: contaminated (signal exists but biased) and missing (signal absent). The missing mode was closed by the ADR-024 classify-driver backfill (docs/research/briefs/adr-024-classify-driver-backfill.md). The contaminated mode (Swing 3 verbal-style confound) and the broader SPOF audit — enumerating all central extractions, not just E_classify_driver, building the dependency graph the schema named as "Closure A," and deciding mitigations — were deferred. This ADR is that audit. It also ships Swing 3 Closure A (verbal-style normalization in E_classify_driver) as the one load-bearing code fix.

Bar applied throughout: evidence of impact, not theoretical risk. A SPOF earns active mitigation only where there is documented impact (a swing or a real-profile decomposition that shows the failure materializing). Everywhere else the decision is accept-with-documentation or cross-doc handoff. Redundancy (a parallel classifier/model) is the most expensive mitigation and is proposed nowhere — cheaper fixes (prompt normalization, prompt versioning, existing fallbacks) cover the cases that clear the bar.


At a glance

What it decides: Of the eight central extractions on main, exactly one — E_classify_driver — earns active mitigation, because it's the only silent SPOF whose impact is documented. It gets prompt normalization (Swing 3 Closure A) plus prompt versioning; everything else is accept-with-documentation or cross-doc handoff. No redundancy anywhere.

  • The organizing lens — silent vs. observable — prediction extractions are checked against the player's actual choice every round, so failure self-announces and they ensemble; classification extractions are never ground-truth-checked at runtime, so a biased classifier degrades every downstream signal silently. That inversion is why the headline-feeding predictors are the safe ones.
  • Bar applied throughout: evidence of impact, not theoretical risk — a SPOF earns mitigation only where a swing or real-profile decomposition shows the failure materializing (Swing 3 verbal-style confound; D4 LMA, D37 Cici).
  • Rejected: redundancy (a parallel classifier) — it adds cost, latency, and a second silent SPOF for a confound that is prompt-level; the keyword fallback already gives a free divergence cross-check. Closure B (a dedicated hedging index) also rejected — verbal style is a style attribute, not a behavioral signal.
  • Both SPOF failure modes now closedmissing by the ADR-024 backfill, contaminated by this ADR. The audit also resolved a prompt-duplication fragility (live route vs. backfill [MIRROR]) by extracting shared SSOT modules. Watch: A13 + E22 are unbuilt prospective SPOFs — when built, reuse the single normalized classify path.
flowchart TD
    CE["Central extractions on main"] --> PRED["Prediction (E_ai_pred, E_rule_pred)"]
    CE --> CLS["Classification (E_classify_driver, classify-note-semantics)"]
    PRED --> OBS["Ground-truth-checked vs C1 each round → observable, self-correcting → accept + monitor"]
    CLS --> SIL["Never checked at runtime → silent fan-out"]
    SIL --> ECD["E_classify_driver: HIGH + impact documented"]
    SIL --> A8["classify-note-semantics: fail-soft to neutral → accept"]
    ECD --> MIT["Mitigate: Closure A prompt normalization + prompt versioning + keyword cross-check (no redundancy)"]

The prediction-vs-classification split is the reusable test: observable extractions self-correct and are accepted; only the silent, impact-documented E_classify_driver clears the bar for active mitigation.

The central organizing distinction

Two classes of central extraction, with very different risk profiles:

  • Prediction extractions (E_ai_pred, E_rule_pred) are checked against ground truth every round — the player's actual choice (C1) reveals whether the prediction was right. Contamination is therefore observable: a degraded predictor shows up as falling sync accuracy. These feed the headline sync_score (A1), yet are low-priority SPOFs precisely because failure is self-announcing and the two predictors ensemble (ADR-010).
  • Classification extractions (E_classify_driver, classify-note-semantics) are never checked against ground truth at runtime — there is no later event that reveals whether "consequentialist / interpersonal-attention" was the right label. Contamination is therefore silent: a biased classifier degrades every downstream signal with no error signal of its own.

This is what makes E_classify_driver the canonical SPOF — not its fan-out alone, but that its fan-out is silent and its inputs are free-text (maximally confoundable, per Swing 3).


Enumeration — central extractions live on main

Extraction Mechanism Fallback Ground-truth-checkable? Failure visibility
E_classify_driver (api/ai/classify-driver) Claude (Sonnet) keyword (detectPrimaryDriver) Silent (partial: classifier_disagreements rate; E10 alignment-rate drop)
classify-note-semantics (A8, ADR-078) Claude fail-soft → neutral Silent, but conservative-by-default (collapses to neutral)
E_ai_pred (api/ai/solo-predict, multiplayer-predict) Claude (Sonnet) rule-pred ensemble / null ✅ (vs C1 each round) Observable (sync accuracy)
E_rule_pred (predictFromProfile) rule engine null < 3 games Observable
Learning note (ADR-057, learning-notes.ts) Claude (Sonnet) 3-tier → prose n/a (display, not profile-feeding) n/a
agent_context_block (agent/summary.ts) Claude (Haiku) deterministic fallback Terminal (exposure-layer; does not propagate into aggregates)
insights-headlines (twin/insights-headlines) Claude fallback prose Terminal (exposure)
Cube tagger (scenario-tagger.ts) Claude (Sonnet) skip/null at author time partial Substrate — cross-doc

Not yet implemented (named in the schema as future classifiers; only A8 of the "Wave 1/2 classifiers" actually shipped): A13 agent-feedback-positivity free-text classification (today the rating is captured, the free-text is not classified) and E22 E_context_bleed. Their SPOF risk is prospective — it begins when they are built.


Dependency graph — E_classify_driver consumers ("Closure A" the schema named)

This is the graph the schema asked to be made explicit so consumers know which signals are at risk if the central extraction is contaminated.

Direct consumers (write-time, in player-model.ts → updatePlayerProfile):

E_classify_driver output Direct consumer Then feeds
chosenDriver (semantic, else keyword) A4 category_patterns; A5 context_rules; aggregates_by_mode (ADR-079) X4 cards, X5 prose, rule predictor
detectedTriggers (semantic, else keyword) A5 context_rules; trigger_exposure rule predictor, X5
reasoning_mode (+ confidence) reasoning_style_distribution → E15 → A7 X3/X5
attention_focus attention_distribution → E16 → A7 X3/X5
classifiedRationaleDriver vs keywordDriver E10 rationale-choice expression match → A7 held back from exposure

Transitive / prospective consumers (named in the schema; status verified against code):

Consumer Status on main Dependency
E12 occasion noise (rationale-controlled refinement) prospective — current occasion_noise only counts repeats; the reasoning-similarity gate is not yet coded would consume reasoning_mode/driver/attention similarity
E18 self-awareness partial — milestone/resonance live; the "computed-profile baseline" it compares C7 against is built from classify-driver aggregates inherits contamination of A4–A7
E14 ideal-actual reference frequency prospective — not implemented C2/C6 free-text classification (classifier family)
E22 E_context_bleed / A14 prospective — not implemented classifier family
A13 free-text positivity prospective — rating-only today classifier family

Audit correction to the schema's stated graph: the schema lists E_classify_driver as feeding A6 option_preferences. In code, A6 (updateKeywordPreferences) is derived from option text + rationale keyword extraction and does not consume the classifier output — it is independent of E_classify_driver. The schema's "feeds A6" overstates the coupling; flagged for a schema-doc tweak.


Per-SPOF risk classification + mitigation

Contamination risk and missing risk assessed independently.

SPOF Contamination Missing Mitigation (decision)
E_classify_driver HIGH + silent. Verbal-style confound (Swing 3): reasoning_mode_confidence tracks hedging surface-style as much as actual reasoning confidence. Impact materialized in real data — D4 LMA (the schema misses meta-blindness when contaminated) and D37 Cici (authenticity 0.14, canonical dual-interpretation). CLOSED. Was HIGH (75% of real profiles pre-ADR-024 empty); closed by the ADR-024 backfill. Ship Swing 3 Closure A (verbal-style normalization — this PR) + prompt versioning (CLASSIFY_PROMPT_VERSION) + periodic classifier_disagreements audit. Detection assist: watch E10 alignment-rate. No redundancy — keyword fallback already provides a cheap divergence cross-check.
classify-note-semantics (A8) LOW–MED. Stance classification; a heavily hedged note could skew toward neutral. Same free-text family as E_classify_driver but lower stakes — feeds only A8 stratification → M5. No impact evidence yet. Built-in fail-soft → neutral (conservative default). Accept + document. Fail-soft-to-neutral is the correct conservative default. Extend Closure A normalization only if A8 stratification shows verbal-style skew (trigger: observed, not hypothetical).
E_ai_pred MED but observable (sync accuracy drops). LOW — always predicts; new-player confidence capped. Accept + monitor. Ensemble with E_rule_pred already provides redundancy. Bar for active mitigation not met.
E_rule_pred LOW — deterministic, inspectable. Handled — null < 3 games by design. Accept.
Learning note LOW — display-only, does not feed the profile. 3-tier fallback. Accept + document.
agent_context_block MED but terminal — exposure-layer prose; a bad block misframes one read but does not propagate into stored aggregates. Deterministic fallback guarantees non-null. Accept + document. Input discipline already governed by Convention 6 (N-propagation, ADR-078) and Convention 7 (staleness, ADR-079) + Principle 7.
insights-headlines LOW–MED, terminal exposure. fallback prose. Accept.
Cube tagger Mis-tagged substrate propagates to E_chosen_driver / E_chosen_trigger and every Q4c/Q4d-stratified primitive. author-time skip/null. Cross-doc handoff to scenario-dimension-cube.md. Not mitigated schema-side.
A13 / E22 (unbuilt) n/a until built. n/a (signals honestly null today). When built: reuse the normalized classify path as a single call — do not add a second model dependency (ADR-078's "second call adds a second SPOF"). Ship after Closure A so they inherit normalization.

New structural finding from this audit — prompt duplication of the canonical SPOF

The E_classify_driver classification prompt was duplicated: the live route (classify-driver/route.ts) and a [MIRROR] copy in scripts/backfill-classify-driver.ts (commented "keep in sync"). Duplicating the canonical SPOF's prompt is itself a fragility — a fix applied to one and not the other silently reintroduces contamination on the next re-backfill (and the backfill is exactly the mechanism that closed the missing mode).

The duplication was wider than just the prompt — the backfill carried hand-synced [MIRROR] copies of the output-validation sets, the keyword-driver detector, and the EMA signal-update helpers, all duplicating player-model.ts. The drift this risks was already real, found during the dedup: getGameTypeWeight had lost its speed_round case in the mirror, getUpdateWeight had a looser parameter type, and detectPrimaryDriver had a different signature — three silent divergences between the live updater and the backfill.

Resolved (two follow-up dedups). Everything that must stay in lockstep between the live route/updater and the backfill now has one source of truth: - src/lib/ai/classify-driver-prompt.ts — the prompt, its input type, CLASSIFY_PROMPT_VERSION, and the output vocab (VALID_REASONING_MODES, VALID_ATTENTION_CATEGORIES, VALID_VERBAL_STYLES). - src/lib/ai/profile-signal-helpers.ts — the EMA signal-update helpers (updateDistribution, the reasoning/attention/authenticity updaters), the weights (getUpdateWeight, getGameTypeWeight — canonical, with speed_round), EMA_ALPHA, and the keyword-driver detector (detectPrimaryDriver + DRIVER_KEYWORDS). Signal-shape types are derived from the DB SSOT (NonNullable<PlayerDecisionProfile[...]>) so there is no second shape declaration to drift. - Drivers/triggers reuse the existing driver-trigger-constants.ts (DRIVERS/TRIGGERS/isDriver/isTrigger).

The route, player-model.ts, and the backfill all import these; the route imports via @/ and the backfill via the established ../src/... script convention (same pattern as backfill-scenario-tags.ts, verified to resolve transitively under tsx). Co-locating CLASSIFY_PROMPT_VERSION with the prompt makes cache invalidation more robust — one file to edit, and the backfill hashes the shared version into its cache key. Scope held to lockstep logic only: request-specific orchestration (updatePlayerProfile, DB writes, the calibration/timing/learning/occasion/name-bias updaters the backfill never uses) stays in player-model.ts. The only remaining backfill mirror is MODEL (the model-version string).


Decision

  1. Ship Swing 3 Closure A in E_classify_driver: the prompt now instructs the model to judge reasoning_mode and reasoning_mode_confidence from the logical structure of the rationale, explicitly treating hedging/decisive wording as surface style; and to emit a separate verbal_style tag so style is captured rather than leaking into the reasoning-confidence signal. (Implemented in this PR.)
  2. Adopt prompt versioning (CLASSIFY_PROMPT_VERSION, logged with output) as the standing lightweight mitigation for E_classify_driver — enables version-vs-output delta tracking without a migration.
  3. Sync the backfill mirror and bump its cache version so Closure A reaches re-backfilled data.
  4. Accept-with-documentation for E_ai_pred, E_rule_pred, learning-note, agent_context_block, insights-headlines — existing fallbacks/observability are sufficient and the impact bar is not met.
  5. classify-note-semantics: keep fail-soft-to-neutral; normalize only on observed verbal-style skew.
  6. Cube tagger: cross-doc handoff to the cube doc.
  7. A13 / E22 (when built): single normalized classify call, no second SPOF.
  8. Do NOT adopt Closure B (a dedicated E_hedging_index) — the schema deemed it unneeded; verbal style is a signal-style attribute, not a separate behavioral signal.

The canonical schema doc's SPOF section references this ADR for the dependency graph and mitigations.


Rationale

  • Why prompt normalization, not redundancy, for the one SPOF that clears the bar. The documented impact (Swing 3 verbal-style; D4/D37) is a prompt-level confound — the classifier conflates surface style with reasoning confidence. The cheapest fix that addresses the actual mechanism is a prompt instruction, not a second model. A parallel classifier would add API cost, latency, and a second silent SPOF, while the existing keyword fallback already gives a free divergence signal (logged to classifier_disagreements).
  • Why prompt versioning earns its place. It is nearly free (one constant + a log field) and turns an otherwise-silent extraction into one whose behavior changes are attributable to prompt revisions — the minimum viable observability for a silent SPOF.
  • Why the prediction SPOFs are deprioritized despite feeding sync_score. Ground-truth-checkability inverts the naive ranking: the headline-feeding predictors are the safest central extractions because failure is observable and self-correcting; the quiet classifier is the dangerous one.

Alternatives considered

  • Redundancy (parallel classifier, different prompt/model) for E_classify_driver. Rejected — cost + latency + a second silent SPOF, with no advantage over prompt normalization for a prompt-level confound.
  • Explicit confidence-interval propagation for signal quality. Deferred — Convention 6 already propagates sample-size N; a separate signal-quality CV is heavier and not yet load-bearing. The contaminated/missing distinction plus prompt versioning covers the current need.
  • Closure B — dedicated E_hedging_index extraction. Rejected per the schema (verbal style is a style attribute, not a behavioral signal).
  • Extract the shared classify-driver logic into modules. Done as follow-up dedups: the prompt + output vocab in classify-driver-prompt.ts, the EMA/keyword helpers in profile-signal-helpers.ts; route, player-model.ts, and backfill now import one source each. Originally deferred to keep the Closure A change discrete, then shipped as contained no-behavior-change PRs. Scope held to lockstep logic — orchestration stays in player-model.ts.
  • Persist verbal_style as a stored signal (migration). Rejected for this PR — logged-only is enough to validate the normalization; promote to a column later only if A8/E10 framing needs it.

Consequences

  • Closure A unblocks the HG (hedger) and LMA (low-meta-awareness) profile-class discriminations that the schema flagged as gated on verbal-style normalization (D4, D37).
  • verbal_style is logged (not stored); promotable to a column later with no rework.
  • CLASSIFY_PROMPT_VERSION enables version-vs-output delta tracking in logs.
  • Backfill re-runs now apply normalization (cache version unified to the shared cd-2026-06-01-verbal-style-norm).
  • The SPOF finding's two modes are now both addressed: missing closed (ADR-024 backfill), contaminated closed (this ADR).
  • The route↔backfill lockstep duplication is fully resolved (prompt, output vocab, EMA/keyword helpers all shared); the only remaining backfill mirror is the MODEL version string. A pre-existing tsc nit in the backfill's dry-run cost estimate (a Round passed where the prompt input is expected) is left untouched — unrelated to the dedup, dry-run only, tsx runs regardless.
  • Residual / watch: A13 + E22 remain unbuilt (prospective SPOFs, documented); classify-note-semantics verbal-style skew is unmonitored until A8 data justifies it.

Discussion

The audit's main value was re-baselining against reality. Three things only became clear by reading main rather than the schema's prose: (1) the missing mode is genuinely closed, so contamination is the only open mode; (2) classify-note-semantics (A8) is a live second member of the classifier family — the first realized instance of the schema's "Principle 7 closures compound the SPOF" tension, which is the concrete argument for shipping Closure A before E22/A13; and (3) the schema's "feeds A6" coupling does not exist in code. The bar — evidence of impact — did most of the pruning: of eight live central extractions, exactly one (E_classify_driver) cleared it, which matches the schema's intuition that it is canonical and the rest are not load-bearing enough to mitigate. The prediction-vs-classification (ground-truth-checkable-vs-silent) framing is the reusable lens: it explains why the headline-feeding predictors are safe and the quiet classifier is not, and it is the test to apply to any future central extraction before deciding whether it needs mitigation.

Key files: - src/lib/ai/classify-driver-prompt.ts — shared SSOT for the prompt + input type + CLASSIFY_PROMPT_VERSION + output vocab (reasoning modes / attention / verbal styles) - src/lib/ai/profile-signal-helpers.ts — shared SSOT for the EMA signal-update helpers + weights + keyword-driver detector (the former backfill [MIRROR]) - src/app/api/ai/classify-driver/route.ts — Swing 3 Closure A (prompt normalization + verbal_style); imports the shared prompt + vocab + isDriver/isTrigger - src/lib/ai/player-model.ts — imports the shared helpers + vocab (local copies removed; orchestration unchanged) - scripts/backfill-classify-driver.ts — imports the shared prompt/vocab/helpers (all [MIRROR] blocks removed except MODEL) - docs/research/foundations/measurement-schema.md — SPOF section references this ADR - docs/research/briefs/adr-024-classify-driver-backfill.md — the missing-mode closure - Enumeration sources: src/lib/ai/classify-note-semantics.ts, src/lib/agent/summary.ts, src/app/api/ai/solo-predict/route.ts + multiplayer-predict, src/lib/ai/player-model.ts, src/lib/ai/scenario-tagger.ts, src/lib/ai/learning-notes.ts