From 585f972a0527e49e718ed9d8bccfcc033336558f Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Tue, 23 Jun 2026 19:58:09 +0200 Subject: [PATCH] =?UTF-8?q?feat(linkedin-studio):=20SB-S3b=20=E2=80=94=20s?= =?UTF-8?q?upersede=20arm=20in=20the=20consolidation=20engine=20[skip-docs?= =?UTF-8?q?]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine can now RETIRE a fact via an operator-gated, explicitly-signalled temporal-update supersede — completing the consolidation motor's one S2 TODO ("no supersede in S2 — that's S3"). A `supersedes` signal on a candidate retires the stale fact (re-minted to an archival id, status: superseded, REPLACED IN PLACE, retained as audit) and installs the new winner under the canonical key-id, so mintEntityId(key) always points at the live fact. - consolidate.ts: Candidate.supersedes? + ProfileDiff.supersedes (SupersedeOp carries the full winner fact, so applyDiff stays a pure projector). proposeDiff value-guarded routing fork (routes only when the target holds a DIFFERENT value — else a re-sent signal would self-supersede every run) + intra-batch first-supersede-wins guard. applyDiff replace-in-place + value-matched state-check (oldId present, active, value===oldValue) → idempotent re-apply + stale-diff safe; superseded facts never bumped/promoted (supersede wins). Decay excludes superseded. archivalId seeded with the pre-archival id (collision-free). - cli.ts: renderDiffMd `## Supersessions (old → new)` (rendered last, only when present → zero-supersession diffs stay byte-identical); validateCandidates optional single-line `supersedes`; `--gather` profileFacts filtered to active (superseded archival facts never re-presented as live context). - Tests: +12 brain (consolidate 10 + consolidate-cli 2). TDD: RED (6 fail) → GREEN (94/94). BRAIN_TESTS_FLOOR 82->94; ASSERT_BASELINE_FLOOR unchanged at 80 (no new test-runner.sh section). Gate 95/0/0. - Docs: consolidation-loop.md rule table + honest-limit reconciled (the operator gate is the only classification net); engine docstring updated. All 13 success criteria deterministically tested (unlike S3a, no behavioural-only SC). READ-only gate unchanged — brain consolidate --apply --confirm stays the sole profile.md writer. Scope held: scripts/brain/ only; temporal-update only (condition-dependent/distractor deferred). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RigJBiRFNtFZKCz21qNbQ4 --- docs/second-brain/consolidation-loop.md | 14 ++- scripts/brain/src/cli.ts | 11 +- scripts/brain/src/consolidate.ts | 98 +++++++++++++-- scripts/brain/tests/consolidate-cli.test.ts | 32 +++++ scripts/brain/tests/consolidate.test.ts | 129 +++++++++++++++++++- scripts/test-runner.sh | 2 +- 6 files changed, 267 insertions(+), 19 deletions(-) diff --git a/docs/second-brain/consolidation-loop.md b/docs/second-brain/consolidation-loop.md index 6aae33d..6c71073 100644 --- a/docs/second-brain/consolidation-loop.md +++ b/docs/second-brain/consolidation-loop.md @@ -31,8 +31,9 @@ by the candidate's `key`): | **add** | no matching fact, provenance `published`/`human` | new dynamic fact, `evidence_count: 1` | | **evidence-bump** | matching fact, same value | `evidence_count++`, `last_seen = today` | | **promote** | a dynamic fact reaches `N = 3` observations | dynamic → static | -| **conflict** | matching key, different value | **keep both**, timestamped, with DISTINCT ids; the old fact is untouched (**no supersede** in S2 — that's S3) | -| **decay-flag** | a dynamic fact's `last_seen` > `90` days | listed in `staleFlags` (informational; never auto-removed) | +| **conflict** | matching key, different value, NO supersede signal | **keep both**, timestamped, with DISTINCT ids; the old fact is untouched (a genuine contradiction — both views coexist) | +| **supersede** (SB-S3b) | matching key, different value, explicit `supersedes` signal on the candidate | retire the old fact (re-minted to an archival id, `status: superseded`, retained as audit) + install the new winner at the canonical key-id — a temporal update, not a contradiction | +| **decay-flag** | a dynamic **active** fact's `last_seen` > `90` days | listed in `staleFlags` (informational; never auto-removed; superseded facts are decay-exempt) | **Id model (no duplicate ids):** a concept's primary fact id is `mintEntityId({kind:'observed', key})`; a conflict alt fact id is `mintContentId('observed-alt:'+key+'::'+value+'::'+date)` — byte-distinct, so @@ -68,8 +69,13 @@ Defaults: `promoteThreshold = 3`, `decayDays = 90` (operator-confirmed). first consumer — `strategy-advisor` reads it as *evidence-to-test* (anti-sycophancy: counter-pressured, never parroted). Broader consumption (more content agents, a hook-level prompt digest) remains later S3 work, and the profile is still mutated ONLY via `brain consolidate --apply --confirm`. -- **No supersede / no auto-demotion.** Conflicts keep both; stale facts are flagged, never auto-removed — - the operator (or S3) reconciles. Conflict alt facts persist until then. +- **Supersede is explicit + operator-gated; no AUTO-demotion (SB-S3b).** A temporal update retires the old + fact ONLY when the candidate carries an explicit `supersedes` signal (the session's judgement), and only + after the operator confirms the rendered `## Supersessions` in the diff. The engine guarantees the supersede + *mechanics* (replace-in-place, archival re-mint, audit-retain); whether a contradiction is really a temporal + update vs a genuine contradiction is the session's + operator's call — **the operator gate is the only + classification net** (a mis-classified supersede silently retires a fact that should have been kept-both). + Genuine contradictions (no signal) still **keep both**; stale facts are still only *flagged*, never auto-removed. - **No AI at session-start.** The nudge is a deterministic file-count + sidecar read; the consolidation pass is always operator-invoked. - **The session-start nudge is consolidation-due only** — it counts published records + days since last run; diff --git a/scripts/brain/src/cli.ts b/scripts/brain/src/cli.ts index 36cd00f..e9b998c 100644 --- a/scripts/brain/src/cli.ts +++ b/scripts/brain/src/cli.ts @@ -141,6 +141,10 @@ function renderDiffMd(diff: ProfileDiff): string { section("Promotions (dynamic→static)", diff.promotions.map((p) => p.id)); section("⚠ Conflicts (both kept)", diff.conflicts.map((c) => `"${c.primaryValue}" (${c.primaryId}) vs new (${c.altId})`)); section("Stale (dynamic, >decay)", diff.staleFlags.map((s) => `${s.id} — last seen ${s.last_seen} (${s.daysStale}d)`)); + // SB-S3b: rendered LAST and only when present, so a zero-supersession diff stays byte-identical. + if ((diff.supersedes ?? []).length > 0) { + section("Supersessions (old → new)", diff.supersedes.map((s) => `\`${s.oldValue}\` → \`${s.winner.value}\` [${s.winner.provenance}, retired ${s.oldId}]`)); + } return lines.join("\n") + "\n"; } @@ -152,6 +156,10 @@ function validateCandidates(raw: unknown): Candidate[] { } if (!PROVENANCES.includes(c.provenance)) usage(`candidate ${i}: provenance must be one of ${PROVENANCES.join(", ")}`); if (/[\n\r]/.test(c.key) || /[\n\r]/.test(c.value)) usage(`candidate ${i}: key/value must be single-line (no newline/CR)`); + // SB-S3b: the optional supersede signal, when present, is a non-empty single-line target key. + if (c.supersedes !== undefined && (typeof c.supersedes !== "string" || c.supersedes === "" || /[\n\r]/.test(c.supersedes))) { + usage(`candidate ${i}: "supersedes" must be a non-empty single-line string when present`); + } }); return raw as Candidate[]; } @@ -171,7 +179,8 @@ function runConsolidate(flags: Record): void { const fresh = records.filter((r) => last_run === null || r.published_date > last_run); const profile = loadProfile(); if (flags.json === "true") { - console.log(JSON.stringify({ since: last_run, published: fresh.map((r) => ({ id: r.id, published_date: r.published_date, body: r.body })), profileFacts: [...profile.static, ...profile.dynamic] }, null, 2)); + // SB-S3b: only ACTIVE facts are live context — superseded archival facts must not be re-presented to the extraction session. + console.log(JSON.stringify({ since: last_run, published: fresh.map((r) => ({ id: r.id, published_date: r.published_date, body: r.body })), profileFacts: [...profile.static, ...profile.dynamic].filter((f) => f.status === "active") }, null, 2)); return; } console.log(`Consolidation gather — ${fresh.length} new published record(s) since ${last_run ?? "the beginning"}:`); diff --git a/scripts/brain/src/consolidate.ts b/scripts/brain/src/consolidate.ts index 65372f8..44c152b 100644 --- a/scripts/brain/src/consolidate.ts +++ b/scripts/brain/src/consolidate.ts @@ -8,8 +8,10 @@ * Invariants enforced IN CODE (not just docs): * - provenance-gated: `ai-draft` candidates are rejected outright (model-collapse guard); * - evidence-threshold promotion (dynamic→static at N observations); - * - contradiction → keep-both with DISTINCT ids (no supersede in S2 — that's S3); - * - temporal decay flagging (dynamic facts only; static facts are decay-exempt). + * - contradiction → keep-both with DISTINCT ids (a genuine contradiction; both views coexist); + * - temporal-update → supersede (SB-S3b): an explicit `supersedes` signal retires the old fact + * (archival re-mint + status:superseded, replaced in place) and installs the winner at the key-id; + * - temporal decay flagging (dynamic ACTIVE facts only; static + superseded facts are decay-exempt). * * Id model (the no-duplicate-id guarantee): a concept's PRIMARY fact id is * `mintEntityId({kind:'observed', key})` (key-only); a conflict ALT fact id is @@ -36,6 +38,21 @@ export interface Candidate { provenance: Provenance; source: string; // e.g. "published:" | "manual" observed_date: string; // YYYY-MM-DD + // SB-S3b: explicit temporal-update signal — the key of the active prior fact this + // candidate retires. Absent for ordinary candidates (fully backward-compatible). + supersedes?: string; +} + +/** + * SB-S3b temporal-update retirement: the active fact at `oldId` (whose value was + * `oldValue` at propose time) is retired (re-minted to an archival id, status + * `superseded`) and replaced by `winner` (filed at the canonical key-id). The full + * winner fact is carried so `applyDiff` stays a pure projector (it has no clock). + */ +export interface SupersedeOp { + oldId: string; + oldValue: string; + winner: ProfileFact; } export interface ProfileDiff { @@ -44,6 +61,7 @@ export interface ProfileDiff { promotions: { id: string }[]; // dynamic→static (post-bump count ≥ N) conflicts: { primaryId: string; primaryValue: string; altId: string }[]; staleFlags: { id: string; last_seen: string; daysStale: number }[]; + supersedes: SupersedeOp[]; // SB-S3b temporal-update retirements } export interface ConsolidateOpts { @@ -59,6 +77,15 @@ function altId(c: Candidate): string { return mintContentId(`observed-alt:${c.key}::${c.value}::${c.observed_date}`); } +/** + * SB-S3b: archival id for a retired (superseded) fact. Seeded with the fact's + * PRE-archival id (unique among active facts by the no-duplicate-id invariant), so + * the re-mint is collision-free and never reuses the canonical key-id the winner takes. + */ +function archivalId(oldId: string, oldValue: string): string { + return mintContentId(`superseded:${oldId}::${oldValue}`); +} + function newFact(id: string, c: Candidate, today: string): ProfileFact { return { id, @@ -93,8 +120,11 @@ export function proposeDiff(args: { const promotions: ProfileDiff["promotions"] = []; const conflicts: ProfileDiff["conflicts"] = []; - // Track ids added/bumped this pass so a repeated candidate in one batch doesn't double-add. + // Track ids added/bumped/superseded this pass so a repeated candidate in one batch + // doesn't double-add, and a key retired this pass ignores later candidates for it. const touched = new Set(); + const supersededKeys = new Set(); + const supersedes: SupersedeOp[] = []; const bump = (id: string, prevCount: number) => { const newCount = prevCount + 1; evidenceBumps.push({ id, newCount, last_seen: today }); @@ -104,8 +134,28 @@ export function proposeDiff(args: { for (const c of candidates) { if (c.provenance === "ai-draft") continue; // model-collapse guard (SC1b) const primaryId = mintEntityId({ kind: OBSERVED_KIND, key: c.key }); - const prev = byId.get(primaryId); + // A key retired earlier in this same batch ignores later candidates for it (first-supersede-wins, SC8). + if (supersededKeys.has(primaryId)) continue; + + // SB-S3b supersede fork (explicit signal): retire the active fact at the target key + // and install the winner at the candidate's key-id. Routed ONLY when the target + // exists, is active, and holds a DIFFERENT value — else a re-sent signal whose target + // already holds the new value would self-supersede every run (value-guard, SC3a). + if (c.supersedes) { + const targetId = mintEntityId({ kind: OBSERVED_KIND, key: c.supersedes }); + const target = byId.get(targetId); + if (target && target.status === "active" && target.value !== c.value && !touched.has(targetId) && !supersededKeys.has(targetId)) { + supersedes.push({ oldId: targetId, oldValue: target.value, winner: newFact(primaryId, c, today) }); + supersededKeys.add(targetId); + touched.add(targetId); + touched.add(primaryId); // reserve the winner's id + continue; + } + // else: no / inactive / value-equal target → fall through to ordinary add/bump/conflict (graceful, SC4) + } + + const prev = byId.get(primaryId); if (!prev) { if (!touched.has(primaryId)) { additions.push(newFact(primaryId, c, today)); @@ -129,34 +179,58 @@ export function proposeDiff(args: { } } + // Decay flags exclude superseded facts (SB-S3b, SC11): a retained archival fact is + // audit, not a live signal, so it never throws perpetual staleFlags. const staleFlags = current.dynamic - .filter((f) => daysBetween(f.last_seen, today) > DECAY) + .filter((f) => f.status !== "superseded" && daysBetween(f.last_seen, today) > DECAY) .map((f) => ({ id: f.id, last_seen: f.last_seen, daysStale: daysBetween(f.last_seen, today) })); - return { additions, evidenceBumps, promotions, conflicts, staleFlags }; + return { additions, evidenceBumps, promotions, conflicts, staleFlags, supersedes }; } /** * Apply a proposed diff to produce the next ProfileDoc. Pure (returns a new doc). * Because primary and alt ids are byte-distinct, no two facts ever share an id, so * the bump/promote targets are unambiguous and the doc stays well-formed (SC3). + * + * SB-S3b supersede: a retired fact is REPLACED IN PLACE (re-minted to an archival id + + * `status: superseded`, kept in its layer, never bumped/promoted — supersede wins) and + * the winner is installed under the canonical key-id. A state-check (oldId present, still + * active, value still === oldValue) makes re-apply a no-op and a stale diff safe. */ export function applyDiff(current: ProfileDoc, diff: ProfileDiff): ProfileDoc { + // Resolve supersessions that still apply against THIS doc (idempotency + stale-diff guard). + const allById = new Map([...current.static, ...current.dynamic].map((f) => [f.id, f])); + const archive = new Map(); // oldId → archival replacement + const winners: ProfileFact[] = []; + for (const s of diff.supersedes ?? []) { + const f = allById.get(s.oldId); + if (!f || f.status === "superseded" || f.value !== s.oldValue) continue; // skip: re-apply / stale + archive.set(s.oldId, { ...f, id: archivalId(s.oldId, s.oldValue), status: "superseded" }); + winners.push({ ...s.winner }); + } + const bumpMap = new Map(diff.evidenceBumps.map((b) => [b.id, b])); - const applyBump = (f: ProfileFact): ProfileFact => { + // A superseded fact is replaced in place and NEVER bumped (supersede wins, SC12). + const transform = (f: ProfileFact): ProfileFact => { + const replaced = archive.get(f.id); + if (replaced) return replaced; const b = bumpMap.get(f.id); return b ? { ...f, evidence_count: b.newCount, last_seen: b.last_seen } : { ...f }; }; - let staticF = current.static.map(applyBump); - let dynamicF = current.dynamic.map(applyBump); + let staticF = current.static.map(transform); + let dynamicF = current.dynamic.map(transform); + // Promote active dynamic facts only — a just-superseded fact is never promoted (SC12). const promoteIds = new Set(diff.promotions.map((p) => p.id)); - const promoted = dynamicF.filter((f) => promoteIds.has(f.id)); - dynamicF = dynamicF.filter((f) => !promoteIds.has(f.id)); + const promoting = (f: ProfileFact) => promoteIds.has(f.id) && f.status === "active"; + const promoted = dynamicF.filter(promoting); + dynamicF = dynamicF.filter((f) => !promoting(f)); staticF = [...staticF, ...promoted]; - dynamicF = [...dynamicF, ...diff.additions.map((f) => ({ ...f }))]; + // Additions + supersede winners land in the dynamic layer (winners re-earn promotion). + dynamicF = [...dynamicF, ...diff.additions.map((f) => ({ ...f })), ...winners]; return { schemaVersion: SCHEMA_VERSION, static: staticF, dynamic: dynamicF }; } diff --git a/scripts/brain/tests/consolidate-cli.test.ts b/scripts/brain/tests/consolidate-cli.test.ts index c9d7f80..fdd3abd 100644 --- a/scripts/brain/tests/consolidate-cli.test.ts +++ b/scripts/brain/tests/consolidate-cli.test.ts @@ -94,4 +94,36 @@ describe("brain consolidate CLI (SC5)", () => { assert.equal(runCli(root, ["ingest", "--file", f]).code, 0); assert.equal(runCli(root, ["published", "list"]).code, 0); }); + + const supCand = { key: "topic", value: "AI governance", provenance: "published", source: "manual", observed_date: "2026-06-24", supersedes: "topic" }; + + test("S3b-SC6 — Supersessions section renders one line per entry; a plain diff renders none; gated apply writes superseded", () => { + // plain propose → NO Supersessions section (byte-identity decoy) + runCli(root, ["consolidate", "--propose", "--candidates", candidatesFile(root, [validCand])]); + assert.ok(!readFileSync(pendingMd(root), "utf8").includes("## Supersessions"), "no section when 0 supersessions"); + // apply to seed the active fact, then propose a supersede of it + runCli(root, ["consolidate", "--apply", "--diff", pendingJson(root), "--confirm"]); + runCli(root, ["consolidate", "--propose", "--candidates", candidatesFile(root, [supCand])]); + const md = readFileSync(pendingMd(root), "utf8"); + assert.match(md, /## Supersessions/, "section rendered when a supersession exists"); + assert.match(md, /AI safety.*→.*AI governance/, "old → new line rendered"); + const diff = JSON.parse(readFileSync(pendingJson(root), "utf8")); + assert.equal(diff.supersedes.length, 1, "one supersede op in the JSON"); + // the gated apply writes the superseded status to profile.md + runCli(root, ["consolidate", "--apply", "--diff", pendingJson(root), "--confirm"]); + assert.match(readFileSync(profilePath(root), "utf8"), /superseded/, "apply writes the superseded status"); + }); + + test("S3b-SC10 — --gather excludes superseded facts from profileFacts", () => { + runCli(root, ["consolidate", "--propose", "--candidates", candidatesFile(root, [validCand])]); + runCli(root, ["consolidate", "--apply", "--diff", pendingJson(root), "--confirm"]); + runCli(root, ["consolidate", "--propose", "--candidates", candidatesFile(root, [supCand])]); + runCli(root, ["consolidate", "--apply", "--diff", pendingJson(root), "--confirm"]); + // profile now holds an archival "AI safety" (superseded) + winner "AI governance" (active) + const { stdout } = runCli(root, ["consolidate", "--gather", "--json"]); + const out = JSON.parse(stdout); + const values = out.profileFacts.map((f: any) => f.value); + assert.ok(values.includes("AI governance"), "active winner present in gather"); + assert.ok(!out.profileFacts.some((f: any) => f.status === "superseded"), "no superseded fact leaks into gather"); + }); }); diff --git a/scripts/brain/tests/consolidate.test.ts b/scripts/brain/tests/consolidate.test.ts index 52eb11b..39560a6 100644 --- a/scripts/brain/tests/consolidate.test.ts +++ b/scripts/brain/tests/consolidate.test.ts @@ -1,7 +1,7 @@ import { describe, test } from "node:test"; import assert from "node:assert/strict"; -import { proposeDiff, applyDiff, type Candidate } from "../src/consolidate.js"; +import { proposeDiff, applyDiff, type Candidate, type ProfileDiff } from "../src/consolidate.js"; import { mintEntityId } from "../src/id.js"; import { parseProfile, serializeProfile } from "../src/profile.js"; import type { ProfileDoc, ProfileFact } from "../src/types.js"; @@ -158,3 +158,130 @@ describe("proposeDiff purity + applyDiff round-trip (SC2, SC3, SC4)", () => { assert.equal(factsTwice, factsOnce, "no duplicate fact on re-run (bump only)"); }); }); + +describe("supersede arm (SB-S3b)", () => { + test("S3b-SC1 — routing: a supersede candidate retires the active prior, no keep-both", () => { + const id = observedId("role"); + const cur = doc([], [fact({ id, value: "advisor", evidence_count: 2 })]); + const diff = proposeDiff({ current: cur, candidates: [cand({ key: "role", value: "architect", supersedes: "role" })], today: TODAY }); + assert.equal(diff.supersedes.length, 1, "one supersede op"); + assert.equal(diff.conflicts.length, 0, "supersede routes away from keep-both"); + assert.equal(diff.supersedes[0].oldId, id); + assert.equal(diff.supersedes[0].oldValue, "advisor"); + assert.equal(diff.supersedes[0].winner.value, "architect"); + assert.equal(diff.supersedes[0].winner.id, observedId("role"), "winner takes the canonical key-id"); + assert.equal(diff.additions.length, 0, "winner is carried in the op, not additions"); + }); + + test("S3b-SC2 — apply: retired fact superseded+re-minted, winner at key-id, both retained", () => { + const id = observedId("role"); + const cur = doc([], [fact({ id, value: "advisor", evidence_count: 2, first_seen: "2026-01-01" })]); + const next = applyDiff(cur, proposeDiff({ current: cur, candidates: [cand({ key: "role", value: "architect", supersedes: "role" })], today: TODAY })); + const all = [...next.static, ...next.dynamic]; + const winner = all.find((f) => f.value === "architect")!; + const retired = all.find((f) => f.value === "advisor")!; + assert.ok(winner && retired, "both winner + retired present"); + assert.equal(winner.id, id, "winner takes the canonical key-id"); + assert.equal(winner.status, "active"); + assert.equal(winner.evidence_count, 1, "winner re-earns evidence"); + assert.equal(retired.status, "superseded"); + assert.notEqual(retired.id, id, "retired fact re-minted off the key-id (archival)"); + assert.equal(retired.first_seen, "2026-01-01", "retired fact keeps its OWN first_seen (audit)"); + const ids = all.map((f) => f.id); + assert.equal(new Set(ids).size, ids.length, "no duplicate id"); + }); + + test("S3b-SC3a — propose-idempotent: a re-sent supersede whose target already holds the new value bumps, no new supersession", () => { + const id = observedId("role"); + const cur = doc([], [fact({ id, value: "advisor", evidence_count: 2 })]); + const once = applyDiff(cur, proposeDiff({ current: cur, candidates: [cand({ key: "role", value: "architect", supersedes: "role" })], today: TODAY })); + // re-extract the same candidate (value now matches the winner) WITH the stale signal still set + const diff2 = proposeDiff({ current: once, candidates: [cand({ key: "role", value: "architect", supersedes: "role" })], today: TODAY }); + assert.equal(diff2.supersedes.length, 0, "no self-supersede when the target already holds the value"); + assert.equal(diff2.evidenceBumps.length, 1, "it bumps the winner instead"); + }); + + test("S3b-SC3b — apply-idempotent: re-applying the same supersede diff is a no-op (value-matched skip)", () => { + const id = observedId("role"); + const cur = doc([], [fact({ id, value: "advisor", evidence_count: 2 })]); + const diff = proposeDiff({ current: cur, candidates: [cand({ key: "role", value: "architect", supersedes: "role" })], today: TODAY }); + const once = applyDiff(cur, diff); + const twice = applyDiff(once, diff); + assert.deepEqual(twice, once, "re-applying the same supersede diff changes nothing"); + }); + + test("S3b-SC4 — graceful no-target: a supersede signal with no active prior degrades to a plain add", () => { + const cur = doc([], []); + const diff = proposeDiff({ current: cur, candidates: [cand({ key: "role", value: "architect", supersedes: "role" })], today: TODAY }); + assert.equal(diff.supersedes.length, 0); + assert.equal(diff.additions.length, 1, "degrades to a plain add"); + assert.equal(diff.additions[0].value, "architect"); + assert.equal(diff.additions[0].id, observedId("role")); + }); + + test("S3b-SC5 — round-trip: a superseded fact produced by applyDiff parses/serializes identically", () => { + const id = observedId("role"); + const cur = doc([], [fact({ id, value: "advisor", evidence_count: 2 })]); + const next = applyDiff(cur, proposeDiff({ current: cur, candidates: [cand({ key: "role", value: "architect", supersedes: "role" })], today: TODAY })); + const round = parseProfile(serializeProfile(next)); + assert.deepEqual(round, next, "superseded fact round-trips through the grammar"); + const ids = [...round.static, ...round.dynamic].map((f) => f.id); + assert.equal(new Set(ids).size, ids.length, "all ids unique"); + }); + + test("S3b-SC7 — no regression: a candidate WITHOUT the signal still keeps-both (no supersede)", () => { + const id = observedId("role"); + const cur = doc([], [fact({ id, value: "advisor", evidence_count: 2 })]); + const diff = proposeDiff({ current: cur, candidates: [cand({ key: "role", value: "architect" })], today: TODAY }); + assert.equal(diff.supersedes.length, 0); + assert.equal(diff.conflicts.length, 1, "keep-both unchanged when no signal"); + }); + + test("S3b-SC8 — intra-batch: two supersede candidates for the same key resolve deterministically (first wins)", () => { + const id = observedId("role"); + const cur = doc([], [fact({ id, value: "advisor", evidence_count: 2 })]); + const diff = proposeDiff({ + current: cur, + candidates: [ + cand({ key: "role", value: "architect", supersedes: "role" }), + cand({ key: "role", value: "principal", supersedes: "role" }), + ], + today: TODAY, + }); + assert.equal(diff.supersedes.length, 1, "only the first supersede for the key is taken"); + assert.equal(diff.supersedes[0].winner.value, "architect"); + const next = applyDiff(cur, diff); + const ids = [...next.static, ...next.dynamic].map((f) => f.id); + assert.equal(new Set(ids).size, ids.length, "no duplicate id after intra-batch supersede"); + }); + + test("S3b-SC11 — decay excludes superseded: a retained superseded dynamic fact is not stale-flagged", () => { + const old = "2026-03-01"; // ~114 days before TODAY + const superseded = fact({ id: observedId("old-role"), value: "advisor", last_seen: old, status: "superseded" }); + const active = fact({ id: observedId("topic"), value: "x", last_seen: old, status: "active" }); + const cur = doc([], [superseded, active]); + const diff = proposeDiff({ current: cur, candidates: [], today: TODAY }); + assert.equal(diff.staleFlags.length, 1, "only the active stale fact is flagged"); + assert.equal(diff.staleFlags[0].id, active.id); + }); + + test("S3b-SC12 — supersede wins over a same-id bump in one diff", () => { + const id = observedId("role"); + const cur = doc([], [fact({ id, value: "advisor", evidence_count: 2 })]); + const winner = fact({ id, value: "architect", evidence_count: 1, status: "active" }); + const diff: ProfileDiff = { + additions: [], + promotions: [], + conflicts: [], + staleFlags: [], + evidenceBumps: [{ id, newCount: 3, last_seen: TODAY }], // a stale bump targeting the same id + supersedes: [{ oldId: id, oldValue: "advisor", winner }], + }; + const next = applyDiff(cur, diff); + const all = [...next.static, ...next.dynamic]; + const w = all.find((f) => f.value === "architect")!; + const r = all.find((f) => f.value === "advisor")!; + assert.equal(r.status, "superseded", "the old fact is superseded, not bumped"); + assert.equal(w.evidence_count, 1, "winner unaffected by the stale bump"); + }); +}); diff --git a/scripts/test-runner.sh b/scripts/test-runner.sh index 4b08af8..090e97f 100755 --- a/scripts/test-runner.sh +++ b/scripts/test-runner.sh @@ -713,7 +713,7 @@ if [ -x "$BR_DIR/node_modules/.bin/tsx" ]; then BR_OUT=$( set +e; (cd "$BR_DIR" && npm test) 2>&1; echo "BR_EXIT:$?" ) BR_EXIT=$(echo "$BR_OUT" | grep -oE 'BR_EXIT:[0-9]+' | grep -oE '[0-9]+' | head -1) BR_TESTS=$(echo "$BR_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1) - BRAIN_TESTS_FLOOR=82 # SB-S0 34 [id(11)+profile(6)+fold(12)+scaffold(5)] + SB-S1 29 [ingest(14)+publish(9)+cli(6)] + SB-S2 19 [consolidate(12)+consolidate-cli(7)] + BRAIN_TESTS_FLOOR=94 # SB-S0 34 [id(11)+profile(6)+fold(12)+scaffold(5)] + SB-S1 29 [ingest(14)+publish(9)+cli(6)] + SB-S2 19 [consolidate(12)+consolidate-cli(7)] + SB-S3b 12 [consolidate(10)+consolidate-cli(2)] if [ "$BR_EXIT" = "0" ] && [ -n "$BR_TESTS" ] && [ "$BR_TESTS" -ge "$BRAIN_TESTS_FLOOR" ]; then pass "brain suite green: $BR_TESTS tests pass (floor $BRAIN_TESTS_FLOOR)" else