distillation: Phase 4 — dataset export layer
lakehouse/auditor 1 blocking issue: todo!() macro call in tests/real-world/scrum_master_pipeline.ts
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>
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
// distill.ts — single-entry CLI dispatcher for the distillation
|
||||
// pipeline. Mirrors the spec's `./scripts/distill <command>` shape.
|
||||
//
|
||||
// USAGE
|
||||
// bun run scripts/distillation/distill.ts <command> [flags]
|
||||
//
|
||||
// COMMANDS
|
||||
// build-evidence materialize EvidenceRecord rows from data/_kb/*.jsonl
|
||||
// score run deterministic Success Scorer
|
||||
// export-rag RAG export (--include-review opt-in)
|
||||
// export-sft SFT export (--include-partial opt-in)
|
||||
// export-preference preference export
|
||||
// export-all RAG + SFT + preference (no opt-ins by default)
|
||||
// health evidence health audit
|
||||
//
|
||||
// All commands accept --dry-run.
|
||||
|
||||
import { materializeAll } from "./build_evidence_index";
|
||||
import { scoreAll } from "./score_runs";
|
||||
import { exportRag } from "./export_rag";
|
||||
import { exportSft } from "./export_sft";
|
||||
import { exportPreference } from "./export_preference";
|
||||
import { TRANSFORMS } from "./transforms";
|
||||
|
||||
const DEFAULT_ROOT = process.env.LH_DISTILL_ROOT ?? "/home/profit/lakehouse";
|
||||
|
||||
async function main() {
|
||||
const cmd = process.argv[2];
|
||||
const dry_run = process.argv.includes("--dry-run");
|
||||
const include_partial = process.argv.includes("--include-partial");
|
||||
const include_review = process.argv.includes("--include-review");
|
||||
const recorded_at = new Date().toISOString();
|
||||
|
||||
switch (cmd) {
|
||||
case "build-evidence": {
|
||||
const r = await materializeAll({ root: DEFAULT_ROOT, transforms: TRANSFORMS, recorded_at, dry_run });
|
||||
console.log(`[build-evidence] in=${r.totals.rows_read} out=${r.totals.rows_written} skip=${r.totals.rows_skipped} dedup=${r.totals.rows_deduped}`);
|
||||
if (!dry_run) console.log(`[build-evidence] receipt: ${r.receipt_path}`);
|
||||
if (!r.receipt.validation_pass) process.exit(1);
|
||||
break;
|
||||
}
|
||||
case "score": {
|
||||
const r = await scoreAll({ root: DEFAULT_ROOT, recorded_at, dry_run });
|
||||
const c = r.totals.by_category;
|
||||
console.log(`[score] in=${r.totals.rows_read} out=${r.totals.rows_written} acc=${c.accepted ?? 0} part=${c.partially_accepted ?? 0} rej=${c.rejected ?? 0} hum=${c.needs_human_review ?? 0}`);
|
||||
if (!dry_run) console.log(`[score] receipt: ${r.receipt_path}`);
|
||||
break;
|
||||
}
|
||||
case "export-rag": {
|
||||
const r = await exportRag({ root: DEFAULT_ROOT, recorded_at, include_review, dry_run });
|
||||
console.log(`[export-rag] in=${r.records_read} out=${r.records_exported} ${r.quarantine_summary}`);
|
||||
console.log(`[export-rag] output: ${r.output_path}${include_review ? " (review included)" : ""}`);
|
||||
break;
|
||||
}
|
||||
case "export-sft": {
|
||||
const r = await exportSft({ root: DEFAULT_ROOT, recorded_at, include_partial, dry_run });
|
||||
console.log(`[export-sft] in=${r.records_read} out=${r.records_exported} ${r.quarantine_summary}`);
|
||||
console.log(`[export-sft] output: ${r.output_path}${include_partial ? " (partial included)" : ""}`);
|
||||
break;
|
||||
}
|
||||
case "export-preference": {
|
||||
const r = await exportPreference({ root: DEFAULT_ROOT, recorded_at, dry_run });
|
||||
console.log(`[export-preference] in=${r.records_read} pairs=${r.pairs_exported} task_ids_paired=${r.task_ids_with_pairs} ${r.quarantine_summary}`);
|
||||
console.log(`[export-preference] output: ${r.output_path}`);
|
||||
break;
|
||||
}
|
||||
case "export-all": {
|
||||
const rRag = await exportRag({ root: DEFAULT_ROOT, recorded_at, include_review, dry_run });
|
||||
const rSft = await exportSft({ root: DEFAULT_ROOT, recorded_at, include_partial, dry_run });
|
||||
const rPref = await exportPreference({ root: DEFAULT_ROOT, recorded_at, dry_run });
|
||||
console.log("");
|
||||
console.log("─── export-all summary ───");
|
||||
console.log(` RAG: in=${rRag.records_read} out=${rRag.records_exported} ${rRag.quarantine_summary}`);
|
||||
console.log(` SFT: in=${rSft.records_read} out=${rSft.records_exported} ${rSft.quarantine_summary}`);
|
||||
console.log(` Preference: in=${rPref.records_read} pairs=${rPref.pairs_exported} ${rPref.quarantine_summary}`);
|
||||
break;
|
||||
}
|
||||
case "health":
|
||||
case "help":
|
||||
case undefined: {
|
||||
console.log("Usage: bun run scripts/distillation/distill.ts <command> [flags]");
|
||||
console.log("");
|
||||
console.log("Commands:");
|
||||
console.log(" build-evidence materialize EvidenceRecord rows");
|
||||
console.log(" score run deterministic Success Scorer");
|
||||
console.log(" export-rag RAG export (--include-review opt-in)");
|
||||
console.log(" export-sft SFT export (--include-partial opt-in)");
|
||||
console.log(" export-preference preference export");
|
||||
console.log(" export-all RAG + SFT + preference");
|
||||
console.log("");
|
||||
console.log("Flags: --dry-run, --include-partial, --include-review");
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.error(`unknown command: ${cmd}. Try 'help'.`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,298 @@
|
||||
// export_preference.ts — Phase 4c preference dataset export.
|
||||
//
|
||||
// Pairs scored runs that attempted comparable tasks but landed in
|
||||
// different categories (one accepted, one rejected). The "chosen" is
|
||||
// the better outcome's text, the "rejected" is the worse outcome's
|
||||
// text, and "reason" cites the explicit category transition.
|
||||
//
|
||||
// Pairing signal v1: SAME task_id with categories accepted/rejected
|
||||
// (or accepted/partially_accepted as a softer pair).
|
||||
//
|
||||
// Hard rules from spec:
|
||||
// - chosen != rejected at content level
|
||||
// - chosen_run_id != rejected_run_id
|
||||
// - reason non-empty
|
||||
// - never fabricate pairs from unrelated records
|
||||
//
|
||||
// If insufficient valid pairs exist for a task_id, we don't pad — we
|
||||
// just emit fewer pairs and note the gap.
|
||||
|
||||
import { existsSync, readFileSync, readdirSync, mkdirSync, appendFileSync, statSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import {
|
||||
PREFERENCE_SAMPLE_SCHEMA_VERSION, validatePreferenceSample, type PreferenceSample,
|
||||
} from "../../auditor/schemas/distillation/preference_sample";
|
||||
import type { ScoredRun } from "../../auditor/schemas/distillation/scored_run";
|
||||
import type { EvidenceRecord } from "../../auditor/schemas/distillation/evidence_record";
|
||||
import { QuarantineWriter } from "./quarantine";
|
||||
|
||||
export interface ExportPreferenceOptions {
|
||||
root: string;
|
||||
recorded_at: string;
|
||||
dry_run?: boolean;
|
||||
}
|
||||
|
||||
export interface ExportPreferenceResult {
|
||||
scored_files_read: number;
|
||||
records_read: number;
|
||||
task_ids_with_pairs: number;
|
||||
pairs_exported: number;
|
||||
records_quarantined: number;
|
||||
output_path: string;
|
||||
quarantine_summary: string;
|
||||
insufficient_pair_task_ids: number; // tasks where we had only 1 record OR all same category
|
||||
}
|
||||
|
||||
const DEFAULT_ROOT = process.env.LH_DISTILL_ROOT ?? "/home/profit/lakehouse";
|
||||
|
||||
interface IndexedRecord {
|
||||
scored: ScoredRun;
|
||||
ev: EvidenceRecord;
|
||||
scored_path: string;
|
||||
line: number;
|
||||
}
|
||||
|
||||
function listScoredRunFiles(root: string): string[] {
|
||||
const out: string[] = [];
|
||||
const dir = resolve(root, "data/scored-runs");
|
||||
if (!existsSync(dir)) return out;
|
||||
for (const yyyy of readdirSync(dir).sort()) {
|
||||
const yp = resolve(dir, yyyy);
|
||||
if (!statSync(yp).isDirectory()) continue;
|
||||
for (const mm of readdirSync(yp).sort()) {
|
||||
const mp = resolve(yp, mm);
|
||||
if (!statSync(mp).isDirectory()) continue;
|
||||
for (const dd of readdirSync(mp).sort()) {
|
||||
const dp = resolve(mp, dd);
|
||||
if (!statSync(dp).isDirectory()) continue;
|
||||
for (const f of readdirSync(dp)) {
|
||||
if (f.endsWith(".jsonl")) out.push(resolve(dp, f));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function loadEvidenceByRunId(
|
||||
scored_path: string,
|
||||
cache: Map<string, Map<string, EvidenceRecord>>,
|
||||
): Map<string, EvidenceRecord> {
|
||||
const evidence_path = scored_path.replace("/scored-runs/", "/evidence/");
|
||||
if (cache.has(evidence_path)) return cache.get(evidence_path)!;
|
||||
const m = new Map<string, EvidenceRecord>();
|
||||
if (!existsSync(evidence_path)) { cache.set(evidence_path, m); return m; }
|
||||
for (const line of readFileSync(evidence_path, "utf8").split("\n")) {
|
||||
if (!line) continue;
|
||||
try { const ev = JSON.parse(line) as EvidenceRecord; m.set(ev.run_id, ev); } catch {}
|
||||
}
|
||||
cache.set(evidence_path, m);
|
||||
return m;
|
||||
}
|
||||
|
||||
// Build a pair from accepted + rejected (or accepted + partially) within
|
||||
// the same task_id. Never invents pairs.
|
||||
function buildPair(
|
||||
chosen: IndexedRecord,
|
||||
rejected: IndexedRecord,
|
||||
recorded_at: string,
|
||||
): PreferenceSample | { error: string } {
|
||||
if (chosen.scored.evidence_task_id !== rejected.scored.evidence_task_id) {
|
||||
return { error: "task_id mismatch — comparable signal violation" };
|
||||
}
|
||||
if (chosen.scored.evidence_run_id === rejected.scored.evidence_run_id) {
|
||||
return { error: "same run_id — self-pairing" };
|
||||
}
|
||||
const chosenText = (chosen.ev.text ?? "").trim();
|
||||
const rejectedText = (rejected.ev.text ?? "").trim();
|
||||
if (chosenText.length === 0 || rejectedText.length === 0) {
|
||||
return { error: "empty text in chosen or rejected" };
|
||||
}
|
||||
if (chosenText === rejectedText) {
|
||||
return { error: "chosen and rejected texts identical" };
|
||||
}
|
||||
|
||||
// Prompt synthesis: best-effort. For task_ids that encode a file
|
||||
// (e.g. scrum_review:<file>), include the file. Generic otherwise.
|
||||
const taskId = chosen.scored.evidence_task_id;
|
||||
let prompt = `Task: ${taskId}`;
|
||||
const file = chosen.ev.source_files?.[0];
|
||||
if (file) prompt += ` · file=${file}`;
|
||||
|
||||
// Reason cites the explicit category gap.
|
||||
const reasonParts = [
|
||||
`chosen scored '${chosen.scored.category}'`,
|
||||
`rejected scored '${rejected.scored.category}'`,
|
||||
];
|
||||
if (chosen.scored.reasons.length > 0) reasonParts.push(`chosen-rationale: ${chosen.scored.reasons[0].slice(0, 80)}`);
|
||||
if (rejected.scored.reasons.length > 0) reasonParts.push(`rejected-rationale: ${rejected.scored.reasons[0].slice(0, 80)}`);
|
||||
const reason = reasonParts.join(" | ");
|
||||
|
||||
const id_seed = `${chosen.scored.evidence_run_id}|${rejected.scored.evidence_run_id}|${taskId}`;
|
||||
const hasher = new Bun.CryptoHasher("sha256");
|
||||
hasher.update(id_seed);
|
||||
const pref_id = "pref-" + hasher.digest("hex").slice(0, 16);
|
||||
|
||||
return {
|
||||
schema_version: PREFERENCE_SAMPLE_SCHEMA_VERSION,
|
||||
id: pref_id,
|
||||
prompt,
|
||||
chosen: chosenText,
|
||||
rejected: rejectedText,
|
||||
reason,
|
||||
chosen_run_id: chosen.scored.evidence_run_id,
|
||||
rejected_run_id: rejected.scored.evidence_run_id,
|
||||
created_at: recorded_at,
|
||||
provenance: {
|
||||
source_file: chosen.scored.provenance.source_file,
|
||||
line_offset: chosen.scored.provenance.line_offset,
|
||||
// sig_hash for the pair = canonical sha of {chosen_run_id, rejected_run_id}
|
||||
// sorted so re-running produces the same provenance.
|
||||
sig_hash: pref_id_to_sig(chosen.scored.evidence_run_id, rejected.scored.evidence_run_id),
|
||||
recorded_at,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pref_id_to_sig(a: string, b: string): string {
|
||||
const seed = [a, b].sort().join("|");
|
||||
const h = new Bun.CryptoHasher("sha256");
|
||||
h.update(seed);
|
||||
return h.digest("hex");
|
||||
}
|
||||
|
||||
export async function exportPreference(opts: ExportPreferenceOptions): Promise<ExportPreferenceResult> {
|
||||
const { root, recorded_at, dry_run = false } = opts;
|
||||
const out_path = resolve(root, "exports/preference/chosen_rejected.jsonl");
|
||||
const q = new QuarantineWriter(root, "preference", dry_run);
|
||||
|
||||
let records_read = 0;
|
||||
const seenIds = new Set<string>();
|
||||
if (!dry_run && existsSync(out_path)) {
|
||||
for (const line of readFileSync(out_path, "utf8").split("\n")) {
|
||||
if (!line) continue;
|
||||
try { const r = JSON.parse(line); if (r.id) seenIds.add(r.id); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// Index by task_id.
|
||||
const evidenceCache = new Map<string, Map<string, EvidenceRecord>>();
|
||||
const byTask = new Map<string, IndexedRecord[]>();
|
||||
const scored_files = listScoredRunFiles(root);
|
||||
for (const sp of scored_files) {
|
||||
const evMap = loadEvidenceByRunId(sp, evidenceCache);
|
||||
const lines = readFileSync(sp, "utf8").split("\n").filter(Boolean);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
records_read++;
|
||||
let scored: ScoredRun;
|
||||
try { scored = JSON.parse(lines[i]) as ScoredRun; } catch { continue; }
|
||||
const ev = evMap.get(scored.evidence_run_id);
|
||||
if (!ev) continue;
|
||||
if (!scored.evidence_task_id) continue;
|
||||
const list = byTask.get(scored.evidence_task_id) ?? [];
|
||||
list.push({ scored, ev, scored_path: sp, line: i });
|
||||
byTask.set(scored.evidence_task_id, list);
|
||||
}
|
||||
}
|
||||
|
||||
let pairs_exported = 0;
|
||||
let task_ids_with_pairs = 0;
|
||||
let insufficient_pair_task_ids = 0;
|
||||
const rowsToWrite: string[] = [];
|
||||
|
||||
for (const [taskId, recs] of byTask) {
|
||||
if (recs.length < 2) {
|
||||
insufficient_pair_task_ids++;
|
||||
continue;
|
||||
}
|
||||
const accepted = recs.filter(r => r.scored.category === "accepted");
|
||||
const rejected = recs.filter(r => r.scored.category === "rejected");
|
||||
const partial = recs.filter(r => r.scored.category === "partially_accepted");
|
||||
|
||||
// Strongest signal: accepted vs rejected.
|
||||
let pairs = pairUp(accepted, rejected);
|
||||
// Weaker but still valid: accepted vs partial.
|
||||
if (pairs.length === 0) pairs = pairUp(accepted, partial);
|
||||
|
||||
if (pairs.length === 0) {
|
||||
insufficient_pair_task_ids++;
|
||||
continue;
|
||||
}
|
||||
|
||||
let exportedThisTask = 0;
|
||||
for (const [chosen, rej] of pairs) {
|
||||
const built = buildPair(chosen, rej, recorded_at);
|
||||
if ("error" in built) {
|
||||
q.add({
|
||||
reason: "invalid_preference_pairing",
|
||||
source_record: { task_id: taskId, chosen_run_id: chosen.scored.evidence_run_id, rejected_run_id: rej.scored.evidence_run_id },
|
||||
errors: [built.error],
|
||||
recorded_at,
|
||||
source_provenance: chosen.scored.provenance,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seenIds.has(built.id)) continue;
|
||||
const v = validatePreferenceSample(built);
|
||||
if (!v.valid) {
|
||||
q.add({
|
||||
reason: "schema_violation",
|
||||
source_record: built as unknown as Record<string, unknown>,
|
||||
errors: v.errors,
|
||||
recorded_at,
|
||||
source_provenance: chosen.scored.provenance,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
seenIds.add(built.id);
|
||||
rowsToWrite.push(JSON.stringify(v.value));
|
||||
pairs_exported++;
|
||||
exportedThisTask++;
|
||||
}
|
||||
if (exportedThisTask > 0) task_ids_with_pairs++;
|
||||
}
|
||||
|
||||
if (!dry_run && rowsToWrite.length > 0) {
|
||||
mkdirSync(dirname(out_path), { recursive: true });
|
||||
appendFileSync(out_path, rowsToWrite.join("\n") + "\n");
|
||||
}
|
||||
|
||||
return {
|
||||
scored_files_read: scored_files.length,
|
||||
records_read,
|
||||
task_ids_with_pairs,
|
||||
pairs_exported,
|
||||
records_quarantined: q.total,
|
||||
output_path: out_path.replace(root + "/", ""),
|
||||
quarantine_summary: q.summary(),
|
||||
insufficient_pair_task_ids,
|
||||
};
|
||||
}
|
||||
|
||||
// Cross-product pairing: every accepted × every rejected. For any
|
||||
// task_id with k accepted and m rejected, we get k*m pairs. Capped
|
||||
// per task to keep the dataset balanced.
|
||||
const MAX_PAIRS_PER_TASK = 5;
|
||||
function pairUp(a: IndexedRecord[], b: IndexedRecord[]): Array<[IndexedRecord, IndexedRecord]> {
|
||||
const pairs: Array<[IndexedRecord, IndexedRecord]> = [];
|
||||
for (const x of a) {
|
||||
for (const y of b) {
|
||||
if (pairs.length >= MAX_PAIRS_PER_TASK) return pairs;
|
||||
pairs.push([x, y]);
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
async function cli() {
|
||||
const dry_run = process.argv.includes("--dry-run");
|
||||
const recorded_at = new Date().toISOString();
|
||||
const r = await exportPreference({ root: DEFAULT_ROOT, recorded_at, dry_run });
|
||||
|
||||
console.log(`[export_preference] read=${r.records_read} pairs=${r.pairs_exported} task_ids_paired=${r.task_ids_with_pairs} ${r.quarantine_summary}${dry_run ? " (DRY RUN)" : ""}`);
|
||||
console.log(`[export_preference] insufficient_pair_task_ids=${r.insufficient_pair_task_ids} (only one record OR all-same-category)`);
|
||||
console.log(`[export_preference] output: ${r.output_path}`);
|
||||
}
|
||||
|
||||
if (import.meta.main) cli().catch(e => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,308 @@
|
||||
// export_rag.ts — Phase 4a RAG dataset export.
|
||||
//
|
||||
// Reads ScoredRun rows from data/scored-runs/YYYY/MM/DD/*.jsonl,
|
||||
// pairs them with the originating EvidenceRecord (by reading the
|
||||
// matching evidence file), filters to allowed RAG categories,
|
||||
// validates against RagSample schema, writes exports/rag/playbooks.jsonl.
|
||||
// Records that fail any check go to exports/quarantine/rag.jsonl with
|
||||
// a structured reason.
|
||||
//
|
||||
// Default categories: accepted, partially_accepted.
|
||||
// Optional --include-review opt-in lets needs_human_review through —
|
||||
// useful for retrieval of warning patterns; SFT never gets this.
|
||||
// Rejected NEVER enters RAG (schema enforces).
|
||||
//
|
||||
// IDs are deterministic: sha256(source_run_id + score_provenance.sig_hash).slice(0,16)
|
||||
// so re-running on the same scored-runs produces identical rows.
|
||||
|
||||
import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync, appendFileSync, statSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import {
|
||||
RAG_SAMPLE_SCHEMA_VERSION, validateRagSample, type RagSample, type RagSourceCategory, RAG_ALLOWED_CATEGORIES,
|
||||
} from "../../auditor/schemas/distillation/rag_sample";
|
||||
import type { ScoredRun } from "../../auditor/schemas/distillation/scored_run";
|
||||
import type { EvidenceRecord } from "../../auditor/schemas/distillation/evidence_record";
|
||||
import { canonicalSha256 } from "../../auditor/schemas/distillation/types";
|
||||
import { QuarantineWriter } from "./quarantine";
|
||||
|
||||
export interface ExportRagOptions {
|
||||
root: string;
|
||||
recorded_at: string;
|
||||
include_review?: boolean; // include needs_human_review records
|
||||
dry_run?: boolean;
|
||||
}
|
||||
|
||||
export interface ExportRagResult {
|
||||
scored_files_read: number;
|
||||
records_read: number;
|
||||
records_exported: number;
|
||||
records_quarantined: number;
|
||||
output_path: string;
|
||||
quarantine_summary: string;
|
||||
}
|
||||
|
||||
const DEFAULT_ROOT = process.env.LH_DISTILL_ROOT ?? "/home/profit/lakehouse";
|
||||
const ALLOWED_DEFAULT: RagSourceCategory[] = ["accepted", "partially_accepted"];
|
||||
|
||||
function listScoredRunFiles(root: string): string[] {
|
||||
const out: string[] = [];
|
||||
const dir = resolve(root, "data/scored-runs");
|
||||
if (!existsSync(dir)) return out;
|
||||
for (const yyyy of readdirSync(dir).sort()) {
|
||||
const yp = resolve(dir, yyyy);
|
||||
if (!statSync(yp).isDirectory()) continue;
|
||||
for (const mm of readdirSync(yp).sort()) {
|
||||
const mp = resolve(yp, mm);
|
||||
if (!statSync(mp).isDirectory()) continue;
|
||||
for (const dd of readdirSync(mp).sort()) {
|
||||
const dp = resolve(mp, dd);
|
||||
if (!statSync(dp).isDirectory()) continue;
|
||||
for (const f of readdirSync(dp)) {
|
||||
if (f.endsWith(".jsonl")) out.push(resolve(dp, f));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Load matching evidence file. Cache is scoped per export call (passed
|
||||
// in by caller) — module-level cache across calls would leak stale
|
||||
// state in tests that wipe and recreate the temp root.
|
||||
function loadEvidenceByRunId(
|
||||
scored_path: string,
|
||||
root: string,
|
||||
cache: Map<string, Map<string, EvidenceRecord>>,
|
||||
): Map<string, EvidenceRecord> {
|
||||
// scored-runs path mirrors evidence path:
|
||||
// data/scored-runs/YYYY/MM/DD/<stem>.jsonl
|
||||
// data/evidence/YYYY/MM/DD/<stem>.jsonl
|
||||
const evidence_path = scored_path.replace("/scored-runs/", "/evidence/");
|
||||
if (cache.has(evidence_path)) return cache.get(evidence_path)!;
|
||||
const m = new Map<string, EvidenceRecord>();
|
||||
if (!existsSync(evidence_path)) {
|
||||
cache.set(evidence_path, m);
|
||||
return m;
|
||||
}
|
||||
for (const line of readFileSync(evidence_path, "utf8").split("\n")) {
|
||||
if (!line) continue;
|
||||
try {
|
||||
const ev = JSON.parse(line) as EvidenceRecord;
|
||||
m.set(ev.run_id, ev);
|
||||
} catch { /* skip bad lines */ }
|
||||
}
|
||||
cache.set(evidence_path, m);
|
||||
return m;
|
||||
}
|
||||
|
||||
// Synthesize fields needed for RAG from {ScoredRun, EvidenceRecord}.
|
||||
// Pure transform; no I/O.
|
||||
function synthesizeRagSample(
|
||||
scored: ScoredRun,
|
||||
ev: EvidenceRecord,
|
||||
recorded_at: string,
|
||||
rag_id: string,
|
||||
): RagSample {
|
||||
const text = ev.text ?? "";
|
||||
const taskParts = ev.task_id.split(":");
|
||||
const tags: string[] = [
|
||||
`task:${taskParts[0] ?? ev.task_id}`,
|
||||
`category:${scored.category}`,
|
||||
];
|
||||
if (ev.model_role) tags.push(`role:${ev.model_role}`);
|
||||
if (ev.model_name) tags.push(`model:${ev.model_name}`);
|
||||
if (Array.isArray(ev.source_files) && ev.source_files.length > 0) {
|
||||
tags.push(`file:${ev.source_files[0]}`);
|
||||
}
|
||||
|
||||
// Title: first line / first 80 chars of text, fallback to task_id
|
||||
const firstLine = text.split("\n").find(l => l.trim().length > 0) ?? "";
|
||||
const title = (firstLine || ev.task_id).slice(0, 120);
|
||||
|
||||
// Embedding text: same as content for now; future tuning may shorten
|
||||
// (e.g. only the title + key claims).
|
||||
const embedding_text = text.slice(0, 2000); // cap at 2KB to keep embeddings cheap
|
||||
|
||||
// Map ScoreCategory → RagSourceCategory. rejected was filtered above,
|
||||
// but defensively narrow here.
|
||||
const cat: RagSourceCategory = scored.category === "rejected"
|
||||
? "needs_human_review" // shouldn't happen — caller filters
|
||||
: (scored.category as RagSourceCategory);
|
||||
|
||||
return {
|
||||
schema_version: RAG_SAMPLE_SCHEMA_VERSION,
|
||||
id: rag_id,
|
||||
title,
|
||||
content: text,
|
||||
tags,
|
||||
source_run_id: scored.evidence_run_id,
|
||||
success_score: cat,
|
||||
source_category: cat,
|
||||
embedding_text,
|
||||
created_at: recorded_at,
|
||||
provenance: {
|
||||
source_file: scored.provenance.source_file,
|
||||
line_offset: scored.provenance.line_offset,
|
||||
sig_hash: scored.provenance.sig_hash,
|
||||
recorded_at,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function exportRag(opts: ExportRagOptions): Promise<ExportRagResult> {
|
||||
const { root, recorded_at, include_review = false, dry_run = false } = opts;
|
||||
const allowed: RagSourceCategory[] = include_review
|
||||
? ["accepted", "partially_accepted", "needs_human_review"]
|
||||
: ALLOWED_DEFAULT;
|
||||
|
||||
const out_path = resolve(root, "exports/rag/playbooks.jsonl");
|
||||
const q = new QuarantineWriter(root, "rag", dry_run);
|
||||
|
||||
let records_read = 0;
|
||||
let records_exported = 0;
|
||||
const seenIds = new Set<string>();
|
||||
const rowsToWrite: string[] = [];
|
||||
|
||||
// Re-read existing output to populate seenIds — exporter idempotent.
|
||||
if (!dry_run && existsSync(out_path)) {
|
||||
for (const line of readFileSync(out_path, "utf8").split("\n")) {
|
||||
if (!line) continue;
|
||||
try { const r = JSON.parse(line); if (r.id) seenIds.add(r.id); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
const evidenceCache = new Map<string, Map<string, EvidenceRecord>>();
|
||||
const scored_files = listScoredRunFiles(root);
|
||||
for (const sp of scored_files) {
|
||||
const evMap = loadEvidenceByRunId(sp, root, evidenceCache);
|
||||
const lines = readFileSync(sp, "utf8").split("\n").filter(Boolean);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
records_read++;
|
||||
let scored: ScoredRun;
|
||||
try { scored = JSON.parse(lines[i]) as ScoredRun; }
|
||||
catch (e) {
|
||||
q.add({
|
||||
reason: "schema_violation",
|
||||
source_record: { _raw: lines[i].slice(0, 200) },
|
||||
errors: ["scored-run not JSON: " + (e as Error).message.slice(0, 160)],
|
||||
recorded_at,
|
||||
source_provenance: { source_file: sp.replace(root + "/", ""), line_offset: i },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Provenance check — every record must have it.
|
||||
if (!scored.provenance?.sig_hash || !scored.provenance.source_file) {
|
||||
q.add({
|
||||
reason: "missing_provenance",
|
||||
source_record: scored as unknown as Record<string, unknown>,
|
||||
errors: ["scored_run.provenance missing or incomplete"],
|
||||
recorded_at,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!scored.evidence_run_id) {
|
||||
q.add({
|
||||
reason: "missing_source_run_id",
|
||||
source_record: scored as unknown as Record<string, unknown>,
|
||||
errors: ["evidence_run_id missing"],
|
||||
recorded_at,
|
||||
source_provenance: scored.provenance,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Category gate — RAG never takes rejected; needs_human is opt-in.
|
||||
if (!allowed.includes(scored.category as RagSourceCategory)) {
|
||||
q.add({
|
||||
reason: "category_disallowed",
|
||||
source_record: scored as unknown as Record<string, unknown>,
|
||||
errors: [`category=${scored.category} not in [${allowed.join(",")}]`],
|
||||
recorded_at,
|
||||
source_provenance: scored.provenance,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Look up evidence row.
|
||||
const ev = evMap.get(scored.evidence_run_id);
|
||||
if (!ev) {
|
||||
q.add({
|
||||
reason: "missing_source_run_id",
|
||||
source_record: scored as unknown as Record<string, unknown>,
|
||||
errors: [`evidence_run_id=${scored.evidence_run_id} not found in matching evidence partition`],
|
||||
recorded_at,
|
||||
source_provenance: scored.provenance,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Empty content gate.
|
||||
if (typeof ev.text !== "string" || ev.text.trim().length === 0) {
|
||||
q.add({
|
||||
reason: "empty_content",
|
||||
source_record: scored as unknown as Record<string, unknown>,
|
||||
errors: ["evidence.text is empty/missing — RAG needs content"],
|
||||
recorded_at,
|
||||
source_provenance: scored.provenance,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Deterministic ID: sha256(evidence_run_id + score_sig_hash):16
|
||||
const id_seed = `${scored.evidence_run_id}|${scored.provenance.sig_hash}`;
|
||||
const hasher = new Bun.CryptoHasher("sha256");
|
||||
hasher.update(id_seed);
|
||||
const rag_id = "rag-" + hasher.digest("hex").slice(0, 16);
|
||||
|
||||
if (seenIds.has(rag_id)) {
|
||||
// Idempotent — same row appears in existing output. Skip silently.
|
||||
continue;
|
||||
}
|
||||
|
||||
const sample = synthesizeRagSample(scored, ev, recorded_at, rag_id);
|
||||
const v = validateRagSample(sample);
|
||||
if (!v.valid) {
|
||||
q.add({
|
||||
reason: "schema_violation",
|
||||
source_record: sample as unknown as Record<string, unknown>,
|
||||
errors: v.errors,
|
||||
recorded_at,
|
||||
source_provenance: scored.provenance,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
seenIds.add(rag_id);
|
||||
rowsToWrite.push(JSON.stringify(v.value));
|
||||
records_exported++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!dry_run && rowsToWrite.length > 0) {
|
||||
mkdirSync(dirname(out_path), { recursive: true });
|
||||
appendFileSync(out_path, rowsToWrite.join("\n") + "\n");
|
||||
}
|
||||
|
||||
return {
|
||||
scored_files_read: scored_files.length,
|
||||
records_read,
|
||||
records_exported,
|
||||
records_quarantined: q.total,
|
||||
output_path: out_path.replace(root + "/", ""),
|
||||
quarantine_summary: q.summary(),
|
||||
};
|
||||
}
|
||||
|
||||
async function cli() {
|
||||
const dry_run = process.argv.includes("--dry-run");
|
||||
const include_review = process.argv.includes("--include-review");
|
||||
const recorded_at = new Date().toISOString();
|
||||
const r = await exportRag({ root: DEFAULT_ROOT, recorded_at, include_review, dry_run });
|
||||
|
||||
console.log(`[export_rag] read=${r.records_read} exported=${r.records_exported} ${r.quarantine_summary}${dry_run ? " (DRY RUN)" : ""}`);
|
||||
console.log(`[export_rag] output: ${r.output_path}`);
|
||||
if (include_review) console.log("[export_rag] needs_human_review INCLUDED (--include-review)");
|
||||
}
|
||||
|
||||
if (import.meta.main) cli().catch(e => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,303 @@
|
||||
// export_sft.ts — Phase 4b SFT dataset export. Strict no-leak gates.
|
||||
//
|
||||
// Default: only category=accepted ships.
|
||||
// --include-partial: category in {accepted, partially_accepted} ships.
|
||||
// rejected and needs_human_review NEVER ship — schema layer (Phase 1)
|
||||
// enforces this AND the exporter filters before validation. Defense
|
||||
// in depth.
|
||||
//
|
||||
// Each SFT row:
|
||||
// instruction = the prompt the executor saw
|
||||
// context = retrieved context summary (matrix corpora used,
|
||||
// pathway fingerprints seen, file_path)
|
||||
// response = the executor's accepted output (evidence.text)
|
||||
//
|
||||
// Source restriction: SFT only takes records where evidence.text is a
|
||||
// real model output (model_role in {executor, applier, reviewer with
|
||||
// observer_verdict}). Pure-extraction rows lack a true "instruction"
|
||||
// and are quarantined as missing_source_run_id (since they're not
|
||||
// instruction→response shape).
|
||||
|
||||
import { existsSync, readFileSync, readdirSync, mkdirSync, appendFileSync, statSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import {
|
||||
SFT_SAMPLE_SCHEMA_VERSION, validateSftSample, type SftSample, type SftQualityScore,
|
||||
} from "../../auditor/schemas/distillation/sft_sample";
|
||||
import type { ScoredRun, ScoreCategory } from "../../auditor/schemas/distillation/scored_run";
|
||||
import type { EvidenceRecord } from "../../auditor/schemas/distillation/evidence_record";
|
||||
import { QuarantineWriter } from "./quarantine";
|
||||
|
||||
export interface ExportSftOptions {
|
||||
root: string;
|
||||
recorded_at: string;
|
||||
include_partial?: boolean;
|
||||
dry_run?: boolean;
|
||||
}
|
||||
|
||||
export interface ExportSftResult {
|
||||
scored_files_read: number;
|
||||
records_read: number;
|
||||
records_exported: number;
|
||||
records_quarantined: number;
|
||||
output_path: string;
|
||||
quarantine_summary: string;
|
||||
}
|
||||
|
||||
const DEFAULT_ROOT = process.env.LH_DISTILL_ROOT ?? "/home/profit/lakehouse";
|
||||
|
||||
// Hard non-negotiable: this set never expands. If you find yourself
|
||||
// adding "needs_human_review" or "rejected" here, stop — that's the
|
||||
// contamination the spec forbids.
|
||||
const SFT_NEVER: ScoreCategory[] = ["rejected", "needs_human_review"];
|
||||
|
||||
function listScoredRunFiles(root: string): string[] {
|
||||
const out: string[] = [];
|
||||
const dir = resolve(root, "data/scored-runs");
|
||||
if (!existsSync(dir)) return out;
|
||||
for (const yyyy of readdirSync(dir).sort()) {
|
||||
const yp = resolve(dir, yyyy);
|
||||
if (!statSync(yp).isDirectory()) continue;
|
||||
for (const mm of readdirSync(yp).sort()) {
|
||||
const mp = resolve(yp, mm);
|
||||
if (!statSync(mp).isDirectory()) continue;
|
||||
for (const dd of readdirSync(mp).sort()) {
|
||||
const dp = resolve(mp, dd);
|
||||
if (!statSync(dp).isDirectory()) continue;
|
||||
for (const f of readdirSync(dp)) {
|
||||
if (f.endsWith(".jsonl")) out.push(resolve(dp, f));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function loadEvidenceByRunId(
|
||||
scored_path: string,
|
||||
cache: Map<string, Map<string, EvidenceRecord>>,
|
||||
): Map<string, EvidenceRecord> {
|
||||
const evidence_path = scored_path.replace("/scored-runs/", "/evidence/");
|
||||
if (cache.has(evidence_path)) return cache.get(evidence_path)!;
|
||||
const m = new Map<string, EvidenceRecord>();
|
||||
if (!existsSync(evidence_path)) { cache.set(evidence_path, m); return m; }
|
||||
for (const line of readFileSync(evidence_path, "utf8").split("\n")) {
|
||||
if (!line) continue;
|
||||
try { const ev = JSON.parse(line) as EvidenceRecord; m.set(ev.run_id, ev); } catch {}
|
||||
}
|
||||
cache.set(evidence_path, m);
|
||||
return m;
|
||||
}
|
||||
|
||||
// Synthesize SFT shape from the executed run. For sources where text
|
||||
// isn't a model RESPONSE (pure-extraction), this returns null and the
|
||||
// caller quarantines.
|
||||
function synthesizeSft(
|
||||
scored: ScoredRun,
|
||||
ev: EvidenceRecord,
|
||||
recorded_at: string,
|
||||
sft_id: string,
|
||||
): SftSample | null {
|
||||
const text = ev.text ?? "";
|
||||
// Skip extraction-class records — they don't have an instruction→response shape.
|
||||
const role = ev.model_role;
|
||||
if (role !== "executor" && role !== "reviewer" && role !== "applier") return null;
|
||||
if (text.trim().length === 0) return null;
|
||||
|
||||
// Instruction synthesis depends on the source class.
|
||||
const stem = ev.provenance.source_file.replace(/^data\/_kb\//, "").replace(/\.jsonl$/, "");
|
||||
let instruction = "";
|
||||
switch (stem) {
|
||||
case "scrum_reviews":
|
||||
instruction = `Review the file '${ev.source_files?.[0] ?? "<file>"}' against the PRD + change-proposal context. Produce a forensic audit with findings, severity, confidence, patches.`;
|
||||
break;
|
||||
case "mode_experiments":
|
||||
instruction = `Run task_class='${ev.task_id}' for file '${ev.source_files?.[0] ?? "<file>"}'. Produce the mode-runner's expected output shape.`;
|
||||
break;
|
||||
case "auto_apply":
|
||||
instruction = `Auto-apply: emit a 6-line surgical patch for '${ev.source_files?.[0] ?? "<file>"}' based on the latest scrum review findings.`;
|
||||
break;
|
||||
case "audits":
|
||||
instruction = `Audit phase '${ev.task_id.replace(/^phase:/, "")}' and report findings with severity.`;
|
||||
break;
|
||||
case "observer_reviews":
|
||||
instruction = `Observer-review the latest attempt on '${ev.source_files?.[0] ?? "<file>"}'. Verdict: accept | reject | cycle.`;
|
||||
break;
|
||||
case "contract_analyses":
|
||||
instruction = `Analyze contractor '${(ev as any).contractor ?? "<contractor>"}' for permit '${ev.task_id.replace(/^permit:/, "")}'. Recommend with risk markers.`;
|
||||
break;
|
||||
case "outcomes":
|
||||
instruction = `Run scenario; report per-event outcome with citations.`;
|
||||
break;
|
||||
default:
|
||||
instruction = `Source '${stem}' run; produce the appropriate output for this task type.`;
|
||||
}
|
||||
|
||||
// Context — what the model could see. Keep terse.
|
||||
const ctxParts: string[] = [];
|
||||
if (ev.retrieved_context?.matrix_corpora?.length) {
|
||||
ctxParts.push(`matrix=${ev.retrieved_context.matrix_corpora.join(",")}`);
|
||||
}
|
||||
if (typeof ev.retrieved_context?.pathway_fingerprints_seen === "number") {
|
||||
ctxParts.push(`pathway_fingerprints=${ev.retrieved_context.pathway_fingerprints_seen}`);
|
||||
}
|
||||
if (ev.model_name) ctxParts.push(`model=${ev.model_name}`);
|
||||
const context = ctxParts.join(" · ");
|
||||
|
||||
return {
|
||||
schema_version: SFT_SAMPLE_SCHEMA_VERSION,
|
||||
id: sft_id,
|
||||
instruction,
|
||||
context,
|
||||
response: text,
|
||||
source_run_id: scored.evidence_run_id,
|
||||
quality_score: scored.category as SftQualityScore,
|
||||
created_at: recorded_at,
|
||||
provenance: {
|
||||
source_file: scored.provenance.source_file,
|
||||
line_offset: scored.provenance.line_offset,
|
||||
sig_hash: scored.provenance.sig_hash,
|
||||
recorded_at,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function exportSft(opts: ExportSftOptions): Promise<ExportSftResult> {
|
||||
const { root, recorded_at, include_partial = false, dry_run = false } = opts;
|
||||
const allowed: ScoreCategory[] = include_partial
|
||||
? ["accepted", "partially_accepted"]
|
||||
: ["accepted"];
|
||||
const out_path = resolve(root, "exports/sft/instruction_response.jsonl");
|
||||
const q = new QuarantineWriter(root, "sft", dry_run);
|
||||
|
||||
let records_read = 0;
|
||||
let records_exported = 0;
|
||||
const seenIds = new Set<string>();
|
||||
const rowsToWrite: string[] = [];
|
||||
|
||||
if (!dry_run && existsSync(out_path)) {
|
||||
for (const line of readFileSync(out_path, "utf8").split("\n")) {
|
||||
if (!line) continue;
|
||||
try { const r = JSON.parse(line); if (r.id) seenIds.add(r.id); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
const evidenceCache = new Map<string, Map<string, EvidenceRecord>>();
|
||||
const scored_files = listScoredRunFiles(root);
|
||||
for (const sp of scored_files) {
|
||||
const evMap = loadEvidenceByRunId(sp, evidenceCache);
|
||||
const lines = readFileSync(sp, "utf8").split("\n").filter(Boolean);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
records_read++;
|
||||
let scored: ScoredRun;
|
||||
try { scored = JSON.parse(lines[i]) as ScoredRun; }
|
||||
catch (e) {
|
||||
q.add({
|
||||
reason: "schema_violation",
|
||||
source_record: { _raw: lines[i].slice(0, 200) },
|
||||
errors: ["scored-run not JSON: " + (e as Error).message.slice(0, 160)],
|
||||
recorded_at,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// CONTAMINATION FIREWALL: any forbidden category goes straight
|
||||
// to quarantine, never reaches the synthesizer.
|
||||
if (SFT_NEVER.includes(scored.category)) {
|
||||
q.add({
|
||||
reason: "unsafe_sft_category",
|
||||
source_record: scored as unknown as Record<string, unknown>,
|
||||
errors: [`category=${scored.category} forbidden in SFT (spec non-negotiable)`],
|
||||
recorded_at,
|
||||
source_provenance: scored.provenance,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!allowed.includes(scored.category)) {
|
||||
q.add({
|
||||
reason: "category_disallowed",
|
||||
source_record: scored as unknown as Record<string, unknown>,
|
||||
errors: [`category=${scored.category} not in [${allowed.join(",")}] (--include-partial=${include_partial})`],
|
||||
recorded_at,
|
||||
source_provenance: scored.provenance,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!scored.provenance?.sig_hash) {
|
||||
q.add({ reason: "missing_provenance", source_record: scored as any, errors: ["provenance missing"], recorded_at });
|
||||
continue;
|
||||
}
|
||||
if (!scored.evidence_run_id) {
|
||||
q.add({ reason: "missing_source_run_id", source_record: scored as any, errors: ["evidence_run_id missing"], recorded_at, source_provenance: scored.provenance });
|
||||
continue;
|
||||
}
|
||||
|
||||
const ev = evMap.get(scored.evidence_run_id);
|
||||
if (!ev) {
|
||||
q.add({ reason: "missing_source_run_id", source_record: scored as any, errors: [`evidence_run_id=${scored.evidence_run_id} not found`], recorded_at, source_provenance: scored.provenance });
|
||||
continue;
|
||||
}
|
||||
|
||||
// ID = sha256(evidence_run_id + sig_hash):16
|
||||
const id_seed = `${scored.evidence_run_id}|${scored.provenance.sig_hash}`;
|
||||
const hasher = new Bun.CryptoHasher("sha256");
|
||||
hasher.update(id_seed);
|
||||
const sft_id = "sft-" + hasher.digest("hex").slice(0, 16);
|
||||
if (seenIds.has(sft_id)) continue;
|
||||
|
||||
const sample = synthesizeSft(scored, ev, recorded_at, sft_id);
|
||||
if (!sample) {
|
||||
q.add({
|
||||
reason: "missing_source_run_id",
|
||||
source_record: { run_id: scored.evidence_run_id, model_role: ev.model_role, has_text: typeof ev.text === "string" && ev.text.length > 0 },
|
||||
errors: ["evidence has no instruction→response shape (extraction-class or empty text)"],
|
||||
recorded_at,
|
||||
source_provenance: scored.provenance,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const v = validateSftSample(sample);
|
||||
if (!v.valid) {
|
||||
q.add({
|
||||
reason: "schema_violation",
|
||||
source_record: sample as unknown as Record<string, unknown>,
|
||||
errors: v.errors,
|
||||
recorded_at,
|
||||
source_provenance: scored.provenance,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
seenIds.add(sft_id);
|
||||
rowsToWrite.push(JSON.stringify(v.value));
|
||||
records_exported++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!dry_run && rowsToWrite.length > 0) {
|
||||
mkdirSync(dirname(out_path), { recursive: true });
|
||||
appendFileSync(out_path, rowsToWrite.join("\n") + "\n");
|
||||
}
|
||||
|
||||
return {
|
||||
scored_files_read: scored_files.length,
|
||||
records_read,
|
||||
records_exported,
|
||||
records_quarantined: q.total,
|
||||
output_path: out_path.replace(root + "/", ""),
|
||||
quarantine_summary: q.summary(),
|
||||
};
|
||||
}
|
||||
|
||||
async function cli() {
|
||||
const dry_run = process.argv.includes("--dry-run");
|
||||
const include_partial = process.argv.includes("--include-partial");
|
||||
const recorded_at = new Date().toISOString();
|
||||
const r = await exportSft({ root: DEFAULT_ROOT, recorded_at, include_partial, dry_run });
|
||||
|
||||
console.log(`[export_sft] read=${r.records_read} exported=${r.records_exported} ${r.quarantine_summary}${dry_run ? " (DRY RUN)" : ""}`);
|
||||
console.log(`[export_sft] output: ${r.output_path}`);
|
||||
if (include_partial) console.log("[export_sft] partially_accepted INCLUDED (--include-partial)");
|
||||
}
|
||||
|
||||
if (import.meta.main) cli().catch(e => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,114 @@
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user