feat(linkedin-studio): SB-S3b — supersede arm in the consolidation engine [skip-docs]

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RigJBiRFNtFZKCz21qNbQ4
This commit is contained in:
Kjell Tore Guttormsen 2026-06-23 19:58:09 +02:00
commit 585f972a05
6 changed files with 267 additions and 19 deletions

View file

@ -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<string, string>): 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"}:`);

View file

@ -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 (dynamicstatic 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:<id>" | "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<string>();
const supersededKeys = new Set<string>();
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<string, ProfileFact>(); // 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 };
}