ms-ai-architect/scripts/kb-update/lib/full-pass-worklist.mjs
Kjell Tore Guttormsen a1d72fadc0 feat(ms-ai-architect): Spor 3 Port 3 (P3d) — periodisk full-pass worklist (kadens-judge enumerering) [skip-docs]
Ren lib full-pass-worklist.mjs (classifyFullPassDue + buildFullPassReport, skriver
aldri til disk) + CLI report-full-pass-worklist.mjs (gitignored full-pass-worklist.json).
Lastmod-UAVHENGIG: gater paa Port 1-kontrakt (type:reference + source) + cadence-alder
paa Verified, fanger drift-klassen inkrementell bommer (staleness-recall 0/40).
Buckets: never-verified / cadence-due / fresh / unsourced / unmigrated / out-of-scope.
Arbeidsliste (operatoer-valgt design), IKKE inline judge: enumererer + rangerer,
operatoer kickstarter judging separat (par.5 ikke auto-lansert, par.4c aldri auto-fiks).
Ekte kjoering: 389 ref-filer alle unmigrated = dormant til Spor 1-migrering. Suite 637/637.
2026-06-29 15:34:45 +02:00

179 lines
7.4 KiB
JavaScript
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.

// full-pass-worklist.mjs — Spor 3 Port 3, PERIODIC full-pass (P3d). A PURE
// worklist generator: it computes which sourced `reference` files are DUE for a
// full-pass re-judge by cadence, and NEVER touches disk (mirrors verified-
// staleness.mjs — the I/O lives in the CLI).
//
// Why a full-pass on top of the incremental pass: the incremental pass only
// re-judges files whose source sitemap_lastmod moved past **Verified:**, so it
// only catches lastmod-VISIBLE drift. MS can change a page without bumping its
// sitemap lastmod, and the base-rate measurement showed staleness-recall 0/40.
// The full-pass is therefore deliberately LASTMOD-INDEPENDENT: it gates on the
// Port 1 contract (type:reference + a Source URL to anchor against) and on how
// long since the **Verified:** stamp, NOT on lastmod. buildFileSourceMap (which
// skips sources lacking a lastmod) is reused only to ANNOTATE entries.
//
// Operator-chosen design (2026-06-29): a WORKLIST, not an inline judge. This
// produces the ranked list of files due for re-judge; the operator kicks off
// the actual judging separately. Rationale: §5 "ikke auto-lansert", §4c "aldri
// auto-fiks", and the honest ~2700-fetch/multi-session full-pass cost forecloses
// an in-session run. Flag → human-confirm → fix → bump **Verified:** stays the
// loop; this file only enumerates and ranks.
//
// Scope = the Port 1 frontmatter contract (same as the incremental pass):
// - type 'reference' WITH a source → in full-pass scope (judgeable)
// - type 'reference' WITHOUT a source → unsourced (contract breach; no
// anchor → not listed, counted)
// - type template|methodology|regulatory → out-of-scope (exempt)
// - no Type header (legacy corpus) → unmigrated (Spor 1 territory)
const REFERENCE_TYPE = 'reference';
const OUT_OF_SCOPE_TYPES = new Set(['template', 'methodology', 'regulatory']);
/** YYYY-MM → YYYY-MM-01; full date as-is. Mirrors verified-staleness' normalizeDate. */
function normalizeDate(d) {
if (!d) return null;
return d.length === 7 ? d + '-01' : d;
}
/** Parse a normalized YYYY-MM-DD into a UTC epoch (deterministic — explicit input). */
function toUTC(yyyymmdd) {
const [y, m, day] = yyyymmdd.split('-').map(Number);
return Date.UTC(y, m - 1, day);
}
const DAY_MS = 86_400_000;
/** today n days as YYYY-MM-DD (the cadence threshold; verified before it is due). */
function subtractDays(today, n) {
return new Date(toUTC(today) - n * DAY_MS).toISOString().slice(0, 10);
}
/** Whole days from `earlier` to `later` (both YYYY-MM-DD). */
function diffDays(later, earlier) {
return Math.round((toUTC(later) - toUTC(earlier)) / DAY_MS);
}
const REASON_ORDER = { 'never-verified': 0, 'cadence-due': 1 };
/**
* Classify reference files for full-pass due-ness by verification cadence.
*
* @param {Array<{path: string, type: string|null, source: string|null,
* verified: string|null, newestSourceLastmod?: string|null}>} files
* @param {{today: string, maxAgeDays: number}} opts — today as YYYY-MM-DD
* (injected; Date.now is impure), maxAgeDays = cadence window in days
* @returns {{worklist: Array<{path,reason,verified,ageDays,source,newestSourceLastmod}>,
* counts: {total,due,fresh,neverVerified,cadenceDue,unsourced,unmigrated,outOfScope}}}
*/
export function classifyFullPassDue(files, { today, maxAgeDays }) {
const list = Array.isArray(files) ? files : [];
const threshold = subtractDays(today, maxAgeDays);
const worklist = [];
const counts = {
total: list.length, due: 0, fresh: 0,
neverVerified: 0, cadenceDue: 0, unsourced: 0, unmigrated: 0, outOfScope: 0,
};
for (const file of list) {
const type = file.type == null ? null : String(file.type).toLowerCase();
// Out of correctness scope — template/methodology/regulatory are exempt.
if (type && OUT_OF_SCOPE_TYPES.has(type)) {
counts.outOfScope++;
continue;
}
// No Type header — legacy file predating the Port 1 contract (Spor 1 migrates it).
if (type !== REFERENCE_TYPE) {
counts.unmigrated++;
continue;
}
// Declared reference but no Source URL → nothing to anchor a full-pass against.
// A create-guard (Port 2) breach; counted so the gap is an honest number.
if (!file.source) {
counts.unsourced++;
continue;
}
const entry = {
path: file.path,
verified: file.verified ?? null,
source: file.source,
newestSourceLastmod: file.newestSourceLastmod ?? null,
};
// Never verified → highest priority (sourced but never confirmed against source).
if (file.verified == null) {
counts.neverVerified++;
counts.due++;
worklist.push({ ...entry, reason: 'never-verified', ageDays: null });
continue;
}
// Cadence-due: last verified strictly before the threshold (today maxAgeDays).
// Strict < so a file verified exactly maxAgeDays ago is still fresh — same
// boundary convention as the incremental pass.
const verifiedNorm = normalizeDate(file.verified);
if (verifiedNorm < threshold) {
counts.cadenceDue++;
counts.due++;
worklist.push({ ...entry, reason: 'cadence-due', ageDays: diffDays(today, verifiedNorm) });
continue;
}
counts.fresh++;
}
// never-verified first, then cadence-due by oldest verification (highest drift risk).
worklist.sort((a, b) => {
const r = (REASON_ORDER[a.reason] ?? 9) - (REASON_ORDER[b.reason] ?? 9);
if (r !== 0) return r;
const va = normalizeDate(a.verified) ?? '';
const vb = normalizeDate(b.verified) ?? '';
if (va !== vb) return va.localeCompare(vb); // oldest verified first
return String(a.path).localeCompare(String(b.path)); // stable tiebreak
});
return { worklist, counts };
}
/**
* Assemble the full-pass worklist report. PURE: the caller injects the corpus
* paths, a header reader, and the registry source map (annotation only). The CLI
* wraps this with a corpus walk + a readFileSync-backed reader + saveReport.
*
* @param {string[]} paths — corpus reference-file paths (PLUGIN_ROOT-relative)
* @param {(path: string) => ({type, source, verified}|null)} headerReader
* returns the file's Port 1 headers, or null if the file is absent (deleted) —
* absent files are skipped entirely, not counted.
* @param {Map<string,string>} sourceMap — refFile → newest sitemap_lastmod
* (from buildFileSourceMap; annotation only, never gates due-ness)
* @param {{today: string, maxAgeDays: number}} opts
* @returns {{generated_at, cadence_max_age_days, scope, counts, worklist}}
*/
export function buildFullPassReport(paths, headerReader, sourceMap, { today, maxAgeDays }) {
const map = sourceMap instanceof Map ? sourceMap : new Map();
const files = [];
for (const path of paths) {
const headers = headerReader(path);
if (!headers) continue; // deleted on disk — registry hygiene, not a due signal
files.push({
path,
type: headers.type ?? null,
source: headers.source ?? null,
verified: headers.verified ?? null,
newestSourceLastmod: map.get(path) ?? null,
});
}
const { worklist, counts } = classifyFullPassDue(files, { today, maxAgeDays });
return {
generated_at: today,
cadence_max_age_days: maxAgeDays,
scope: 'full-pass (sourced reference files due by verification cadence)',
counts,
worklist,
};
}