distillation: fix 7 grounding bugs found by Kimi audit

Kimi For Coding (api.kimi.com, kimi-for-coding) ran a forensic audit on
distillation v1.0.0 with full file content. 7/7 flags verified real on
grep. Substrate now matches what v1.0.0 claimed: deterministic, no
schema bypasses, Rust tests compile.

Fixes:
- mode.rs:1035,1042  matrix_corpus Some/None -> vec![..]/vec![]; cargo
                     check --tests now compiles (was silently broken;
                     only bun tests were running)
- scorer.ts:30       SCORER_VERSION env override removed - identical
                     input now produces identical version stamp, not
                     env-dependent drift
- transforms.ts:181  auto_apply wall-clock fallback (new Date()) ->
                     deterministic recorded_at fallback
- replay.ts:378      recorded_run_id Date.now() -> sha256(recorded_at);
                     replay rows now reproducible given recorded_at
- receipts.ts:454,495  input_hash_match hardcoded true was misleading
                       telemetry; bumped DRIFT_REPORT_SCHEMA_VERSION 1->2,
                       field is now boolean|null with honest null when
                       not computed at this layer
- score_runs.ts:89-100,159  dedup keyed only on sig_hash made
                            scorer-version bumps invisible. Composite
                            sig_hash:scorer_version forces re-scoring
- export_sft.ts:126  (ev as any).contractor bypass emitted "<contractor>"
                     placeholder for every contract_analyses SFT row.
                     Added typed EvidenceRecord.metadata bucket;
                     transforms.ts populates metadata.contractor;
                     exporter reads typed value

Verification (all green):
  cargo check -p gateway --tests   compiles
  bun test tests/distillation/     145 pass / 0 fail
  bun acceptance                   22/22 invariants
  bun audit-full                   16/16 required checks

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root 2026-04-27 05:34:31 -05:00
parent d11632a6fa
commit d77622fc6b
9 changed files with 72 additions and 21 deletions

View File

@ -15,7 +15,7 @@ import {
} from "./types"; } from "./types";
import type { StageName } from "./stage_receipt"; import type { StageName } from "./stage_receipt";
export const DRIFT_REPORT_SCHEMA_VERSION = 1; export const DRIFT_REPORT_SCHEMA_VERSION = 2;
export const DRIFT_THRESHOLD_PCT = 0.20; export const DRIFT_THRESHOLD_PCT = 0.20;
export type DriftSeverity = "ok" | "warn" | "alert"; export type DriftSeverity = "ok" | "warn" | "alert";
@ -27,7 +27,11 @@ export interface StageDrift {
delta_accepted: number; delta_accepted: number;
delta_quarantined: number; delta_quarantined: number;
pct_change_out: number | null; // null when prior had 0 records pct_change_out: number | null; // null when prior had 0 records
input_hash_match: boolean; // null when input_hash isn't materialized into the stage summary —
// schema v1 lied and reported `true` here. v2 is honest: callers
// that want determinism enforcement must read the full StageReceipt
// off disk and compute input_hash equality there.
input_hash_match: boolean | null;
output_hash_match: boolean; output_hash_match: boolean;
// alert if input_hash matches but output_hash diverges // alert if input_hash matches but output_hash diverges
deterministic_violation: boolean; deterministic_violation: boolean;

View File

@ -121,6 +121,14 @@ export interface EvidenceRecord {
// and have no text payload. Present for distilled_*, contract_analyses, // and have no text payload. Present for distilled_*, contract_analyses,
// mode_experiments, scrum_reviews etc. // mode_experiments, scrum_reviews etc.
text?: string; text?: string;
// ── Domain-specific metadata bucket ──
// Source-specific fields that don't earn a top-level slot. e.g.
// contract_analyses rows carry `contractor` here; mode_experiments
// could carry `corpus_set`. Typed scalar values only — keep this
// small or it becomes a junk drawer. Added 2026-04-27 (Kimi audit
// flagged `(ev as any).contractor` schema bypass at export_sft.ts:126).
metadata?: Record<string, string | number | boolean>;
} }
export function validateEvidenceRecord(input: unknown): ValidationResult<EvidenceRecord> { export function validateEvidenceRecord(input: unknown): ValidationResult<EvidenceRecord> {

View File

@ -1032,14 +1032,14 @@ mod tests {
preferred_mode: "codereview".into(), preferred_mode: "codereview".into(),
fallback_modes: vec!["consensus".into()], fallback_modes: vec!["consensus".into()],
default_model: "qwen3-coder:480b".into(), default_model: "qwen3-coder:480b".into(),
matrix_corpus: Some("distilled_procedural_v1".into()), matrix_corpus: vec!["distilled_procedural_v1".into()],
}, },
TaskClassEntry { TaskClassEntry {
name: "broken".into(), name: "broken".into(),
preferred_mode: "nonsense_mode".into(), preferred_mode: "nonsense_mode".into(),
fallback_modes: vec!["consensus".into()], fallback_modes: vec!["consensus".into()],
default_model: "x".into(), default_model: "x".into(),
matrix_corpus: None, matrix_corpus: vec![],
}, },
], ],
default: DefaultEntry { default: DefaultEntry {

View File

@ -122,9 +122,18 @@ function synthesizeSft(
case "observer_reviews": case "observer_reviews":
instruction = `Observer-review the latest attempt on '${ev.source_files?.[0] ?? "<file>"}'. Verdict: accept | reject | cycle.`; instruction = `Observer-review the latest attempt on '${ev.source_files?.[0] ?? "<file>"}'. Verdict: accept | reject | cycle.`;
break; break;
case "contract_analyses": case "contract_analyses": {
instruction = `Analyze contractor '${(ev as any).contractor ?? "<contractor>"}' for permit '${ev.task_id.replace(/^permit:/, "")}'. Recommend with risk markers.`; // Read contractor from the typed metadata bucket (populated in
// transforms.ts for contract_analyses rows). Pre-2026-04-27 this
// used `(ev as any).contractor` and silently emitted "<contractor>"
// for every row because EvidenceRecord didn't carry the field.
const contractor = typeof ev.metadata?.contractor === "string" ? ev.metadata.contractor : null;
const permit = ev.task_id.replace(/^permit:/, "");
instruction = contractor
? `Analyze contractor '${contractor}' for permit '${permit}'. Recommend with risk markers.`
: `Analyze permit '${permit}'. Recommend with risk markers.`;
break; break;
}
case "outcomes": case "outcomes":
instruction = `Run scenario; report per-event outcome with citations.`; instruction = `Run scenario; report per-event outcome with citations.`;
break; break;

View File

@ -451,7 +451,7 @@ export function buildDrift(current: RunSummary, prior: RunSummary | null): Drift
delta_accepted: cur.accepted, delta_accepted: cur.accepted,
delta_quarantined: cur.quarantined, delta_quarantined: cur.quarantined,
pct_change_out: null, pct_change_out: null,
input_hash_match: false, input_hash_match: null, // no prior stage to compare
output_hash_match: false, output_hash_match: false,
deterministic_violation: false, deterministic_violation: false,
notes: ["stage not present in prior run"], notes: ["stage not present in prior run"],
@ -461,12 +461,12 @@ export function buildDrift(current: RunSummary, prior: RunSummary | null): Drift
} }
const pct = pctChange(pri.records_out, cur.records_out); const pct = pctChange(pri.records_out, cur.records_out);
const out_match = pri.output_hash === cur.output_hash; const out_match = pri.output_hash === cur.output_hash;
const inp_match = (current.stages.find(s => s.stage === cur.stage)?.output_hash ?? "") // input_hash is NOT materialized into stage summaries (lives on the
!== "" /* placeholder */; // per-stage StageReceipt files on disk). We don't load them here, so
// We have output_hash on stage summaries but not input_hash — // we honestly report null. Schema v2 makes this explicit; v1 returned
// input_hash lives on the full StageReceipt, which we can re-read // `true` unconditionally which made deterministic_violation always
// from the run dir if needed. For simplicity, drift compares the // false even when it should have alerted. Cross-run determinism
// OUTPUT hashes (what really changed). // enforcement is its own pass — see ./scripts/distill audit-full.
const notes: string[] = []; const notes: string[] = [];
if (pct !== null && Math.abs(pct) > DRIFT_THRESHOLD_PCT) { if (pct !== null && Math.abs(pct) > DRIFT_THRESHOLD_PCT) {
const dir = pct > 0 ? "spike" : "drop"; const dir = pct > 0 ? "spike" : "drop";
@ -492,9 +492,9 @@ export function buildDrift(current: RunSummary, prior: RunSummary | null): Drift
delta_accepted: cur.accepted - pri.accepted, delta_accepted: cur.accepted - pri.accepted,
delta_quarantined: cur.quarantined - pri.quarantined, delta_quarantined: cur.quarantined - pri.quarantined,
pct_change_out: pct, pct_change_out: pct,
input_hash_match: true, // simplified — see comment above input_hash_match: null, // not computed at this layer; see comment above
output_hash_match: out_match, output_hash_match: out_match,
deterministic_violation: false, // requires input_hash match, see future tightening deterministic_violation: false, // requires input_hash match — null means "unknown", not "verified"
notes, notes,
}); });
} }

View File

@ -375,7 +375,12 @@ export async function replay(opts: ReplayRequest, root = DEFAULT_ROOT): Promise<
} }
} }
const recorded_run_id = `replay:${task_hash.slice(0, 16)}:${Date.now()}`; // Stable derivation from task_hash + recorded_at (already an ISO
// timestamp captured at start of the call). Avoids a second wall-clock
// read and makes run_id reproducible given a fixed recorded_at — useful
// for fixture-driven tests + acceptance gates. Replaces Date.now()-based
// id post-Kimi-audit 2026-04-27.
const recorded_run_id = `replay:${task_hash.slice(0, 16)}:${(await canonicalSha256(recorded_at)).slice(0, 12)}`;
const result: ReplayResult = { const result: ReplayResult = {
input_task: opts.task, input_task: opts.task,
task_hash, task_hash,

View File

@ -86,6 +86,17 @@ function gitDirty(root: string): boolean {
return r.status === 0 && r.stdout.trim().length > 0; return r.status === 0 && r.stdout.trim().length > 0;
} }
// Composite dedup key — `sig_hash:scorer_version`. Keying on sig_hash
// alone made scorer-rule bumps invisible: a bumped SCORER_VERSION
// produced different scoring categories, but pre-existing rows on disk
// (with the OLD version) still matched the new sig_hash and the new
// scoring was silently skipped. Compositing version forces re-scoring
// when the version changes. Caller tags `scorer_version` on the
// ScoredRun row, which we read alongside sig_hash.
function dedupKey(sig_hash: string, scorer_version: string): string {
return `${sig_hash}:${scorer_version}`;
}
function loadSeenHashes(out_path: string): Set<string> { function loadSeenHashes(out_path: string): Set<string> {
const seen = new Set<string>(); const seen = new Set<string>();
if (!existsSync(out_path)) return seen; if (!existsSync(out_path)) return seen;
@ -93,7 +104,9 @@ function loadSeenHashes(out_path: string): Set<string> {
if (!line) continue; if (!line) continue;
try { try {
const row = JSON.parse(line); const row = JSON.parse(line);
if (row?.provenance?.sig_hash) seen.add(row.provenance.sig_hash); const sh = row?.provenance?.sig_hash;
const sv = row?.scorer_version;
if (sh && sv) seen.add(dedupKey(sh, sv));
} catch { /* malformed — ignore */ } } catch { /* malformed — ignore */ }
} }
return seen; return seen;
@ -156,11 +169,12 @@ async function processEvidenceFile(
} }
const scored = await buildScoredRun(ev.value as EvidenceRecord, out_relpath, i, opts.recorded_at); const scored = await buildScoredRun(ev.value as EvidenceRecord, out_relpath, i, opts.recorded_at);
if (seen.has(scored.provenance.sig_hash)) { const key = dedupKey(scored.provenance.sig_hash, scored.scorer_version);
if (seen.has(key)) {
result.rows_deduped++; result.rows_deduped++;
continue; continue;
} }
seen.add(scored.provenance.sig_hash); seen.add(key);
const sv = validateScoredRun(scored); const sv = validateScoredRun(scored);
if (!sv.valid) { if (!sv.valid) {

View File

@ -27,7 +27,11 @@ import type { ScoreCategory, ScoredRun } from "../../auditor/schemas/distillatio
import { SCORED_RUN_SCHEMA_VERSION } from "../../auditor/schemas/distillation/scored_run"; import { SCORED_RUN_SCHEMA_VERSION } from "../../auditor/schemas/distillation/scored_run";
import { canonicalSha256 } from "../../auditor/schemas/distillation/types"; import { canonicalSha256 } from "../../auditor/schemas/distillation/types";
export const SCORER_VERSION = process.env.LH_SCORER_VERSION ?? "v1.0.0"; // Hardcoded — the deterministic-output contract requires this. Bump the
// literal in the same commit as any scoring-rule change so the version
// stamp moves atomically with logic. Env override removed 2026-04-27
// after Kimi audit flagged identical-input-different-version drift.
export const SCORER_VERSION = "v1.0.0";
export interface ScoreOutput { export interface ScoreOutput {
category: ScoreCategory; category: ScoreCategory;

View File

@ -100,6 +100,9 @@ export const TRANSFORMS: TransformDef[] = [
cost_usd: typeof row.cost === "number" ? row.cost / 1_000_000 : undefined, cost_usd: typeof row.cost === "number" ? row.cost / 1_000_000 : undefined,
latency_ms: row.duration_ms, latency_ms: row.duration_ms,
text: row.analysis, text: row.analysis,
metadata: typeof row.contractor === "string" && row.contractor.length > 0
? { contractor: row.contractor }
: undefined,
}), }),
}, },
{ {
@ -178,7 +181,11 @@ export const TRANSFORMS: TransformDef[] = [
// even though the text field is empty. // even though the text field is empty.
source_file_relpath: "data/_kb/auto_apply.jsonl", source_file_relpath: "data/_kb/auto_apply.jsonl",
transform: ({ row, line_offset, source_file_relpath, recorded_at, sig_hash }) => { transform: ({ row, line_offset, source_file_relpath, recorded_at, sig_hash }) => {
const ts: string = row.ts ?? new Date().toISOString(); // Deterministic fallback: use the source-file's recorded_at when
// the row itself lacks a ts. Wall-clock (new Date()) leaked here
// pre-2026-04-27 — broke bit-identical reproducibility on rows
// that historically wrote without a ts field.
const ts: string = row.ts ?? recorded_at;
const action = String(row.action ?? "unknown"); const action = String(row.action ?? "unknown");
const success = action === "committed"; const success = action === "committed";
const reverted = action.includes("reverted"); const reverted = action.includes("reverted");