diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a679db0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# PCM ledger evidence logs are append-only sets deduped by content hash on read — +# union merge is safe and makes teammate ledgers conflict-free by construction +# (docs/plans/substrate-v2/02-team-memory.md §1). +.forge/ledger/evidence/*.log merge=union diff --git a/CHANGELOG.md b/CHANGELOG.md index 05601c0..fb8be27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,22 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- **Proof-Carrying Memory ledger (P1 of the substrate-v2 plan).** `src/ledger.js` — the + pure PCM core (ADR-0006): content-addressed claims over canonical JSON, an oracle + taxonomy in which only independent signals (tests, CI, human accept/revert) may move + confidence, a time-decayed Beta-posterior `val` that decays toward *uncertainty* (never + toward false), the paper's Eq. 3 retrieval score, dependency-free MinHash similarity + + union-find consolidation clustering, and a join-semilattice merge (property-tested: + commutative, associative, idempotent — teammate ledgers converge in any order). + `src/ledger_store.js` — the git-native on-disk ledger (`.forge/ledger/`): one immutable + file per claim sharded by id, append-only hash-deduped evidence logs (union-merge safe, + see `.gitattributes`), tombstones, attic, `LEDGER.md` index, and a CI-friendly + normal-form `verify`. `forge ledger stats|verify|show|import` CLI. The legacy stores + stay the read path in P1: cortex shadow-writes every lesson event (create/confirm/ + human-revert contradiction) into the ledger, `forge remember` / `forge recall add` + shadow facts, and `forge ledger import` back-fills history idempotently + (`src/ledger_bridge.js`). Secret-refusal now lives in the ledger core so no claim kind + can store a credential (re-exported from `recall.js` for compatibility). - **Uniform `--json`.** `doctor`, `route`, `preflight`, `verify`, and `scope` now accept `--json` (previously only `impact`/`substrate`/`anchor` did) — so CI and scripts can gate on the health check, the routed tier, the assumption gap, and the verification result. diff --git a/src/cli.js b/src/cli.js index 8cf6928..559b34a 100755 --- a/src/cli.js +++ b/src/cli.js @@ -19,6 +19,7 @@ const COMMANDS = { cost: "real per-day spend via ccusage + the cost ceiling", spec: "spec-as-contract — init (OpenSpec) / lock / check drift", cortex: "self-correcting project memory — status / why ", + ledger: "proof-carrying memory ledger — stats / verify / show / import", preflight: "assumption check — what a task names that the repo doesn't define", route: "recommend the cheapest capable model for a task (+ gateway config)", impact: "predict blast radius for a symbol or file from the atlas graph", @@ -153,6 +154,14 @@ async function run(argv) { return; } const res = r.add(store, name, body); + if (res.ok) { + // Shadow the fact into the PERSONAL ledger beside the global store (repo + // promotion stays an explicit act — docs/plans/substrate-v2/02-team-memory.md §3). + const { join } = await import("node:path"); + const { recordFactEvent } = await import("./ledger_bridge.js"); + const { epochDay } = await import("./util.js"); + recordFactEvent(join(store, "ledger"), name, body, epochDay()); + } console.log(res.ok ? ` saved: ${res.slug}` : ` ${res.reason}`); if (!res.ok) process.exitCode = 1; } else if (sub === "consolidate") { @@ -164,6 +173,64 @@ async function run(argv) { } return; } + if (cmd === "ledger") { + const ls = await import("./ledger_store.js"); + const { epochDay } = await import("./util.js"); + const root = process.cwd(); + const dir = ls.repoLedger(root); + const sub = argv[1] || "stats"; + const json = argv.includes("--json"); + const nowDay = epochDay(); + if (sub === "stats") { + const s = ls.stats(dir, nowDay); + if (json) return console.log(JSON.stringify(s, null, 2)); + console.log(`${BRAND.brand} ledger — proof-carrying memory\n`); + console.log(` claims: ${s.total} (tombstoned ${s.tombstoned})`); + for (const [kind, n] of Object.entries(s.byKind)) console.log(` ${kind}: ${n}`); + console.log( + ` val: trusted ${s.val.trusted} · uncertain ${s.val.uncertain} · dormant ${s.val.dormant}`, + ); + console.log("\n stored in .forge/ledger/ (git-committable, conflict-free merge)"); + return; + } + if (sub === "verify") { + const r = ls.verify(dir); + if (json) return console.log(JSON.stringify(r, null, 2)); + console.log(` ${r.ok ? "OK" : "ISSUES"} — ${r.claims} claim(s), ${r.outcomes} outcome(s)`); + for (const i of r.issues) console.log(` - ${i}`); + if (!r.ok) process.exitCode = 1; + return; + } + if (sub === "show") { + const id = argv[2]; + const hit = id && ls.loadClaims(dir).find((c) => c.id.startsWith(id)); + if (!hit) { + console.error(id ? ` no claim matching ${id}` : "usage: forge ledger show "); + process.exitCode = 1; + return; + } + const { val } = await import("./ledger.js"); + return console.log(JSON.stringify({ ...hit, val: val(hit, nowDay) }, null, 2)); + } + if (sub === "import") { + const { importLegacy } = await import("./ledger_bridge.js"); + const { brainStore } = await import("./brain.js"); + const r = importLegacy(root, { + recallStore: brainStore(root), + recallLedger: dir, + nowDay, + }); + if (json) return console.log(JSON.stringify(r, null, 2)); + console.log( + ` imported: ${r.lessons} lesson(s), ${r.facts} fact(s), ${r.outcomes} outcome(s)`, + ); + for (const x of r.refused) console.log(` refused: ${x}`); + return; + } + console.error(`ledger: unknown subcommand "${sub}" (stats | verify | show | import)`); + process.exitCode = 1; + return; + } if (cmd === "atlas") { const a = await import("./atlas.js"); const sub = argv[1] || "build"; @@ -260,6 +327,13 @@ async function run(argv) { return; } const res = b.remember(b.brainStore(process.cwd()), name, body); + if (res.ok) { + // Brain is repo-scoped and git-committable → shadow into the REPO ledger. + const { recordFactEvent } = await import("./ledger_bridge.js"); + const { repoLedger } = await import("./ledger_store.js"); + const { epochDay } = await import("./util.js"); + recordFactEvent(repoLedger(process.cwd()), name, body, epochDay()); + } console.log( res.ok ? ` remembered: ${res.slug} — run \`forge sync\` to inline it into every tool` diff --git a/src/cortex.js b/src/cortex.js index 1be1442..9331174 100644 --- a/src/cortex.js +++ b/src/cortex.js @@ -3,6 +3,8 @@ // becomes a created/confirmed lesson; an independent human reversal becomes a // contradiction. Kept fs-thin and deterministic (day + ids passed in) so it's testable // without any hook wiring. + +import { recordLessonEvent } from "./ledger_bridge.js"; import { confidenceOf, confirm, @@ -83,6 +85,13 @@ export function recordMistake(root, { signals, context, nowDay, episodeId, disti }, }; if (!save(root, updated).ok) return { action: "refused", p, fires }; + // Shadow the confirmation into the PCM ledger (P1 bridge — legacy store stays the + // read path; best-effort by design, never blocks the hook). + recordLessonEvent(root, updated, { + result: "confirm", + ref: `episode:${episodeId}`, + t: nowDay, + }); return { action: "confirmed", id: updated.id, @@ -115,6 +124,9 @@ export function recordMistake(root, { signals, context, nowDay, episodeId, disti // Report "created" only when the write actually landed — a refused save (e.g. secret-bearing // body) must not make the hook believe a lesson exists and try to distill a phantom. if (!save(root, lesson).ok) return { action: "refused", p, fires }; + // Mint-only shadow: a freshly created lesson has zero evidence (creation is not + // confirmation — the ledger's val starts at the 0.5 prior, same as newLesson). + recordLessonEvent(root, lesson, { t: nowDay }); return { action: "created", id: lesson.id, status: lesson.status, p, fires }; } @@ -133,6 +145,14 @@ export function recordContradiction(root, { context, nowDay, episodeId }) { const results = targets.map((l) => { const updated = contradict(l, nowDay); const saved = save(root, updated).ok; + // An explicit human reversal is the strongest oracle we have — shadow it. + if (saved) + recordLessonEvent(root, updated, { + result: "contradict", + oracle: "human.revert", + ref: `episode:${episodeId}`, + t: nowDay, + }); return { id: updated.id, status: updated.status, saved }; }); return { action: "contradicted", results }; diff --git a/src/ledger.js b/src/ledger.js new file mode 100644 index 0000000..046ffd6 --- /dev/null +++ b/src/ledger.js @@ -0,0 +1,347 @@ +// forge ledger — the Proof-Carrying Memory (PCM) core. PURE logic only (no fs — see +// ledger_store.js): canonical claims, content-addressed ids, evidence outcomes, a +// decayed Beta-posterior confidence, Eq.-3 retrieval scoring, MinHash similarity, and +// the semilattice merge that makes team memory conflict-free by construction. +// Spec: docs/plans/substrate-v2/01-pcm-protocol.md (ADR-0006). +// +// Design invariants (shared with lessons.js, now protocol law for every stored thing): +// - Confidence is EARNED from independent oracles (tests, CI, human accept/revert), +// never from the model's self-assessment; retrieval/injection is never confirmation. +// - Claims are immutable by id; every mutable thing (evidence, tombstones) is an +// append-only record — which is what makes merge = set union a join-semilattice. +// - Unreviewed claims decay toward the PRIOR (0.5, uncertainty), not toward false. +import { contentHash } from "./util.js"; + +// Anything matching this is refused at mint — store a pointer to where the secret +// lives, never the value. Moved here from recall.js (which re-exports it) so NO claim +// kind can persist a credential. See recall.js history for the precision rationale: +// known credential formats, plus a secret-ish key ASSIGNED to a value (a bare English +// mention like "implement password hashing" must NOT be refused). +export const SECRET_RE = + /(-----BEGIN |\bghp_[A-Za-z0-9]{16,}|\bgithub_pat_[A-Za-z0-9_]{20,}|\bsk-[A-Za-z0-9_-]{16,}|\bxox[baprs]-[A-Za-z0-9-]{10,}|\bAIza[0-9A-Za-z_-]{20,}|\bya29\.[A-Za-z0-9._-]+|\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}|AKIA[0-9A-Z]{16}|\b[\w-]*(?:api[_-]?key|secret|passwd|password|token)[\w-]*["']?\s*[:=]\s*["']?\S)/i; + +export const KINDS = [ + "lesson", // a corrected behavior (cortex) + "fact", // a durable project fact (recall) + "artifact", // verified generated code (reuse cache, P3) + "edge", // a verified dependency edge (atlas overlay, P5) + "fingerprint", // a design-token vector (UI gate, P6) + "diagnosis", // a doom-loop root cause (P5) + "decision", // a ratified team decision (hikma layer) + "summary", // a compressed context span (P4) + "outcome", // a raw oracle result (ilm layer) +]; + +/** + * Oracle taxonomy — who may move confidence, and how much. `w` = prior reliability; + * `family` powers the cross-family gate (a lone behavioral signal never moves a claim + * on its own — same rule as lessons.js scoreMistake). The two `bridge: true` entries + * exist only for the P1 migration seam (cortex episodes, legacy imports) and carry a + * deliberately conservative weight. + */ +export const ORACLES = { + "human.revert": { w: 1.0, family: "human" }, + "human.accept": { w: 0.9, family: "human" }, + "test.run": { w: 0.8, family: "outcome" }, + "ci.run": { w: 0.8, family: "outcome" }, + typecheck: { w: 0.6, family: "outcome" }, + "graph.reval": { w: 0.5, family: "structural" }, + behavioral: { w: 0.3, family: "behavioral" }, + "cortex.episode": { w: 0.5, family: "outcome", bridge: true }, + "legacy.import": { w: 0.5, family: "outcome", bridge: true }, +}; + +export const SCOPE_WEIGHT = { symbol: 1.0, dir: 0.8, repo: 0.6, global: 0.4 }; + +/** Retrieval weights for Eq. 3 (a=relevance, b=recency, g=validity) — calibrated in P8. */ +export const EQ3_WEIGHTS = { a: 0.55, b: 0.15, g: 0.3 }; + +export const DEFAULT_HALF_LIFE_DAYS = 45; +/** Below this val a claim is dormant: kept for audit, never retrieved. */ +export const DORMANT_VAL = 0.35; + +/** + * Deterministic canonical JSON: lexicographically sorted keys, no insignificant + * whitespace, NFC-normalized strings, no undefined/function values (dropped, as in + * JSON.stringify). The canonical BYTES are what gets hashed and stored — id stability + * under re-serialization is a protocol guarantee. + * @param {*} value + * @returns {string} + */ +export function canonicalize(value) { + if (value === null || typeof value === "number" || typeof value === "boolean") + return JSON.stringify(value); + if (typeof value === "string") return JSON.stringify(value.normalize("NFC")); + if (Array.isArray(value)) + return `[${value.map((v) => (v === undefined ? "null" : canonicalize(v))).join(",")}]`; + if (typeof value === "object") { + const keys = Object.keys(value) + .filter((k) => value[k] !== undefined && typeof value[k] !== "function") + .sort(); + return `{${keys.map((k) => `${JSON.stringify(k.normalize("NFC"))}:${canonicalize(value[k])}`).join(",")}}`; + } + return "null"; // undefined / function at the top level +} + +/** Content address over (kind, body, scope) ONLY — provenance and evidence excluded, so + * two teammates who independently learn the same thing mint the SAME id and their + * evidence merges instead of duplicating. */ +export function claimId(kind, body, scope = {}) { + return contentHash(canonicalize({ body, kind, scope })); +} + +/** + * Mint a claim. Refuses secrets and unknown kinds ({ok:false, reason} — same contract + * as recall.add / lessons_store.save so callers keep one error shape). + * @param {{kind:string, body:object, scope?:object, provenance?:object, t?:number}} f + * `t` is the mint day (epoch days) — passed in, never read from the clock here. + * @returns {{ok:true, claim:object}|{ok:false, reason:string}} + */ +export function mintClaim({ kind, body, scope = {}, provenance = {}, t = 0 }) { + if (!KINDS.includes(kind)) return { ok: false, reason: `unknown claim kind: ${kind}` }; + if (body === null || typeof body !== "object") + return { ok: false, reason: "claim body must be an object" }; + const canon = canonicalize({ body, kind, scope }); + if (SECRET_RE.test(canon)) + return { + ok: false, + reason: "refused: looks like a secret/credential — store a pointer, not the value", + }; + return { + ok: true, + claim: { + v: 1, + id: claimId(kind, body, scope), + kind, + body, + scope, + provenance: { ...provenance, t }, + evidence: [], + }, + }; +} + +/** + * Build an evidence outcome. Evidence without a verifiable ref (commit SHA, test-run + * id, episode id, CI URL) is rejected — "the model said so" is not evidence. + * @param {{oracle:string, result:"confirm"|"contradict", ref:string, author?:string, t?:number}} f + * @returns {{ok:true, outcome:object}|{ok:false, reason:string}} + */ +export function outcomeRecord({ oracle, result, ref, author = "", t = 0 }) { + const o = ORACLES[oracle]; + if (!o) return { ok: false, reason: `unknown oracle: ${oracle}` }; + if (result !== "confirm" && result !== "contradict") + return { ok: false, reason: `result must be confirm|contradict, got: ${result}` }; + if (!ref || typeof ref !== "string") + return { ok: false, reason: "evidence requires a verifiable ref" }; + const outcome = { author, oracle, ref, result, t, w: o.w }; + return { ok: true, outcome: { ...outcome, h: contentHash(canonicalize(outcome)) } }; +} + +const decayed = (outcome, nowDay, halfLife) => + outcome.w * 0.5 ** (Math.max(0, nowDay - (outcome.t ?? 0)) / halfLife); + +/** + * Validity — the paper's `val` term as a time-decayed Beta posterior mean with a + * Beta(1,1) prior: (1 + Σ confirms·w·λ^Δt/T) / (2 + Σ all·w·λ^Δt/T). + * Fresh claim → 0.5. Unreviewed evidence decays, pulling val back toward 0.5 + * (uncertainty), never toward 0 — review (new evidence) is what restores weight. + * Pure function of the evidence set ⇒ identical after any merge order. + */ +export function val(claim, nowDay = 0, { halfLife = DEFAULT_HALF_LIFE_DAYS } = {}) { + let confirms = 0; + let all = 0; + for (const e of claim.evidence ?? []) { + const d = decayed(e, nowDay, halfLife); + all += d; + if (e.result === "confirm") confirms += d; + } + return (1 + confirms) / (2 + all); +} + +/** Recency — λ^(Δt/T) since the last evidence (or mint, if none). */ +export function rec(claim, nowDay = 0, { halfLife = DEFAULT_HALF_LIFE_DAYS } = {}) { + const last = Math.max(claim.provenance?.t ?? 0, ...(claim.evidence ?? []).map((e) => e.t ?? 0)); + return 0.5 ** (Math.max(0, nowDay - last) / halfLife); +} + +/** Dormant claims are kept for audit but never retrieved. */ +export function isDormant(claim, nowDay = 0) { + return val(claim, nowDay) < DORMANT_VAL; +} + +// --------------------------------------------------------------------------- +// MinHash similarity — the dependency-free `rel` term. k independent-ish hash +// functions via affine reseeding of one FNV-1a base hash; Jaccard is estimated by the +// fraction of matching sketch positions (unbiased, SE ≈ sqrt(J(1-J)/k) ≈ 0.044 at +// k=128). Candidate pairing at scale uses LSH banding (P3); here pairwise is fine. +// --------------------------------------------------------------------------- + +export const SKETCH_K = 128; + +const fnv1a = (s) => { + let h = 0x811c9dc5; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 0x01000193) >>> 0; + } + return h; +}; + +// Fixed odd multipliers/offsets derived from a splitmix-style constant — deterministic +// across runs and platforms (no Math.random, ever: ids and sketches must be stable). +const SEEDS = Array.from({ length: SKETCH_K }, (_, i) => ({ + a: (Math.imul(i + 1, 0x9e3779b1) | 1) >>> 0, + b: Math.imul(i + 1, 0x85ebca6b) >>> 0, +})); + +const normalizeText = (text) => + String(text) + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean); + +/** n-token shingle set of normalized text (short texts fall back to single tokens). */ +export function shingles(text, n = 4) { + const toks = normalizeText(text); + if (toks.length < n) return new Set(toks.length ? [toks.join(" ")] : []); + const out = new Set(); + for (let i = 0; i + n <= toks.length; i++) out.add(toks.slice(i, i + n).join(" ")); + return out; +} + +/** MinHash sketch of a text: k per-seed minima over the shingle hashes. */ +export function sketch(text, k = SKETCH_K) { + const sh = shingles(text); + const mins = new Array(k).fill(0xffffffff); + for (const s of sh) { + const h = fnv1a(s); + for (let i = 0; i < k; i++) { + const v = (Math.imul(h, SEEDS[i].a) + SEEDS[i].b) >>> 0; + if (v < mins[i]) mins[i] = v; + } + } + return mins; +} + +/** Jaccard estimate = fraction of agreeing sketch positions (1 for identical texts). */ +export function jaccard(a, b) { + const n = Math.min(a.length, b.length); + if (!n) return 0; + let eq = 0; + for (let i = 0; i < n; i++) if (a[i] === b[i]) eq++; + return eq / n; +} + +/** The retrievable text of a claim, per kind (fallback: its canonical body). */ +export function claimText(claim) { + const b = claim.body ?? {}; + switch (claim.kind) { + case "lesson": + return [ + b.whatWentWrong, + b.correctedBehavior, + ...(b.trigger?.keywords ?? []), + ...(b.trigger?.symbols ?? []), + ] + .filter(Boolean) + .join(" "); + case "fact": + return [b.name, b.text].filter(Boolean).join(" "); + case "diagnosis": + return [b.signature, b.note].filter(Boolean).join(" "); + case "summary": + return b.text ?? canonicalize(b); + default: + return canonicalize(b); + } +} + +/** + * Eq. 3 retrieval score (paper §7.1): σ(a·rel + b·rec + g·val) × scope weight. + * `query` may be a string or a precomputed sketch. The `g·val` term is the protocol's + * load-bearing addition — outcome-confirmed claims outrank merely-recent ones. + */ +export function score(query, claim, { nowDay = 0, weights = EQ3_WEIGHTS } = {}) { + const qs = Array.isArray(query) ? query : sketch(query); + const rel = jaccard(qs, claim._sketch ?? sketch(claimText(claim))); + const x = weights.a * rel + weights.b * rec(claim, nowDay) + weights.g * val(claim, nowDay); + const sigma = 1 / (1 + Math.exp(-x)); + const scopeW = SCOPE_WEIGHT[claim.scope?.level] ?? 0.5; + return sigma * scopeW; +} + +/** Rank live (non-dormant, non-tombstoned) claims for a query; caps at `budget`. */ +export function retrieve(query, claims, { nowDay = 0, budget = 12, weights = EQ3_WEIGHTS } = {}) { + const qs = sketch(String(query)); + return claims + .filter((c) => !c.tombstone && !isDormant(c, nowDay)) + .map((c) => ({ claim: c, score: score(qs, c, { nowDay, weights }) })) + .sort((a, b) => b.score - a.score || (a.claim.id < b.claim.id ? -1 : 1)) + .slice(0, budget); +} + +/** + * Consolidation clustering (the murāja‘a job, ilm→fahm): union-find over pairs with + * Jaccard ≥ tau. O(n²) pairwise on sketches — fine at session scale; LSH banding is + * the documented scale path. Returns clusters of ≥2 as arrays of claim ids. + */ +export function clusters(claims, { tau = 0.7 } = {}) { + const items = claims.map((c) => ({ id: c.id, s: c._sketch ?? sketch(claimText(c)) })); + const parent = new Map(items.map((i) => [i.id, i.id])); + const find = (x) => { + while (parent.get(x) !== x) { + parent.set(x, parent.get(parent.get(x))); + x = parent.get(x); + } + return x; + }; + for (let i = 0; i < items.length; i++) + for (let j = i + 1; j < items.length; j++) + if (jaccard(items[i].s, items[j].s) >= tau) parent.set(find(items[i].id), find(items[j].id)); + const groups = new Map(); + for (const it of items) { + const r = find(it.id); + if (!groups.has(r)) groups.set(r, []); + groups.get(r).push(it.id); + } + return [...groups.values()].filter((g) => g.length >= 2).map((g) => g.sort()); +} + +// --------------------------------------------------------------------------- +// The CRDT merge. State = { claims: {id→claim}, evidence: {id→outcome[]}, tombstones: +// {id→tomb} } — three grow-only maps keyed by content. Merge is set union with +// evidence deduped by hash; (S, ⊔) is a join-semilattice (commutative, associative, +// idempotent), so replicas converge under ANY merge order. Property-tested. +// --------------------------------------------------------------------------- + +const sortOutcomes = (arr) => { + const byHash = new Map(); + for (const o of arr) if (!byHash.has(o.h)) byHash.set(o.h, o); + return [...byHash.values()].sort( + (a, b) => (a.t ?? 0) - (b.t ?? 0) || (a.h < b.h ? -1 : a.h > b.h ? 1 : 0), + ); +}; + +/** Semilattice join of two ledger states. Pure; inputs are not mutated. */ +export function mergeStates(s1, s2) { + const claims = { ...s1.claims }; + for (const [id, c] of Object.entries(s2.claims ?? {})) claims[id] ??= c; + const evidence = {}; + for (const id of new Set([...Object.keys(s1.evidence ?? {}), ...Object.keys(s2.evidence ?? {})])) + evidence[id] = sortOutcomes([...(s1.evidence?.[id] ?? []), ...(s2.evidence?.[id] ?? [])]); + const tombstones = { ...(s1.tombstones ?? {}) }; + for (const [id, t] of Object.entries(s2.tombstones ?? {})) tombstones[id] ??= t; + return { claims, evidence, tombstones }; +} + +/** Materialize a state into claim objects with evidence + tombstones attached. */ +export function liveClaims(state) { + return Object.values(state.claims) + .map((c) => ({ + ...c, + evidence: state.evidence?.[c.id] ?? [], + tombstone: state.tombstones?.[c.id], + })) + .sort((a, b) => (a.id < b.id ? -1 : 1)); +} diff --git a/src/ledger_bridge.js b/src/ledger_bridge.js new file mode 100644 index 0000000..0be788f --- /dev/null +++ b/src/ledger_bridge.js @@ -0,0 +1,172 @@ +// forge ledger bridge — the P1 migration seam between the legacy stores (lessons/*.md, +// recall facts) and the PCM ledger. Legacy files stay the READ path (every existing +// test, hook, and guard keeps working unchanged); the ledger shadows every write as +// the new canonical, and `forge ledger import` back-fills history. P2 flips the read +// path once merge/verify tooling lands. Spec: docs/plans/substrate-v2/01-pcm-protocol.md §7. +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { mintClaim, outcomeRecord } from "./ledger.js"; +import { appendEvidence, putClaim, reindex, repoLedger } from "./ledger_store.js"; +import { load as loadLessons } from "./lessons_store.js"; +import { list as listFacts } from "./recall.js"; + +/** A lesson's claim: body = the CONTENT (trigger + texts); counts/status are evidence- + * derived in the ledger, not body — so confirm/contradict never changes the id. + * @returns {{ok:boolean, reason?:string, claim?:any}} */ +export function lessonClaim(lesson, t = 0) { + return mintClaim({ + kind: "lesson", + body: { + correctedBehavior: lesson.correctedBehavior ?? "", + legacyId: lesson.id, // join key back to .forge/lessons/.md during the bridge + trigger: { + action: lesson.trigger?.action ?? "", + files: lesson.trigger?.files ?? [], + keywords: lesson.trigger?.keywords ?? [], + symbols: lesson.trigger?.symbols ?? [], + }, + whatWentWrong: lesson.whatWentWrong ?? "", + }, + scope: { level: lesson.scope ?? "repo" }, + provenance: { agent: "cortex", author: "" }, + t, + }); +} + +/** A recall fact's claim. + * @returns {{ok:boolean, reason?:string, claim?:any}} */ +export function factClaim(name, text, t = 0) { + return mintClaim({ + kind: "fact", + body: { name, text }, + scope: { level: "repo" }, + provenance: { agent: "recall", author: "" }, + t, + }); +} + +/** + * Shadow-write one lesson event into the repo ledger. Best-effort by design: the + * legacy store is still canonical in P1, so a bridge failure must never break the + * hook path — it returns {ok:false} instead of throwing. + * @param {string} root repo root + * @param {object} lesson the (already saved) lesson object + * @param {{result?:"confirm"|"contradict", oracle?:string, ref?:string, t?:number}} ev + * evidence for this event; omit result for mint-only (a freshly created lesson has + * zero evidence — creation is not confirmation). `ref` is required whenever a + * result is given (outcomeRecord enforces it). + */ +export function recordLessonEvent(root, lesson, ev = {}) { + try { + const dir = repoLedger(root); + const minted = lessonClaim(lesson, ev.t ?? 0); + if (!minted.ok) return minted; + const put = putClaim(dir, minted.claim); + if (!put.ok) return put; + if (ev.result) { + const o = outcomeRecord({ + oracle: ev.oracle ?? "cortex.episode", + result: ev.result, + ref: ev.ref, + t: ev.t ?? 0, + }); + if (!o.ok) return o; + const a = appendEvidence(dir, minted.claim.id, o.outcome); + if (!a.ok) return a; + } + return { ok: true, id: minted.claim.id }; + } catch (err) { + if (process.env.FORGE_DEBUG === "1") + process.stderr.write(`forge ledger bridge: ${err?.message ?? err}\n`); + return { ok: false, reason: String(err?.message ?? err) }; + } +} + +/** Shadow-write one recall fact into the ledger that lives beside the recall store. + * @returns {{ok:boolean, reason?:string, id?:string, existed?:boolean}} */ +export function recordFactEvent(ledgerDir, name, text, t = 0) { + try { + const minted = factClaim(name, text, t); + if (!minted.ok) return { ok: false, reason: minted.reason }; + return putClaim(ledgerDir, minted.claim); + } catch (err) { + if (process.env.FORGE_DEBUG === "1") + process.stderr.write(`forge ledger bridge: ${err?.message ?? err}\n`); + return { ok: false, reason: String(err?.message ?? err) }; + } +} + +/** + * One-shot back-fill: import every existing lesson (with its evidence/contradiction + * counts as conservative `legacy.import` outcomes) and every recall fact into the + * ledger. Idempotent — content addressing dedupes claims, outcome hashes dedupe + * evidence — so re-running an import is always safe. + * @param {string} root repo root (lessons + repo ledger) + * @param {{recallStore?:string, recallLedger?:string, nowDay?:number}} opts + * @returns {{lessons:number, facts:number, outcomes:number, refused:string[]}} + */ +export function importLegacy(root, { recallStore, recallLedger, nowDay = 0 } = {}) { + const dir = repoLedger(root); + const refused = []; + let lessons = 0; + let facts = 0; + let outcomes = 0; + + for (const lesson of loadLessons(root)) { + const minted = lessonClaim(lesson, lesson.createdDay ?? 0); + if (!minted.ok) { + refused.push(`lesson ${lesson.id}: ${minted.reason}`); + continue; + } + const put = putClaim(dir, minted.claim); + if (!put.ok) { + refused.push(`lesson ${lesson.id}: ${put.reason}`); + continue; + } + if (!put.existed) lessons++; + // Aggregate counts become individual, decay-dated outcomes: confirms carry the + // last-confirmed day (what freshness keyed on), contradictions the created day + // (the legacy store kept no per-contradiction date — documented approximation). + /** @type {{result:"confirm"|"contradict", t:number, n:number}[]} */ + const synth = []; + for (let i = 0; i < (lesson.evidenceCount ?? 0); i++) + synth.push({ result: "confirm", t: lesson.lastConfirmedDay ?? 0, n: i }); + for (let i = 0; i < (lesson.contradictionCount ?? 0); i++) + synth.push({ result: "contradict", t: lesson.createdDay ?? 0, n: i }); + for (const s of synth) { + const o = outcomeRecord({ + oracle: "legacy.import", + result: s.result, + ref: `legacy:${lesson.id}#${s.result}${s.n}`, + t: s.t, + }); + if (!o.ok) continue; + const a = appendEvidence(dir, minted.claim.id, o.outcome); + if (a.ok && !a.deduped) outcomes++; + } + } + + if (recallStore) { + const ledgerDir = recallLedger ?? dir; + for (const slug of listFacts(recallStore)) { + const r = importFact(recallStore, ledgerDir, slug, nowDay); + if (r.ok && !r.existed) facts++; + else if (!r.ok) refused.push(`fact ${slug}: ${r.reason}`); + } + } + + reindex(dir, nowDay); + return { lessons, facts, outcomes, refused }; +} + +function importFact(store, ledgerDir, slug, t) { + try { + const raw = readFileSync(join(store, "facts", `${slug}.md`), "utf8"); + const m = raw.match(/^# (.*)\n\n([\s\S]*)$/); + const name = m ? m[1] : slug; + const text = (m ? m[2] : raw).trim(); + return recordFactEvent(ledgerDir, name, text, t); + } catch (err) { + return { ok: false, reason: String(err?.message ?? err) }; + } +} diff --git a/src/ledger_store.js b/src/ledger_store.js new file mode 100644 index 0000000..a3aff1f --- /dev/null +++ b/src/ledger_store.js @@ -0,0 +1,238 @@ +// forge ledger storage — the on-disk PCM ledger (docs/plans/substrate-v2/02-team-memory.md): +// one immutable canonical-JSON file per claim (sharded by id prefix), one append-only +// evidence log per claim, tombstones as marker files, and a generated human index. +// Every path is content-addressed, so two teammates' ledgers merge in git with zero +// conflicts (evidence logs use the union merge driver; see .gitattributes note below). +import { + appendFileSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { canonicalize, claimId, liveClaims, mergeStates, SECRET_RE, val } from "./ledger.js"; + +/** The canonical repo ledger. (recall's global store keeps its own sibling ledger.) */ +export const repoLedger = (root = process.cwd()) => join(root, ".forge", "ledger"); + +const claimPath = (dir, id) => join(dir, "claims", id.slice(0, 2), `${id}.json`); +const evidencePath = (dir, id) => join(dir, "evidence", `${id}.log`); +const tombPath = (dir, id) => join(dir, "tombstones", `${id}.json`); + +const readJson = (path) => { + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch { + return null; // one corrupt file must never take down the whole ledger + } +}; + +/** + * Persist a claim (idempotent — content-addressed, so rewriting the same claim is a + * no-op). Refuses id mismatches and secrets; stores canonical bytes only (evidence and + * tombstones live in their own append-only files, never inside the claim file). + * @returns {{ok:boolean, reason?:string, id?:string, existed?:boolean}} + */ +export function putClaim(dir, claim) { + if (!claim?.id || claim.id !== claimId(claim.kind, claim.body, claim.scope)) + return { ok: false, reason: "claim id does not match canonical content hash" }; + const text = canonicalize({ + body: claim.body, + kind: claim.kind, + provenance: claim.provenance ?? {}, + scope: claim.scope ?? {}, + v: claim.v ?? 1, + }); + if (SECRET_RE.test(text)) + return { ok: false, reason: "refused: claim looks like it contains a secret/credential" }; + const path = claimPath(dir, claim.id); + if (existsSync(path)) return { ok: true, id: claim.id, existed: true }; + mkdirSync(join(dir, "claims", claim.id.slice(0, 2)), { recursive: true }); + writeFileSync(path, `${text}\n`); + return { ok: true, id: claim.id, existed: false }; +} + +/** Append one evidence outcome (deduped by its content hash — append is idempotent). */ +export function appendEvidence(dir, id, outcome) { + if (!outcome?.h) return { ok: false, reason: "outcome missing content hash (use outcomeRecord)" }; + if (!existsSync(claimPath(dir, id))) + return { ok: false, reason: `no such claim in ledger: ${id}` }; + const existing = readEvidence(dir, id); + if (existing.some((e) => e.h === outcome.h)) return { ok: true, deduped: true }; + mkdirSync(join(dir, "evidence"), { recursive: true }); + appendFileSync(evidencePath(dir, id), `${canonicalize(outcome)}\n`); + return { ok: true, deduped: false }; +} + +/** All evidence outcomes for a claim (corrupt lines skipped, duplicates dropped). */ +export function readEvidence(dir, id) { + const path = evidencePath(dir, id); + if (!existsSync(path)) return []; + const seen = new Set(); + const out = []; + for (const line of readFileSync(path, "utf8").split("\n")) { + if (!line.trim()) continue; + try { + const o = JSON.parse(line); + if (o?.h && !seen.has(o.h)) { + seen.add(o.h); + out.push(o); + } + } catch {} + } + return out; +} + +/** Retract a claim (grow-only marker — the claim file stays, for audit). */ +export function tombstone(dir, id, { author = "", reason = "", t = 0 } = {}) { + if (!existsSync(claimPath(dir, id))) + return { ok: false, reason: `no such claim in ledger: ${id}` }; + mkdirSync(join(dir, "tombstones"), { recursive: true }); + const path = tombPath(dir, id); + if (!existsSync(path)) writeFileSync(path, `${canonicalize({ author, reason, t })}\n`); + return { ok: true }; +} + +/** Load the full ledger state {claims, evidence, tombstones} from disk. */ +export function loadState(dir) { + const state = { claims: {}, evidence: {}, tombstones: {} }; + const claimsRoot = join(dir, "claims"); + if (existsSync(claimsRoot)) { + for (const shard of readdirSync(claimsRoot).sort()) { + const shardDir = join(claimsRoot, shard); + for (const f of readdirSync(shardDir) + .filter((f) => f.endsWith(".json")) + .sort()) { + const c = readJson(join(shardDir, f)); + const id = f.replace(/\.json$/, ""); + // Verify the address on read: a tampered/corrupt claim is skipped, not trusted. + if (c && claimId(c.kind, c.body, c.scope) === id) { + state.claims[id] = { ...c, id }; + state.evidence[id] = readEvidence(dir, id); + } + } + } + } + const tombsRoot = join(dir, "tombstones"); + if (existsSync(tombsRoot)) { + for (const f of readdirSync(tombsRoot).filter((f) => f.endsWith(".json"))) { + const t = readJson(join(tombsRoot, f)); + if (t) state.tombstones[f.replace(/\.json$/, "")] = t; + } + } + return state; +} + +/** All claims with evidence + tombstones attached (the retrieval input). */ +export function loadClaims(dir) { + return liveClaims(loadState(dir)); +} + +/** Semilattice import: merge another ledger state into this directory (P2's `forge + * ledger merge` core). Idempotent; safe to re-run. */ +export function importState(dir, other) { + const merged = mergeStates(loadState(dir), other); + let claims = 0; + let outcomes = 0; + for (const c of Object.values(merged.claims)) { + const r = putClaim(dir, c); + if (r.ok && !r.existed) claims++; + for (const o of merged.evidence[c.id] ?? []) { + const a = appendEvidence(dir, c.id, o); + if (a.ok && !a.deduped) outcomes++; + } + } + for (const [id, t] of Object.entries(merged.tombstones)) tombstone(dir, id, t); + reindex(dir); + return { claims, outcomes }; +} + +/** Regenerate LEDGER.md — the human index (like recall's MEMORY.md). */ +export function reindex(dir, nowDay = 0) { + const claims = loadClaims(dir); + mkdirSync(dir, { recursive: true }); + const rows = claims + .filter((c) => !c.tombstone) + .map((c) => `- \`${c.id.slice(0, 12)}\` ${c.kind} · val ${val(c, nowDay).toFixed(2)}`); + writeFileSync( + join(dir, "LEDGER.md"), + ["# Proof-Carrying Memory ledger", "", ...rows, ""].join("\n"), + ); + return rows.length; +} + +/** + * Normal-form check (CI-friendly): every claim parses and matches its address, every + * evidence line parses with a valid shape and a ref, no secrets anywhere. + * @returns {{ok:boolean, claims:number, outcomes:number, issues:string[]}} + */ +export function verify(dir) { + const issues = []; + let claims = 0; + let outcomes = 0; + const claimsRoot = join(dir, "claims"); + if (existsSync(claimsRoot)) { + for (const shard of readdirSync(claimsRoot).sort()) { + for (const f of readdirSync(join(claimsRoot, shard)) + .filter((f) => f.endsWith(".json")) + .sort()) { + const raw = readFileSync(join(claimsRoot, shard, f), "utf8"); + const c = readJson(join(claimsRoot, shard, f)); + const id = f.replace(/\.json$/, ""); + if (!c) issues.push(`claim ${id}: unparseable`); + else if (claimId(c.kind, c.body, c.scope) !== id) issues.push(`claim ${id}: id mismatch`); + else claims++; + if (SECRET_RE.test(raw)) issues.push(`claim ${id}: contains secret-like content`); + } + } + } + const evRoot = join(dir, "evidence"); + if (existsSync(evRoot)) { + for (const f of readdirSync(evRoot).filter((f) => f.endsWith(".log"))) { + const id = f.replace(/\.log$/, ""); + for (const [n, line] of readFileSync(join(evRoot, f), "utf8").split("\n").entries()) { + if (!line.trim()) continue; + let o = null; + try { + o = JSON.parse(line); + } catch {} + if (!o?.h || !o.ref || !o.oracle) issues.push(`evidence ${id}:${n + 1}: invalid outcome`); + else outcomes++; + if (SECRET_RE.test(line)) issues.push(`evidence ${id}:${n + 1}: secret-like content`); + } + } + } + return { ok: issues.length === 0, claims, outcomes, issues }; +} + +/** Move dormant/tombstoned claim files to the attic (audit trail, never retrieved). */ +export function pruneToAttic(dir, id) { + const from = claimPath(dir, id); + if (!existsSync(from)) return { ok: false, reason: "no such claim" }; + mkdirSync(join(dir, "attic"), { recursive: true }); + renameSync(from, join(dir, "attic", `${id}.json`)); + return { ok: true }; +} + +/** Counts + val distribution for `forge ledger stats` and the dashboard. */ +export function stats(dir, nowDay = 0) { + const claims = loadClaims(dir); + const byKind = {}; + const buckets = { dormant: 0, uncertain: 0, trusted: 0 }; + for (const c of claims) { + byKind[c.kind] = (byKind[c.kind] ?? 0) + 1; + const v = val(c, nowDay); + if (v < 0.35) buckets.dormant++; + else if (v < 0.65) buckets.uncertain++; + else buckets.trusted++; + } + return { + total: claims.length, + tombstoned: claims.filter((c) => c.tombstone).length, + byKind, + val: buckets, + }; +} diff --git a/src/recall.js b/src/recall.js index 6b47a75..05e2b35 100644 --- a/src/recall.js +++ b/src/recall.js @@ -5,17 +5,13 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync import { homedir } from "node:os"; import { join } from "node:path"; -// Anything matching this is refused — store a pointer to where the secret lives instead. -// Two arms, both high-precision: -// 1) Known credential FORMATS this tool's users paste most — Anthropic sk-ant-, OpenAI sk-, -// GitHub ghp_/github_pat_, Slack xox*, Google AIza/ya29, AWS AKIA, JWTs, PEM blocks. -// 2) A secret-ish key ASSIGNED to a value — `password = "x"`, `SECRET_KEY: y`, `api_key=z`. -// The second arm deliberately requires the `: `/`=` + value, so a bare English mention -// ("implement password hashing", "rotate the secret", "the api key helper") is NOT refused — -// that over-broad word match previously gutted both the LLM proposer (adjudicate) and the -// memory store (recall/lessons) for a whole class of legitimate auth-related work. -export const SECRET_RE = - /(-----BEGIN |\bghp_[A-Za-z0-9]{16,}|\bgithub_pat_[A-Za-z0-9_]{20,}|\bsk-[A-Za-z0-9_-]{16,}|\bxox[baprs]-[A-Za-z0-9-]{10,}|\bAIza[0-9A-Za-z_-]{20,}|\bya29\.[A-Za-z0-9._-]+|\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}|AKIA[0-9A-Z]{16}|\b[\w-]*(?:api[_-]?key|secret|passwd|password|token)[\w-]*["']?\s*[:=]\s*["']?\S)/i; +// Secret-refusal now lives in the PCM ledger core (ledger.js) so NO claim kind can +// persist a credential; re-exported here because recall is where callers historically +// imported it from (lessons_store, guards, tests). See ledger.js for the precision +// rationale (credential formats + key-assigned-to-value, never a bare English mention). +import { SECRET_RE } from "./ledger.js"; + +export { SECRET_RE }; export function defaultStore() { return join(process.env.FORGE_HOME || join(homedir(), ".forge"), "recall"); diff --git a/test/ledger.test.js b/test/ledger.test.js new file mode 100644 index 0000000..21bd26f --- /dev/null +++ b/test/ledger.test.js @@ -0,0 +1,237 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + canonicalize, + claimId, + clusters, + jaccard, + liveClaims, + mergeStates, + mintClaim, + outcomeRecord, + rec, + retrieve, + score, + sketch, + val, +} from "../src/ledger.js"; +import { fakeAnthropic } from "./_fixtures.js"; + +// --- canonicalization & content addressing ----------------------------------------- + +test("canonicalize: key order never changes the bytes (id stability)", () => { + const a = canonicalize({ b: 1, a: [{ y: 2, x: 1 }], c: "s" }); + const b = canonicalize({ c: "s", a: [{ x: 1, y: 2 }], b: 1 }); + assert.equal(a, b); +}); + +test("canonicalize: drops undefined/function values, keeps null", () => { + assert.equal(canonicalize({ a: undefined, b: null, c: () => 1 }), '{"b":null}'); +}); + +test("claimId: pinned fixture — the protocol's address must never drift across versions", () => { + // If this fixture ever fails, existing ledgers on disk stop resolving. Bump v and + // write a migration before changing canonicalization or the id recipe. + const id = claimId("fact", { name: "n", text: "t" }, { level: "repo" }); + assert.match(id, /^[0-9a-f]{64}$/); + assert.equal(id, claimId("fact", { text: "t", name: "n" }, { level: "repo" })); +}); + +test("claimId: provenance and evidence never affect the address (teammates converge)", () => { + const a = mintClaim({ + kind: "fact", + body: { name: "x", text: "y" }, + provenance: { author: "alice" }, + t: 1, + }); + const b = mintClaim({ + kind: "fact", + body: { name: "x", text: "y" }, + provenance: { author: "bob" }, + t: 9, + }); + assert.ok(a.ok && b.ok); + assert.equal(a.claim.id, b.claim.id); +}); + +test("mintClaim: refuses secrets, unknown kinds, and non-object bodies", () => { + const s = mintClaim({ kind: "fact", body: { name: "k", text: fakeAnthropic() } }); + assert.equal(s.ok, false); + assert.match(s.reason, /secret/); + assert.equal(mintClaim({ kind: "nope", body: {} }).ok, false); + assert.equal(mintClaim({ kind: "fact", body: "text" }).ok, false); +}); + +test("outcomeRecord: requires a known oracle, a valid result, and a verifiable ref", () => { + assert.equal(outcomeRecord({ oracle: "vibes", result: "confirm", ref: "r" }).ok, false); + assert.equal(outcomeRecord({ oracle: "test.run", result: "maybe", ref: "r" }).ok, false); + assert.equal(outcomeRecord({ oracle: "test.run", result: "confirm", ref: "" }).ok, false); + const ok = outcomeRecord({ oracle: "test.run", result: "confirm", ref: "run:1", t: 3 }); + assert.ok(ok.ok); + assert.match(ok.outcome.h, /^[0-9a-f]{64}$/); + assert.equal(ok.outcome.w, 0.8); +}); + +// --- confidence: the decayed Beta posterior ----------------------------------------- + +const mkClaim = (evidence = []) => { + const m = mintClaim({ kind: "fact", body: { name: "f", text: "body" }, t: 0 }); + return { ...m.claim, evidence }; +}; +const ev = (result, t, oracle = "test.run") => + outcomeRecord({ oracle, result, ref: `r:${result}:${t}`, t }).outcome; + +test("val: fresh claim sits at the 0.5 prior; confirms raise; contradictions lower", () => { + assert.equal(val(mkClaim(), 0), 0.5); + assert.ok(val(mkClaim([ev("confirm", 0)]), 0) > 0.5); + assert.ok(val(mkClaim([ev("contradict", 0)]), 0) < 0.5); +}); + +test("val: monotone in confirmations (more independent evidence is never worse)", () => { + let prev = 0.5; + for (let n = 1; n <= 5; n++) { + const v = val(mkClaim(Array.from({ length: n }, () => ev("confirm", 0, "ci.run"))), 0); + assert.ok(v > prev, `val(${n} confirms)=${v} must exceed ${prev}`); + prev = v; + } +}); + +test("val: decays toward the PRIOR (uncertainty), never toward false", () => { + const confirmed = mkClaim([ev("confirm", 0), ev("confirm", 0)]); + const now = val(confirmed, 0); + const later = val(confirmed, 90); // two half-lives + const muchLater = val(confirmed, 900); + assert.ok(later < now, "unreviewed confirmation loses weight"); + assert.ok(later > 0.5, "decayed-but-confirmed stays above the prior"); + assert.ok(Math.abs(muchLater - 0.5) < 0.01, "fully decayed → back to uncertainty, not 0"); + // Same shape from below: an old contradiction also relaxes toward 0.5. + const contradicted = mkClaim([ev("contradict", 0)]); + assert.ok(val(contradicted, 900) > val(contradicted, 0)); +}); + +test("val: oracle weight matters — a human revert outweighs a behavioral signal", () => { + const human = val(mkClaim([ev("contradict", 0, "human.revert")]), 0); + const behav = val(mkClaim([ev("contradict", 0, "behavioral")]), 0); + assert.ok(human < behav, "stronger oracle pulls harder"); +}); + +test("rec: recency keys on the latest evidence, else the mint day", () => { + assert.equal(rec(mkClaim(), 0), 1); + assert.ok(rec(mkClaim(), 45) < rec(mkClaim(), 1)); + const fresh = mkClaim([ev("confirm", 40)]); + assert.ok(rec(fresh, 45) > rec(mkClaim(), 45), "new evidence refreshes recency"); +}); + +// --- similarity ---------------------------------------------------------------------- + +test("sketch/jaccard: identical text = 1, disjoint ≈ 0, near-duplicates score high", () => { + const a = sketch("always run the impacted tests before editing shared utils"); + assert.equal(jaccard(a, sketch("always run the impacted tests before editing shared utils")), 1); + assert.ok(jaccard(a, sketch("completely unrelated words about cooking pasta dinner")) < 0.15); + const near = sketch("always run the impacted tests before editing shared utilities"); + assert.ok(jaccard(a, near) > 0.5, "one-word change stays similar"); +}); + +test("sketch: deterministic across calls (no randomness — ids and sketches are stable)", () => { + assert.deepEqual(sketch("some stable text here"), sketch("some stable text here")); +}); + +test("clusters: near-duplicates group, distinct claims stay apart", () => { + const long = + "before renaming any exported symbol in the shared utilities package always query the " + + "atlas for reverse dependents and run the impacted test selection so silent breakage"; + const c1 = mintClaim({ + kind: "fact", + body: { name: "note", text: `${long} is impossible` }, + }).claim; + const c2 = mintClaim({ kind: "fact", body: { name: "note", text: `${long} is unlikely` } }).claim; + const c3 = mintClaim({ + kind: "fact", + body: { name: "note", text: "the deploy pipeline needs the staging flag set first" }, + }).claim; + const groups = clusters([c1, c2, c3], { tau: 0.5 }); + assert.equal(groups.length, 1); + assert.deepEqual(groups[0], [c1.id, c2.id].sort()); +}); + +// --- retrieval (Eq. 3) --------------------------------------------------------------- + +test("score: outcome-confirmed claims outrank merely-similar unconfirmed ones", () => { + const q = "renaming a shared symbol"; + const confirmed = { + ...mintClaim({ + kind: "fact", + body: { name: "a", text: "check callers before renaming a shared symbol" }, + t: 0, + }).claim, + evidence: [ev("confirm", 0), ev("confirm", 0, "human.accept")], + }; + const unconfirmed = mintClaim({ + kind: "fact", + body: { name: "b", text: "check callers before renaming a shared symbol" }, + t: 0, + }).claim; + assert.ok(score(q, confirmed, { nowDay: 0 }) > score(q, unconfirmed, { nowDay: 0 })); +}); + +test("retrieve: excludes tombstoned and dormant claims, caps at budget", () => { + const alive = mkClaim([ev("confirm", 0)]); + const dead = { ...mkClaim(), tombstone: { reason: "retracted", t: 0, author: "" } }; + const dormant = mkClaim(Array.from({ length: 4 }, (_, i) => ev("contradict", 0, "human.revert"))); + const out = retrieve("body", [alive, dead, dormant], { nowDay: 0, budget: 10 }); + assert.deepEqual( + out.map((r) => r.claim.id), + [alive.id], + ); + assert.equal(retrieve("body", [alive, alive, alive], { budget: 2 }).length, 2); +}); + +// --- the CRDT merge: the semilattice laws property-tested ---------------------------- + +const state = (claims, evidence = {}, tombstones = {}) => ({ + claims: Object.fromEntries(claims.map((c) => [c.id, c])), + evidence, + tombstones, +}); + +test("mergeStates: commutative, associative, idempotent — replicas converge in any order", () => { + const c1 = mintClaim({ kind: "fact", body: { name: "1", text: "one" }, t: 1 }).claim; + const c2 = mintClaim({ kind: "fact", body: { name: "2", text: "two" }, t: 2 }).claim; + const c3 = mintClaim({ + kind: "lesson", + body: { whatWentWrong: "w", correctedBehavior: "c", trigger: {} }, + t: 3, + }).claim; + const sA = state([c1, c2], { [c1.id]: [ev("confirm", 1)] }); + const sB = state([c2, c3], { [c1.id]: [ev("confirm", 1), ev("contradict", 2)] }); + const sC = state([c3], {}, { [c2.id]: { reason: "retracted", t: 4, author: "bob" } }); + + const canon = (s) => canonicalize(liveClaims(s)); + // commutativity + assert.equal(canon(mergeStates(sA, sB)), canon(mergeStates(sB, sA))); + // associativity + assert.equal( + canon(mergeStates(mergeStates(sA, sB), sC)), + canon(mergeStates(sA, mergeStates(sB, sC))), + ); + // idempotence + const m = mergeStates(sA, sB); + assert.equal(canon(mergeStates(m, m)), canon(m)); + assert.equal(canon(mergeStates(m, sA)), canon(m), "absorbing a subset is a no-op"); +}); + +test("mergeStates: evidence unions dedupe by hash; val is identical after any merge order", () => { + const c = mintClaim({ kind: "fact", body: { name: "x", text: "y" }, t: 0 }).claim; + const e1 = ev("confirm", 1); + const e2 = ev("contradict", 2); + const sA = state([c], { [c.id]: [e1] }); + const sB = state([c], { [c.id]: [e1, e2] }); + const ab = mergeStates(sA, sB); + const ba = mergeStates(sB, sA); + assert.equal(ab.evidence[c.id].length, 2, "duplicate outcome merged away"); + assert.equal( + val(liveClaims(ab)[0], 10), + val(liveClaims(ba)[0], 10), + "confidence is merge-order-independent", + ); +}); diff --git a/test/ledger_bridge.test.js b/test/ledger_bridge.test.js new file mode 100644 index 0000000..383ad40 --- /dev/null +++ b/test/ledger_bridge.test.js @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { recordContradiction, recordMistake } from "../src/cortex.js"; +import { val } from "../src/ledger.js"; +import { importLegacy, lessonClaim, recordFactEvent } from "../src/ledger_bridge.js"; +import { loadClaims, repoLedger } from "../src/ledger_store.js"; +import { newLesson } from "../src/lessons.js"; +import { save } from "../src/lessons_store.js"; +import { add as recallAdd } from "../src/recall.js"; + +const tmp = () => mkdtempSync(join(tmpdir(), "forge-bridge-")); +const ctx = { symbols: ["computeTax"], files: ["src/tax.ts"], keywords: [] }; +const strongSignals = [{ signal: "S1" }, { signal: "S6" }]; + +test("cortex dual-write: created lesson mints a claim with ZERO evidence (val = prior)", () => { + const root = tmp(); + const r = recordMistake(root, { + signals: strongSignals, + context: ctx, + nowDay: 1, + episodeId: "e1", + }); + assert.equal(r.action, "created"); + const claims = loadClaims(repoLedger(root)); + assert.equal(claims.length, 1); + assert.equal(claims[0].kind, "lesson"); + assert.equal(claims[0].body.legacyId, r.id); + assert.equal(claims[0].evidence.length, 0, "creation is not confirmation"); + assert.equal(val(claims[0], 1), 0.5); +}); + +test("cortex dual-write: a recurrence appends confirm evidence; a human reversal contradicts", () => { + const root = tmp(); + recordMistake(root, { signals: strongSignals, context: ctx, nowDay: 1, episodeId: "e1" }); + const r2 = recordMistake(root, { + signals: strongSignals, + context: ctx, + nowDay: 2, + episodeId: "e2", + }); + assert.equal(r2.action, "confirmed"); + const dir = repoLedger(root); + let claim = loadClaims(dir)[0]; + assert.equal(claim.evidence.length, 1); + assert.deepEqual( + { + oracle: claim.evidence[0].oracle, + result: claim.evidence[0].result, + ref: claim.evidence[0].ref, + }, + { oracle: "cortex.episode", result: "confirm", ref: "episode:e2" }, + ); + const before = val(claim, 2); + assert.ok(before > 0.5); + + recordContradiction(root, { context: ctx, nowDay: 3, episodeId: "e3" }); + claim = loadClaims(dir)[0]; + assert.equal(claim.evidence.length, 2); + assert.equal(claim.evidence[1].oracle, "human.revert"); + assert.ok(val(claim, 3) < before, "the reversal pulled confidence down"); +}); + +test("recordFactEvent: mints a fact claim; refuses secrets end-to-end", () => { + const dir = join(tmp(), "ledger"); + assert.equal(recordFactEvent(dir, "deploy", "staging needs FLAG=1 first", 4).ok, true); + const claims = loadClaims(dir); + assert.equal(claims.length, 1); + assert.deepEqual(claims[0].body, { name: "deploy", text: "staging needs FLAG=1 first" }); + const refused = recordFactEvent(dir, "creds", "api_key = topsecretvalue", 4); + assert.equal(refused.ok, false); + assert.equal(loadClaims(dir).length, 1); +}); + +test("importLegacy: back-fills lessons (counts → dated outcomes) and facts; re-run is a no-op", () => { + const root = tmp(); + // A legacy lesson with history: 3 confirmations, 1 contradiction. + const lesson = { + ...newLesson( + { + id: "lsn_computetax", + trigger: { symbols: ["computeTax"], files: [], keywords: [] }, + scope: "symbol", + whatWentWrong: "Edited computeTax without checking callers.", + correctedBehavior: "Check callers and tests before editing computeTax.", + }, + 10, + ), + evidenceCount: 3, + contradictionCount: 1, + lastConfirmedDay: 20, + status: "active", + }; + assert.equal(save(root, lesson).ok, true); + const brainStore = join(root, ".forge", "brain"); + assert.equal( + recallAdd(brainStore, "deploy order", "run migrations before the app roll").ok, + true, + ); + + const r = importLegacy(root, { + recallStore: brainStore, + recallLedger: repoLedger(root), + nowDay: 20, + }); + assert.deepEqual( + { lessons: r.lessons, facts: r.facts, outcomes: r.outcomes, refused: r.refused }, + { lessons: 1, facts: 1, outcomes: 4, refused: [] }, + ); + const claims = loadClaims(repoLedger(root)); + assert.equal(claims.length, 2); + const imported = claims.find((c) => c.kind === "lesson"); + assert.equal(imported.evidence.length, 4); + const v = val(imported, 20); + assert.ok(v > 0.5, `3 confirms vs 1 contradiction should trust the lesson (val=${v})`); + + const again = importLegacy(root, { + recallStore: brainStore, + recallLedger: repoLedger(root), + nowDay: 20, + }); + assert.deepEqual({ l: again.lessons, f: again.facts, o: again.outcomes }, { l: 0, f: 0, o: 0 }); +}); + +test("lessonClaim: id is stable under count/status churn — confirms don't re-mint", () => { + const base = newLesson( + { + id: "l1", + trigger: { symbols: ["x"] }, + scope: "symbol", + whatWentWrong: "w", + correctedBehavior: "c", + }, + 0, + ); + const churned = { ...base, evidenceCount: 9, status: "active", lastConfirmedDay: 99 }; + assert.equal(lessonClaim(base).claim.id, lessonClaim(churned).claim.id); +}); diff --git a/test/ledger_store.test.js b/test/ledger_store.test.js new file mode 100644 index 0000000..fa3ada8 --- /dev/null +++ b/test/ledger_store.test.js @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { mintClaim, outcomeRecord, val } from "../src/ledger.js"; +import { + appendEvidence, + importState, + loadClaims, + loadState, + pruneToAttic, + putClaim, + readEvidence, + reindex, + stats, + tombstone, + verify, +} from "../src/ledger_store.js"; + +const tmp = () => mkdtempSync(join(tmpdir(), "forge-ledger-")); +const fact = (name, text, t = 0) => mintClaim({ kind: "fact", body: { name, text }, t }).claim; +const ev = (result, ref, t = 0) => outcomeRecord({ oracle: "test.run", result, ref, t }).outcome; + +test("putClaim/loadClaims: roundtrip; rewrite of the same claim is a no-op", () => { + const dir = tmp(); + const c = fact("style", "tabs are actually spaces here"); + assert.deepEqual(putClaim(dir, c), { ok: true, id: c.id, existed: false }); + assert.equal(putClaim(dir, c).existed, true, "content-addressed → idempotent"); + const loaded = loadClaims(dir); + assert.equal(loaded.length, 1); + assert.equal(loaded[0].id, c.id); + assert.deepEqual(loaded[0].body, c.body); +}); + +test("putClaim: refuses an id that doesn't match the content (no forged addresses)", () => { + const dir = tmp(); + const c = { ...fact("a", "b"), id: "0".repeat(64) }; + const r = putClaim(dir, c); + assert.equal(r.ok, false); + assert.match(r.reason, /id does not match/); +}); + +test("appendEvidence: appends, dedupes by hash, requires the claim to exist", () => { + const dir = tmp(); + const c = fact("f", "text"); + putClaim(dir, c); + const o = ev("confirm", "run:1", 3); + assert.deepEqual(appendEvidence(dir, c.id, o), { ok: true, deduped: false }); + assert.deepEqual(appendEvidence(dir, c.id, o), { ok: true, deduped: true }); + assert.equal(readEvidence(dir, c.id).length, 1); + assert.equal(appendEvidence(dir, "f".repeat(64), o).ok, false, "evidence for a ghost claim"); + const loaded = loadClaims(dir)[0]; + assert.ok(val(loaded, 3) > 0.5, "evidence is attached on load"); +}); + +test("corrupt files are quarantined, not fatal: bad claim skipped, bad evidence line skipped", () => { + const dir = tmp(); + const good = fact("good", "content"); + putClaim(dir, good); + // Tampered claim: valid JSON at the right path, wrong content for its address. + writeFileSync( + join(dir, "claims", good.id.slice(0, 2), `${"e".repeat(64)}.json`), + '{"kind":"fact","body":{"name":"evil","text":"tampered"},"scope":{},"v":1}', + ); + appendEvidence(dir, good.id, ev("confirm", "run:1")); + writeFileSync( + join(dir, "evidence", `${good.id}.log`), + `${readFileSync(join(dir, "evidence", `${good.id}.log`), "utf8")}not json at all\n`, + ); + const loaded = loadClaims(dir); + assert.equal(loaded.length, 1, "tampered claim not trusted"); + assert.equal(loaded[0].evidence.length, 1, "corrupt evidence line skipped"); + const v = verify(dir); + assert.equal(v.ok, false, "verify reports what load silently skips"); + assert.ok(v.issues.some((i) => /id mismatch/.test(i))); + assert.ok(v.issues.some((i) => /invalid outcome/.test(i))); +}); + +test("tombstone: retracts from stats' live view but the claim file stays for audit", () => { + const dir = tmp(); + const c = fact("wrong", "this was retracted"); + putClaim(dir, c); + assert.equal(tombstone(dir, c.id, { reason: "superseded", t: 5 }).ok, true); + const loaded = loadClaims(dir); + assert.equal(loaded[0].tombstone.reason, "superseded"); + assert.equal(stats(dir).tombstoned, 1); +}); + +test("importState: semilattice import is idempotent and merges evidence", () => { + const a = tmp(); + const b = tmp(); + const shared = fact("shared", "both replicas know this"); + const onlyB = fact("only-b", "bob learned this alone"); + putClaim(a, shared); + appendEvidence(a, shared.id, ev("confirm", "run:a", 1)); + putClaim(b, shared); + appendEvidence(b, shared.id, ev("confirm", "run:a", 1)); // same outcome, both saw it + appendEvidence(b, shared.id, ev("contradict", "run:b", 2)); + putClaim(b, onlyB); + + const first = importState(a, loadState(b)); + assert.equal(first.claims, 1, "only bob's new claim is new"); + assert.equal(first.outcomes, 1, "only the unseen outcome lands"); + const again = importState(a, loadState(b)); + assert.deepEqual({ c: again.claims, o: again.outcomes }, { c: 0, o: 0 }, "re-import is a no-op"); + assert.equal(loadClaims(a).length, 2); + assert.equal(readEvidence(a, shared.id).length, 2); +}); + +test("reindex + stats: human index and counts reflect the live ledger", () => { + const dir = tmp(); + putClaim(dir, fact("one", "first fact")); + putClaim(dir, fact("two", "second fact")); + assert.equal(reindex(dir), 2); + assert.match(readFileSync(join(dir, "LEDGER.md"), "utf8"), /fact · val 0\.50/); + const s = stats(dir); + assert.equal(s.total, 2); + assert.deepEqual(s.byKind, { fact: 2 }); +}); + +test("pruneToAttic: moves the claim out of the live set but keeps the bytes", () => { + const dir = tmp(); + const c = fact("old", "long dormant"); + putClaim(dir, c); + assert.equal(pruneToAttic(dir, c.id).ok, true); + assert.equal(loadClaims(dir).length, 0); + assert.ok(readFileSync(join(dir, "attic", `${c.id}.json`), "utf8").includes("long dormant")); +});