Pure diff/fold + run-mode selector for course detection (C3.2 av GODKJENT C3-spec). - lib/course-diff.mjs (REN, null imports): normalizeCourse, selectMode, isFullEnumDue, diffCourses, updateRegistry, FULL_ENUM_MAX_AGE_DAYS=30. Full-enum-pin (§4.2): removed beregnes KUN i full-modus; inkrementell og baseline gir removed:[]. Baseline emitterer ingen leads (§8 b). updateRegistry ren (muterer aldri): incremental merger, baseline/full bygger fra API-sett + dropper retirerte UIDs, stamper last_full_enum. - registry-io.mjs +loadCourseRegistry/saveCourseRegistry (additivt, speiler loadRegistry/saveRegistry; fil course-registry.json). - test-course-diff.test.mjs (23) + test-courses-invariant (+3: diff-modul ren). Gate §7 C3.2 møtt. kb-update 237->263, validate PASSED, null regresjon. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
156 lines
6.7 KiB
JavaScript
156 lines
6.7 KiB
JavaScript
// course-diff.mjs — PURE course diff + registry-fold + run-mode selector (C3.2).
|
|
// Zero imports: writes NO files, reads NO secrets, never touches the ledger or
|
|
// KB. The detector (C3.4) loads/saves course-registry.json via registry-io and
|
|
// drives this module — keeping the diff logic pure and unit-testable.
|
|
// See c3-course-detection-plan.md §4.1 (registry schema), §4.2 (diff semantics),
|
|
// and the §8 decisions: (b) baseline emits no leads, (c) `removed` is computed
|
|
// ONLY on a full enumeration.
|
|
|
|
// A full enumeration is forced when the last one is missing or older than this.
|
|
// Only a full enum (no updatedAt.gt filter) can correctly detect retired courses.
|
|
export const FULL_ENUM_MAX_AGE_DAYS = 30;
|
|
const DAY_MS = 86_400_000;
|
|
|
|
/**
|
|
* Normalize a raw Platform API item into the registry/lead shape. Keeps the diff
|
|
* agnostic of the API field names (id→uid, updatedAt→updated_at, products[].id).
|
|
* @param {{id: string, title?: string, url?: string, updatedAt?: string, products?: Array<{id: string}>}} apiItem
|
|
* @param {'module'|'learning-path'} type — which endpoint the item came from
|
|
* @returns {{uid: string, type: string, title: string, url: string, products: string[], updated_at: string|null}}
|
|
*/
|
|
export function normalizeCourse(apiItem, type) {
|
|
return {
|
|
uid: apiItem.id,
|
|
type,
|
|
title: apiItem.title ?? '',
|
|
url: apiItem.url ?? '',
|
|
products: (apiItem.products ?? []).map((p) => p.id).filter(Boolean),
|
|
updated_at: apiItem.updatedAt ?? null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Is a full enumeration due? True when the registry has never run a full enum
|
|
* (last_full_enum missing) or the last one is strictly older than maxAgeDays.
|
|
* Fails toward `true` on an unparseable timestamp — a full enum is a safe
|
|
* superset of an incremental one.
|
|
* @param {{last_full_enum?: string|null}} registry
|
|
* @param {string} now — full ISO datetime
|
|
* @param {{maxAgeDays?: number}} [opts]
|
|
* @returns {boolean}
|
|
*/
|
|
export function isFullEnumDue(registry, now, { maxAgeDays = FULL_ENUM_MAX_AGE_DAYS } = {}) {
|
|
if (!registry || !registry.last_full_enum) return true;
|
|
const last = new Date(registry.last_full_enum).getTime();
|
|
const t = new Date(now).getTime();
|
|
if (!Number.isFinite(last) || !Number.isFinite(t)) return true;
|
|
return t - last > maxAgeDays * DAY_MS;
|
|
}
|
|
|
|
/**
|
|
* Pick the run mode from registry state (§4.1's three run types):
|
|
* - 'baseline' — first ever run (no last_run): establish the registry, emit no leads.
|
|
* - 'full' — a full enumeration is due: new/updated + removed.
|
|
* - 'incremental' — normal run: updatedAt.gt fetch, new/updated, removed:[].
|
|
* @param {{last_run?: string|null, last_full_enum?: string|null}} registry
|
|
* @param {string} now — full ISO datetime
|
|
* @param {{maxAgeDays?: number}} [opts]
|
|
* @returns {'baseline'|'full'|'incremental'}
|
|
*/
|
|
export function selectMode(registry, now, opts = {}) {
|
|
if (!registry || !registry.last_run) return 'baseline';
|
|
return isFullEnumDue(registry, now, opts) ? 'full' : 'incremental';
|
|
}
|
|
|
|
// Strictly newer than what we last stored (an equal or older timestamp is a no-op).
|
|
function isNewer(apiUpdatedAt, storedUpdatedAt) {
|
|
if (!apiUpdatedAt) return false;
|
|
if (!storedUpdatedAt) return true;
|
|
return new Date(apiUpdatedAt).getTime() > new Date(storedUpdatedAt).getTime();
|
|
}
|
|
|
|
/**
|
|
* Diff a set of (already-normalized, already-in-domain) courses against the
|
|
* registry and classify leads. Leads are the normalized course objects; the
|
|
* detector enriches them with suggested_skill/category downstream.
|
|
*
|
|
* @param {Array<object>} courses — normalized courses from the API for this run
|
|
* @param {{courses?: object}} registry
|
|
* @param {{mode: 'baseline'|'full'|'incremental'}} options
|
|
* @returns {{new: object[], updated: object[], removed: Array<{uid: string, title: string, url: string}>}}
|
|
*/
|
|
export function diffCourses(courses, registry, { mode } = {}) {
|
|
// §8 (b): a baseline run establishes the registry without emitting any leads —
|
|
// flagging hundreds of "new" courses on day one trains the operator that the
|
|
// tool is noise, not signal.
|
|
if (mode === 'baseline') return { new: [], updated: [], removed: [] };
|
|
|
|
const known = registry?.courses ?? {};
|
|
const newLeads = [];
|
|
const updatedLeads = [];
|
|
for (const c of courses) {
|
|
const prev = known[c.uid];
|
|
if (!prev) newLeads.push(c);
|
|
else if (isNewer(c.updated_at, prev.updated_at)) updatedLeads.push(c);
|
|
}
|
|
|
|
// §4.2 correctness pin: `removed` is computed ONLY on a full enumeration. An
|
|
// incremental updatedAt.gt response omits everything unchanged, so "in
|
|
// registry, not in response" is true for nearly the whole registry — a naive
|
|
// removed-diff would be a false-positive flood. Incremental forces removed:[].
|
|
let removed = [];
|
|
if (mode === 'full') {
|
|
const apiUids = new Set(courses.map((c) => c.uid));
|
|
removed = Object.entries(known)
|
|
.filter(([uid]) => !apiUids.has(uid))
|
|
.map(([uid, e]) => ({ uid, title: e.title ?? '', url: e.url ?? '' }));
|
|
}
|
|
return { new: newLeads, updated: updatedLeads, removed };
|
|
}
|
|
|
|
// Build a stored entry, preserving first_seen across runs and advancing last_seen.
|
|
function foldEntry(prev, c, now) {
|
|
return {
|
|
type: c.type,
|
|
title: c.title,
|
|
url: c.url,
|
|
products: c.products,
|
|
updated_at: c.updated_at,
|
|
first_seen: prev?.first_seen ?? now,
|
|
last_seen: now,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fold this run's courses into the registry. Pure — returns a new registry,
|
|
* never mutates the input.
|
|
* - incremental: merge (keep untouched UIDs, add/update the ones returned).
|
|
* - baseline | full: the API set IS the complete registry — rebuild from it,
|
|
* dropping UIDs absent from the API (retired courses) so they aren't
|
|
* re-flagged as removed on the next full enum. Stamps last_full_enum = now.
|
|
* Every run advances last_run = now and seeds created_at when missing.
|
|
*
|
|
* @param {object} registry
|
|
* @param {Array<object>} courses — normalized courses from the API for this run
|
|
* @param {string} now — full ISO datetime
|
|
* @param {{mode: 'baseline'|'full'|'incremental'}} options
|
|
* @returns {object} new registry
|
|
*/
|
|
export function updateRegistry(registry, courses, now, { mode } = {}) {
|
|
const base = registry ?? { version: 1, created_at: null, last_run: null, last_full_enum: null, courses: {} };
|
|
const prevCourses = base.courses ?? {};
|
|
const isFull = mode === 'baseline' || mode === 'full';
|
|
|
|
// incremental keeps the prior set; baseline/full rebuild from the API set only.
|
|
const nextCourses = isFull ? {} : { ...prevCourses };
|
|
for (const c of courses) nextCourses[c.uid] = foldEntry(prevCourses[c.uid], c, now);
|
|
|
|
return {
|
|
...base,
|
|
version: base.version ?? 1,
|
|
created_at: base.created_at ?? now,
|
|
last_run: now,
|
|
last_full_enum: isFull ? now : base.last_full_enum,
|
|
courses: nextCourses,
|
|
};
|
|
}
|