15 batcher (Opus 4.8 xhigh, read-only) verifiserte 55 sample-frame-filer mot live MS Learn (strikt v2 bevis-krav: correct/outdated/wrong krever hentet learn-sitat, ellers unsourced). 373 påstander: 259 correct, 29 outdated, 11 wrong, 74 unsourced. 40 reelle feil. lastmod_changed joinet i kode (registry sitemap_lastmod vs filens dato): 0 filer ville staleness-loopen flagget -> alle 40 feil er judge-unike (rå funn, krever tolkning i base-rate-rapporten). lib/base-rate.mjs (Wilson, lastmod, aggregering) TDD 15 tester. Suite 538/538. Steg 3 (compute-base-rate + gate) gjenstår.
125 lines
5 KiB
JavaScript
125 lines
5 KiB
JavaScript
// scripts/kb-eval/lib/base-rate.mjs — Fase 0 base-rate aggregation (deterministic).
|
|
//
|
|
// Turns the per-claim subagent verdicts (gold-correctness-set) into a defensible
|
|
// base-rate report. Pure functions only — no I/O, no Date.now/Math.random.
|
|
//
|
|
// Verdict vocabulary (set by the per-file verification subagents):
|
|
// correct — a fetched learn.microsoft.com page states the claimed value
|
|
// outdated — fetched source shows a different, superseded value (time drift)
|
|
// wrong — fetched source contradicts the claim; it was never correct
|
|
// unsourced — no fetchable MS Learn page states the value (cannot verify)
|
|
//
|
|
// "Genuine errors" = outdated + wrong. unsourced is NOT an error (it is the
|
|
// unverifiable mass: prices on JS-rendered Azure pages a fetch-based judge also
|
|
// cannot reach). The verifiable error rate therefore EXCLUDES unsourced from its
|
|
// denominator. The gate-critical figure is errorsJudgeUnique: genuine errors
|
|
// whose cited source lastmod did NOT change since the file date — the only class
|
|
// a correctness judge catches that the existing staleness loop would miss.
|
|
|
|
const ERROR_VERDICTS = new Set(['outdated', 'wrong']);
|
|
const VERDICTS = ['correct', 'outdated', 'wrong', 'unsourced'];
|
|
|
|
// Parse the "**Last updated:** YYYY-MM[-DD]" header. Month-only normalises to the
|
|
// first of the month (the conservative choice for the gate: it makes more sources
|
|
// count as "changed since the file", which understates — never overstates — the
|
|
// judge's unique value). Returns an ISO date string or null.
|
|
export function parseLastUpdated(text) {
|
|
if (!text) return null;
|
|
const m = text.match(/\*\*Last updated:\*\*\s*(\d{4})-(\d{2})(?:-(\d{2}))?/i);
|
|
if (!m) return null;
|
|
const [, y, mo, d] = m;
|
|
return `${y}-${mo}-${d || '01'}`;
|
|
}
|
|
|
|
// Wilson score interval for a binomial proportion. Returns {p, low, high}.
|
|
// Empty sample -> all zero (never NaN). Default z = 1.96 (95%).
|
|
export function wilson(successes, n, z = 1.96) {
|
|
if (!n || n <= 0) return { p: 0, low: 0, high: 0 };
|
|
const phat = successes / n;
|
|
const z2 = z * z;
|
|
const denom = 1 + z2 / n;
|
|
const center = (phat + z2 / (2 * n)) / denom;
|
|
const margin =
|
|
(z / denom) * Math.sqrt((phat * (1 - phat)) / n + z2 / (4 * n * n));
|
|
return {
|
|
p: phat,
|
|
low: Math.max(0, center - margin),
|
|
high: Math.min(1, center + margin),
|
|
};
|
|
}
|
|
|
|
// Did any of the file's cited sources change on the MS Learn sitemap after the
|
|
// file's own "last updated" date? This is exactly the signal the existing KB
|
|
// staleness loop sees (it polls sitemap_lastmod per cited URL). Returns:
|
|
// true — at least one cited source has sitemap_lastmod > fileDate
|
|
// false — no cited source is newer (staleness loop would NOT flag the file)
|
|
// null — fileDate unknown (cannot determine)
|
|
export function fileLastmodChanged(citedUrls, registry, fileDate) {
|
|
if (!fileDate) return null;
|
|
const urls = (registry && registry.urls) || {};
|
|
for (const u of citedUrls || []) {
|
|
const entry = urls[u];
|
|
if (entry && entry.sitemap_lastmod && entry.sitemap_lastmod > fileDate) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function emptyBucket() {
|
|
return {
|
|
total: 0,
|
|
byVerdict: { correct: 0, outdated: 0, wrong: 0, unsourced: 0 },
|
|
errors: 0,
|
|
verifiable: 0,
|
|
unsourced: 0,
|
|
errorsStalenessCatchable: 0,
|
|
errorsJudgeUnique: 0,
|
|
};
|
|
}
|
|
|
|
function tally(bucket, claim) {
|
|
bucket.total += 1;
|
|
const v = claim.verdict;
|
|
if (v in bucket.byVerdict) bucket.byVerdict[v] += 1;
|
|
if (v === 'unsourced') {
|
|
bucket.unsourced += 1;
|
|
} else {
|
|
bucket.verifiable += 1;
|
|
}
|
|
if (ERROR_VERDICTS.has(v)) {
|
|
bucket.errors += 1;
|
|
// lastmod_changed === true -> staleness loop already flags this file
|
|
// anything else (false/null) -> only a correctness judge would catch it
|
|
if (claim.lastmod_changed === true) bucket.errorsStalenessCatchable += 1;
|
|
else bucket.errorsJudgeUnique += 1;
|
|
}
|
|
}
|
|
|
|
function finalize(bucket) {
|
|
bucket.errorRate = bucket.verifiable ? bucket.errors / bucket.verifiable : 0;
|
|
bucket.errorRateWilson = wilson(bucket.errors, bucket.verifiable);
|
|
bucket.unsourcedRate = bucket.total ? bucket.unsourced / bucket.total : 0;
|
|
return bucket;
|
|
}
|
|
|
|
// Aggregate a flat list of claims into overall + per-skill + per-stratum +
|
|
// per-claim_type base-rate buckets. Each claim needs: verdict, skill, stratum,
|
|
// claim_type, lastmod_changed.
|
|
export function computeBaseRate(claims) {
|
|
const overall = emptyBucket();
|
|
const bySkill = {};
|
|
const byStratum = {};
|
|
const byClaimType = {};
|
|
for (const c of claims) {
|
|
tally(overall, c);
|
|
(bySkill[c.skill] ||= emptyBucket()), tally(bySkill[c.skill], c);
|
|
(byStratum[c.stratum] ||= emptyBucket()), tally(byStratum[c.stratum], c);
|
|
(byClaimType[c.claim_type] ||= emptyBucket()), tally(byClaimType[c.claim_type], c);
|
|
}
|
|
finalize(overall);
|
|
for (const b of Object.values(bySkill)) finalize(b);
|
|
for (const b of Object.values(byStratum)) finalize(b);
|
|
for (const b of Object.values(byClaimType)) finalize(b);
|
|
return { overall, bySkill, byStratum, byClaimType, _verdicts: VERDICTS };
|
|
}
|