distillation: Phase 5 — receipts harness (system-level observability)
Forensic-grade per-stage receipts wrapping all 5 implemented pipeline
stages. Pure additive observability — does NOT modify scoring,
filtering, or schemas (spec non-negotiable).
Files (6 new):
auditor/schemas/distillation/stage_receipt.ts StageReceipt v1
auditor/schemas/distillation/run_summary.ts RunSummary v1
auditor/schemas/distillation/drift_report.ts DriftReport v1, severity {ok|warn|alert}
scripts/distillation/receipts.ts runAllWithReceipts + buildDrift + CLI
tests/distillation/receipts.test.ts 18 tests (schema, hash, drift, aggregation)
reports/distillation/phase5-receipts-report.md acceptance report
Stages wrapped:
collect (build_evidence_index → data/evidence/)
score (score_runs → data/scored-runs/)
export-rag (exports/rag/playbooks.jsonl)
export-sft (exports/sft/instruction_response.jsonl)
export-preference (exports/preference/chosen_rejected.jsonl)
Reserved (not yet implemented): extract-playbooks, index.
Output tree (per run_id):
reports/distillation/<run_id>/
collect.json score.json export-rag.json export-sft.json export-preference.json
summary.json summary.md drift.json
Test metrics: 135 distillation tests pass · 0 fail · 353 expects · 1.5s
(Phase 5 added 18; total 117→135)
Real-data run-all (run_id=78072357-835d-...):
total_records_in: 5,277 (across 5 stages)
total_records_out: 4,319
datasets: rag=448 sft=353 preference=83
total_quarantined: 1,937 (score's partial+human + each export's quarantine)
overall_passed: false (collect skipped 2 outcomes.jsonl rows missing created_at —
carry-over from Phase 2; faithfully propagated)
run_hash: 7a14d8cdd6980048a075efe97043683a4f9aabb38ec1faa8982c9887593090e0
Drift detection (second run):
prior_run_id detected automatically
severity=ok (no count or category swung >20%)
flags: ["run_hash differs from prior run"] — expected, since recorded_at
is baked into provenance and changes per run. No false alert.
Contamination firewall — verified at receipt level:
export-sft validation.errors: [] (re-reads SFT output, fails loud if any
quality_score is rejected/needs_human_review)
export-preference validation.errors: [] (re-reads, fails loud if any
chosen_run_id == rejected_run_id or chosen text == rejected text)
Invariants enforced (proven by tests + real run):
- Every stage emits ONE receipt per run (5/5 on disk)
- All receipts share run_id (uuid generated per run-all)
- aggregateIoHash is order-independent + collision-free across path/content
- Schema validators gate every receipt before write (defense in depth)
- Drift detection: pct_change > 20% → warn; new error class → warn
- Failure propagation: any stage validation.passed=false → overall_passed=false
- Self-validation: harness throws if RunSummary/DriftReport fail their own schema
CLI:
bun run scripts/distillation/receipts.ts run-all
bun run scripts/distillation/receipts.ts read --run-id <id>
Spec acceptance gate (now.md Phase 5):
[x] every stage emits receipts
[x] summary files exist
[x] drift detection works (severity ok|warn|alert)
[x] hashes stable across identical runs
[x] tests pass (18 new + 117 cumulative = 135)
[x] real pipeline run produces full receipt tree (8 files)
[x] failures visible and explicit
Known gaps (carry-overs):
- deterministic_violation flag exists in DriftReport but not yet populated
(requires comparing input_hash AND output_hash across runs; current
implementation compares output only)
- recorded_at baked into provenance means identical source produces different
output_hash on different runs — workaround: --recorded-at pin for repro tests
- drift threshold hard-coded at 20%; should be env-overridable for noisy datasets
- stages still continue running even if upstream stage failed; exports use stale
scored-runs in that case. Acceptable because export validation_pass reflects
health, but future tightening could short-circuit.
Phase 6 (acceptance gate suite) unblocked.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
// drift_report.ts — comparison of a current run summary vs the
|
||||
// previous run summary on disk. Spec calls this "drift detection";
|
||||
// concretely it answers: did the pipeline behave the same way as
|
||||
// last time, and if not, was the change explained by an input change
|
||||
// or did it appear out of nowhere (silent drift)?
|
||||
//
|
||||
// Severity:
|
||||
// ok — within 20% on every metric, no hash surprises
|
||||
// warn — record-count or category swing > 20%, OR new error class
|
||||
// alert — output_hash differs while input_hash is identical
|
||||
// (deterministic violation — same input → different output)
|
||||
|
||||
import {
|
||||
ValidationResult, requireString, requireIsoTimestamp,
|
||||
} from "./types";
|
||||
import type { StageName } from "./stage_receipt";
|
||||
|
||||
export const DRIFT_REPORT_SCHEMA_VERSION = 1;
|
||||
export const DRIFT_THRESHOLD_PCT = 0.20;
|
||||
|
||||
export type DriftSeverity = "ok" | "warn" | "alert";
|
||||
|
||||
export interface StageDrift {
|
||||
stage: StageName;
|
||||
delta_records_in: number; // current - prior
|
||||
delta_records_out: number;
|
||||
delta_accepted: number;
|
||||
delta_quarantined: number;
|
||||
pct_change_out: number | null; // null when prior had 0 records
|
||||
input_hash_match: boolean;
|
||||
output_hash_match: boolean;
|
||||
// alert if input_hash matches but output_hash diverges
|
||||
deterministic_violation: boolean;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export interface DriftReport {
|
||||
schema_version: number;
|
||||
run_id: string;
|
||||
prior_run_id: string | null; // null when no prior run on disk
|
||||
generated_at: string;
|
||||
severity: DriftSeverity;
|
||||
stages: StageDrift[];
|
||||
// Top-level swings the human reader should see immediately.
|
||||
flags: string[];
|
||||
}
|
||||
|
||||
export function validateDriftReport(input: unknown): ValidationResult<DriftReport> {
|
||||
const errors: string[] = [];
|
||||
if (typeof input !== "object" || input === null) {
|
||||
return { valid: false, errors: ["expected object"] };
|
||||
}
|
||||
const r = input as Record<string, unknown>;
|
||||
let ok = true;
|
||||
|
||||
if (r.schema_version !== DRIFT_REPORT_SCHEMA_VERSION) {
|
||||
errors.push(`schema_version: expected ${DRIFT_REPORT_SCHEMA_VERSION}, got ${JSON.stringify(r.schema_version)}`);
|
||||
ok = false;
|
||||
}
|
||||
ok = requireString(r.run_id, "run_id", errors) && ok;
|
||||
if (r.prior_run_id !== null && typeof r.prior_run_id !== "string") {
|
||||
errors.push("prior_run_id: must be string or null");
|
||||
ok = false;
|
||||
}
|
||||
ok = requireIsoTimestamp(r.generated_at, "generated_at", errors) && ok;
|
||||
if (!["ok", "warn", "alert"].includes(r.severity as string)) {
|
||||
errors.push(`severity: must be ok|warn|alert, got ${JSON.stringify(r.severity)}`);
|
||||
ok = false;
|
||||
}
|
||||
if (!Array.isArray(r.stages)) {
|
||||
errors.push("stages: expected array");
|
||||
ok = false;
|
||||
}
|
||||
if (!Array.isArray(r.flags)) {
|
||||
errors.push("flags: expected array");
|
||||
ok = false;
|
||||
}
|
||||
|
||||
if (!ok) return { valid: false, errors };
|
||||
return { valid: true, value: r as unknown as DriftReport };
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// run_summary.ts — aggregates StageReceipt rows for one run_id.
|
||||
// Spec field set: total records processed, total accepted/rejected/
|
||||
// quarantined, dataset sizes, validation status, overall hash of run.
|
||||
|
||||
import {
|
||||
ValidationResult, requireString, requireNumber, requireIsoTimestamp, requireSha256,
|
||||
} from "./types";
|
||||
import type { StageName } from "./stage_receipt";
|
||||
|
||||
export const RUN_SUMMARY_SCHEMA_VERSION = 1;
|
||||
|
||||
export interface RunStageSummary {
|
||||
stage: StageName;
|
||||
records_in: number;
|
||||
records_out: number;
|
||||
accepted: number;
|
||||
rejected: number;
|
||||
quarantined: number;
|
||||
skipped: number;
|
||||
passed: boolean;
|
||||
duration_ms: number;
|
||||
output_hash: string;
|
||||
}
|
||||
|
||||
export interface RunSummary {
|
||||
schema_version: number;
|
||||
run_id: string;
|
||||
started_at: string; // earliest stage timestamp
|
||||
ended_at: string; // latest stage timestamp + duration
|
||||
git_commit: string;
|
||||
stages: RunStageSummary[];
|
||||
// Aggregates across stages
|
||||
total_records_in: number;
|
||||
total_records_out: number;
|
||||
total_accepted: number;
|
||||
total_rejected: number;
|
||||
total_quarantined: number;
|
||||
total_skipped: number;
|
||||
// Dataset sizes — final outputs of each export stage
|
||||
rag_records: number;
|
||||
sft_records: number;
|
||||
preference_pairs: number;
|
||||
// Pipeline-wide pass = AND of every stage validation.passed
|
||||
overall_passed: boolean;
|
||||
// Run-wide hash: sha256 over each stage's output hash, sorted by stage name.
|
||||
// Detects ANY change in any stage output across runs.
|
||||
run_hash: string;
|
||||
total_duration_ms: number;
|
||||
}
|
||||
|
||||
export function validateRunSummary(input: unknown): ValidationResult<RunSummary> {
|
||||
const errors: string[] = [];
|
||||
if (typeof input !== "object" || input === null) {
|
||||
return { valid: false, errors: ["expected object"] };
|
||||
}
|
||||
const r = input as Record<string, unknown>;
|
||||
let ok = true;
|
||||
|
||||
if (r.schema_version !== RUN_SUMMARY_SCHEMA_VERSION) {
|
||||
errors.push(`schema_version: expected ${RUN_SUMMARY_SCHEMA_VERSION}, got ${JSON.stringify(r.schema_version)}`);
|
||||
ok = false;
|
||||
}
|
||||
ok = requireString(r.run_id, "run_id", errors) && ok;
|
||||
ok = requireIsoTimestamp(r.started_at, "started_at", errors) && ok;
|
||||
ok = requireIsoTimestamp(r.ended_at, "ended_at", errors) && ok;
|
||||
if (typeof r.git_commit !== "string" || !/^[0-9a-f]{40}$/.test(r.git_commit as string)) {
|
||||
errors.push("git_commit: must be 40-char hex");
|
||||
ok = false;
|
||||
}
|
||||
if (typeof r.overall_passed !== "boolean") {
|
||||
errors.push("overall_passed: must be boolean");
|
||||
ok = false;
|
||||
}
|
||||
ok = requireSha256(r.run_hash, "run_hash", errors) && ok;
|
||||
for (const k of ["total_records_in", "total_records_out", "total_accepted", "total_rejected",
|
||||
"total_quarantined", "total_skipped", "rag_records", "sft_records",
|
||||
"preference_pairs", "total_duration_ms"]) {
|
||||
if (typeof (r as any)[k] !== "number") {
|
||||
errors.push(`${k}: expected number`);
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(r.stages)) {
|
||||
errors.push("stages: expected array");
|
||||
ok = false;
|
||||
}
|
||||
|
||||
if (!ok) return { valid: false, errors };
|
||||
return { valid: true, value: r as unknown as RunSummary };
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
// stage_receipt.ts — forensic-grade per-stage receipt.
|
||||
//
|
||||
// Distinct from auditor/schemas/distillation/receipt.ts (Phase 1):
|
||||
// - Phase 1 Receipt is per-script invocation, format inherited from
|
||||
// the early auditor wiring
|
||||
// - StageReceipt (THIS file) matches the now.md Phase 5 spec exactly
|
||||
// and is the canonical artifact for pipeline observability
|
||||
//
|
||||
// Every pipeline stage (collect, score, export-rag, export-sft,
|
||||
// export-preference, future extract-playbooks/index) emits ONE
|
||||
// StageReceipt per run. Receipts are joined by `run_id` (shared
|
||||
// across all stages of a single `run-all` invocation) so a future
|
||||
// query can aggregate across the whole pipeline.
|
||||
|
||||
import {
|
||||
ValidationResult, requireString, requireNumber, requireIsoTimestamp, requireSha256,
|
||||
requireStringArray,
|
||||
} from "./types";
|
||||
|
||||
export const STAGE_RECEIPT_SCHEMA_VERSION = 1;
|
||||
|
||||
export const STAGE_NAMES = [
|
||||
"collect", // build_evidence_index — materialize source jsonls → EvidenceRecord
|
||||
"score", // score_runs — EvidenceRecord → ScoredRun
|
||||
"export-rag", // exports/rag/playbooks.jsonl
|
||||
"export-sft", // exports/sft/instruction_response.jsonl
|
||||
"export-preference",// exports/preference/chosen_rejected.jsonl
|
||||
// Reserved for future stages — accept them in the schema so a stage
|
||||
// can be added without bumping schema_version.
|
||||
"extract-playbooks",
|
||||
"index",
|
||||
] as const;
|
||||
export type StageName = (typeof STAGE_NAMES)[number];
|
||||
|
||||
export interface StageFileRef {
|
||||
path: string; // relative to repo root
|
||||
sha256: string; // 64-char hex
|
||||
bytes?: number;
|
||||
record_count?: number; // line count for jsonl, when meaningful
|
||||
}
|
||||
|
||||
export interface StageIO {
|
||||
files: StageFileRef[];
|
||||
record_count: number;
|
||||
hash: string; // 64-char hex — aggregate over all file hashes (sorted)
|
||||
}
|
||||
|
||||
export interface StageStats {
|
||||
accepted: number; // rows that ended up in the stage's output
|
||||
rejected: number; // explicit category=rejected (Score), invalid pairs (Preference), etc.
|
||||
quarantined: number; // routed to exports/quarantine/* with structured reason
|
||||
skipped: number; // parse failures, schema violations at write time
|
||||
}
|
||||
|
||||
export interface StageValidation {
|
||||
passed: boolean; // explicit boolean — never inferred (spec non-negotiable)
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface StageReceipt {
|
||||
schema_version: number;
|
||||
run_id: string; // shared across all stages of one pipeline run
|
||||
stage: StageName;
|
||||
timestamp: string; // ISO 8601 — stage start
|
||||
git_commit: string; // 40-char hex
|
||||
inputs: StageIO;
|
||||
outputs: StageIO;
|
||||
stats: StageStats;
|
||||
validation: StageValidation;
|
||||
duration_ms: number;
|
||||
}
|
||||
|
||||
function validateStageIO(v: unknown, field: string, errors: string[]): boolean {
|
||||
if (typeof v !== "object" || v === null) {
|
||||
errors.push(`${field}: expected object`);
|
||||
return false;
|
||||
}
|
||||
const io = v as Record<string, unknown>;
|
||||
let ok = true;
|
||||
if (!Array.isArray(io.files)) {
|
||||
errors.push(`${field}.files: expected array`);
|
||||
ok = false;
|
||||
} else {
|
||||
for (let i = 0; i < io.files.length; i++) {
|
||||
const f = io.files[i] as Record<string, unknown>;
|
||||
if (typeof f !== "object" || f === null) {
|
||||
errors.push(`${field}.files[${i}]: expected object`);
|
||||
ok = false;
|
||||
continue;
|
||||
}
|
||||
ok = requireString(f.path, `${field}.files[${i}].path`, errors) && ok;
|
||||
ok = requireSha256(f.sha256, `${field}.files[${i}].sha256`, errors) && ok;
|
||||
if (f.bytes !== undefined && typeof f.bytes !== "number") {
|
||||
errors.push(`${field}.files[${i}].bytes: expected number when present`);
|
||||
ok = false;
|
||||
}
|
||||
if (f.record_count !== undefined && typeof f.record_count !== "number") {
|
||||
errors.push(`${field}.files[${i}].record_count: expected number when present`);
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
ok = requireNumber(io.record_count, `${field}.record_count`, errors) && ok;
|
||||
ok = requireSha256(io.hash, `${field}.hash`, errors) && ok;
|
||||
return ok;
|
||||
}
|
||||
|
||||
export function validateStageReceipt(input: unknown): ValidationResult<StageReceipt> {
|
||||
const errors: string[] = [];
|
||||
if (typeof input !== "object" || input === null) {
|
||||
return { valid: false, errors: ["expected object"] };
|
||||
}
|
||||
const r = input as Record<string, unknown>;
|
||||
let ok = true;
|
||||
|
||||
if (r.schema_version !== STAGE_RECEIPT_SCHEMA_VERSION) {
|
||||
errors.push(`schema_version: expected ${STAGE_RECEIPT_SCHEMA_VERSION}, got ${JSON.stringify(r.schema_version)}`);
|
||||
ok = false;
|
||||
}
|
||||
ok = requireString(r.run_id, "run_id", errors) && ok;
|
||||
if (typeof r.run_id === "string" && r.run_id.length < 8) {
|
||||
errors.push("run_id: too short — expect uuid-like");
|
||||
ok = false;
|
||||
}
|
||||
if (typeof r.stage !== "string" || !STAGE_NAMES.includes(r.stage as StageName)) {
|
||||
errors.push(`stage: must be one of ${STAGE_NAMES.join("|")}`);
|
||||
ok = false;
|
||||
}
|
||||
ok = requireIsoTimestamp(r.timestamp, "timestamp", errors) && ok;
|
||||
if (typeof r.git_commit !== "string" || !/^[0-9a-f]{40}$/.test(r.git_commit as string)) {
|
||||
errors.push("git_commit: must be 40-char hex");
|
||||
ok = false;
|
||||
}
|
||||
if (typeof r.duration_ms !== "number") {
|
||||
errors.push("duration_ms: expected number");
|
||||
ok = false;
|
||||
}
|
||||
if (typeof r.inputs !== "object" || r.inputs === null) {
|
||||
errors.push("inputs: expected object");
|
||||
ok = false;
|
||||
} else {
|
||||
ok = validateStageIO(r.inputs, "inputs", errors) && ok;
|
||||
}
|
||||
if (typeof r.outputs !== "object" || r.outputs === null) {
|
||||
errors.push("outputs: expected object");
|
||||
ok = false;
|
||||
} else {
|
||||
ok = validateStageIO(r.outputs, "outputs", errors) && ok;
|
||||
}
|
||||
if (typeof r.stats !== "object" || r.stats === null) {
|
||||
errors.push("stats: expected object");
|
||||
ok = false;
|
||||
} else {
|
||||
const s = r.stats as Record<string, unknown>;
|
||||
for (const k of ["accepted", "rejected", "quarantined", "skipped"]) {
|
||||
if (typeof s[k] !== "number") { errors.push(`stats.${k}: expected number`); ok = false; }
|
||||
}
|
||||
}
|
||||
if (typeof r.validation !== "object" || r.validation === null) {
|
||||
errors.push("validation: expected object");
|
||||
ok = false;
|
||||
} else {
|
||||
const v = r.validation as Record<string, unknown>;
|
||||
if (typeof v.passed !== "boolean") {
|
||||
errors.push("validation.passed: must be boolean (explicit, never inferred)");
|
||||
ok = false;
|
||||
}
|
||||
if (!Array.isArray(v.errors)) { errors.push("validation.errors: expected array"); ok = false; }
|
||||
if (!Array.isArray(v.warnings)) { errors.push("validation.warnings: expected array"); ok = false; }
|
||||
if (Array.isArray(v.errors)) ok = requireStringArray(v.errors, "validation.errors", errors) && ok;
|
||||
if (Array.isArray(v.warnings)) ok = requireStringArray(v.warnings, "validation.warnings", errors) && ok;
|
||||
}
|
||||
|
||||
if (!ok) return { valid: false, errors };
|
||||
return { valid: true, value: r as unknown as StageReceipt };
|
||||
}
|
||||
|
||||
// Compute the canonical aggregate hash over a list of file refs.
|
||||
// Sorted by path so order-of-iteration doesn't drift the hash.
|
||||
// Each entry contributes "<path>|<sha256>|<record_count>" so two
|
||||
// files with identical content but different paths produce distinct
|
||||
// digests (real difference = real hash difference).
|
||||
export async function aggregateIoHash(files: StageFileRef[]): Promise<string> {
|
||||
const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
|
||||
const parts = sorted.map(f => `${f.path}|${f.sha256}|${f.record_count ?? 0}`);
|
||||
const h = new Bun.CryptoHasher("sha256");
|
||||
h.update(parts.join("\n"));
|
||||
return h.digest("hex");
|
||||
}
|
||||
Reference in New Issue
Block a user