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>
73 lines
3.2 KiB
TypeScript
73 lines
3.2 KiB
TypeScript
// RagSample — entry in exports/rag/playbooks.jsonl. Spec shape exactly,
|
|
// plus provenance + success_score (so the index can re-rank by quality).
|
|
import {
|
|
ValidationResult, requireString, requireNumber, requireIsoTimestamp, requireProvenance, requireStringArray,
|
|
} from "./types";
|
|
|
|
export const RAG_SAMPLE_SCHEMA_VERSION = 1;
|
|
|
|
// Allowed source_category values. RAG accepts accepted/partial freely;
|
|
// needs_human_review is opt-in (must be tagged so consumers can filter
|
|
// it out for SFT).
|
|
export const RAG_ALLOWED_CATEGORIES = ["accepted", "partially_accepted", "needs_human_review"] as const;
|
|
export type RagSourceCategory = (typeof RAG_ALLOWED_CATEGORIES)[number];
|
|
|
|
export interface RagSample {
|
|
schema_version: number;
|
|
id: string;
|
|
title: string;
|
|
content: string;
|
|
tags: string[];
|
|
source_run_id: string;
|
|
// Snapshot of the score the source carried at export time. Lets a
|
|
// consumer see "this was partial" without re-reading scored-runs.
|
|
success_score: RagSourceCategory;
|
|
// Same value as success_score by spec (now.md asks for both fields).
|
|
// Kept distinct so future schemas can diverge them (e.g. an
|
|
// "is_review_material" flag) without breaking old consumers.
|
|
source_category: RagSourceCategory;
|
|
embedding_text: string; // the text to embed (often == content but can be shorter)
|
|
created_at: string;
|
|
provenance: { source_file: string; line_offset?: number; sig_hash: string; recorded_at: string };
|
|
}
|
|
|
|
export function validateRagSample(input: unknown): ValidationResult<RagSample> {
|
|
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 !== RAG_SAMPLE_SCHEMA_VERSION) {
|
|
errors.push(`schema_version: expected ${RAG_SAMPLE_SCHEMA_VERSION}, got ${JSON.stringify(r.schema_version)}`);
|
|
ok = false;
|
|
}
|
|
ok = requireString(r.id, "id", errors) && ok;
|
|
ok = requireString(r.title, "title", errors) && ok;
|
|
ok = requireString(r.content, "content", errors) && ok;
|
|
ok = requireString(r.embedding_text, "embedding_text", errors) && ok;
|
|
ok = requireString(r.source_run_id, "source_run_id", errors) && ok;
|
|
ok = requireIsoTimestamp(r.created_at, "created_at", errors) && ok;
|
|
ok = requireStringArray(r.tags, "tags", errors) && ok;
|
|
ok = requireProvenance(r.provenance, "provenance", errors) && ok;
|
|
|
|
if (!RAG_ALLOWED_CATEGORIES.includes(r.success_score as RagSourceCategory)) {
|
|
errors.push(`success_score: must be one of ${RAG_ALLOWED_CATEGORIES.join("|")} (rejected never enters RAG)`);
|
|
ok = false;
|
|
}
|
|
if (!RAG_ALLOWED_CATEGORIES.includes(r.source_category as RagSourceCategory)) {
|
|
errors.push(`source_category: must be one of ${RAG_ALLOWED_CATEGORIES.join("|")}`);
|
|
ok = false;
|
|
}
|
|
if (r.success_score !== r.source_category) {
|
|
errors.push("success_score and source_category must match (mirrored fields per spec)");
|
|
ok = false;
|
|
}
|
|
if (typeof r.content === "string" && (r.content as string).trim().length === 0) {
|
|
errors.push("content: must be non-whitespace");
|
|
ok = false;
|
|
}
|
|
|
|
if (!ok) return { valid: false, errors };
|
|
return { valid: true, value: r as unknown as RagSample };
|
|
}
|