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>
8.7 KiB
Phase 4 — Dataset Export Layer Report
Run: 2026-04-27 · branch scrum/auto-apply-19814 head c989253+ (uncommitted Phase 4 work)
Spec: /home/profit/now.md — Phase 4a/b/c
Summary
The dataset export layer ships RAG, SFT, and Preference datasets from the materialized + scored substrate built in Phases 0-3. Each exporter:
- Reads scored-runs, joins to evidence by run_id
- Applies category gates + provenance gates + content gates
- Validates every output row against its schema
- Routes rejections to
exports/quarantine/<exporter>.jsonlwith structured reasons - Produces deterministic IDs (sha256 over evidence_run_id + sig_hash)
- Idempotent: re-running produces zero new rows
Files added (8)
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)
scripts/distillation/export_preference.ts preference exporter with task_id pairing
scripts/distillation/distill.ts CLI dispatcher (build-evidence|score|export-rag|export-sft|export-preference|export-all|health)
tests/distillation/exports.test.ts 15 contamination-firewall tests
Schema updates (Phase 1 schemas aligned with Phase 4 spec field names):
rag_sample.ts— addedsource_category, renamedexported_at→created_atsft_sample.ts— addedid, renamedexported_at→created_at, acceptedpartially_acceptedat schema layer (CLI gate decides)preference_sample.ts— addedid, separatedsource_run_ids→chosen_run_id/rejected_run_id, renamedexported_at→created_at
Test metrics
117 distillation tests pass · 0 fail · 315 expect() calls · 327ms
By file:
evidence_record.test.ts 10
realdata.test.ts 8
schemas.test.ts 33 (3 new tests for RAG/SFT/Preference field changes)
build_evidence_index.test.ts 9
scorer.test.ts 30
score_runs.test.ts 8 (added 4 audit-severity cases earlier)
exports.test.ts 15 (NEW)
Real-data export run (2026-04-27)
Counts
| Export | Read | Exported | Quarantined |
|---|---|---|---|
| RAG | 1052 | 446 | 606 (empty_content=70, category_disallowed=536) |
| SFT | 1052 | 351 | 701 (unsafe_sft_category=536, missing_source_run_id=33, category_disallowed=132) |
| Preference | 1052 | 83 pairs | 16 (invalid_preference_pairing) |
Contamination firewall — VERIFIED HELD
SFT quality_score distribution: 351 'accepted', ZERO rejected/needs_human/partial
RAG success_score distribution: 351 accepted + 95 partially_accepted, ZERO rejected
Preference self-pair check: 0 records have chosen_run_id == rejected_run_id
The 536 unsafe_sft_category quarantines = exact count of rejected+needs_human_review records in scored-runs. Every forbidden category was caught before write.
Category distribution
- accepted (446 RAG-eligible / 351 SFT-eligible after extraction-class filter)
- partially_accepted (95 ship to RAG, 0 to SFT by default —
--include-partialopens to ~132 more) - rejected (39 — quarantined from SFT, excluded from RAG)
- needs_human_review (479 — quarantined from SFT, excluded from RAG by default)
Output paths
exports/rag/playbooks.jsonl 446 rows
exports/sft/instruction_response.jsonl 351 rows
exports/preference/chosen_rejected.jsonl 83 rows
exports/quarantine/rag.jsonl 606 rows with reason + source_provenance
exports/quarantine/sft.jsonl 701 rows with reason + source_provenance
exports/quarantine/preference.jsonl 16 rows with reason + source_provenance
Sample exported records
RAG (accepted scrum_review):
{"id":"rag-b16f0a66f021e211","title":"# Review: `crates/vectord/src/playbook_memory.rs` vs. Lakeho","success_score":"accepted","source_run_id":"scrum:1776910485757:crates/vectord/src/playbook_memory.rs","tags":["task:scrum_review","category:accepted","role:executor"]}
SFT (instruction → response from accepted run):
{"id":"sft-...","instruction":"Review the file 'crates/...' against the PRD + change-proposal context...","context":"matrix=lakehouse_arch_v1,lakehouse_symbols_v1 · model=...","response":"# Review: ...","quality_score":"accepted",...}
Preference (chosen_rejected pair):
{"id":"pref-...","prompt":"Task: scrum_review:<file>","chosen":"<accepted text>","rejected":"<rejected text>","reason":"chosen scored 'accepted' | rejected scored 'rejected' | chosen-rationale: ...","chosen_run_id":"scrum:...","rejected_run_id":"scrum:...",...}
Sample quarantined records
unsafe_sft_category (the firewall in action):
{"exporter":"sft","reason":"unsafe_sft_category","source_record":{...,"category":"rejected"},"errors":["category=rejected forbidden in SFT (spec non-negotiable)"],...}
empty_content (RAG):
{"exporter":"rag","reason":"empty_content","source_record":{...},"errors":["evidence.text is empty/missing — RAG needs content"],...}
invalid_preference_pairing:
{"exporter":"preference","reason":"invalid_preference_pairing","source_record":{...},"errors":["chosen and rejected texts identical"],...}
Invariants enforced (proven by tests + real-data run)
- No leak into SFT —
quality_scoreschema enum bars rejected/needs_human at write time; exporter filter bars them at read time. Defense in depth. - No fabricated preference pairs — only same-task_id with category gap. Never invents pairs from unrelated records.
- No empty content — RAG and SFT both reject whitespace-only
text/response/instruction. - Provenance on every row — schema enforces; exporter quarantines on missing.
- Deterministic IDs — sha256(evidence_run_id + sig_hash) gives byte-stable IDs across reruns.
- Idempotent — exporter re-reads existing output, dedupes by ID.
- No silent drops — every input row is either exported OR quarantined with structured reason.
Quarantine taxonomy (11 reasons)
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
Known limitations
- mode_experiments 168 records all needs_human (Phase 3 carry-over). Once their scoring transform derives markers from grounding/latency, the SFT eligible pool grows substantially.
- Extraction-class records (distilled_*, audit_facts, observer_escalations) excluded from SFT — they have no instruction→response shape. Phase 3 v2 JOIN-to-parent strategy could unlock them.
- Preference dataset is small (83 pairs) — limited by how rarely we have accepted+rejected on the same task_id today. Most scrum_reviews land 'accepted' or 'partially' for the file; rejection is per-attempt within the ladder, not per-file. Future improvement: pair scrum_reviews against observer_reviews on the same file when they disagree.
--include-partialnot exercised in real run — 132 partial records would expand SFT to ~483 if opted in.- Hallucinated file path check NOT implemented — quarantine reason
hallucinated_file_pathis reserved but no exporter currently asserts that referenced files exist on disk. Adding this requires a fs lookup per row and a config of which fields contain paths.
Recommendation for Phase 5 (Receipts Harness)
Each exporter currently emits to stdout + writes export files but does NOT emit a per-stage reports/distillation/<ts>/receipt.json. Phase 5 wraps each exporter (and the existing build_evidence_index + score_runs) in a withReceipt() helper that:
- Captures git_sha + git_branch + git_dirty
- sha256 of every input file + every output file + bytes
- record_counts (in / out / quarantined / by_category)
- validation_pass: boolean derived from quarantine count or explicit error gate
- duration_ms
Phase 2 + Phase 3 already emit Receipt-conforming JSON; Phase 5 generalizes the pattern so all 5 pipeline stages share one harness. The harness can also write reports/distillation/latest.md aggregating the most recent run of each stage.
Acceptance gate — Phase 4 done?
- all Phase 4 exporters exist (RAG, SFT, Preference)
- all export schemas validate (51 schema tests)
- all tests pass (117 distillation tests · 0 fail)
- real data export succeeds (446 RAG + 351 SFT + 83 Preference rows)
- SFT leak-prevention proven by tests (3 explicit no-leak cases) AND by real-data inspection (351/351 are 'accepted')
- quarantine populated where appropriate (606+701+16 rows with structured reasons)
- phase report exists (this file)
- changes committed and pushed (next step)