Some checks failed
lakehouse/auditor 1 blocking issue: todo!() macro call in tests/real-world/scrum_master_pipeline.ts
Build the contamination firewall: RAG, SFT, and Preference exporters
that turn scored evidence into clean training datasets without
leaking rejected, unvalidated, hallucinated, or provenance-free
records.
Files (8 new + 4 schema updates):
scripts/distillation/quarantine.ts shared QuarantineWriter, 11-reason taxonomy
scripts/distillation/export_rag.ts RAG exporter (--include-review opt-in)
scripts/distillation/export_sft.ts SFT exporter (--include-partial opt-in, SFT_NEVER constant)
scripts/distillation/export_preference.ts preference exporter, same task_id pairing
scripts/distillation/distill.ts CLI dispatcher (build-evidence/score/export-*)
tests/distillation/exports.test.ts 15 contamination-firewall tests
reports/distillation/phase4-export-report.md acceptance report
Schema field-name alignment with now.md:
rag_sample.ts +source_category, exported_at→created_at
sft_sample.ts +id, exported_at→created_at, partially_accepted at schema (CLI gates)
preference_sample.ts +id, source_run_ids→chosen_run_id+rejected_run_id, +created_at
Test metrics: 117 distillation tests pass · 0 fail · 315 expects · 327ms
Real-data export run (1052 scored input rows):
RAG: 446 exported (351 acc + 95 partial), 606 quarantined
SFT: 351 exported (all 'accepted'), 701 quarantined
Preference: 83 pairs exported, 16 quarantined
CONTAMINATION FIREWALL — verified held on real data:
- SFT output: 351/351 quality_score='accepted' (ZERO leaked)
- RAG output: 351 acc + 95 partial (ZERO rejected leaked)
- Preference: 0 self-pairs (chosen_run_id != rejected_run_id)
- 536 rejected+needs_human_review records caught at unsafe_sft_category
gate, exact match to scored-runs forbidden-category total
Defense in depth (the firewall is two layers, not one):
1. Schema layer (Phase 1): SftSample.quality_score enum forbids
rejected/needs_human at write time
2. Exporter layer: SFT_NEVER constant in export_sft.ts checks
category before synthesis. Even if synthesis produced a row
with quality_score=rejected, validateSftSample would reject it.
Quarantine reasons (11): missing_provenance, missing_source_run_id,
empty_content, schema_violation, unsafe_sft_category,
unsafe_rag_category, invalid_preference_pairing,
hallucinated_file_path, duplicate_id, self_pairing,
category_disallowed.
Bug surfaced + fixed during testing: module-level evidenceCache
shared state across test runs (tests wipe TMP, cache holds stale
empty Map). Moved cache to per-call scope. Same pattern bit Phase 2
materializer would have hit if its tests had multiple runs sharing
state — preventive fix.
Pairing logic v1: same task_id with category gap. accepted×rejected
preferred, accepted×partially_accepted as fallback. MAX_PAIRS_PER_TASK=5
cap prevents one hot task from dominating. Future: cross-source
pairing (scrum_reviews chosen vs observer_reviews rejected on same
file) to grow dataset beyond 83.
CLI: ./scripts/distill.ts {build-evidence|score|export-rag|export-sft|export-preference|export-all|health}
Flags: --dry-run, --include-partial (SFT only), --include-review (RAG only)
Carry-overs to Phase 5 (Receipts Harness):
- Each exporter currently writes results but no per-stage receipt.json.
Phase 5 wraps build_evidence_index + score_runs + export_* in a
withReceipt() helper that captures git_sha + sha256 of inputs/outputs
+ record_counts + validation_pass.
- reports/distillation/latest.md aggregating most-recent run of each stage.
Carry-overs to Phase 3 v2:
- mode_experiments scoring (168 needs_human_review): derive markers from
validation_results.grounded_fraction
- extraction-class JOIN: distilled_*/audit_facts/observer_escalations
→ JOIN to verdict-bearing parent by task_id
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
115 lines
4.2 KiB
TypeScript
115 lines
4.2 KiB
TypeScript
// quarantine.ts — shared sink for records the exporters refuse to emit.
|
|
//
|
|
// Every exporter routes skipped records here with a structured reason
|
|
// + the original record + provenance back to the source. Spec
|
|
// non-negotiable: no silent drops. If a record can't ship, it must be
|
|
// observable here.
|
|
//
|
|
// Path: exports/quarantine/<exporter>.jsonl (one file per exporter,
|
|
// append-mode, JSONL lines).
|
|
|
|
import { mkdirSync, appendFileSync, existsSync, readFileSync } from "node:fs";
|
|
import { resolve, dirname } from "node:path";
|
|
|
|
export const QUARANTINE_REASONS = [
|
|
"missing_provenance",
|
|
"missing_source_run_id",
|
|
"empty_content",
|
|
"schema_violation",
|
|
"unsafe_sft_category", // rejected/needs_human_review tried to enter SFT
|
|
"unsafe_rag_category", // rejected tried to enter RAG
|
|
"invalid_preference_pairing", // pair shares no comparable signal
|
|
"hallucinated_file_path", // referenced file doesn't exist on disk
|
|
"duplicate_id", // id collision within the same export
|
|
"self_pairing", // chosen == rejected (preference)
|
|
"category_disallowed", // exporter-specific category gate
|
|
] as const;
|
|
export type QuarantineReason = (typeof QUARANTINE_REASONS)[number];
|
|
|
|
export interface QuarantineEntry {
|
|
exporter: "rag" | "sft" | "preference";
|
|
reason: QuarantineReason;
|
|
source_record: Record<string, unknown>; // the scored-run that was rejected
|
|
errors: string[]; // detailed error list (from validators or pairing logic)
|
|
recorded_at: string; // ISO 8601
|
|
// Provenance carried over from the source so the quarantine row can
|
|
// be traced back to the underlying evidence/scored-run.
|
|
source_provenance?: {
|
|
source_file?: string;
|
|
line_offset?: number;
|
|
sig_hash?: string;
|
|
};
|
|
}
|
|
|
|
export class QuarantineWriter {
|
|
private root: string;
|
|
private exporter: "rag" | "sft" | "preference";
|
|
private path: string;
|
|
private dry_run: boolean;
|
|
// Counts by reason so the exporter can emit a summary without reading
|
|
// the file back.
|
|
public readonly counts: Record<QuarantineReason, number> = QUARANTINE_REASONS.reduce(
|
|
(acc, r) => { acc[r] = 0; return acc; },
|
|
{} as Record<QuarantineReason, number>,
|
|
);
|
|
public total = 0;
|
|
// Buffer in dry_run so callers can still see what would have been
|
|
// quarantined.
|
|
public readonly buffered: QuarantineEntry[] = [];
|
|
|
|
constructor(root: string, exporter: "rag" | "sft" | "preference", dry_run = false) {
|
|
this.root = root;
|
|
this.exporter = exporter;
|
|
this.path = resolve(root, "exports/quarantine", `${exporter}.jsonl`);
|
|
this.dry_run = dry_run;
|
|
}
|
|
|
|
add(entry: Omit<QuarantineEntry, "recorded_at" | "exporter"> & { recorded_at: string }) {
|
|
const full: QuarantineEntry = {
|
|
exporter: this.exporter,
|
|
reason: entry.reason,
|
|
source_record: entry.source_record,
|
|
errors: entry.errors,
|
|
recorded_at: entry.recorded_at,
|
|
source_provenance: entry.source_provenance,
|
|
};
|
|
this.counts[full.reason]++;
|
|
this.total++;
|
|
if (this.dry_run) {
|
|
this.buffered.push(full);
|
|
} else {
|
|
mkdirSync(dirname(this.path), { recursive: true });
|
|
appendFileSync(this.path, JSON.stringify(full) + "\n");
|
|
}
|
|
}
|
|
|
|
// Summary string useful for CLI output / reports.
|
|
summary(): string {
|
|
if (this.total === 0) return "0 quarantined";
|
|
const parts = Object.entries(this.counts)
|
|
.filter(([, n]) => n > 0)
|
|
.map(([r, n]) => `${r}=${n}`)
|
|
.join(" ");
|
|
return `${this.total} quarantined (${parts})`;
|
|
}
|
|
|
|
outputPath(): string {
|
|
return this.path;
|
|
}
|
|
}
|
|
|
|
// Helper: load existing quarantine entries to dedupe by sig_hash on
|
|
// re-runs. Only used when the caller wants per-record idempotency.
|
|
export function loadQuarantinedSigs(quarantine_path: string): Set<string> {
|
|
const seen = new Set<string>();
|
|
if (!existsSync(quarantine_path)) return seen;
|
|
for (const line of readFileSync(quarantine_path, "utf8").split("\n")) {
|
|
if (!line) continue;
|
|
try {
|
|
const e = JSON.parse(line) as QuarantineEntry;
|
|
if (e.source_provenance?.sig_hash) seen.add(e.source_provenance.sig_hash);
|
|
} catch { /* malformed — skip */ }
|
|
}
|
|
return seen;
|
|
}
|