Deterministisk de-risk-harness for Fase 3-judgen (kjøres på frosset 373-påstands gull-sett): - lib/judge-bakeoff.mjs: P-filter (volatil+fetchbar, price ekskl.), confusion-matrix for 3 armer (staleness/judge/hybrid), Wilson-bånd, forhåndsregistrert gate. 14 tester. - extract-judge-claims.mjs: blind manifest (255 påstander, 0 label-lekkasje — testet invariant). - judge-claim-prompt.md: blind per-påstands groundedness-judge (Opus xhigh, microsoft_docs_fetch). - run-judge-bakeoff.mjs: join gull+results på id, gate-rapport (.json/.md). Gate FORHÅNDSREGISTRERT (operatørvalg, før fan-out): recall ≥0.80, presisjon ≥0.70, OG slår staleness (0/38). Evalueringspop P = 240 verifiserbare, 38 positive. Suite 551/551 (538 + 13 nye).
206 lines
8.4 KiB
JavaScript
206 lines
8.4 KiB
JavaScript
// scripts/kb-eval/lib/judge-bakeoff.mjs — S1 judge bake-off aggregation (deterministic).
|
|
//
|
|
// De-risks the Fase 3 correctness judge BEFORE production scaffolding (S3) is built:
|
|
// grade a per-claim groundedness judge against the frozen gold set and decide whether
|
|
// it beats the cheap staleness baseline (recall 0/40) by enough to justify ~2700
|
|
// non-batchable microsoft_docs_fetch calls per full pass. Pure functions only — no
|
|
// I/O, no Date.now/Math.random.
|
|
//
|
|
// Eval population P = volatile stratum + fetchable claim_type (price excluded). This
|
|
// is the only population that matters: it is where the real errors concentrate, and
|
|
// scoping to it structurally avoids the "inverted leverage" trap (a judge that wins
|
|
// only by auto-scoring cheap stable claims it was never at risk on).
|
|
//
|
|
// Detection confusion matrix vs the gold verdicts:
|
|
// gold positive = verdict ∈ {outdated, wrong} (a real error the judge should catch)
|
|
// gold negative = verdict === 'correct'
|
|
// excluded = verdict === 'unsourced' (no ground-truth value to grade against)
|
|
// predicted positive (flag) = judge_verdict === 'not_grounded'
|
|
// judge_verdict vocabulary: 'grounded' | 'not_grounded' | 'source_silent'.
|
|
|
|
import { wilson } from './base-rate.mjs';
|
|
|
|
export const FETCHABLE_TYPES = new Set([
|
|
'taxonomy', 'status', 'version', 'tpm', 'sku', 'region',
|
|
]);
|
|
const ERROR_VERDICTS = new Set(['outdated', 'wrong']);
|
|
|
|
// P = the claims the bake-off is measured on. A claim is in P iff it is volatile and
|
|
// of a fetchable claim_type (price is excluded — 74% unsourced, JS-rendered Azure
|
|
// pages defeat microsoft_docs_fetch, so a fetch-based judge cannot reach it either).
|
|
export function evalPopulation(claims) {
|
|
return claims.filter(
|
|
(c) => c.stratum === 'volatile' && FETCHABLE_TYPES.has(c.claim_type),
|
|
);
|
|
}
|
|
|
|
// The BLIND manifest handed to the judge subagents: every claim in P, stripped to
|
|
// the fields the judge is allowed to see. The gold verdict, notes, lastmod_changed
|
|
// and stratum are deliberately withheld so the eval is blind (no label leakage).
|
|
// All of P is included (unsourced too) — the judge must not know which claims the
|
|
// human could not source; reproducing that boundary as source_silent is itself a
|
|
// measured signal.
|
|
const BLIND_FIELDS = ['id', 'file', 'skill', 'claim', 'claim_type', 'evidence_url'];
|
|
export function blindClaims(claims) {
|
|
return evalPopulation(claims).map((c) => {
|
|
const out = {};
|
|
for (const k of BLIND_FIELDS) out[k] = c[k];
|
|
return out;
|
|
});
|
|
}
|
|
|
|
export function stalenessFlag(claim) {
|
|
return claim.lastmod_changed === true;
|
|
}
|
|
export function judgeFlag(claim) {
|
|
return claim.judge_verdict === 'not_grounded';
|
|
}
|
|
export function hybridFlag(claim) {
|
|
return stalenessFlag(claim) || judgeFlag(claim);
|
|
}
|
|
|
|
// Grade one detection arm over a list of *verifiable* claims (gold correct/outdated/
|
|
// wrong only — unsourced must be filtered out before calling). flagFn(claim) -> bool
|
|
// is the arm's predicted-error rule. Returns the confusion matrix plus precision /
|
|
// recall / F1. precision is null when nothing is flagged (0/0, not NaN); recall is
|
|
// null when there are no positives at all.
|
|
export function gradeArm(verifiableClaims, flagFn) {
|
|
let tp = 0, fp = 0, fn = 0, tn = 0;
|
|
for (const c of verifiableClaims) {
|
|
const isError = ERROR_VERDICTS.has(c.verdict);
|
|
const flagged = flagFn(c);
|
|
if (isError && flagged) tp += 1;
|
|
else if (!isError && flagged) fp += 1;
|
|
else if (isError && !flagged) fn += 1;
|
|
else tn += 1;
|
|
}
|
|
const positives = tp + fn;
|
|
const negatives = fp + tn;
|
|
const flagged = tp + fp;
|
|
const precision = flagged ? tp / flagged : null;
|
|
const recall = positives ? tp / positives : null;
|
|
const f1 =
|
|
precision != null && recall != null && precision + recall > 0
|
|
? (2 * precision * recall) / (precision + recall)
|
|
: null;
|
|
// Wilson 95% bands surface sampling noise — the positive sample is small (≈38),
|
|
// so a point estimate near the threshold should not be read as crisp. Bands are
|
|
// reported context; the gate itself uses the point estimate.
|
|
const recallWilson = positives ? wilson(tp, positives) : null;
|
|
const precisionWilson = flagged ? wilson(tp, flagged) : null;
|
|
return {
|
|
tp, fp, fn, tn, positives, negatives, flagged,
|
|
precision, recall, f1, recallWilson, precisionWilson,
|
|
};
|
|
}
|
|
|
|
// The PRE-REGISTERED gate. thresholds = { minRecall, minPrecision }. Pass requires
|
|
// the judge to clear both thresholds AND strictly beat the staleness baseline's
|
|
// recall (the directional point of S1: staleness recall is 0/40, so any positive
|
|
// recall beats it — but the threshold guards against a judge too weak to justify
|
|
// the fetch cost). A null judge precision (judge flagged nothing) cannot clear a
|
|
// positive minPrecision.
|
|
export function gateDecision(judgeArm, stalenessArm, thresholds) {
|
|
const { minRecall, minPrecision } = thresholds;
|
|
const recall = judgeArm.recall ?? 0;
|
|
const precision = judgeArm.precision; // may be null
|
|
const staleRecall = stalenessArm.recall ?? 0;
|
|
|
|
const recallOk = recall >= minRecall;
|
|
const precisionOk = precision != null && precision >= minPrecision;
|
|
const beatsStaleness = recall > staleRecall;
|
|
const pass = recallOk && precisionOk && beatsStaleness;
|
|
|
|
const reasons = [];
|
|
if (!recallOk) reasons.push(`recall ${fmt(recall)} < minRecall ${minRecall}`);
|
|
if (!precisionOk) {
|
|
reasons.push(
|
|
precision == null
|
|
? 'judge flagged nothing (precision undefined)'
|
|
: `precision ${fmt(precision)} < minPrecision ${minPrecision}`,
|
|
);
|
|
}
|
|
if (!beatsStaleness) {
|
|
reasons.push(`recall ${fmt(recall)} does not beat staleness ${fmt(staleRecall)}`);
|
|
}
|
|
if (pass) reasons.push('all criteria met');
|
|
return { pass, recallOk, precisionOk, beatsStaleness, thresholds, reasons };
|
|
}
|
|
|
|
function fmt(x) {
|
|
return x == null ? 'n/a' : x.toFixed(3);
|
|
}
|
|
|
|
// source_silent is the judge's "I fetched but the page does not state this value"
|
|
// verdict. It is diagnostic, not a flag. Two populations matter:
|
|
// - on verifiable claims (the human DID find a value): every source_silent is a
|
|
// judge verification miss. Split by whether the gold value was correct vs an error
|
|
// (a source_silent on a real error is a missed catch via "can't verify").
|
|
// - on unsourced-in-P claims (the human ALSO found no value): source_silent here is
|
|
// agreement — a good sign the judge reproduces the unverifiable boundary.
|
|
function sourceSilentDiagnostics(P) {
|
|
let onVerifiableNegative = 0;
|
|
let onVerifiableError = 0;
|
|
let agreesWithUnsourced = 0;
|
|
let disagreesWithUnsourced = 0;
|
|
for (const c of P) {
|
|
const silent = c.judge_verdict === 'source_silent';
|
|
if (c.verdict === 'unsourced') {
|
|
if (silent) agreesWithUnsourced += 1;
|
|
else disagreesWithUnsourced += 1;
|
|
} else if (silent) {
|
|
if (ERROR_VERDICTS.has(c.verdict)) onVerifiableError += 1;
|
|
else onVerifiableNegative += 1;
|
|
}
|
|
}
|
|
return {
|
|
onVerifiableNegative,
|
|
onVerifiableError,
|
|
agreesWithUnsourced,
|
|
disagreesWithUnsourced,
|
|
};
|
|
}
|
|
|
|
// Per claim_type judge confusion matrix over the verifiable subset (so the report can
|
|
// show where the judge earns its keep — sku/version/tpm carry the highest error rates).
|
|
function judgeByClaimType(verifiable) {
|
|
const byType = {};
|
|
for (const c of verifiable) {
|
|
(byType[c.claim_type] ||= []).push(c);
|
|
}
|
|
const out = {};
|
|
for (const [t, arr] of Object.entries(byType)) {
|
|
out[t] = gradeArm(arr, judgeFlag);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Top-level: join already done by the caller (each claim carries judge_verdict).
|
|
// Filters to P, splits verifiable vs unsourced, grades all three arms over the same
|
|
// verifiable set, computes diagnostics + the gate, returns one report object.
|
|
export function computeBakeoff(joinedClaims, thresholds) {
|
|
const P = evalPopulation(joinedClaims);
|
|
const verifiable = P.filter((c) => c.verdict !== 'unsourced');
|
|
const unsourcedInP = P.length - verifiable.length;
|
|
|
|
const arms = {
|
|
staleness: gradeArm(verifiable, stalenessFlag),
|
|
judge: gradeArm(verifiable, judgeFlag),
|
|
hybrid: gradeArm(verifiable, hybridFlag),
|
|
};
|
|
const gate = gateDecision(arms.judge, arms.staleness, thresholds);
|
|
|
|
return {
|
|
population: {
|
|
total: P.length,
|
|
verifiable: verifiable.length,
|
|
positives: verifiable.filter((c) => ERROR_VERDICTS.has(c.verdict)).length,
|
|
negatives: verifiable.filter((c) => c.verdict === 'correct').length,
|
|
unsourcedInP,
|
|
},
|
|
arms,
|
|
sourceSilent: sourceSilentDiagnostics(P),
|
|
byClaimType: judgeByClaimType(verifiable),
|
|
gate,
|
|
};
|
|
}
|