ms-ai-architect/scripts/kb-eval/judge-pass-manifest.mjs

201 lines
9 KiB
JavaScript
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
// judge-pass-manifest.mjs — R7R10 full-pass judge LEDGER (Spor 3 pre-pass infra, R6 Step 4).
//
// The durable per-file record of the corpus judge pass: one entry per never-verified reference
// file, carrying its per-file verdict (born-verified `pass` → surgical stamp, or `flagged` →
// R11 fix work-list), the judge provenance, and any not-grounded claim flags. Mirrors
// scripts/kb-eval/data/spor0-fix-manifest.json in shape ({_meta, <records[]>}) and discipline
// (git-tracked under data/ so drift is diff-reviewable; hand/subagent-maintained).
//
// Durability + resume are the whole point (brief NFR): R7 calls appendJudgedFile after EVERY
// judged file and writes atomically, so a crash on file 200/243 loses at most ONE file; a
// resume calls pendingFiles(manifest, worklist) and re-processes only the remainder. The
// aggregate-in-memory-write-once alternative loses a whole økt on a crash — rejected.
//
// Pure core: validateManifest / mergeBatch / appendJudgedFile / pendingFiles are disk-free and
// deterministic (path-sorted output). IO-shell: --json / --write [--merge] [--out <path>].
//
// Exit codes: 0 ok · 2 usage error · 3 re-entrance guard (judged ledger, no --merge)
import { readFileSync, existsSync, realpathSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { atomicWriteJson } from '../kb-update/lib/atomic-write.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Pinned: the single git-tracked ledger downstream R7R10 batches append to (verified NOT
// under .gitignore, so the judged ledger is review-visible — brief preference).
export const DEFAULT_MANIFEST_PATH = join(__dirname, 'data', 'judge-pass-manifest.json');
// A file's per-pass verdict: `pass` = every judgeable claim grounded (AND) → born-verified
// stamp; `flagged` = ≥1 not-grounded claim OR unstampable → R11 work-list. Nothing else.
export const VALID_VERDICTS = ['pass', 'flagged'];
const KNOWN_FLAGS = new Set(['--json', '--write', '--merge', '--out']);
const byFilePath = (a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0);
/** A fresh, empty ledger scaffold. Deterministic (no live timestamp) so --write is stable. */
export function scaffoldManifest() {
return {
_meta: {
purpose:
'R7R10 full-pass judge ledger: one record per never-verified reference file — ' +
'born-verified pass (surgical stamp) or flagged (R11 work-list). Durable per file.',
derived_from: 'scripts/kb-update/data/full-pass-worklist.json (243 due, all never-verified)',
cadence: 'R7R10 (5 økter × ~49, 810 samtidige)',
batch: null,
count: 0,
generated: null,
},
files: [],
};
}
/**
* Validate the ledger shape. Pure. Returns {valid, errors[]}. Enforces the born-verified
* contract at rest: a `pass` record MUST carry verified + verified_by; a `flagged` record MUST
* carry ≥1 flag. Duplicate file records are an error (one record per file).
*/
export function validateManifest(manifest) {
const errors = [];
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
return { valid: false, errors: ['manifest must be an object'] };
}
if (!manifest._meta || typeof manifest._meta !== 'object') errors.push('_meta must be an object');
if (!Array.isArray(manifest.files)) {
errors.push('files must be an array');
return { valid: false, errors };
}
const seen = new Set();
for (const rec of manifest.files) {
if (!rec || typeof rec !== 'object') { errors.push('file record must be an object'); continue; }
if (typeof rec.file !== 'string' || rec.file.trim() === '') { errors.push("record missing 'file'"); continue; }
if (seen.has(rec.file)) errors.push(`duplicate file record: ${rec.file}`);
seen.add(rec.file);
if (!VALID_VERDICTS.includes(rec.per_file_verdict)) {
errors.push(`${rec.file}: per_file_verdict must be one of ${VALID_VERDICTS.join('|')}`);
}
if (!Array.isArray(rec.flags)) errors.push(`${rec.file}: flags must be an array`);
if (rec.per_file_verdict === 'pass' && (!rec.verified || !rec.verified_by)) {
errors.push(`${rec.file}: a pass record must carry verified + verified_by`);
}
if (rec.per_file_verdict === 'flagged' && !(Array.isArray(rec.flags) && rec.flags.length > 0)) {
errors.push(`${rec.file}: a flagged record must carry ≥1 flag`);
}
}
return { valid: errors.length === 0, errors };
}
/**
* Add or update ONE file record, returning a NEW manifest (pure). The durability primitive:
* R7 calls this after each judged file, then writes atomically → max one file lost on crash.
* An existing record for the same `file` is replaced (idempotent re-judge). Files stay
* path-sorted and _meta.count is refreshed, so the on-disk ledger is deterministic + diff-clean.
*/
export function appendJudgedFile(manifest, record) {
if (!record || typeof record.file !== 'string' || record.file.trim() === '') {
throw new Error('appendJudgedFile: record.file is required');
}
const base = manifest && typeof manifest === 'object' && !Array.isArray(manifest) ? manifest : scaffoldManifest();
const kept = (Array.isArray(base.files) ? base.files : []).filter((r) => r.file !== record.file);
const files = [...kept, record].sort(byFilePath);
return { ...base, _meta: { ...(base._meta ?? {}), count: files.length }, files };
}
/**
* Merge a batch of file records into an existing ledger, preserving already-judged records
* verbatim (a judged entry is authoritative — a re-run never silently re-judges it). Records
* for not-yet-judged files are added/updated. Pure; output path-sorted, _meta.count refreshed.
* @param {object} existing — the current ledger
* @param {Array|object} incoming — a batch (array of records, or a manifest with files[])
*/
export function mergeBatch(existing, incoming) {
const existingFiles = Array.isArray(existing?.files) ? existing.files : [];
const judged = new Set(
existingFiles.filter((r) => VALID_VERDICTS.includes(r?.per_file_verdict)).map((r) => r.file),
);
const byFile = new Map(existingFiles.map((r) => [r.file, r]));
const incomingFiles = Array.isArray(incoming) ? incoming : Array.isArray(incoming?.files) ? incoming.files : [];
for (const rec of incomingFiles) {
if (!rec || typeof rec.file !== 'string') continue;
if (judged.has(rec.file)) continue; // judged wins — preserve verbatim
byFile.set(rec.file, rec);
}
const files = [...byFile.values()].sort(byFilePath);
return { ...(existing ?? {}), _meta: { ...(existing?._meta ?? {}), count: files.length }, files };
}
/**
* Resume primitive: given the full worklist of file paths, return those NOT yet judged (no
* ledger record with a per_file_verdict set). R7 resume processes only these — a re-run after
* a crash skips every already-judged file. Pure; preserves worklist order.
*/
export function pendingFiles(manifest, worklistPaths) {
const judged = new Set(
(manifest?.files ?? [])
.filter((r) => r && VALID_VERDICTS.includes(r.per_file_verdict))
.map((r) => r.file),
);
return (worklistPaths ?? []).filter((p) => !judged.has(p));
}
function hasJudged(manifest) {
return Array.isArray(manifest?.files) && manifest.files.some((r) => VALID_VERDICTS.includes(r?.per_file_verdict));
}
function usageExit(msg, code) {
process.stderr.write(`${msg}\nusage: judge-pass-manifest.mjs [--json] [--write] [--merge] [--out <path>]\n`);
process.exit(code);
}
function main(argv) {
const args = argv.slice(2);
let outPath = DEFAULT_MANIFEST_PATH;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--out') {
if (!args[i + 1]) usageExit('--out requires a path', 2);
outPath = args[++i];
continue;
}
if (!KNOWN_FLAGS.has(args[i])) usageExit(`unknown flag: ${args[i]}`, 2);
}
const json = args.includes('--json');
const write = args.includes('--write');
const merge = args.includes('--merge');
const existing = existsSync(outPath) ? JSON.parse(readFileSync(outPath, 'utf8')) : null;
if (existing) {
const check = validateManifest(existing);
if (!check.valid) usageExit(`invalid manifest at ${outPath}:\n ${check.errors.join('\n ')}`, 2);
}
let manifest = existing ?? scaffoldManifest();
if (write) {
if (existing && hasJudged(existing) && !merge) {
process.stderr.write(
`refusing --write: ledger at ${outPath} already carries judged record(s); a fresh write ` +
'would clobber the judged pass. Re-run with --merge to preserve it.\n',
);
process.exit(3);
}
manifest = mergeBatch(manifest, []); // normalize: path-sort + refresh count, judged preserved
atomicWriteJson(outPath, manifest);
}
if (json) {
process.stdout.write(JSON.stringify(manifest, null, 2) + '\n');
} else {
console.log(`judge-pass-manifest: ${manifest.files.length} record(s)` + (write ? `${outPath}` : ''));
}
}
const isMain = (() => {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
})();
if (isMain) main(process.argv);