feat(scoring): schema 115 — score explanations persist, audits can finally ask "why" after the fact - #564
Merged
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.rsstored only score/version/signal columns, andsource_items.rank_factorscovers only the display-rank lane.mcp_score_autopsyitself 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_VERSION114 → 115):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}}scoreis the raw score the scorer produced for THIS evaluation — embedded because the durablerelevance_scorecan lag it by up to the 0.05 hysteresis band, and the batch layer re-ranks on top. The row answers "why" without joins.breakdownis the completeScoreBreakdown(every axis, dep match, cap, degraded-input marker), serialized compact.truncatedis 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 intruncated); 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_scoresgrew a fifth tuple element (ScorePersistRowalias) 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 newEvaluatedItem.breakdown_json, serialized at capture time fromevidence_score— the batch layer may have deleted the full result by persist time), and backfill/drain (viapersistable(), serialized afterfinalize_scoresso 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:
NO PIPELINE_VERSION bump — this is write-only additional data; scores are byte-identical with or without it.
Retention
ON DELETE CASCADEfromsource_items(thesource_item_dependenciesprecedent;PRAGMA foreign_keys = ONis set inDatabase::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_autopsykeeps 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: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 targetssource_itemswithCASCADE, 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,truncatedrecords original lengths, typed re-read still works.test_pruned_item_cascades_explanation—DELETE FROM source_itemstakes 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).Gates:
cargo fmtclean,cargo clippy -- -D warningsclean (lib + both binaries compile),cargo test --libgreen,node scripts/check-file-sizes.cjspasses (warnings only;commands.rsat 966 — next touch should split the autopsy module out).🤖 Generated with Claude Code
https://claude.ai/code/session_01LrXvdHoDGUj99Fqf1fCYJY