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:
parent
d11632a6fa
commit
d77622fc6b
@ -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;
|
||||||
|
|||||||
@ -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> {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user