feat(linkedin-studio): SB-S2 consolidation engine — proposeDiff/applyDiff [skip-docs]

Pure deterministic engine over the two-layer profile: add / reject-ai-draft /
evidence-bump / promote-at-N(3) / conflict-keep-both / decay-flag(90d). No-duplicate-id
guarantee: primary id = mintEntityId(observed,key), conflict-alt id =
mintContentId(observed-alt🔑:value::date) — byte-distinct. Folded profile-field
seeds immutable (different kind); static facts decay-exempt; no supersede (S3).
applyDiff produces the next ProfileDoc that round-trips through parse/serialize;
re-running is idempotent (bump, not duplicate). + consolidation-state.json sidecar IO.
12 engine tests (SC1a–g, SC2, SC3, SC4). brain 63→75, tsc clean.

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 17:00:56 +02:00
commit ff39d14206
2 changed files with 346 additions and 0 deletions

View file

@ -0,0 +1,186 @@
/**
* Consolidation engine (SB-S2) the compounding mechanism.
*
* PURE core: `proposeDiff` reads candidate facts + the current two-layer profile
* and returns a typed DIFF (never mutates, never writes); `applyDiff` produces the
* next ProfileDoc. The operator-gated CLI is the only thing that writes profile.md.
*
* 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).
*
* 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
* `mintContentId('observed-alt:'+key+'::'+value+'::'+date)` (byte-distinct). Folded
* `profile-field` static seeds use a different kind, so consolidation never collides
* with them (they stay immutable in S2). Matching always starts from the candidate's
* key, so an existing fact's key never needs to be recovered.
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { dataRoot } from "./dataRoot.js";
import { mintContentId, mintEntityId } from "./id.js";
import { SCHEMA_VERSION } from "./types.js";
import type { ProfileDoc, ProfileFact, Provenance } from "./types.js";
const OBSERVED_KIND = "observed";
/** A candidate fact the invoking session extracts from the gathered deltas. */
export interface Candidate {
key: string; // the concept (keyed for the id; single-line)
value: string; // single-line (no newline/CR — profile grammar)
provenance: Provenance;
source: string; // e.g. "published:<id>" | "manual"
observed_date: string; // YYYY-MM-DD
}
export interface ProfileDiff {
additions: ProfileFact[]; // new dynamic facts (primary adds + conflict alts)
evidenceBumps: { id: string; newCount: number; last_seen: string }[];
promotions: { id: string }[]; // dynamic→static (post-bump count ≥ N)
conflicts: { primaryId: string; primaryValue: string; altId: string }[];
staleFlags: { id: string; last_seen: string; daysStale: number }[];
}
export interface ConsolidateOpts {
promoteThreshold?: number; // default 3
decayDays?: number; // default 90
}
function daysBetween(from: string, to: string): number {
return Math.floor((Date.parse(to) - Date.parse(from)) / 86400000);
}
function altId(c: Candidate): string {
return mintContentId(`observed-alt:${c.key}::${c.value}::${c.observed_date}`);
}
function newFact(id: string, c: Candidate, today: string): ProfileFact {
return {
id,
value: c.value,
first_seen: c.observed_date,
last_seen: today,
evidence_count: 1,
provenance: c.provenance,
status: "active",
};
}
/**
* Propose a diff over the current profile from a batch of candidates. Pure never
* mutates `current`/`candidates`, never touches the filesystem.
*/
export function proposeDiff(args: {
current: ProfileDoc;
candidates: Candidate[];
today: string;
opts?: ConsolidateOpts;
}): ProfileDiff {
const { current, candidates, today, opts } = args;
const N = opts?.promoteThreshold ?? 3;
const DECAY = opts?.decayDays ?? 90;
const byId = new Map<string, ProfileFact>([...current.static, ...current.dynamic].map((f) => [f.id, f]));
const dynamicIds = new Set(current.dynamic.map((f) => f.id));
const additions: ProfileFact[] = [];
const evidenceBumps: ProfileDiff["evidenceBumps"] = [];
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.
const touched = new Set<string>();
const bump = (id: string, prevCount: number) => {
const newCount = prevCount + 1;
evidenceBumps.push({ id, newCount, last_seen: today });
if (dynamicIds.has(id) && newCount >= N) promotions.push({ id });
};
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);
if (!prev) {
if (!touched.has(primaryId)) {
additions.push(newFact(primaryId, c, today));
touched.add(primaryId);
}
continue;
}
if (prev.value === c.value) {
bump(primaryId, prev.evidence_count); // SC1c/SC1d
continue;
}
// conflict — keep both with a distinct alt id (SC1e), old fact untouched
const aId = altId(c);
const altPrev = byId.get(aId);
if (altPrev && altPrev.value === c.value) {
bump(aId, altPrev.evidence_count); // idempotent re-conflict → bump the alt
} else if (!touched.has(aId)) {
additions.push(newFact(aId, c, today));
conflicts.push({ primaryId, primaryValue: prev.value, altId: aId });
touched.add(aId);
}
}
const staleFlags = current.dynamic
.filter((f) => 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 };
}
/**
* 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).
*/
export function applyDiff(current: ProfileDoc, diff: ProfileDiff): ProfileDoc {
const bumpMap = new Map(diff.evidenceBumps.map((b) => [b.id, b]));
const applyBump = (f: ProfileFact): ProfileFact => {
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);
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));
staticF = [...staticF, ...promoted];
dynamicF = [...dynamicF, ...diff.additions.map((f) => ({ ...f }))];
return { schemaVersion: SCHEMA_VERSION, static: staticF, dynamic: dynamicF };
}
// ── consolidation-state sidecar (the only IO; brain data-root, reachable by both
// the CLI via dataRoot and the session-start hook via getDataRoot) ────────────
const STATE_SUB = "brain/consolidation-state.json";
/** Read the last-run date; absent/malformed → {last_run:null}. */
export function readConsolidationState(): { last_run: string | null } {
const p = dataRoot(STATE_SUB);
if (!existsSync(p)) return { last_run: null };
try {
const j = JSON.parse(readFileSync(p, "utf8"));
return { last_run: typeof j?.last_run === "string" ? j.last_run : null };
} catch {
return { last_run: null };
}
}
/** Record the last-run date (called only by the gated --apply path). */
export function writeConsolidationState(date: string): void {
const p = dataRoot(STATE_SUB);
mkdirSync(dirname(p), { recursive: true });
writeFileSync(p, JSON.stringify({ last_run: date }, null, 2) + "\n", "utf8");
}