Skip to content

feat(scoring): schema 115 — score explanations persist, audits can finally ask "why" after the fact - #564

Merged
runyourempire merged 1 commit into
mainfrom
feat/schema-115-scoring-explanations
Aug 31, 2026
Merged

feat(scoring): schema 115 — score explanations persist, audits can finally ask "why" after the fact#564
runyourempire merged 1 commit into
mainfrom
feat/schema-115-scoring-explanations

Conversation

@runyourempire

Copy link
Copy Markdown
Collaborator

The gap (E5010, standing since the 2026-08-21/23 audits)

Scoring explanations were session-only. The LLM lane persists its explanations (llm_judgments.explanation), but the deterministic scorer's per-item breakdown — which axes fired, which dependency matches grounded it, which caps applied — evaporated with the process. Twice during audits the question "why did zustand score 0.092" was unanswerable post-hoc: db/cache.rs stored only score/version/signal columns, and source_items.rank_factors covers only the display-rank lane. mcp_score_autopsy itself documented the dead end: "a restart destroys exactly the state the autopsy needs."

What ships

Schema 115 — scoring_explanations (migration follows the #558 pattern; TARGET_VERSION 114 → 115):

CREATE TABLE scoring_explanations (
    source_item_id   INTEGER PRIMARY KEY,          -- one row per item, newest evaluation wins
    pipeline_version INTEGER NOT NULL,
    breakdown        TEXT NOT NULL,                -- bounded JSON envelope, hard-capped 8 KB
    scored_at        TEXT NOT NULL DEFAULT (datetime('now')),
    FOREIGN KEY (source_item_id) REFERENCES source_items(id) ON DELETE CASCADE
);

Shape chosen

The stored value is a self-contained envelope, not the raw struct:

{"score": 0.42, "breakdown": { ...full ScoreBreakdown... },
 "truncated": {"breakdown.matched_deps": 57}}
  • score is the raw score the scorer produced for THIS evaluation — embedded because the durable relevance_score can lag it by up to the 0.05 hysteresis band, and the batch layer re-ranks on top. The row answers "why" without joins.
  • breakdown is the complete ScoreBreakdown (every axis, dep match, cap, degraded-input marker), serialized compact.
  • truncated is the loud marker the size bound requires: each shortened path mapped to its original length. Arrays are shortened homogeneously — never replaced with marker strings — so the stored object still deserializes into the typed struct.

Size bounds

Two-pass bounding in db/scoring_explanations.rs: arrays > 8 entries and strings > 400 chars truncate (recorded in truncated); if the envelope still exceeds 8192 bytes a second pass at 3/80 runs; the unconditional floor is a {"score", "elided": true} scalar envelope. Typical real breakdowns serialize at 1.5–3 KB and are stored verbatim.

Write path (and its cost)

The envelope rides the existing persist boundary — persist_analysis_scores grew a fifth tuple element (ScorePersistRow alias) and upserts the explanation in the same transaction as the score write, one prepared statement. All three persist paths feed it: the analysis cycle (via a new EvaluatedItem.breakdown_json, serialized at capture time from evidence_score — the batch layer may have deleted the full result by persist time), and backfill/drain (via persistable(), serialized after finalize_scores so the envelope score is exactly what persists).

Measured (measure_explanation_write_overhead, ignored/manual, 1000 items, dev profile): +5.6 µs/item inside the transaction (10.6 ms → 16.2 ms per 1000-row persist batch) plus ~129 µs/item serialization that runs on the scoring threads outside the transaction — against a scoring path that spends ~100 ms+ per item on KNN, well under 0.2% of batch wall-clock. Release builds shrink the serialization further.

Two deliberate skip rules:

  • Hysteresis-suppressed writes keep the old explanation. The durable score kept its old value, so the explanation that produced that value stands; replacing it would attach a breakdown whose score doesn't match the durable column.
  • 0-row score UPDATEs never touch the explanation table, so a stale id can never violate the FK.

NO PIPELINE_VERSION bump — this is write-only additional data; scores are byte-identical with or without it.

Retention

ON DELETE CASCADE from source_items (the source_item_dependencies precedent; PRAGMA foreign_keys = ON is set in Database::new). Every existing prune path (prune_noise, the retention sweeps, husk deletion) deletes the explanation in the same statement — nothing leaks, no new cleanup code to forget.

Read path — provenance-labeled fallback, no new command

mcp_score_autopsy keeps its live lane (full session context: similar items, live ACE matching) and now labels it "provenance": {"kind": "live_session"}. On a session miss — a restart, or an item only a background cycle scored — it falls back to the persisted envelope instead of erroring, labeled:

"provenance": {"kind": "persisted", "label": "persisted at scoring time (<ts> UTC, pipeline vN)", ...}

The persisted response reuses the same measured-axis component builder (extracted as breakdown_components, shared by both lanes), derives matched deps/confirmed signals from the breakdown itself, and deliberately does not replay ACE matching against today's context — that would mislabel its provenance. The raw stored breakdown is included (raw_breakdown) so audits get the full record even if typed parsing of a future envelope fails. Response changes are purely additive — existing keys unchanged.

Tests

  • test_phase_115_scoring_explanations_table — migration: columns, FK targets source_items with CASCADE, idempotent re-run keeps rows (repo phase-test pattern).
  • test_explanation_write_then_read_roundtrip — persist → read; pipeline version stamped; second evaluation replaces (PRIMARY KEY upsert, exactly one row).
  • test_bounded_breakdown_json_caps_size_and_marks_truncation — 500 matched deps + 14k-char llm_reason stays ≤ 8 KB, truncated records original lengths, typed re-read still works.
  • test_pruned_item_cascades_explanationDELETE FROM source_items takes the explanation with it.
  • test_hysteresis_suppressed_write_keeps_old_explanation — a Δ<0.05 re-score does not replace the durable score's explanation.
  • measure_explanation_write_overhead#[ignore]d manual timing (numbers above).
  • Existing persist/churn/hysteresis tests updated to the 5-tuple; semantics untouched.

Gates: cargo fmt clean, cargo clippy -- -D warnings clean (lib + both binaries compile), cargo test --lib green, node scripts/check-file-sizes.cjs passes (warnings only; commands.rs at 966 — next touch should split the autopsy module out).

🤖 Generated with Claude Code

https://claude.ai/code/session_01LrXvdHoDGUj99Fqf1fCYJY

…nally ask "why" after the fact

Closes the E5010 auditability gap (standing finding, 2026-08-21/23 audits):
the scorer's per-item breakdown lived only in the session's in-memory
analysis results, so "why did zustand score 0.092" was unanswerable
post-hoc. New scoring_explanations table (one bounded JSON envelope per
item, newest evaluation wins, ON DELETE CASCADE from source_items) written
by persist_analysis_scores in the same transaction as the score write.
mcp_score_autopsy falls back to the persisted breakdown on a session miss,
provenance-labeled ("persisted at scoring time, pipeline vN"). Write-only
additional data — scores unchanged, NO PIPELINE_VERSION bump.

Measured overhead: +5.6 us/item inside the persist transaction; ~129
us/item serialization off-transaction on the scoring threads (dev profile),
<0.2% of batch wall-clock.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LrXvdHoDGUj99Fqf1fCYJY
@runyourempire
runyourempire enabled auto-merge (squash) August 31, 2026 02:49
@runyourempire
runyourempire merged commit 8b43c90 into main Aug 31, 2026
15 checks passed
@runyourempire
runyourempire deleted the feat/schema-115-scoring-explanations branch August 31, 2026 03:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant