feat(ms-ai-architect): Fase 0 steg 2 — verifiserings-fan-out (373 påstander) + gull-sett m/ deterministisk lastmod-join + TDD [skip-docs]
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.
This commit is contained in:
parent
f7fba5e4e7
commit
6985ed1f5d
19 changed files with 5239 additions and 0 deletions
125
scripts/kb-eval/lib/base-rate.mjs
Normal file
125
scripts/kb-eval/lib/base-rate.mjs
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
// 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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue