fix(knowledge): freshness can no longer be green on outdated evidence

assessFreshness aged only the entry's own source.verified stamp, so an
entry re-verified against an old source stayed green while a newer source
sat unnoticed (BP-SUB-001 was stamped 2026-07-31, a week after the
superseding-grade article of 2026-07-24 was published). The stamp
certifies the old source; it says nothing about the evidence.

- entries may carry corroborating sources[] with published dates
- new evidence-age rule: stale when the NEWEST published date across all
  sources exceeds evidenceStaleAfterDays (default 365); re-verifying the
  old source never clears it, only newer evidence does
- source.supersededBy marks a replaced source: stale regardless of stamp
- stale items now carry reasons[] (verified-age / no-verified-date /
  superseded / evidence-age)
- BP-SUB-001 gains the 2026-07-24 context-engineering article as a
  verified corroborating source (near-verbatim coverage). NOT added to
  BP-MECH-*/BP-SIZE-001: own verification found no mechanism-choice or
  size-limit content in the article, contrary to the brief's assumption.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019NeaMRXVGzh9oSwigJDjE9
This commit is contained in:
Kjell Tore Guttormsen 2026-08-03 11:36:57 +02:00
commit d66035ed86
3 changed files with 213 additions and 23 deletions

View file

@ -16,6 +16,14 @@
/** Default re-verify cadence: a confirmed best-practice older than this needs a re-check. */
export const STALE_AFTER_DAYS_DEFAULT = 90;
/**
* Default evidence cadence: when the NEWEST `published` date across an entry's
* sources is older than this, the entry is flagged even if recently re-verified.
* Re-verifying the old source does not clear it only a newer source does
* (the BP-SUB-001 defect class: green stamp, substantially outdated evidence).
*/
export const EVIDENCE_STALE_AFTER_DAYS_DEFAULT = 365;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const DAY_MS = 86_400_000;
@ -46,15 +54,40 @@ function verifiedMs(entry) {
return Number.isNaN(ms) ? null : ms;
}
/** Parse a YYYY-MM-DD string to UTC-midnight ms, or null. */
function dateMs(v) {
if (typeof v !== 'string' || !DATE_RE.test(v)) return null;
const ms = Date.parse(`${v}T00:00:00Z`);
return Number.isNaN(ms) ? null : ms;
}
/**
* Newest `published` across the primary `source` and any corroborating `sources[]`.
* Null when no source carries a parseable published date (the evidence-age rule
* is then silent for that entry it cannot judge evidence it cannot date).
*/
function newestEvidenceMs(entry) {
const candidates = [];
if (entry && entry.source) candidates.push(entry.source);
if (entry && Array.isArray(entry.sources)) candidates.push(...entry.sources);
let newest = null;
for (const s of candidates) {
const ms = dateMs(s && s.published);
if (ms !== null && (newest === null || ms > newest)) newest = ms;
}
return newest;
}
/**
* Classify every register entry as fresh or stale by the age of its source.verified stamp.
*
* @param {{entries:object[]}} register
* @param {{ referenceDate: string|Date, staleAfterDays?: number }} opts
* @param {{ referenceDate: string|Date, staleAfterDays?: number, evidenceStaleAfterDays?: number }} opts
* @returns {{
* referenceDate: string,
* staleAfterDays: number,
* stale: Array<{id:string, verified:string|undefined, ageDays:number|null, url:string|undefined, claim:string|undefined}>,
* evidenceStaleAfterDays: number,
* stale: Array<{id:string, verified:string|undefined, ageDays:number|null, url:string|undefined, claim:string|undefined, reasons:string[]}>,
* fresh: Array<{id:string, verified:string|undefined, ageDays:number}>,
* counts: { total:number, stale:number, fresh:number }
* }}
@ -63,6 +96,10 @@ export function assessFreshness(register, opts = {}) {
const ref = normalizeReferenceDate(opts.referenceDate);
const staleAfterDays =
typeof opts.staleAfterDays === 'number' ? opts.staleAfterDays : STALE_AFTER_DAYS_DEFAULT;
const evidenceStaleAfterDays =
typeof opts.evidenceStaleAfterDays === 'number'
? opts.evidenceStaleAfterDays
: EVIDENCE_STALE_AFTER_DAYS_DEFAULT;
const entries = (register && Array.isArray(register.entries)) ? register.entries : [];
const stale = [];
@ -71,14 +108,30 @@ export function assessFreshness(register, opts = {}) {
for (const e of entries) {
const verified = e && e.source ? e.source.verified : undefined;
const vms = verifiedMs(e);
const reasons = [];
let ageDays = null;
if (vms === null) {
// No re-checkable date → needs attention. Stale with ageDays null.
stale.push({ id: e && e.id, verified, ageDays: null, url: e && e.source && e.source.url, claim: e && e.claim });
continue;
// No re-checkable date → needs attention.
reasons.push('no-verified-date');
} else {
ageDays = Math.floor((ref.ms - vms) / DAY_MS);
if (ageDays > staleAfterDays) reasons.push('verified-age');
}
const ageDays = Math.floor((ref.ms - vms) / DAY_MS);
if (ageDays > staleAfterDays) {
stale.push({ id: e.id, verified, ageDays, url: e.source && e.source.url, claim: e.claim });
// A source explicitly marked as superseded is stale no matter how fresh the
// verified stamp is — the stamp certifies the OLD source.
if (e && e.source && e.source.supersededBy) reasons.push('superseded');
// Evidence age: keyed on the newest published date across all sources, so a
// re-read of the old source never clears it — only newer evidence does.
const evMs = newestEvidenceMs(e);
if (evMs !== null && Math.floor((ref.ms - evMs) / DAY_MS) > evidenceStaleAfterDays) {
reasons.push('evidence-age');
}
if (reasons.length > 0) {
stale.push({ id: e && e.id, verified, ageDays, url: e && e.source && e.source.url, claim: e && e.claim, reasons });
} else {
fresh.push({ id: e.id, verified, ageDays });
}
@ -87,6 +140,7 @@ export function assessFreshness(register, opts = {}) {
return {
referenceDate: ref.iso,
staleAfterDays,
evidenceStaleAfterDays,
stale,
fresh,
counts: { total: entries.length, stale: stale.length, fresh: fresh.length },