From 7dec999effc5abd6a681f33dbb2b677350130f42 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:58:47 +0200 Subject: [PATCH 01/72] fix(ci): restore the executable bit on global/guards/run.mjs forge doctor's plugin-hook check access(X_OK)s every script a hook names. run.mjs landed in #140 as 100644, so the check warned on Linux and test/doctor.test.js failed on Node 20/22 for every push since. Windows ignores X_OK, which is why the Windows job stayed green. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 5 +++++ global/guards/run.mjs | 0 2 files changed, 5 insertions(+) mode change 100644 => 100755 global/guards/run.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 12d9f11..83d748f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,11 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- **CI is green again on Linux.** `global/guards/run.mjs` was committed without its + executable bit, so `forge doctor`'s plugin-hook check (which `access(X_OK)`s every script a + hook names) reported `warn` on Linux and failed `test/doctor.test.js` on Node 20 and 22 for + every push since #140. Windows ignores `X_OK`, which is why the Windows job stayed green. + The file now carries mode `100755`, like its sibling `secret-redact.mjs`. - **The test suite is hermetic.** It inherited the developer's environment, so it was green in CI and red on any machine where forge was actually installed and enabled — the two things a maintainer does. An exported `FORGE_LLM=1` both flipped the "llm off by default" diff --git a/global/guards/run.mjs b/global/guards/run.mjs old mode 100644 new mode 100755 From 1a823885e136181dd0a27871473488952474a7a2 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:59:36 +0200 Subject: [PATCH 02/72] docs: re-render the ARCHITECTURE.md repo-map block The committed block carried both sides of a merge (test at 105 and 106 files, two test->src edges) and a local-only .remember node. Regenerated with `forge docs render` from a clean LF checkout. Co-Authored-By: Claude Opus 5 --- ARCHITECTURE.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 123fc3e..f2267fd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -597,20 +597,18 @@ from the tree it describes. ```mermaid %%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','edgeLabelBackground':'#201a15','clusterBkg':'#171310','clusterBorder':'#4a3b2e','fontFamily':'ui-sans-serif, system-ui, sans-serif','fontSize':'14px'},'flowchart':{'curve':'basis','padding':10,'nodeSpacing':36,'rankSpacing':44}}}%% flowchart LR - test["test
105 files"] - test["test
106 files"] - src["src
97 files"] + test["test
109 files"] + src["src
98 files"] landing["landing
61 files"] research["research
35 files"] global["global
3 files"] bench["bench
2 files"] scripts["scripts
2 files"] - _remember[".remember
1 file"] docs["docs
1 file"] - test -- 201 --> src examples["examples
1 file"] - test -- 206 --> src + test -- 210 --> src bench -- 7 --> src + examples -- 4 --> src test -- 2 --> scripts scripts --> src src --> global From 49da2ef718ea13a29b60a244be628b0cff283473 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:17:06 +0200 Subject: [PATCH 03/72] fix(secrets): linear-time detection, close credential gaps, exempt integrity digests - B1: bound every quantifier in the key-assigned branch; the unbounded `\b[\w-]*KEY[\w-]*` was cubic (12 KB of `token-` took 40 s, past the hook timeout). 40 KB of every pathological shape now takes < 5 ms. - B2: detect and mask URL userinfo credentials, AWS ASIA keys, AUTH=/ CREDENTIALS= env values, Authorization headers, glpat- and TypeSafe keys; mask assigned values whole (past `/`, and below 8 chars in env form). - B4: npm/yarn/SRI sha*- and go.sum h1: digests are exempt from the entropy leg, so a lockfile commit is no longer refused as a leaked secret. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 27 ++++++++ src/secrets.js | 101 ++++++++++++++++++++++----- test/secrets.test.js | 162 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 273 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83d748f..b0e4f1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,33 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). were corrected: `doctor` asserted a global `failed === 0` to prove a local property about `na` rows, and a comment in `substrate` claimed no runner reaches the real CLI — the opposite of the truth, and the reason that file spent 85s on live calls. +- **Lockfile commits are no longer refused as leaking a secret.** The entropy leg flagged + content-integrity digests as secrets: 100% of package-lock and yarn.lock `sha512-` hashes, + 99% of SRI `sha384-` and 88% of go.sum `h1:` hashes. The real `left-pad@1.3.0` integrity + line was refused by the commit gate, which pushed users to `--no-verify` and switched off + the whole scan. These digest shapes are now exempt from the entropy leg only, so format + rules still apply. All four rows now score 0%. + +### Security + +- **The secret filter can no longer be made to hang.** The key-assigned branch of + `hasSecret`/`redactSecrets` (`\b[\w-]*KEY[\w-]*…`) backtracked cubically on long runs of + key-ish words: 6 KB of `token-token-…` took 5 s and 12 KB took 40 s. It runs on every tool + output via the secret-redact hook, so a large output outlived the hook timeout and passed + through unredacted. Every quantifier that could re-scan a run is now bounded; 40 KB of each + pathological shape (`token-`, `secret_`, `password=`, `auth=`, `x://a:`, …) now takes + under 5 ms, pinned by a timing regression test. +- **Credentials in URLs, STS keys and short or slash-bearing values are now caught and masked + whole.** URL userinfo (`postgres://`, `mongodb+srv://`, `amqp://`, `redis://:pw@`, + `https://oauth2:glpat-…@`), AWS `ASIA…` STS key ids, `AUTH=`/`CREDENTIALS=` env values, + `Authorization: ` headers, GitLab `glpat-` tokens and TypeSafe + `apikey_<40hex>_<64hex>` keys went from 0% to 100% detected and redacted in the review's + matrix. `DB_PASSWORD=hunter2` (under 8 chars) was detected but never masked; unquoted values + were masked only up to their first `/`, which left about 16 chars of 30% of AWS secrets + visible. Of 2,000 random `AWS_SECRET_ACCESS_KEY=<40 base64>` lines, 1,995 are now masked + whole, up from 1,038. The rest (about 0.2%) start with `/`, so they are read as a path. Ordinary URLs, + `$VAR` references, kwargs like `f(password=pw)` and counters like `MAX_TOKENS=4096` are + still left alone. ### Documentation diff --git a/src/secrets.js b/src/secrets.js index 9131120..efc499d 100644 --- a/src/secrets.js +++ b/src/secrets.js @@ -5,8 +5,9 @@ // // Two complementary detectors: // (i) FORMAT grammars — regexes over *documented* credential shapes (GitHub PAT, -// Anthropic/OpenAI sk-, Slack xox, Google AIza/ya29, JWT, AWS AKIA, PEM). These -// are parsers of known token grammars, kept as regex deliberately. +// GitLab PAT, Anthropic/OpenAI sk-, Slack xox, Google AIza/ya29, JWT, AWS +// AKIA/ASIA, PEM, credentials embedded in a URL). These are parsers of known +// token grammars, kept as regex deliberately. // (ii) ENTROPY scoring (src/math.js) — a graded gate for tokens no format list has // an entry for. A ≥20-char mixed-case-plus-digit token whose Shannon entropy // reaches random-credential territory is treated as a secret even when its @@ -14,29 +15,59 @@ // construction: they lack the mixed-case signal and are indistinguishable from // content hashes anyway — precision first (see the recall.js history: a bare // English mention like "implement password hashing" must NOT be refused). +// +// LINEAR TIME is a hard requirement: these regexes run on every tool output (the +// secret-redact hook), on staged diffs and on model prompts. Every quantifier that +// could re-scan a long run from many start positions is BOUNDED (`{0,64}`, never `*` +// next to another unbounded run) — an unbounded `[\w-]*KEY[\w-]*` was cubic on +// `token-token-…` (12 KB took 40 s, past the hook timeout, so the output passed +// through unredacted). import { shannonEntropy } from "./math.js"; -// (i) Known credential grammars. `-----BEGIN ` is the PEM header; the final branch -// is a secret-ish key ASSIGNED to a value (never a bare English mention). +// (i) Known credential grammars. `-----BEGIN ` is the PEM header. const FORMATS = [ "-----BEGIN ", "\\bghp_[A-Za-z0-9]{16,}", "\\bgithub_pat_[A-Za-z0-9_]{20,}", + "\\bglpat-[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}", + "\\bapikey_[0-9a-f]{40}_[0-9a-f]{64}\\b", ]; +// Grammars that only mean "credential" in their exact case: AWS STS temporary key +// ids (`ASIA…`). Under the /i flag the prefix would also match ordinary words. +const CASE_FORMATS = ["\\bASIA[0-9A-Z]{16}\\b"]; +// A credential embedded in a URL's userinfo: `scheme://user:PASSWORD@host` (postgres, +// mongodb+srv, amqp, redis `://:pw@`, `https://oauth2:glpat-…@`). Requires BOTH the +// `:` and the `@` — a plain `https://host:8080/path` or `ssh://git@host` never matches. +// Group 1 is kept on redaction (scheme + user stay readable), group 2 is the password. +const URL_CRED = "(\\b[a-z][a-z0-9+.-]{0,31}://[^\\s/:@]{0,256}:)([^\\s/@]{1,256})(?=@)"; +// Key names whose ASSIGNED value is a credential, matched anywhere in the key +// (DB_PASSWORD, apiKey, x-auth-token). The bounded suffix lets `SECRET_KEY_BASE=` match. const KEYISH = "(?:api[_-]?key|secret|passwd|password|token)"; -const ASSIGNED = `\\b[\\w-]*${KEYISH}[\\w-]*["']?\\s*[:=]\\s*["']?\\S`; +// Broader words ("auth", "credentials") are credential-bearing only in the env/query +// grammar (`AUTH=…`, `?auth=…`, `CREDENTIALS=…`) — never in prose or YAML ("auth: use +// OAuth"), and never as a prefix (`author=`): they must END the key, then a bare `=`. +const AUTHISH = "(?:auth(?:orization)?|credentials?)"; +// The final branches: a secret-ish key ASSIGNED to a value (never a bare English +// mention), and an `Authorization: ` header. +const ASSIGNED = `${KEYISH}[\\w-]{0,64}["']?\\s*[:=]\\s*["']?\\S`; +const AUTH_ASSIGNED = `${AUTHISH}["']?=["']?[^\\s"'&;]`; +const AUTH_HEADER = `\\bauthorization["']?\\s*:\\s*["']?(?:basic|bearer|digest|token)\\s+[^\\s"']{4}`; -/** The historical detection regex (formats + key-assigned-to-value), unchanged - * semantics — kept exported because tests and downstream code match against it. - * New code should call hasSecret(), which adds the entropy gate. */ -export const SECRET_RE = new RegExp(`(${[...FORMATS, ASSIGNED].join("|")})`, "i"); +/** The detection regex (formats + URL credentials + key-assigned-to-value) — kept + * exported because tests and downstream code match against it. New code should call + * hasSecret(), which adds the case-sensitive grammars and the entropy gate. */ +export const SECRET_RE = new RegExp( + `(${[...FORMATS, URL_CRED, ASSIGNED, AUTH_ASSIGNED, AUTH_HEADER].join("|")})`, + "i", +); +const CASE_RE = new RegExp(CASE_FORMATS.join("|")); // (ii) Entropy gate thresholds, exported so tests pin the calibration. Entropy alone // cannot separate long camelCase identifiers from keys (both clear 4 bits/char at @@ -53,6 +84,12 @@ export const ENTROPY_MIN_DIGIT_RUNS = 3; // file path splits into segments instead of scoring as one token — paths were the #1 // false positive (a redacted path corrupts the very tool output the guard protects). const TOKEN_RE = /[A-Za-z0-9+=_-]{20,}/g; +// Content-integrity digests are random-looking by design but PUBLIC: npm/yarn lockfile +// and SRI `sha512-` (also sha1/256/384), and go.sum `h1:`. The entropy +// leg flagged 90-100% of them, so every lockfile commit was refused. Such a digest is +// consumed whole (group 1) and never scored; format grammars still apply to it. +const INTEGRITY = "\\b(?:sha(?:1|256|384|512)-[A-Za-z0-9+/]{16,}={0,2}|h1:[A-Za-z0-9+/]{43}=)"; +const ENTROPY_SCAN_G = new RegExp(`(${INTEGRITY})|${TOKEN_RE.source}`, "g"); /** * Is this bare token secret-shaped by math alone? Requires all of: length, mixed @@ -76,9 +113,11 @@ export function isHighEntropyToken(tok) { */ export function hasSecret(text) { const s = String(text); - if (SECRET_RE.test(s)) return true; - const toks = s.match(TOKEN_RE); - return toks ? toks.some(isHighEntropyToken) : false; + if (SECRET_RE.test(s) || CASE_RE.test(s)) return true; + for (const m of s.matchAll(ENTROPY_SCAN_G)) { + if (!m[1] && isHighEntropyToken(m[0])) return true; + } + return false; } // Redaction machinery — used by the secret-redact guard (via node import) and any @@ -88,16 +127,34 @@ export function hasSecret(text) { // branch is case-insensitive too, and a detected-but-unredacted block would leak // straight through the guard ("one truth, two verbs" means these must agree). const PEM_BLOCK_G = /-----BEGIN [\s\S]*?(?:-----END [^\n-]*-----|$)/gi; +const URL_CRED_G = new RegExp(URL_CRED, "gi"); const FORMAT_G = new RegExp(FORMATS.slice(1).join("|"), "gi"); +const CASE_FORMAT_G = new RegExp(CASE_FORMATS.join("|"), "g"); // Redaction is deliberately NARROWER than detection here: detection (SECRET_RE's // ASSIGNED branch) refuses on any assigned value — cheap and conservative for a // store. Redaction rewrites live tool output, so it only masks values that look -// like opaque tokens (quoted, or an 8+ char credential-class run) — never a code -// expression: reading `const token = jwt.sign(payload, key)` must NOT be mangled. +// like opaque tokens — never a code expression: reading +// `const token = jwt.sign(payload, key)` must NOT be mangled. Two value grammars: +// - ENV_ASSIGNED_G: the env/shell/query form `…PASSWORD=value` — the key ENDS in a +// secret word and `=` is bare (no spaces). The WHOLE shell word is the value, at any +// length and with any punctuation (`DB_PASSWORD=hunter2`, `p@ssw0rd!2024`, AWS keys +// with `/`), as long as it ends at whitespace/quote/`;`/`&`/`|` — so a kwarg like +// `f(password=pw)` and a `$VAR` reference are left alone. An auth scheme word +// (`AUTH=Basic `) stays readable; the credential after it is masked. +// - ASSIGNED_G: any separator (`key = value`, `key: value`) — a quoted literal, or an +// 8+ char credential-class run (`/` allowed, so a base64 AWS secret is masked whole, +// not only up to its first `/`). A value that STARTS with `/` is a path unless it is +// high-entropy once its slashes are dropped (`secret_dir = /etc/app` stays readable). +const ENV_ASSIGNED_G = new RegExp( + `((?:${KEYISH}|${AUTHISH})["']?=(?:(?:basic|bearer|digest|token) +)?)(?![\\s$"'{])([^\\s"'\`;&|<>()]{1,512})(?=[\\s"'\`;&|]|$)`, + "gi", +); const ASSIGNED_G = new RegExp( - `(\\b[\\w-]*${KEYISH}[\\w-]*["']?\\s*[:=]\\s*)("[^"\\n]{4,}"|'[^'\\n]{4,}'|[A-Za-z0-9+=_-]{8,}(?![\\w(]))`, + `(${KEYISH}[\\w-]{0,64}["']?\\s*[:=]\\s*(?:(?:basic|bearer|token)\\s+)?)("[^"\\n]{4,512}"|'[^'\\n]{4,512}'|[A-Za-z0-9+/=_!@#%^&*~-][A-Za-z0-9+/=_!@#$%^&*~-]{7,511}(?![\\w(]))`, "gi", ); +const AUTH_HEADER_G = + /(\bauthorization["']?\s*:\s*["']?(?:basic|bearer|digest|token)\s+)([^\s"']{4,512})/gi; /** * Replace every detected secret with [REDACTED], preserving surrounding text. @@ -107,8 +164,18 @@ const ASSIGNED_G = new RegExp( export function redactSecrets(text) { let s = String(text); s = s.replace(PEM_BLOCK_G, "[REDACTED]"); + // URL userinfo before the key rules: `https://x-access-token:PW@host` must mask PW, + // not swallow `PW@host` as the value of a `token:` assignment. + s = s.replace(URL_CRED_G, "$1[REDACTED]"); s = s.replace(FORMAT_G, "[REDACTED]"); - s = s.replace(ASSIGNED_G, "$1[REDACTED]"); - s = s.replace(TOKEN_RE, (t) => (isHighEntropyToken(t) ? "[REDACTED]" : t)); + s = s.replace(CASE_FORMAT_G, "[REDACTED]"); + s = s.replace(AUTH_HEADER_G, "$1[REDACTED]"); + s = s.replace(ENV_ASSIGNED_G, "$1[REDACTED]"); + s = s.replace(ASSIGNED_G, (m, key, val) => + val.startsWith("/") && !isHighEntropyToken(val.replaceAll("/", "")) ? m : `${key}[REDACTED]`, + ); + s = s.replace(ENTROPY_SCAN_G, (t, integrity) => + !integrity && isHighEntropyToken(t) ? "[REDACTED]" : t, + ); return s; } diff --git a/test/secrets.test.js b/test/secrets.test.js index e60457b..4461cb8 100644 --- a/test/secrets.test.js +++ b/test/secrets.test.js @@ -132,3 +132,165 @@ test("redaction implies detection: anything redactSecrets rewrites, hasSecret ca } } }); + +// ── B1: linear time. The old ASSIGNED branch (`\b[\w-]*KEY[\w-]*…`) was cubic on long +// runs of key-ish words: 6 KB of `token-token-…` took 5 s, 12 KB took 40 s — past the +// hook timeout, so a large tool output passed through UNREDACTED. Sizes grow so a +// regression fails fast at the first over-budget size instead of hanging the suite. +test("hasSecret/redactSecrets: linear time on pathological inputs (ReDoS regression)", () => { + const BUDGET_MS = 500; + const units = [ + "token-", + "a-", + "a", + "-----BEGIN ", + "secret_", + "password=", + "token:", + "x://a:", + "auth=", + "Authorization: Bearer ", + "sha512-", + ]; + const time = (fn) => { + const t0 = process.hrtime.bigint(); + fn(); + return Number(process.hrtime.bigint() - t0) / 1e6; + }; + for (const u of units) { + for (const n of [2500, 5000, 10000, 20000, 40000]) { + const s = u.repeat(Math.ceil(n / u.length)).slice(0, n); + const has = time(() => hasSecret(s)); + const red = time(() => redactSecrets(s)); + assert.ok(has < BUDGET_MS, `hasSecret(${JSON.stringify(u)} ×${n}) took ${has.toFixed(0)}ms`); + assert.ok( + red < BUDGET_MS, + `redactSecrets(${JSON.stringify(u)} ×${n}) took ${red.toFixed(0)}ms`, + ); + } + } +}); + +// ── B2: detection gaps. Credential literals are assembled at runtime (see _fixtures.js). +const b64ish = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; +const pw = ["Xk9pLm2Q", "r7Ws4Tz8"].join(""); + +test("hasSecret/redactSecrets: URL userinfo credentials are caught and masked (B2)", () => { + for (const [url, keep] of [ + [`postgres://app:${pw}@db.example.com:5432/app`, "postgres://app:[REDACTED]@db.example.com"], + [`amqp://guest:${pw}@rabbit.internal:5672/vhost`, "amqp://guest:[REDACTED]@rabbit"], + [`redis://:${pw}@cache:6379/0`, "redis://:[REDACTED]@cache:6379/0"], + [ + `mongodb+srv://admin:${pw}@cluster0.abcde.mongodb.net/test`, + "mongodb+srv://admin:[REDACTED]@", + ], + [ + `https://oauth2:${["glpat", "AbCdEfGhIjKlMnOpQrSt"].join("-")}@gitlab.com/g/r.git`, + "@gitlab.com", + ], + [`DATABASE_URL=postgres://app:${pw}@db.example.com/app`, "DATABASE_URL=postgres://app:"], + ]) { + assert.ok(hasSecret(url), `detect: ${url}`); + const out = redactSecrets(url); + assert.equal(out.includes(pw), false, `redact: ${url} -> ${out}`); + assert.ok(out.includes(keep), `keeps context: ${out}`); + } +}); + +test("hasSecret/redactSecrets: ordinary URLs without userinfo are not secrets (B2 precision)", () => { + for (const url of [ + "https://example.com:8080/path?x=1", + "http://localhost:3000/api", + "ssh://git@github.com:22/org/repo.git", + "git@github.com:org/repo.git", + "http://[::1]:8080/", + "https://user@host.example.com/x", + ]) { + assert.equal(hasSecret(url), false, `no FP: ${url}`); + assert.equal(redactSecrets(url), url, `untouched: ${url}`); + } +}); + +test("hasSecret/redactSecrets: AWS STS, AUTH/CREDENTIALS env, TypeSafe keys (B2)", () => { + const asia = ["AS", "IA", "Q7K2M9X4B8N3P5R6"].join(""); + const hex40 = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b"; + const hex64 = "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"; + const typesafe = ["api", `key_${hex40}_${hex64}`].join(""); + for (const s of [ + `aws_session key ${asia}`, + "AUTH=Basic dXNlcjpwYXNzd29yZA==", + "CREDENTIALS=abcdefghijklmnop", + typesafe, + `Authorization: Bearer ${typesafe}`, + ]) { + assert.ok(hasSecret(s), `detect: ${s.slice(0, 30)}`); + const out = redactSecrets(s); + assert.match(out, /\[REDACTED\]/, `redact: ${s.slice(0, 30)}`); + for (const frag of [asia, "dXNlcjpwYXNzd29yZA", "abcdefghijklmnop", hex40]) { + assert.equal(out.includes(frag), false, `no leak of ${frag} in ${out}`); + } + } + // "auth"/"credentials" only count in the env/query grammar — never prose, YAML, or a + // longer word that merely starts with them. + for (const s of [ + "auth: use OAuth", + '"author": "someone"', + "author=someone", + 'fetch(u, { credentials: "include" })', + ]) { + assert.equal(hasSecret(s), false, `no FP: ${s}`); + } +}); + +test("redactSecrets: an assigned value is masked WHOLE — past '/' and below 8 chars (B2)", () => { + // Unquoted values used to be masked only up to the first '/', leaking ~16 chars of + // 30% of AWS secrets; values under 8 chars were detected but never masked. + for (const s of [ + `export AWS_SECRET_ACCESS_KEY=${b64ish}`, + `aws_secret_access_key = ${b64ish}`, + "DB_PASSWORD=hunter2", + "DB_PASSWORD=p@ssw0rd!2024", + "password: Tr0ub4dor&3", + "curl https://api.example.com/cb?state=x&access_token=abc123", + ]) { + const out = redactSecrets(s); + assert.ok(hasSecret(s), `detect: ${s}`); + for (const frag of ["K7MDENG", "bPxRfiCY", "wJalrX", "hunter2", "ssw0rd", "4dor", "abc123"]) { + assert.equal(out.includes(frag), false, `no leak of ${frag}: ${out}`); + } + } + assert.equal(redactSecrets("DB_PASSWORD=hunter2 ok"), "DB_PASSWORD=[REDACTED] ok"); + // …but never a variable reference, a kwarg, a path, or a counter that merely has a + // secret word in its NAME. + for (const s of [ + ["DB_PASSWORD=$", "{DB_PASSWORD}"].join(""), // a shell expansion, not a value + "export TOKEN=$1", + "f(password=pw)", + "secret_dir = /etc/app/config", + "MAX_TOKENS=4096", + "TOKEN_TTL=3600", + "token = process.env.TOKEN", + "password: string;", + ]) { + assert.equal(redactSecrets(s), s, `untouched: ${s}`); + } +}); + +// ── B4: content-integrity digests are public, random-looking by design. The entropy leg +// flagged 90-100% of lockfile/SRI/go.sum hashes, so every lockfile commit was refused. +test("hasSecret/redactSecrets: lockfile / SRI / go.sum integrity digests are not secrets (B4)", () => { + const leftPad = + '"integrity": "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==",'; + for (const line of [ + leftPad, // the real left-pad@1.3.0 package-lock line + " integrity sha512-+SEC/mFk1a+5mvUANZgbZTaiZXs1nj4iMhL/PHiqDT5TPUEPFIlliEtkKKZB4N862yylHC3UI+/Sj2I0HJEqhA==", + '', + "github.com/foo/bar v1.2.3 h1:Zq7Rt2Xk9Lp4Vm1Nc8Yb5Ws3Hd6Fg0Aa1Bb2Cc3Dd4E=", + '"integrity": "sha1-Zq7Rt2Xk9Lp4Vm1Nc8Yb5Ws3Hd6=",', + ]) { + assert.equal(hasSecret(line), false, `no FP: ${line.slice(0, 40)}`); + assert.equal(redactSecrets(line), line); + } + // The exemption is shape-bound: a real token beside a digest is still caught. + assert.ok(hasSecret(`${leftPad} ${fakeGithubPat()}`)); +}); From e4f499eabeed2d25e8cd565f481eaaa8970ca16e Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:18:27 +0200 Subject: [PATCH 04/72] fix(consensus): make the verify --deep residual dependence-aware The silent-miss residual multiplied (1 - w_j) over every lens that "ran": precision-style weights were used as catch probabilities, lenses aimed at disjoint defect classes were treated as independent tries at one defect, and lenses that ran over nothing (empty diff) still claimed coverage. With tests never run and an empty diff it reported 0.042. Each lens now carries a target class and an assumed catch probability in its own column. Same-class lenses combine as nested checks (1 - c_max, the dependence-free bound from review F2), a lens that examined no input catches nothing, and the residual is the worst class, with residualByClass in the provenance. That case now reports 1. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 10 ++++ docs/GUIDE.md | 15 +++-- src/consensus.js | 126 ++++++++++++++++++++++++++++++++--------- test/consensus.test.js | 78 +++++++++++++++++++++++-- 4 files changed, 192 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83d748f..8c080ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,16 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). were corrected: `doctor` asserted a global `failed === 0` to prove a local property about `na` rows, and a comment in `substrate` claimed no runner reaches the real CLI — the opposite of the truth, and the reason that file spent 85s on live calls. +- **`verify --deep` no longer claims coverage it never had.** The `residual` silent-miss + bound multiplied `∏(1 − wⱼ)` over every lens that "ran": it used the precision-style lens + weights as catch probabilities, multiplied checks aimed at disjoint defect classes as if + they were independent tries at one defect, and counted lenses that ran over nothing. With + the tests never run and an empty diff it reported **0.042** — 96% coverage from zero + checks. Each lens now names its target class and an assumed catch probability in its own + `catch` column; same-class lenses combine as nested checks (`1 − c_max`, review F2), a lens + that examined no input catches nothing, and the figure is the worst class, with + `residualByClass` in the provenance. The same case now reports **1**; a typical clean run + reports 0.7 instead of 0.005. ### Documentation diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 8c1bec9..63f33e5 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -570,10 +570,15 @@ the way the lesson miner scores mistakes: a noisy-OR **defect risk score (heuris `p = 1 − ∏(1 − wᵢsᵢ)` (shown as `P(defect)` in the CLI), with a **cross-family gate**, so any number of correlated structural signals stays advisory while a failing test suite or a leaked secret blocks on its own. `p` is a calibrated heuristic, not a measured probability -of defect. Every run reports the `residual` `∏(1 − cⱼ)` over the lenses that actually ran -— the **remaining unchecked weight**, i.e. how much silent-miss weight a PASS still leaves -uncovered — and extends `.forge/provenance.json` with the per-lens evidence plus one -`stage:"verify"` metrics record. +of defect. Every run reports the `residual` — the **remaining unchecked weight**, i.e. how +much silent-miss weight a PASS still leaves uncovered. It is dependence-aware: each lens +targets one defect class (behavior, symbol, dependents, docs, secret) with an assumed catch +probability `cⱼ` (its own column, not the precision weight `w`); lenses on the same class +are treated as nested checks (`1 − c_max`, never a product), a lens that examined no input +catches nothing, and the reported figure is the worst class (`residualByClass` has each). +An empty diff with no test run therefore reports `1`, not a near-zero product. Each run +extends `.forge/provenance.json` with the per-lens evidence plus one `stage:"verify"` +metrics record. `--llm` (or `FORGE_LLM=1`) adds the reviewer lens: three independent model samples over the added lines, strict-majority vote, abstaining honestly when fewer than half @@ -593,7 +598,7 @@ $ forge verify --deep ! dependents of the changed code are not in this diff: src/route.js P(defect): █░░░░░░░░░ 0.07 (families: structural) - residual: 0.005 — Theorem-D silent-miss bound + residual: 0.700 — Theorem-D silent-miss bound PASS ``` diff --git a/src/consensus.js b/src/consensus.js index d5508f4..1db6810 100644 --- a/src/consensus.js +++ b/src/consensus.js @@ -13,9 +13,10 @@ // // Mizan (weighed judgment — a philosophical/ethical framing, not a technical guarantee): // the verdict ships WITH its evidence. Every lens reports whether it ran and what it saw, -// and the remaining-unchecked-weight bound ∏ⱼ(1 − cⱼ) over the lenses that actually ran -// (the field is `residual`) states how much silent-miss weight remains even on PASS — a -// green light is an evidenced heuristic claim, never a vibe, and never a proof. The reviewer lens (LLM +// and the silent-miss bound `residual` (the worst defect class's 1 − best catch among the +// lenses that examined real input — see silentMissBound) states how much silent-miss +// weight remains even on PASS — a green light is an evidenced heuristic claim, never a +// vibe, and never a proof. The reviewer lens (LLM // majority-of-N) is opt-in, fail-safe, and can never block alone: it is a proposer // in the adjudicate.js sense, one voice among deterministic checks. import { mkdirSync, writeFileSync } from "node:fs"; @@ -31,39 +32,92 @@ import { verify } from "./verify.js"; /** * Lens taxonomy — mirrors lessons.js SIGNALS / ledger.js ORACLES: `weight` = prior - * that a firing lens reflects a real defect (and the Theorem-D catch probability cⱼ - * of a lens that ran); `family` powers the cross-family gate; `solo: true` = trusted - * to block on its own (only the project's own failing tests and a leaked secret - * qualify). Everything structural — and the model reviewer — needs a second family. + * that a FIRING lens reflects a real defect (precision — feeds `p`); `family` powers the + * cross-family gate; `solo: true` = trusted to block on its own (only the project's own + * failing tests and a leaked secret qualify). Everything structural — and the model + * reviewer — needs a second family. + * + * `target` + `catch` feed the silent-miss bound (`residual`), and are deliberately NOT + * the weight: `catch` is the Theorem-D cⱼ — P(this lens flags a defect | a defect of its + * `target` class is in the diff), i.e. recall, where `weight` is precision. They are + * ASSUMED priors, not measured recall, kept modest on purpose: a lens only sees its own + * class, and a proxy check (docsdrift: "no doc moved") is satisfied by an unrelated doc + * edit, so its recall for the real miss (a stale doc) is lower than its detection of the + * proxy (review F3). */ export const LENSES = { - tests: { weight: 0.8, family: "outcome", solo: true }, // the project's own suite failed - symbols: { weight: 0.4, family: "structural" }, // calls to symbols defined nowhere - impact: { weight: 0.35, family: "structural" }, // atlas dependents the diff never touched - docsdrift: { weight: 0.3, family: "structural" }, // code moved, no doc artifact moved - secrets: { weight: 0.9, family: "security", solo: true }, // secret-shaped token in added lines - speclock: { weight: 0.4, family: "structural" }, // a spec still claims a dropped symbol - reviewer: { weight: 0.3, family: "model" }, // N-sample LLM majority — never solo + // the project's own suite failed — catches a behavioral defect only where it has coverage + tests: { weight: 0.8, family: "outcome", solo: true, target: "behavior", catch: 0.6 }, + // calls to symbols defined nowhere — exact for static calls, blind to dynamic dispatch + symbols: { weight: 0.4, family: "structural", target: "symbol", catch: 0.6 }, + // atlas dependents the diff never touched — only as good as the graph's resolved edges + impact: { weight: 0.35, family: "structural", target: "dependents", catch: 0.4 }, + // code moved, no doc artifact moved — a proxy; any doc edit satisfies it + docsdrift: { weight: 0.3, family: "structural", target: "docs", catch: 0.3 }, + // secret-shaped token in added lines — known formats + entropy; novel formats slip + secrets: { weight: 0.9, family: "security", solo: true, target: "secret", catch: 0.6 }, + // a spec still claims a dropped symbol — exact for locked specs, blind to unlocked ones + speclock: { weight: 0.4, family: "structural", target: "docs", catch: 0.6 }, + // N-sample LLM majority — never solo; judges the same behavior class the tests do + reviewer: { weight: 0.3, family: "model", target: "behavior", catch: 0.3 }, }; +/** The defect classes the lenses target — the unit the silent-miss bound is taken over. */ +export const TARGET_CLASSES = [...new Set(Object.values(LENSES).map((l) => l.target))]; + /** A firing consensus below this P(defect) stays advisory — same bar as lessons.js classify. */ export const BLOCK_THRESHOLD = 0.5; /** - * @typedef {{lens: string, ran?: boolean, s?: number}} LensEvent - * `ran !== false` means the lens executed; `s` in [0,1] is its signal strength - * (0 = clean). Unknown lens names are ignored (a bad event can't corrupt the verdict). + * @typedef {{lens: string, ran?: boolean, examined?: boolean, s?: number}} LensEvent + * `ran !== false` means the lens executed; `examined === false` means it executed over + * NOTHING (no changed files, no added lines, no symbols) — it can't have caught + * anything, so it claims no coverage. `s` in [0,1] is its signal strength (0 = clean). + * Unknown lens names are ignored (a bad event can't corrupt the verdict). */ +/** + * The silent-miss bound: P(every lens misses | the diff holds a defect), dependence-aware. + * + * Assumptions (review E1/F2 — Theorem D Eq. 5 multiplied ∏(1 − cⱼ) across every lens, + * which assumes the lenses are independent tries at the SAME defect; they are not): + * - A lens only ever catches defects of its own `target` class. Lenses aimed at different + * classes never combine — a secret scan says nothing about a behavioral bug. + * - Within a class, lenses are NOT assumed independent (they read the same diff, and + * checks of one event are positively correlated). The only dependence-free bound is + * P(all miss) ≤ minⱼ P(miss) = 1 − c_max — the nested/identical-check case (F2). + * - The mix of defect classes is unknown, so the reported figure is the WORST class: + * residual = max over classes of (1 − c_max). A class no lens examined is 1 — so an + * empty diff with no test run claims no coverage at all (it was 0.042 under Eq. 5). + * - The agent's own miss rate (1 − p in Theorem D) is not known here and is not folded + * in; the figure is the checks' share only. `catch` values are assumed priors (LENSES). + * @param {LensEvent[]} events + * @returns {{residual:number, byClass:Record}} + */ +export function silentMissBound(events) { + /** @type {Record} */ + const best = Object.fromEntries(TARGET_CLASSES.map((k) => [k, 0])); + for (const e of events ?? []) { + const lens = e && LENSES[e.lens]; + if (!lens || e.ran === false || e.examined === false) continue; // c = 0: caught nothing + best[lens.target] = Math.max(best[lens.target], lens.catch); + } + /** @type {Record} */ + const byClass = {}; + for (const k of TARGET_CLASSES) byClass[k] = 1 - best[k]; + return { residual: Math.max(...Object.values(byClass)), byClass }; +} + /** * Aggregate lens events — byte-for-byte the scoreMistake shape (lessons.js): * noisy-OR over firing lenses (bounded in [0,1), so many weak signals can't fake * one strong one) + the cross-family gate. `p` is the defect risk score (heuristic). - * `residual` is the remaining-unchecked-weight bound ∏ⱼ(1 − cⱼ) over every lens that - * RAN (firing or clean): the share of silent-miss weight a PASS still leaves uncovered. + * `residual` is the silent-miss bound (silentMissBound): the share of silent-miss + * weight a PASS still leaves uncovered, per defect class in `residualByClass`. * @param {LensEvent[]} events - * @returns {{p:number, fires:boolean, families:string[], residual:number, block:boolean}} - * `p` = defect risk score (heuristic); `residual` = remaining unchecked weight. + * @returns {{p:number, fires:boolean, families:string[], residual:number, + * residualByClass:Record, block:boolean}} + * `p` = defect risk score (heuristic); `residual` = silent-miss bound (heuristic priors). */ export function aggregate(events) { const ran = (events ?? []).filter((e) => e && LENSES[e.lens] && e.ran !== false); @@ -76,8 +130,15 @@ export function aggregate(events) { const families = [...new Set(firing.map((e) => LENSES[e.lens].family))]; const soloOk = firing.some((e) => LENSES[e.lens].solo); const fires = families.length >= 2 || soloOk; - const residual = ran.reduce((acc, e) => acc * (1 - LENSES[e.lens].weight), 1); - return { p, fires, families, residual, block: fires && p >= BLOCK_THRESHOLD }; + const { residual, byClass } = silentMissBound(ran); + return { + p, + fires, + families, + residual, + residualByClass: byClass, + block: fires && p >= BLOCK_THRESHOLD, + }; } // --------------------------------------------------------------------------- @@ -95,12 +156,15 @@ export function testsLens(tests) { }; } -/** symbols — verify()'s hallucinated-symbol heuristic as a structural lens. */ -export function symbolsLens(unknown) { +/** symbols — verify()'s hallucinated-symbol heuristic as a structural lens. `checked` is + * how many called symbols verify() looked up (provenance.symbolsChecked); with none + * checked the lens examined nothing and claims no coverage (unknown count → assume none). */ +export function symbolsLens(unknown, checked) { const list = Array.isArray(unknown) ? unknown : []; return { lens: "symbols", ran: true, + examined: list.length > 0 || Number(checked) > 0, s: list.length ? 1 : 0, unknown: list.slice(0, 12), }; @@ -129,6 +193,7 @@ export function impactLens(atlas, changedFiles = []) { return { lens: "impact", ran: true, + examined: changedFiles.some((f) => classifyPath(f) === "code"), // traversed from ≥1 code file s: clamp01(dependents.size / 5), dependents: list, }; @@ -148,6 +213,7 @@ export function docsDriftLens(changedFiles = []) { return { lens: "docsdrift", ran: true, + examined: changedFiles.length > 0, s: drifted ? 1 : 0, codeFiles: code.slice(0, 10), }; @@ -163,6 +229,7 @@ export function secretsLens(added) { return { lens: "secrets", ran: true, + examined: text.trim().length > 0, s: hasSecret(text) && redactSecrets(text) !== text ? 1 : 0, }; } @@ -231,6 +298,7 @@ export function reviewerLens({ files = [], added = "", n = 3, llm, run } = {}) { return { lens: "reviewer", ran: true, + examined: files.length > 0 || String(added).trim().length > 0, // judged a real diff s: defect ? defects / votes.length : 0, verdict: defect ? "defect" : "pass", votes, @@ -305,7 +373,7 @@ export function verifyDeep({ } catch {} const lenses = [ testsLens(core.tests), - symbolsLens(core.unknown), + symbolsLens(core.unknown, core.provenance?.symbolsChecked), impactLens(atlas, changed), docsDriftLens(changed), secretsLens(added), @@ -327,15 +395,21 @@ export function verifyDeep({ lenses: lenses.map((l) => ({ lens: l.lens, ran: l.ran !== false, + examined: l.ran !== false && l.examined !== false, // additive: ran over real input s: round4(l.s ?? 0), weight: LENSES[l.lens].weight, family: LENSES[l.lens].family, + target: LENSES[l.lens].target, + catch: LENSES[l.lens].catch, })), findings, p: round4(verdict.p), families: verdict.families, fires: verdict.fires, residual: round4(verdict.residual), + residualByClass: Object.fromEntries( + Object.entries(verdict.residualByClass).map(([k, v]) => [k, round4(v)]), + ), block: verdict.block, status, // additive: the four-state deep verdict (RA-01) }; diff --git a/test/consensus.test.js b/test/consensus.test.js index 38784a9..5c5dd95 100644 --- a/test/consensus.test.js +++ b/test/consensus.test.js @@ -13,8 +13,10 @@ import { parseReviewProposal, reviewerLens, secretsLens, + silentMissBound, speclockLens, symbolsLens, + TARGET_CLASSES, testsLens, verifyDeep, } from "../src/consensus.js"; @@ -89,17 +91,76 @@ test("aggregate: P(defect) stays bounded < 1 with every lens firing", () => { assert.ok(r.p < 1); }); -test("aggregate: residual is ∏(1−w) over lenses that RAN — clean lenses count, skipped don't", () => { +test("LENSES: catch (recall prior) is its own column, never the precision weight", () => { + for (const [name, l] of Object.entries(LENSES)) { + assert.ok(l.catch > 0 && l.catch < 1, `${name} catch bounded`); + assert.ok(TARGET_CLASSES.includes(l.target), `${name} targets a known class`); + } + // tests 0.8 / secrets 0.9 are precision priors; a lens's recall for its class is not that + assert.notEqual(LENSES.tests.catch, LENSES.tests.weight); + assert.notEqual(LENSES.secrets.catch, LENSES.secrets.weight); +}); + +test("residual regression (E1): tests never ran + empty diff claims NO coverage, not 0.042", () => { + // Eq. 5 multiplied (1−0.4)(1−0.3)(1−0.9) = 0.042 over three lenses that "ran" on nothing. const r = aggregate([ + testsLens({ ran: false }), + symbolsLens([]), + docsDriftLens([]), + secretsLens(""), + ]); + assert.equal(r.residual, 1, "no lens examined any input — nothing can have been caught"); + for (const k of TARGET_CLASSES) assert.equal(r.residualByClass[k], 1, `${k} unchecked`); + const none = aggregate([{ lens: "tests", ran: false }]); + assert.equal(none.residual, 1, "nothing ran → no coverage claimed"); +}); + +test("residual: disjoint defect classes never multiply — the worst class is the bound", () => { + const events = [ { lens: "tests", s: 0 }, { lens: "symbols", s: 0 }, + { lens: "impact", s: 0 }, + { lens: "docsdrift", s: 0 }, + { lens: "secrets", s: 0 }, { lens: "reviewer", ran: false }, - ]); + ]; + const r = aggregate(events); assert.equal(r.p, 0); assert.equal(r.fires, false); - assert.ok(Math.abs(r.residual - 0.2 * 0.6) < 1e-9, "0.2·0.6 — reviewer skipped"); - const none = aggregate([{ lens: "tests", ran: false }]); - assert.equal(none.residual, 1, "nothing ran → no coverage claimed"); + const eq5 = events + .filter((e) => e.ran !== false) + .reduce((acc, e) => acc * (1 - LENSES[e.lens].catch), 1); + assert.ok(r.residual > eq5, "a secret scan says nothing about a behavioral bug"); + const worst = Math.max(...TARGET_CLASSES.map((k) => r.residualByClass[k])); + assert.equal(r.residual, worst); + assert.ok(Math.abs(r.residualByClass.behavior - (1 - LENSES.tests.catch)) < 1e-12); + assert.ok(Math.abs(r.residualByClass.docs - (1 - LENSES.docsdrift.catch)) < 1e-12); +}); + +test("residual: same-class checks are nested, not independent — 1 − c_max (review F2)", () => { + const both = silentMissBound([ + { lens: "tests", s: 0 }, + { lens: "reviewer", s: 0 }, + ]); + const cMax = Math.max(LENSES.tests.catch, LENSES.reviewer.catch); + assert.ok(Math.abs(both.byClass.behavior - (1 - cMax)) < 1e-12); + const independent = (1 - LENSES.tests.catch) * (1 - LENSES.reviewer.catch); + assert.ok(both.byClass.behavior > independent, "no credit for a correlated second look"); + // a lens that ran over nothing (examined:false) catches nothing: c = 0 + const idle = silentMissBound([{ lens: "secrets", ran: true, examined: false, s: 0 }]); + assert.equal(idle.byClass.secret, 1); +}); + +test("lens builders report whether they examined real input", () => { + assert.equal(symbolsLens([]).examined, false, "unknown checked count → no coverage claimed"); + assert.equal(symbolsLens([], 0).examined, false); + assert.equal(symbolsLens([], 7).examined, true); + assert.equal(symbolsLens(["ghostFn"]).examined, true); + assert.equal(docsDriftLens([]).examined, false); + assert.equal(docsDriftLens(["README.md"]).examined, true); + assert.equal(secretsLens("").examined, false); + assert.equal(secretsLens(" \n\t").examined, false); + assert.equal(secretsLens("const x = 1;").examined, true); }); test("aggregate: clean lenses contribute no family; unknown lens names are ignored", () => { @@ -271,11 +332,16 @@ test("verifyDeep: clean diff passes, persists provenance.deep + one verify metri assert.equal(r.ok, true); assert.deepEqual(r.findings, []); assert.equal(r.p, 0); - assert.ok(r.residual > 0 && r.residual < 1, "some lenses ran — coverage is claimed"); const prov = JSON.parse(readFileSync(join(dir, ".forge", "provenance.json"), "utf8")); assert.equal(prov.deep.block, false); assert.ok(Array.isArray(prov.deep.lenses) && prov.deep.lenses.length === 7); assert.equal(prov.deep.residual, r.residual); + assert.ok(prov.deep.residualByClass.behavior < 1, "the suite ran — behavior coverage claimed"); + assert.ok(prov.deep.residualByClass.secret < 1, "added lines were scanned"); + // no atlas → impact abstained, and no symbol was checked: those classes claim nothing, + // so the worst-class bound honestly stays at 1 instead of multiplying the rest down + assert.equal(prov.deep.residualByClass.dependents, 1); + assert.equal(r.residual, 1); const metrics = readFileSync(join(dir, ".forge", "metrics.jsonl"), "utf8") .trim() .split("\n") From 4ef95d04f98401b9c47f7157c72134fc96c8618b Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:20:30 +0200 Subject: [PATCH 05/72] fix(ledger): torn log lines, NFC key canonicalization, MCP error replies - Every ledger log append terminates a torn final line first, so a record appended after a crash mid-write is no longer glued onto the fragment and silently lost while append reports ok:true. - canonicalize() normalizes object keys to NFC before sorting, so an NFD key no longer produces bytes that fail the claim's own address check on reload. - cortex-mcp answers a throwing tool handler with a JSON-RPC -32603 error instead of swallowing it and leaving the client hanging. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 17 +++++++++++++++++ src/cortex_mcp.js | 29 +++++++++++++++++++++++++++-- src/ledger.js | 17 +++++++++++++---- src/ledger_store.js | 33 ++++++++++++++++++++++++++++++--- test/cortex_mcp.test.js | 36 +++++++++++++++++++++++++++++++++++- test/ledger.test.js | 13 +++++++++++++ test/ledger_store.test.js | 16 ++++++++++++++++ 7 files changed, 151 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83d748f..a9eb533 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,23 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- **A torn ledger line no longer swallows the next record.** A process killed mid-append (or a + union merge that dropped the trailing newline) left a final line without `\n`; the next + `appendEvidence` was glued onto it, became one unparseable line, and vanished from every read + while the append still returned `ok:true` — the repro showed `[run-1]` visible after + appending `run-3`. Every ledger log append (evidence, provenance, tombstones, quarantine) now + terminates a torn final line first: `[run-1, run-3]`, and a re-append dedupes. +- **Claim canonicalization normalizes keys before sorting them.** Keys were sorted by their raw + spelling and NFC-normalized afterwards, so an NFD key (`e` + combining accent) sorted before + `f` while its NFC twin sorts after it. A claim minted with such a key was written with one + byte order and re-hashed with another on reload: `loadClaims` saw 0 claims and `verify` + reported an id mismatch. Keys are now normalized first; the pinned ASCII fixture ids are + unchanged. +- **An MCP tool that throws now answers with a JSON-RPC error.** `serve()` swallowed handler + exceptions (`.catch(() => {})`), so a request whose handler threw — e.g. `forge_remember` with + an unwritable `.forge` — never got a reply and the client waited for its own timeout. The + server now returns `-32603` with the tool name and message, and keeps serving (the repro + received replies for ids `[2]` before, `[2, 1]` after). - **CI is green again on Linux.** `global/guards/run.mjs` was committed without its executable bit, so `forge doctor`'s plugin-hook check (which `access(X_OK)`s every script a hook names) reported `warn` on Linux and failed `test/doctor.test.js` on Node 20 and 22 for diff --git a/src/cortex_mcp.js b/src/cortex_mcp.js index c01c573..8c489cb 100644 --- a/src/cortex_mcp.js +++ b/src/cortex_mcp.js @@ -206,7 +206,21 @@ export async function handle(msg) { } if (method === "tools/list") return { jsonrpc: "2.0", id, result: { tools: TOOLS } }; if (method === "tools/call") { - const text = await callTool(params?.name, params?.arguments); + let text; + try { + text = await callTool(params?.name, params?.arguments); + } catch (err) { + // A throwing handler (unwritable .forge, corrupt store, …) must still ANSWER — a + // request with no reply leaves the MCP client waiting until its own timeout. + return { + jsonrpc: "2.0", + id, + error: { + code: -32603, + message: `tool ${params?.name} failed: ${String(err?.message ?? err).slice(0, 300)}`, + }, + }; + } if (text === null) { return { jsonrpc: "2.0", @@ -242,7 +256,18 @@ export function serve(input = process.stdin, output = process.stdout) { .then((res) => { if (res) output.write(`${JSON.stringify(res)}\n`); }) - .catch(() => {}); + .catch((err) => { + // Last line of defence: anything handle() itself throws still gets a reply for a + // request (id present); notifications stay silent per JSON-RPC. + if (msg?.id === undefined) return; + output.write( + `${JSON.stringify({ + jsonrpc: "2.0", + id: msg.id, + error: { code: -32603, message: String(err?.message ?? err).slice(0, 300) }, + })}\n`, + ); + }); }); } diff --git a/src/ledger.js b/src/ledger.js index a76e73b..dd31706 100644 --- a/src/ledger.js +++ b/src/ledger.js @@ -83,10 +83,19 @@ export function canonicalize(value) { 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(",")}}`; + // Normalize keys BEFORE sorting: sorting the raw spelling and normalizing afterwards made + // an NFD key sort where its NFC twin doesn't, so a claim written with one spelling failed + // its own address check once re-parsed (the NFC bytes sort differently). Two raw keys that + // collapse to one NFC key are a malformed input; the first in raw-key order wins, + // deterministically. + const entries = new Map(); + for (const k of Object.keys(value).sort()) { + if (value[k] === undefined || typeof value[k] === "function") continue; + const nk = k.normalize("NFC"); + if (!entries.has(nk)) entries.set(nk, value[k]); + } + const keys = [...entries.keys()].sort(); + return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalize(entries.get(k))}`).join(",")}}`; } return "null"; // undefined / function at the top level } diff --git a/src/ledger_store.js b/src/ledger_store.js index 4895c83..32edf66 100644 --- a/src/ledger_store.js +++ b/src/ledger_store.js @@ -7,11 +7,15 @@ import { execFileSync } from "node:child_process"; import { appendFileSync, + closeSync, existsSync, mkdirSync, + openSync, readdirSync, readFileSync, + readSync, renameSync, + statSync, writeFileSync, } from "node:fs"; import { dirname, isAbsolute, join } from "node:path"; @@ -79,6 +83,29 @@ const fileResolver = (root) => (p) => { }; const LOGS = ["evidence", "provenance", "tombstones"]; + +/** Append one line to a log, first terminating a TORN final line (a process killed + * mid-append, or a union merge that dropped the trailing newline). Without this the next + * record is glued onto the fragment, becomes one unparseable line, and silently vanishes + * while the append still reports ok:true. The fragment itself stays unparseable — readLog + * skips it and verify() names it. */ +function appendLine(path, line) { + let torn = false; + try { + const size = statSync(path).size; + if (size > 0) { + const fd = openSync(path, "r"); + try { + const last = Buffer.alloc(1); + readSync(fd, last, 0, 1, size - 1); + torn = last[0] !== 0x0a; + } finally { + closeSync(fd); + } + } + } catch {} // no file yet — nothing to terminate + appendFileSync(path, `${torn ? "\n" : ""}${line}\n`); +} const claimPath = (dir, id) => join(dir, "claims", id.slice(0, 2), `${id}.json`); const logPath = (dir, log, id) => join(dir, log, `${id}.log`); @@ -140,7 +167,7 @@ function appendRecord(dir, log, id, record) { return { ok: false, reason: `no such claim in ledger: ${id}` }; if (readLog(dir, log, id).some((e) => e.h === record.h)) return { ok: true, deduped: true }; mkdirSync(join(dir, log), { recursive: true }); - appendFileSync(logPath(dir, log, id), `${canonicalize(record)}\n`); + appendLine(logPath(dir, log, id), canonicalize(record)); return { ok: true, deduped: false }; } @@ -410,9 +437,9 @@ function quarantineRecord(dir, id, rec, reason) { const qhash = contentHash(canonicalize({ reason, rec: redacted })); if (readLog(dir, "quarantine", id).some((q) => q.qhash === qhash)) return 0; mkdirSync(join(dir, "quarantine"), { recursive: true }); - appendFileSync( + appendLine( logPath(dir, "quarantine", id), - `${canonicalize(sealRecord({ qhash, reason, rec: redacted, t: rec?.t ?? 0 }))}\n`, + canonicalize(sealRecord({ qhash, reason, rec: redacted, t: rec?.t ?? 0 })), ); return 1; } diff --git a/test/cortex_mcp.test.js b/test/cortex_mcp.test.js index af52c29..540c5cb 100644 --- a/test/cortex_mcp.test.js +++ b/test/cortex_mcp.test.js @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, readFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; @@ -147,6 +147,40 @@ test("forge_remember writes a fact to .forge/brain/ via stdio", () => { assert.match(written, /testing MCP write/); }); +test("handle: a tool handler that throws still gets a JSON-RPC error reply (no client hang)", async () => { + const root = mkdtempSync(join(tmpdir(), "forge-mcp-throw-")); + // `.forge` is a FILE, so every store write under it throws ENOTDIR inside the handler. + writeFileSync(join(root, ".forge"), "not a dir"); + const requests = [ + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "forge_remember", arguments: { name: "x", body: "y" } }, + }), + JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list" }), + ].join("\n"); + const r = spawnSync("node", [SERVER], { + input: `${requests}\n`, + encoding: "utf8", + env: { ...process.env, FORGE_ROOT: root }, + timeout: 10000, + }); + const responses = r.stdout + .trim() + .split("\n") + .filter(Boolean) + .map((l) => JSON.parse(l)); + const failed = responses.find((x) => x.id === 1); + assert.ok(failed, "the throwing call is answered"); + assert.equal(failed.error?.code, -32603); + assert.match(failed.error.message, /forge_remember/); + assert.ok( + responses.some((x) => x.id === 2), + "the server keeps serving after the failure", + ); +}); + test("forge_ledger_retract returns error for missing claim via stdio", () => { const root = mkdtempSync(join(tmpdir(), "forge-mcp-ret-")); mkdirSync(join(root, ".forge", "ledger"), { recursive: true }); diff --git a/test/ledger.test.js b/test/ledger.test.js index cbf3d5c..d436891 100644 --- a/test/ledger.test.js +++ b/test/ledger.test.js @@ -41,6 +41,19 @@ test("canonicalize: drops undefined/function values, keeps null", () => { assert.equal(canonicalize({ a: undefined, b: null, c: () => 1 }), '{"b":null}'); }); +test("canonicalize: keys are NFC-normalized BEFORE sorting — NFD and NFC spellings give one byte string", () => { + const nfd = "é"; // é as e + combining acute + const nfc = "é"; + // Sorting the raw NFD key ("é" < "f") and then normalizing it produced {"é":1,"f":2}, + // while the NFC spelling sorts after "f" — two byte strings for one value, so a claim with + // an NFD key failed its own address check on reload. + assert.equal(canonicalize({ [nfd]: 1, f: 2 }), canonicalize({ [nfc]: 1, f: 2 })); + assert.equal(canonicalize({ [nfc]: 1, f: 2 }), '{"f":2,"é":1}'); + const m = mintClaim({ kind: "fact", body: { name: "x", meta: { [nfd]: 1, f: 2 } } }); + const reparsed = JSON.parse(canonicalize({ body: m.claim.body, kind: "fact", scope: {} })); + assert.equal(claimId("fact", reparsed.body, reparsed.scope), m.claim.id, "id survives a reload"); +}); + 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. diff --git a/test/ledger_store.test.js b/test/ledger_store.test.js index 5c9b495..f9b8476 100644 --- a/test/ledger_store.test.js +++ b/test/ledger_store.test.js @@ -125,6 +125,22 @@ test("appendEvidence: appends, dedupes by hash, requires the claim to exist and assert.ok(val(loaded, 3) > 0.5, "evidence is attached on load"); }); +test("appendEvidence: a torn final line (killed mid-append) never swallows the next record", () => { + const dir = tmp(); + const c = fact("torn", "text"); + putClaim(dir, c); + appendEvidence(dir, c.id, ev("confirm", "run-1", 1)); + const log = join(dir, "evidence", `${c.id}.log`); + writeFileSync(log, `${readFileSync(log, "utf8")}{"author":"","oracle":"test.run","ref":"run-2"`); + const r = appendEvidence(dir, c.id, ev("confirm", "run-3", 1)); + assert.deepEqual(r, { ok: true, deduped: false }); + assert.deepEqual( + readEvidence(dir, c.id).map((e) => e.ref), + ["run-1", "run-3"], + "the record reported as appended is actually readable", + ); +}); + test("corrupt files are quarantined, not fatal — and verify names what load skips", () => { const dir = tmp(); const good = fact("good", "content"); From 563dd6d89066e182f4c7ca31bf22ae6f824d7c99 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:25:50 +0200 Subject: [PATCH 06/72] fix(commit-gate): fail closed when the staged diff cannot be read The secret scan read `git diff --cached` with the default 1 MiB buffer, so any leak staged next to a >1 MiB file became an empty diff and passed; a `-diff`/`binary` attribute or a textconv driver hid added lines too. Diff with --text --no-ext-diff --no-textconv and a 256 MiB buffer, retry per file on failure, and refuse any file git still cannot diff (B3). computeCodeState had the same blind spot (a stale verify PASS survived a later edit when the pending diff exceeded 1 MiB): hash a --binary diff with the same buffer and report "cannot bind" when git fails. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 11 ++++ src/commit_gate.js | 121 ++++++++++++++++++++++++++++++++++----- src/verify.js | 27 +++++++-- test/commit_gate.test.js | 84 ++++++++++++++++++++++++++- test/stop_gate.test.js | 21 +++++++ 5 files changed, 244 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0e4f1c..766f50b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,17 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). whole, up from 1,038. The rest (about 0.2%) start with `/`, so they are read as a path. Ordinary URLs, `$VAR` references, kwargs like `f(password=pw)` and counters like `MAX_TOKENS=4096` are still left alone. +- **The commit gate's secret scan now fails closed.** It read `git diff --cached` with the + default 1 MiB `execFileSync` buffer. On overflow the diff became `""`, so a `ghp_` leak + alone was refused (exit 1), but the same leak staged next to a 1.5 MB file was "allowed" + (exit 0). A repo-controlled `.gitattributes` `-diff`/`binary` marking or a `textconv` + driver also hid the added lines. The scan now diffs with `--text --no-ext-diff + --no-textconv` and a 256 MiB buffer. If that fails, it retries one file at a time, and any + file git still cannot diff is refused as unscanned instead of passed. All three bypasses + are refused now. The Stop gate's code-state fingerprint (`computeCodeState`) had the same + 1 MiB blind spot: a stale `verify` PASS survived a later code edit whenever the pending + diff was over 1 MiB. It now hashes a `--binary` diff with the same buffer, and it reports + "cannot bind" rather than hashing `""` when git fails. ### Documentation diff --git a/src/commit_gate.js b/src/commit_gate.js index bde5d0c..037dce8 100644 --- a/src/commit_gate.js +++ b/src/commit_gate.js @@ -17,7 +17,11 @@ // blocks in BOTH warn and block modes — evidence-proportional (mizan): a missing doc is // repairable in the next commit, a credential in history is not; the kill switch and // `git commit --no-verify` remain the explicit overrides. Fail-open like stopGate: -// any internal error resolves to allow — the gate must never brick a commit. +// any internal error resolves to allow — the gate must never brick a commit. The ONE +// exception is the secret scan itself: if git cannot produce the staged lines (an error, +// or a diff past the buffer), those files are UNSCANNED and the commit is refused — +// "couldn't look" must never read as "nothing there" (the old 1 MiB default buffer +// turned any leak + one big file into a silent pass). import { execFileSync } from "node:child_process"; import { BRAND } from "./brand.js"; @@ -34,19 +38,41 @@ import { IGNORE_DIRS } from "./util.js"; // one source of truth (secrets.js), calibrated to the verb (mizan). const lineBlockSecret = (text) => hasSecret(text) && redactSecrets(text) !== text; -// Exact bytes, no trim — same discipline as gate.js's gitRaw. +// Exact bytes, no trim — same discipline as gate.js's gitRaw. `gitStrict` THROWS on a git +// error or an over-large output (ENOBUFS) — the secret scan turns that into an unscanned +// file, never into an empty diff. +const MAX_DIFF_BYTES = 256 * 1024 * 1024; +function gitStrict(root, args) { + return execFileSync("git", args, { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + maxBuffer: MAX_DIFF_BYTES, + }); +} function gitRaw(root, args) { try { - return execFileSync("git", args, { - cwd: root, - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }); + return gitStrict(root, args); } catch { return ""; } } +// The staged diff exactly as it will be COMMITTED, immune to repo-controlled rendering: +// `--text` defeats a `.gitattributes` `-diff`/`binary` marking (which printed "Binary +// files differ" and hid every added line), `--no-textconv` a `diff=` textconv +// that rewrites what is shown, `--no-ext-diff` a configured external diff tool. +const DIFF_ARGS = [ + "--literal-pathspecs", + "diff", + "--cached", + "--unified=0", + "--no-color", + "--text", + "--no-ext-diff", + "--no-textconv", +]; + // Vendor/build trees force-staged past .gitignore are never pinned on the committer — // but .forge/ stays IN scope: the ledger/decisions are deliberately git-committable // and a staged .forge/decisions.md must keep its docs credit. @@ -63,10 +89,15 @@ const vendorPrefixed = (p) => { * @returns {string[]} */ export function stagedFiles(root) { - return gitRaw(root, ["diff", "--cached", "--name-only", "-z"]) + return stagedPaths(root).filter((p) => !vendorPrefixed(p)); +} + +/** Every staged path, vendor trees included (a credential there still leaks). `strict` + * throws on a git error instead of reading it as "nothing staged". */ +function stagedPaths(root, strict = false) { + return (strict ? gitStrict : gitRaw)(root, ["diff", "--cached", "--name-only", "-z"]) .split("\0") - .filter(Boolean) - .filter((p) => !vendorPrefixed(p)); + .filter(Boolean); } /** @@ -77,7 +108,47 @@ export function stagedFiles(root) { * @returns {Map} */ export function stagedAddedLines(root) { - const raw = gitRaw(root, ["diff", "--cached", "--unified=0", "--no-color"]); + return scanStagedAdded(root).byFile; +} + +/** + * The secret scan's input, FAIL-CLOSED: one whole-index diff on the fast path; if git + * fails or the diff outgrows the buffer, fall back to one diff PER FILE so a single huge + * or broken file cannot hide the others — and any file git still cannot diff lands in + * `unscanned`, which the decision table refuses. + * @param {string} root + * @returns {{byFile: Map, unscanned: string[]}} + */ +export function scanStagedAdded(root) { + try { + return { byFile: parseAddedLines(gitStrict(root, DIFF_ARGS)), unscanned: [] }; + } catch {} + /** @type {string[]} */ + let paths; + try { + paths = stagedPaths(root, true); + } catch { + return { byFile: new Map(), unscanned: ["(staged file list: git diff --cached failed)"] }; + } + const byFile = new Map(); + const unscanned = []; + for (const p of paths) { + try { + const lines = [...parseAddedLines(gitStrict(root, [...DIFF_ARGS, "--", p])).values()].flat(); + if (lines.length) byFile.set(p, lines); + } catch { + unscanned.push(p); + } + } + return { byFile, unscanned }; +} + +/** + * Parse a `--unified=0` diff into the added lines of each file. + * @param {string} raw + * @returns {Map} + */ +function parseAddedLines(raw) { const byFile = new Map(); let file = null; // A `+++ ` line is the file header ONLY between `diff --git` and the first `@@`; once @@ -121,14 +192,28 @@ export function gateMode(v = process.env.FORGE_COMMIT_GATE) { /** * PURE decision table over already-gathered facts — the testable core, no git. - * @param {{staged?: string[], secretFiles?: string[], mode?: "warn"|"block"|"off"}} [opts] + * `unscanned` = staged files the secret scan could not read: refused like a secret. + * @param {{staged?: string[], secretFiles?: string[], unscanned?: string[], mode?: "warn"|"block"|"off"}} [opts] * @returns {{allow: boolean, row: string, findings: {kind: string, severity: string, detail: string, files: string[]}[], classes?: Record}} */ -export function commitGateDecision({ staged = [], secretFiles = [], mode = "warn" } = {}) { +export function commitGateDecision({ + staged = [], + secretFiles = [], + unscanned = [], + mode = "warn", +} = {}) { if (mode === "off") return { allow: true, row: "kill-switch", findings: [] }; const classes = Object.fromEntries(CLASSES.map((c) => [c, []])); for (const f of staged) classes[classifyPath(f)].push(f); const findings = []; + if (unscanned.length) { + findings.push({ + kind: "secret-scan", + severity: "block", // fail closed: an unread diff is not a clean diff + detail: `could not read the staged lines of: ${unscanned.join(", ")} — refusing rather than committing unscanned content`, + files: unscanned, + }); + } if (secretFiles.length) { findings.push({ kind: "secret", @@ -185,11 +270,12 @@ export function commitGate(root, { env = process.env } = {}) { staged: [], }; const secretFiles = []; - for (const [file, lines] of stagedAddedLines(root)) { + const { byFile, unscanned } = scanStagedAdded(root); + for (const [file, lines] of byFile) { if (lineBlockSecret(lines.join("\n"))) secretFiles.push(file); } return { - ...commitGateDecision({ staged, secretFiles, mode }), + ...commitGateDecision({ staged, secretFiles, unscanned, mode }), mode, staged, }; @@ -228,6 +314,11 @@ export function renderCommitGate(r) { " fix: remove the credential from the staged lines (use an env var), then re-stage.", ); } + if (f.kind === "secret-scan") { + lines.push( + " fix: make `git diff --cached --text -- ` work (or unstage the file); if it is intentionally huge, commit it with FORGE_COMMIT_GATE=0.", + ); + } } lines.push( r.allow diff --git a/src/verify.js b/src/verify.js index adee2f4..fec83fa 100644 --- a/src/verify.js +++ b/src/verify.js @@ -20,9 +20,23 @@ export function findUnknownSymbols(atlas, symbols) { return symbols.filter((s) => !has(atlas, s)); } +// git output can be large (a lockfile regen, a generated asset): the 1 MiB execFileSync +// default turned an over-size diff into "" — for computeCodeState that made every state +// with a big pending change hash identically, so a stale PASS survived later edits. +const GIT_MAX_BUFFER = 256 * 1024 * 1024; +/** @param {string[]} args @param {string} cwd — THROWS on any git error / overflow. */ +function gitStrict(args, cwd) { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + maxBuffer: GIT_MAX_BUFFER, + }); +} + function git(args, cwd) { try { - return execFileSync("git", args, { cwd, encoding: "utf8" }); + return execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: GIT_MAX_BUFFER }); } catch (err) { if (process.env.FORGE_DEBUG === "1") process.stderr.write(`forge verify git: ${err?.message ?? err}\n`); @@ -37,7 +51,10 @@ function git(args, cwd) { * `verify` stamp can be BOUND to the exact code state it validated (HI-02): at Stop the * gate recomputes this and only trusts the PASS when the hash still matches. Never throws; * `gitAvailable:false` / `dirtyHash:null` is the honest "cannot bind" signal (the gate then - * refuses to count the stamp). Pure w.r.t. the tree — reads git + files, writes nothing. + * refuses to count the stamp) — including when git cannot produce a diff (an error or an + * over-size output hashes as "cannot bind", never as the empty diff). Diffs are taken with + * `--binary --no-ext-diff --no-textconv`, so repo attributes/drivers cannot hide a change. + * Pure w.r.t. the tree — reads git + files, writes nothing. * @param {string} [cwd] * @returns {{head: string|null, dirtyHash: string|null, gitAvailable: boolean}} */ @@ -54,8 +71,10 @@ export function computeCodeState(cwd = process.cwd()) { .filter((f) => f && !f.startsWith(".forge/")) .sort(); const h = createHash("sha256"); - h.update(git(["diff", "HEAD"], cwd)); - h.update(git(["diff", "--cached"], cwd)); + const raw = ["--binary", "--no-ext-diff", "--no-textconv", "--no-color"]; + // Unborn HEAD (no commit yet): index-vs-worktree + staged covers the whole change. + h.update(gitStrict(head ? ["diff", "HEAD", ...raw] : ["diff", ...raw], cwd)); + h.update(gitStrict(["diff", "--cached", ...raw], cwd)); for (const f of untracked) { try { h.update(readFileSync(join(cwd, f))); diff --git a/test/commit_gate.test.js b/test/commit_gate.test.js index 2a96ce4..b55fda5 100644 --- a/test/commit_gate.test.js +++ b/test/commit_gate.test.js @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; @@ -203,3 +203,85 @@ test("pure decision table: vendor-free classes, block only on the stated rows", }); assert.equal(off.allow, true); }); + +// ── B3: the secret scan must fail CLOSED. It used to read `git diff --cached` with +// execFileSync's default 1 MiB buffer (overflow → "" → "nothing to scan") and through the +// repo's own diff rendering (a `-diff` attribute or a textconv driver hid added lines). +const leak = () => `export const t = "${fakeGithubPat()}";\n`; + +test("a staged secret still blocks when another staged file makes the diff > 1 MiB (B3)", () => { + const { root, git } = gitFixture(); + writeFileSync(join(root, "cfg.js"), leak()); + writeFileSync(join(root, "README.md"), "# app\n\ndocumented\n"); + let big = ""; + for (let i = 0; i < 40000; i++) big += `row ${i} lorem ipsum dolor sit amet\n`; + writeFileSync(join(root, "data.txt"), big); // ~1.5 MB of added lines + git("add", "-A"); + const r = commitGate(root, { env: env() }); + assert.equal(r.allow, false, "the leak must not hide behind a big file"); + assert.ok(r.findings.some((f) => f.kind === "secret" && f.files.includes("cfg.js"))); + assert.equal(cli(root).status, 1); +}); + +test("a repo's .gitattributes / textconv cannot hide staged secret lines (B3)", () => { + for (const [attrs, cfg] of [ + ["*.js -diff\n", null], + ["*.js binary\n", null], + ["*.js diff=hide\n", ["diff.hide.textconv", "sed d"]], + ]) { + const { root, git } = gitFixture(); + writeFileSync(join(root, ".gitattributes"), attrs); + if (cfg) git("config", ...cfg); + writeFileSync(join(root, "cfg.js"), leak()); + writeFileSync(join(root, "README.md"), "# app\n\ndocumented\n"); + git("add", "-A"); + const r = commitGate(root, { env: env() }); + assert.equal(r.allow, false, `hidden by ${attrs.trim()}`); + assert.ok(r.findings.some((f) => f.kind === "secret" && f.files.includes("cfg.js"))); + } +}); + +test("a staged file git cannot diff is refused as unscanned, never passed (B3 fail-closed)", () => { + const { root, git } = gitFixture(); + writeFileSync(join(root, "cfg.js"), leak()); + writeFileSync(join(root, "README.md"), "# app\n\ndocumented\n"); + git("add", "-A"); + // Corrupt the object store under the staged blob: every diff of it now errors. + const sha = String(git("ls-files", "-s", "cfg.js")).split(/\s+/)[1]; + rmSync(join(root, ".git", "objects", sha.slice(0, 2), sha.slice(2)), { force: true }); + const r = commitGate(root, { env: env() }); + assert.equal(r.allow, false, "an unreadable diff is not a clean diff"); + const f = r.findings.find((x) => x.kind === "secret-scan"); + assert.ok(f, "reported as an unscanned file"); + assert.deepEqual(f.files, ["cfg.js"], "only the unreadable file is unscanned"); + assert.match(renderCommitGate(r), /could not read the staged lines of: cfg\.js/); + assert.equal(cli(root).status, 1); + const pure = commitGateDecision({ staged: ["x.md"], unscanned: ["x.md"], mode: "warn" }); + assert.equal(pure.allow, false, "the pure table refuses unscanned files in every mode"); +}); + +// ── B4 at the gate: the real left-pad@1.3.0 package-lock line was refused as a secret. +test("a lockfile integrity line passes the commit gate (B4)", () => { + const { root, git } = gitFixture(); + writeFileSync( + join(root, "package-lock.json"), + `${JSON.stringify( + { + packages: { + "node_modules/left-pad": { + version: "1.3.0", + resolved: "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + integrity: + "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==", + }, + }, + }, + null, + 2, + )}\n`, + ); + git("add", "-A"); + const r = commitGate(root, { env: env() }); + assert.equal(r.allow, true, renderCommitGate(r)); + assert.equal(r.findings.filter((f) => f.kind.startsWith("secret")).length, 0); +}); diff --git a/test/stop_gate.test.js b/test/stop_gate.test.js index c193957..77b1bd5 100644 --- a/test/stop_gate.test.js +++ b/test/stop_gate.test.js @@ -262,6 +262,27 @@ test("HI-02: a verify PASS goes stale once code changes after it (dirtyHash mism assert.match(out.reason, /test evidence/i); }); +test("HI-02: a stale PASS is caught even when the pending diff exceeds 1 MiB (B3)", () => { + // computeCodeState read `git diff HEAD` with the 1 MiB default buffer: past it the diff + // hashed as "", so every state with a big pending change looked identical and the stale + // PASS survived a post-verify edit. + const { root, git } = gitFixture(); + writeFileSync(join(root, "data.txt"), "seed\n"); + git("add", "-A"); + git("-c", "commit.gpgsign=false", "commit", "-qm", "data"); + start(root, "b3big"); + let big = ""; + for (let i = 0; i < 40000; i++) big += `row ${i} lorem ipsum dolor sit amet\n`; + writeFileSync(join(root, "data.txt"), big); // ~1.5 MB tracked diff + writeFileSync(join(root, "a.js"), "export const one = 24;\n"); + writeFileSync(join(root, ".forge", "state.md"), "# state\n"); + writeProvenance(root, "PASS"); + writeFileSync(join(root, "a.js"), "export const one = () => { throw new Error('x'); };\n"); + const out = JSON.parse(stopGate(root, "b3big").stdout || "{}"); + assert.equal(out.decision, "block", "the edit after verify must invalidate the stamp"); + assert.notEqual(computeCodeState(root).dirtyHash, null, "a big diff still binds"); +}); + test("HI-02: a verify PASS bound to the FINAL code state + handoff → allow", () => { const { root } = gitFixture(); start(root, "hi2b"); From 2e9de1ae67bdb5e03d5728c2844293f63b2b58ee Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:26:54 +0200 Subject: [PATCH 07/72] fix(predictor): let the pre-edit risk advisory reach "high"; tie-safe AUC-PR and sound kill criteria The pre-edit hook passed the predictor only the file path, so four of its seven features were pinned to 0 and risk topped out at sigmoid(-1.0) = 0.27, below the 0.66 "high" band: the advisory could never fire. The hook's risk block now computes caller fan-out and test coverage from one bounded git grep of the module name, whether the edit rewrites an existing declaration, and whether any caller is in the working diff, and names the reasons. A hot file with ten importers, no test and a rewritten signature scores 0.91. predictor.js: aucPr ranks tied scores as one threshold (the same data gave 1.0 or 0.333 depending on input order). The kill criteria no longer decide on a held-out split under 10 samples / 2 per class, and compare AUC-PR with the exact expected AP of a random ranking instead of a fixed 0.6 floor. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 15 +++++ src/cortex_hook_main.js | 130 ++++++++++++++++++++++++++++++++++-- src/predictor.js | 83 ++++++++++++++++++----- test/cortex_preedit.test.js | 70 ++++++++++++++++++- test/predictor.test.js | 91 +++++++++++++++++++++++-- 5 files changed, 359 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c080ff..421d488 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,21 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). that examined no input catches nothing, and the figure is the worst class, with `residualByClass` in the provenance. The same case now reports **1**; a typical clean run reports 0.7 instead of 0.005. +- **The pre-edit risk advisory can fire.** The hook passed the predictor only the file path, + so four of its seven features were pinned to 0 and the heuristic topped out at + σ(−1.0) = **0.27**, below the 0.66 "high" band: the high-risk advisory could never appear. + The hook now computes them from the repo and the edit itself — callers and tests from one + bounded `git grep` of the module name, whether the edit rewrites an existing declaration, + and whether any caller is in the working diff — and the advisory names the reasons. A + hot file with ten importers, no test, and a rewritten exported signature now scores 0.91 + ("high"); the same file with a covering test and a body-only edit stays quiet. In + `src/predictor.js`, `aucPr` now ranks tied scores as one threshold (the same data gave + **1.0 or 0.333** depending on input order; now 0.333 either way), and the kill criteria + no longer decide on a held-out split under 10 samples or 2 of each class (a 4-sample split + with no positive used to disable a perfectly predictive feature) and compare AUC-PR with + the exact chance baseline of a random ranking instead of a fixed 0.6 (pure noise at 80% + positives passed 0.6 and let the learned model take over; a real 10%-prevalence signal + at AP 0.33 was disabled). ### Documentation diff --git a/src/cortex_hook_main.js b/src/cortex_hook_main.js index 3acbb76..bb2607f 100644 --- a/src/cortex_hook_main.js +++ b/src/cortex_hook_main.js @@ -142,7 +142,7 @@ async function main() { // A doom loop (the same failure recurring) is the loudest thing to say — it means "stop", // so it takes precedence over lesson/risk advice. const loop = doomLoopAdvisory(readSession(root, sid)); - const advice = loop || (await preEditAdvisory(root, hook.tool_input?.file_path, today)); + const advice = loop || (await preEditAdvisory(root, hook.tool_input, today)); const docs = await staleDocsAdvisory(root, hook.tool_input?.file_path); const currency = await currencyAdvisory(root, hook.tool_input?.file_path); const combined = [advice, docs, currency].filter(Boolean).join("\n\n"); @@ -227,7 +227,8 @@ function emit(hookEventName, additionalContext) { // Advisory before an edit: surface matching lessons (cheap), and — only if none matched — // a one-line high-risk note from the predictor. Advisory only, never blocks. Low-nag by // design: nothing is emitted unless there's a real lesson or genuinely high risk. -async function preEditAdvisory(root, file, today) { +async function preEditAdvisory(root, input, today) { + const file = input?.file_path; if (!file) return ""; const { block, selected } = lessonsForContext( root, @@ -235,16 +236,131 @@ async function preEditAdvisory(root, file, today) { { nowDay: today, budget: 3 }, ); if (selected.length) return block; // learned lessons for this file win - const { featuresForEdit } = await import("./cortex_features.js"); const { riskFor } = await import("./predictor.js"); - const { band } = riskFor(featuresForEdit(root, { file }, { nowDay: today }), { - mode: "heuristic", - }); + const features = await liveEditFeatures(root, file, input, today); + const { band } = riskFor(features, { mode: "heuristic" }); return band === "high" - ? `Forge Cortex — ${file} looks high-risk (churn / prior mistakes here). Re-read and check impact before editing.` + ? `Forge Cortex — ${file} looks high-risk (${riskReasons(features).join("; ")}). Re-read and check impact before editing.` : ""; } +// The predictor's features for a REAL edit (review E2). The hook used to pass only the +// path, so caller_fanout / test_coverage_gap / signature_change / no_caller_update sat at +// 0 and the heuristic topped out at σ(−1.5 + 0.5) = 0.27 — the "high" band (≥ 0.66) could +// never fire. Each signal is cheap and bounded (1.5s per git call, like cortex_features' +// tryExec): one `git grep` for the files that name this module, one `git diff` for the +// working set, and the edit payload itself for the signature check. Outside a git work +// tree nothing is inferred (no test gap, no fan-out): absent evidence never raises risk. +async function liveEditFeatures(root, file, input, today) { + const { computeFeatures, gitChurn } = await import("./cortex_features.js"); + const { toPosix } = await import("./util.js"); + const { execFileSync } = await import("node:child_process"); + const { readFileSync } = await import("node:fs"); + const { basename, dirname, isAbsolute, join, relative } = await import("node:path"); + const git = (args) => { + try { + return execFileSync("git", args, { + cwd: root, + encoding: "utf8", + timeout: 1500, + stdio: ["ignore", "pipe", "ignore"], + }); + } catch { + return ""; + } + }; + const lines = (out) => + out + .split("\n") + .map((f) => toPosix(f.trim())) + .filter(Boolean); + const abs = isAbsolute(file) ? file : join(root, file); + const rel = toPosix(relative(root, abs)); + const inRepo = git(["rev-parse", "--is-inside-work-tree"]).trim() === "true"; + let callers = []; + let tests = []; + if (inRepo) { + // The module's import name: its stem, or its directory for index/__init__/mod/main. + let stem = basename(rel).replace(/\.[^.]+$/, ""); + if (/^(index|__init__|mod|main)$/i.test(stem)) stem = basename(dirname(rel)); + if (stem.length >= 3) { + const hits = lines(git(["grep", "-l", "-I", "-w", "-F", "-e", stem])).filter( + (f) => f !== rel, + ); + tests = hits.filter((f) => TEST_PATH_RE.test(f)); + callers = hits.filter((f) => !TEST_PATH_RE.test(f) && CALLER_EXT_RE.test(f)); + } + } + const inDiff = new Set(lines(git(["diff", "--name-only", "--relative", "HEAD"]))); + const before = () => { + try { + return readFileSync(abs, "utf8"); + } catch { + return ""; // a new file rewrites no declaration + } + }; + return computeFeatures( + { file }, + { + activeLessons: mergedLessons(root, today).filter((l) => l.status === "active"), + nowDay: today, + churnCommits: gitChurn(root, file), + callerCount: callers.length, + hasTest: !inRepo || tests.length > 0, + signatureChange: signatureChanged(input, before), + callersInDiff: callers.length === 0 || callers.some((f) => inDiff.has(f)), + }, + ); +} + +const TEST_PATH_RE = + /(^|\/)(tests?|__tests__|spec)\/|[._-](test|spec)\.[^/]+$|(^|\/)test_[^/]+\.py$/i; +const CALLER_EXT_RE = + /\.(m?[jt]sx?|cjs|py|go|rs|java|kt|rb|php|cs|c|cc|cpp|h|hpp|swift|vue|svelte)$/i; + +// A declaration header (function / class / def / fn / func, or a const-bound function). +// Rough on purpose — one line, no parser — and only used to ask "does this edit remove or +// rewrite an existing declaration?" (adding a new one breaks no caller). +const DECL_RE = + /^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function\*?\s*\w+|class\s+\w+|(?:const|let|var)\s+\w+\s*=\s*(?:async\s*)?(?:function\b|\([^)]*\)\s*=>|\w+\s*=>))|^\s*(?:async\s+)?def\s+\w+|^\s*(?:pub(?:\([^)]*\))?\s+)?fn\s+\w+|^\s*func\s+/; +const declarations = (text) => + new Set( + String(text ?? "") + .split("\n") + .filter((l) => DECL_RE.test(l)) + .map((l) => + l + .replace(/\s+/g, " ") + .replace(/\s*\{\s*$/, "") + .trim(), + ), + ); + +/** Does this Edit / MultiEdit / Write remove or rewrite an existing declaration line? */ +function signatureChanged(input, before) { + const pairs = Array.isArray(input?.edits) + ? input.edits.map((e) => [e?.old_string, e?.new_string]) + : typeof input?.content === "string" + ? [[before(), input.content]] + : [[input?.old_string, input?.new_string]]; + return pairs.some(([was, now]) => { + const kept = declarations(now); + return [...declarations(was)].some((d) => !kept.has(d)); + }); +} + +/** Which features pushed the risk up — the advisory says why, not just "high". */ +function riskReasons(f) { + const out = []; + if (f.signature_change) out.push("this edit rewrites an existing declaration"); + if (f.no_caller_update) out.push("no file that references it is in the working diff yet"); + if (f.caller_fanout > 0) out.push(`${Math.round(f.caller_fanout * 10)}+ files reference it`); + if (f.test_coverage_gap) out.push("no test references it"); + if (f.churn >= 0.5) out.push("frequently changed"); + if (f.lesson_match || f.past_mistake_here) out.push("a past mistake matched here"); + return out.length ? out : ["churn / prior mistakes here"]; +} + // Docs that reference the file about to change (atlas doc edges, CACHED graph only — // a hook never builds). The end-to-end nudge: a code edit carries its docs with it. async function staleDocsAdvisory(root, file) { diff --git a/src/predictor.js b/src/predictor.js index c5a161f..a2fed49 100644 --- a/src/predictor.js +++ b/src/predictor.js @@ -59,34 +59,72 @@ export function predictLogistic(w, features, keys = FEATURE_KEYS) { /** * Average precision (area under precision-recall). PR, not ROC: mistakes are the rare * positive class, and ROC-AUC flatters a model under imbalance. + * + * Tied scores are ONE threshold: the items sharing a score enter the ranking together, so + * the result cannot depend on input order (the same data once gave 1.0 with the positive + * listed first and 0.333 with it last). AP = Σ over distinct scores of ΔRecall × Precision + * — the standard step-wise definition (as sklearn's average_precision_score). * @param {{score:number, label:number}[]} scored */ export function aucPr(scored) { const positives = scored.filter((s) => s.label === 1).length; if (!positives) return 0; - const sorted = [...scored].sort((a, b) => b.score - a.score); + // NaN would make the sort comparator inconsistent and never equal itself — rank it last. + const key = (x) => (Number.isNaN(x) ? Number.NEGATIVE_INFINITY : x); + const sorted = [...scored].sort((a, b) => key(b.score) - key(a.score) || 0); let tp = 0; let fp = 0; - let sumPrecision = 0; - for (const s of sorted) { - if (s.label === 1) { - tp += 1; - sumPrecision += tp / (tp + fp); - } else { - fp += 1; - } + let ap = 0; + for (let i = 0; i < sorted.length; ) { + const threshold = key(sorted[i].score); + let groupTp = 0; + do { + if (sorted[i].label === 1) groupTp += 1; + else fp += 1; + i += 1; + } while (i < sorted.length && key(sorted[i].score) === threshold); + tp += groupTp; + if (groupTp) ap += (groupTp / positives) * (tp / (tp + fp)); } - return sumPrecision / positives; + return ap; +} + +/** + * Expected AUC-PR of a RANDOM ranking of `n` items holding `pos` positives — the chance + * baseline a ranking must beat before it counts as signal. It tends to the prevalence + * pos/n as n grows, but sits above it at the small held-out sizes the kill criteria see: + * E[AP] = (1/n)·Σᵢ₌₁ⁿ [1 + (pos−1)(i−1)/(n−1)] / i + * (a positive lands at rank i with probability pos/n, and then the other pos−1 positives + * fill the i−1 ranks above it at rate (pos−1)/(n−1)). + * @param {number} n + * @param {number} pos + */ +export function chanceAucPr(n, pos) { + if (!n || !pos) return 0; + if (n === 1) return 1; + let sum = 0; + for (let i = 1; i <= n; i += 1) sum += (1 + ((pos - 1) * (i - 1)) / (n - 1)) / i; + return sum / n; } /** * Prequential (train-on-past / test-on-future) evaluation + the KILL CRITERIA that decide * whether the learned model is allowed to take over. This is the anti-vaporware gate. + * + * Both decisions are measured on the held-out future split, so that split must be big + * enough to measure anything: below `minTest` samples, or with fewer than `minPerClass` + * positives or negatives, the heuristic is kept and nothing is killed (a 4-sample split + * with no positive scores AP 0 and used to disable a perfectly predictive feature). The + * "no signal" bar is the chance baseline (chanceAucPr) plus `margin`, not a fixed floor: + * AUC-PR scales with prevalence, so a fixed 0.6 let pure noise pass at 80% positives and + * killed a real signal at 10%. * @param {{features:object, label:number}[]} samples - time-ordered. - * @returns {{mode:"heuristic"|"learned"|"disabled", reason:string, heuristicAucPr?:number, learnedAucPr?:number, weights?:object, n:number}} + * @param {string[]} [keys] + * @param {{minSamples?:number, minTest?:number, minPerClass?:number, margin?:number}} [opts] + * @returns {{mode:"heuristic"|"learned"|"disabled", reason:string, heuristicAucPr?:number, learnedAucPr?:number, chanceAucPr?:number, weights?:object, n:number}} */ export function evaluate(samples, keys = FEATURE_KEYS, opts = {}) { - const { minSamples = 20, floor = 0.6, margin = 0.05 } = opts; + const { minSamples = 20, minTest = 10, minPerClass = 2, margin = 0.05 } = opts; if (samples.length < minSamples) { return { mode: "heuristic", @@ -97,17 +135,27 @@ export function evaluate(samples, keys = FEATURE_KEYS, opts = {}) { const cut = Math.floor(samples.length * 0.8); const train = samples.slice(0, cut); const test = samples.slice(cut); + const pos = test.filter((s) => s.label === 1).length; + if (test.length < minTest || pos < minPerClass || test.length - pos < minPerClass) { + return { + mode: "heuristic", + reason: `held-out split too small to judge (${test.length} samples, ${pos} positive; need ≥${minTest} with ≥${minPerClass} of each class)`, + n: samples.length, + }; + } + const chance = chanceAucPr(test.length, pos); const heuristicAucPr = aucPr( test.map((s) => ({ score: heuristicRisk(s.features), label: s.label })), ); - // If even the heuristic can't separate mistakes here, the features carry no signal for - // this repo — disable prediction entirely rather than nag on noise. - if (heuristicAucPr < floor) { + // If even the heuristic ranks no better than chance here, the features carry no signal + // for this repo — disable prediction entirely rather than nag on noise. + if (heuristicAucPr < chance + margin) { return { mode: "disabled", - reason: "features carry no signal in this repo", - heuristicAucPr, + reason: "features carry no signal in this repo (heuristic AUC-PR no better than chance)", + heuristicAucPr: Number(heuristicAucPr.toFixed(3)), + chanceAucPr: Number(chance.toFixed(3)), n: samples.length, }; } @@ -127,6 +175,7 @@ export function evaluate(samples, keys = FEATURE_KEYS, opts = {}) { : "heuristic retained — learned did not beat it by the margin", heuristicAucPr: Number(heuristicAucPr.toFixed(3)), learnedAucPr: Number(learnedAucPr.toFixed(3)), + chanceAucPr: Number(chance.toFixed(3)), weights: beats ? w : undefined, n: samples.length, }; diff --git a/test/cortex_preedit.test.js b/test/cortex_preedit.test.js index e5f85cc..23be5d9 100644 --- a/test/cortex_preedit.test.js +++ b/test/cortex_preedit.test.js @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { mkdtempSync } from "node:fs"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; @@ -58,3 +58,69 @@ test("pre-edit is fail-safe: no file path → exit 0, no output", () => { assert.equal(r.status, 0); assert.equal(r.stdout.trim(), ""); }); + +// --- E2: the live risk path can actually reach "high" ----------------------------------- + +// A genuinely risky edit: a hot file (≥10 commits) that 10 modules import, that no test +// references, whose EXPORTED SIGNATURE this edit rewrites while no caller is in the diff. +const riskyRepo = ({ withTest = false } = {}) => { + const root = mkdtempSync(join(tmpdir(), "forge-pre-risk-")); + const git = (...args) => + execFileSync("git", ["-c", "commit.gpgsign=false", ...args], { cwd: root, stdio: "pipe" }); + git("init", "-q"); + git("config", "user.email", "forge@test.invalid"); + git("config", "user.name", "forge-test"); + mkdirSync(join(root, "src"), { recursive: true }); + for (let i = 0; i < 10; i++) + writeFileSync( + join(root, "src", `caller${i}.js`), + `import { pricing } from "./pricing.js";\nexport const v${i} = pricing(${i});\n`, + ); + if (withTest) { + mkdirSync(join(root, "test"), { recursive: true }); + writeFileSync(join(root, "test", "pricing.test.js"), 'import "../src/pricing.js";\n'); + } + for (let c = 0; c < 10; c++) { + writeFileSync( + join(root, "src", "pricing.js"), + `export function pricing(qty) {\n return qty * ${c + 1};\n}\n`, + ); + git("add", "-A"); + git("commit", "-qm", `pricing tweak ${c}`); + } + return root; +}; + +const preEditInput = (root, toolInput) => + spawnSync("node", [ENTRY, "pre-edit"], { + input: JSON.stringify({ cwd: root, tool_input: toolInput }), + encoding: "utf8", + timeout: 20000, + }); + +test("pre-edit: a genuinely high-risk edit reaches the 'high' band and says why (E2)", () => { + // Before the fix the hook passed only the path: every feature but churn was 0, so risk + // topped out at σ(−1.0) = 0.27 and this advisory could never fire. + const root = riskyRepo(); + const r = preEditInput(root, { + file_path: join(root, "src", "pricing.js"), + old_string: "export function pricing(qty) {", + new_string: "export function pricing(qty, currency) {", + }); + assert.equal(r.status, 0); + const ctx = JSON.parse(r.stdout).hookSpecificOutput.additionalContext; + assert.match(ctx, /looks high-risk/); + assert.match(ctx, /rewrites an existing declaration/); + assert.match(ctx, /no test references it/); +}); + +test("pre-edit: the same hot file stays quiet for a body-only edit with a covering test", () => { + const root = riskyRepo({ withTest: true }); + const r = preEditInput(root, { + file_path: join(root, "src", "pricing.js"), + old_string: " return qty * 10;", + new_string: " return qty * 11;", + }); + assert.equal(r.status, 0); + assert.equal(r.stdout.trim(), "", "churn + fan-out alone stay below 'high' (low-nag)"); +}); diff --git a/test/predictor.test.js b/test/predictor.test.js index bc1f0e7..a09525e 100644 --- a/test/predictor.test.js +++ b/test/predictor.test.js @@ -3,6 +3,7 @@ import { test } from "node:test"; import { aucPr, band, + chanceAucPr, evaluate, heuristicRisk, predictLogistic, @@ -50,21 +51,103 @@ test("cold-start: below minSamples always falls back to the heuristic", () => { test("kill-criteria: features with no signal DISABLE prediction (no nagging on noise)", () => { // Inverted: high-risk-looking edits are NOT mistakes; zero-risk edits ARE. The heuristic - // ranks backwards, so AUC-PR falls below the floor → prediction disabled. - const samples = Array.from({ length: 24 }, (_, i) => + // ranks backwards, so AUC-PR falls below the chance baseline → prediction disabled. + const samples = Array.from({ length: 60 }, (_, i) => i % 2 === 0 ? { features: { past_mistake_here: 1, no_caller_update: 1 }, label: 0 } : { features: {}, label: 1 }, ); const r = evaluate(samples); assert.equal(r.mode, "disabled"); - assert.ok(r.heuristicAucPr < 0.6, "below the floor"); + assert.ok(r.heuristicAucPr < r.chanceAucPr, "below what a random ranking scores"); +}); + +test("aucPr: tied scores are one threshold — the result no longer depends on input order", () => { + // Regression (review E2): 1.0 with the positive listed first, 0.333 with it last. + const posFirst = [ + { score: 0.5, label: 1 }, + { score: 0.5, label: 0 }, + { score: 0.5, label: 0 }, + ]; + const posLast = [posFirst[1], posFirst[2], posFirst[0]]; + assert.equal(aucPr(posFirst), aucPr(posLast)); + assert.ok(Math.abs(aucPr(posFirst) - 1 / 3) < 1e-12, "all tied → AP = prevalence"); + // ties inside a longer ranking: {0.9:+} then {0.5: +,−} → 1·½ + (2/3)·½ + const mixed = [ + { score: 0.5, label: 0 }, + { score: 0.9, label: 1 }, + { score: 0.5, label: 1 }, + ]; + assert.ok(Math.abs(aucPr(mixed) - (0.5 + (2 / 3) * 0.5)) < 1e-12); + // NaN scores rank last and never hang the grouping loop + assert.equal( + aucPr([ + { score: Number.NaN, label: 0 }, + { score: 0.2, label: 1 }, + ]), + 1, + ); +}); + +test("chanceAucPr is the exact mean AP of a random ranking (→ prevalence as n grows)", () => { + // brute force over every placement of 2 positives among 5 ranks (all orderings equiprobable) + let sum = 0; + let count = 0; + for (let a = 0; a < 5; a++) + for (let b = a + 1; b < 5; b++) { + const scored = Array.from({ length: 5 }, (_, i) => ({ + score: 5 - i, + label: i === a || i === b ? 1 : 0, + })); + sum += aucPr(scored); + count += 1; + } + assert.ok(Math.abs(chanceAucPr(5, 2) - sum / count) < 1e-12); + assert.equal(chanceAucPr(2, 1), 0.75); + assert.ok(Math.abs(chanceAucPr(5000, 500) - 0.1) < 0.005, "large n → prevalence"); + assert.equal(chanceAucPr(10, 0), 0); +}); + +test("kill-criteria: no kill decision on a tiny held-out split (was: disabled on 4 samples)", () => { + // 20 samples → a 4-sample future split with no positives: AP ≡ 0, which used to DISABLE a + // feature that predicts every label perfectly. + const samples = Array.from({ length: 20 }, (_, i) => { + const y = i < 16 && i % 4 === 0 ? 1 : 0; + return { features: { churn: y }, label: y }; + }); + const r = evaluate(samples); + assert.equal(r.mode, "heuristic", "too little held-out evidence to kill anything"); + assert.match(r.reason, /held-out split too small/); +}); + +test("kill-criteria: the bar is the chance baseline, not a prevalence-blind 0.6 floor", () => { + // Pure noise at 80% positives cleared the fixed 0.6 floor (and "learned" took over). + let seed = 7; + const rnd = () => { + seed = (seed * 1103515245 + 12345) % 2147483648; + return seed / 2147483648; + }; + const noise = Array.from({ length: 200 }, () => ({ + features: { churn: rnd() }, + label: rnd() < 0.8 ? 1 : 0, + })); + const r = evaluate(noise); + assert.equal(r.mode, "disabled", "no better than a random ranking → no signal"); + assert.ok(r.heuristicAucPr > 0.6, "it would have passed the old fixed floor"); + // A real low-prevalence signal (10% positives, AP 0.33 ≈ 1.5× chance) was KILLED by 0.6. + const rare = Array.from({ length: 100 }, (_, i) => ({ + features: { churn: i % 10 >= 7 ? 1 : 0 }, + label: i % 10 === 9 ? 1 : 0, + })); + const k = evaluate(rare); + assert.notEqual(k.mode, "disabled"); + assert.ok(k.heuristicAucPr < 0.6 && k.heuristicAucPr > k.chanceAucPr); }); test("kill-criteria INVARIANTS always hold (this is the anti-vaporware guarantee)", () => { // A separable-by-a-single-feature set: the heuristic already ranks it perfectly, so the // learned model is NOT allowed to take over for no gain. - const samples = Array.from({ length: 30 }, (_, i) => ({ + const samples = Array.from({ length: 60 }, (_, i) => ({ features: { churn: i % 2 }, label: i % 2, })); From 2bfe61c9bba91c8139ed452d91742de135b532b0 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:27:24 +0200 Subject: [PATCH 08/72] fix(route): reconcile proposer bands band-to-band, gate on confidence, never raise The M1 reconcile compared the proposer's band floor (0.15/0.40/0.65) with the deterministic point score, so agreement moved the score (fable 0.887 + a Jev "premium" vote -> 0.688 opus, logged "llm-lowered"; 0.431 + "mid" -> 0.400), Jev confidence was ignored (0.34 and 1.00 routed identically), a proposer's self-assessment could raise the tier (contradicting whitepaper 5.1), and the 0.2 point bound blocked confident down-routes from the top of a band. reconcileRoute() maps the score to recommend()'s bands first: same band keeps the score; a lower band moves to its ceiling only when p(band) >= minConfidence (ROUTE_MIN_CONFIDENCE 0.8, an a-priori default, configurable via llm.minConfidence; must be chosen on fresh data) and never below the strong-signal floor; a higher band is recorded as llm.escalateTo and deferred to a verifier failure. routingBand is gone. recommend() now routes a non-finite score to the default tier instead of fable. Co-Authored-By: Claude Opus 5 --- ARCHITECTURE.md | 9 +- CHANGELOG.md | 31 +++++ docs/GUIDE.md | 17 ++- docs/cognitive-substrate/README.md | 16 ++- source/substrate.json | 4 +- src/route.js | 185 ++++++++++++++++++++++------- src/substrate.js | 9 +- test/jev.test.js | 102 +++++++++++++++- test/route.test.js | 119 ++++++++++++++++--- 9 files changed, 411 insertions(+), 81 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f2267fd..d982d61 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -290,10 +290,11 @@ of text plus JSON parsing. The module reuses the adjudicate contract verbatim: o (null → text-LLM fallback → deterministic rubric; a null never moves a verdict), zero-dependency (the `llm.js` spawned-child pattern, key in child env as `_FORGE_JEV_KEY`), and secret-refusing on the outgoing state. Jev answers are validated against the questions asked — a choice naming -an option we never offered is garble and fails safe. The reconciles are untouched: `BAND_FLOOR` -still floors the routing band, the assumption gate still bounds completeness to ±band, and -clarifying free-text questions stay with the deterministic rubric, because a System One model -judges but does not author prose. Provenance records which proposer answered +an option we never offered is garble and fails safe. The reconciles judge Jev like any proposer: +`reconcileRoute` compares its band with the deterministic score's band and gates on p(band), the +assumption gate still bounds completeness to ±band, and clarifying free-text questions stay with +the deterministic rubric, because a System One model judges but does not author prose. +Provenance records which proposer answered (`llm.provider: "jev"` in `forge route --json`, `assumption.provenance.provider` in preflight). **Intent cards (`src/intent.js`).** Prompt → intent by the same exemplar k-NN math as diff --git a/CHANGELOG.md b/CHANGELOG.md index 83d748f..4717487 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- **`recommend()` no longer sends a non-finite score to the most expensive tier.** Every + comparison is false for NaN, so `recommend(NaN)` — and `±Infinity`/`undefined` — fell + through to fable. A non-finite score now routes to the default tier (sonnet) with an + `unknown-score` reason, logged under `FORGE_DEBUG=1`. - **CI is green again on Linux.** `global/guards/run.mjs` was committed without its executable bit, so `forge doctor`'s plugin-hook check (which `access(X_OK)`s every script a hook names) reported `warn` on Linux and failed `test/doctor.test.js` on Node 20 and 22 for @@ -69,6 +73,33 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Changed +- **Model routing reconciles the proposer's band with the deterministic band, not a point + score.** `routeTask` compared the proposer's band floor (cheap 0.15 / mid 0.40 / premium 0.65) + against the deterministic point score, so even a vote that _agreed_ moved the score: a + fable-level task (0.887) with a Jev "premium" vote dropped to 0.688 (opus) and was logged + `llm-lowered`; a sonnet-level 0.431 with a "mid" vote became 0.400, also "lowered"; a 0.087 + prime-finder with a "cheap" vote was "raised" to 0.150; and a premium vote could never yield + fable. The new pure `reconcileRoute` maps the score to its band first (recommend()'s 0.25 / + 0.55 cutoffs): the same band keeps the score (`llm-agreed`), a lower band moves it to that + band's ceiling. The old one-band point bound (`routingBand`, removed from + `source/substrate.json`) also blocked correct down-routes from the top of a band — a 0.508 + task with a 0.95 "cheap" vote stayed on sonnet; it now lands on haiku. The strong-signal floor + (`signalFloor`) still holds a confidently-hard topic at mid. +- **A proposer vote moves the tier only when the proposer is confident.** Jev's confidence was + logged but ignored — a 0.34 and a 1.00 "cheap" vote routed identically. A vote now needs + p(band) (Jev's probability on the voted band, else its confidence) ≥ `minConfidence`: + `ROUTE_MIN_CONFIDENCE` = 0.8, configurable per call and as `llm.minConfidence` in + `source/substrate.json`. 0.8 is an a-priori conservative default, **not fit to data** — it + has to be chosen on fresh labelled tasks (the frozen 80-task held-out set is spent). The + text-LLM proposer reports no probability, so by default it can no longer move the tier + (`llm-overruled`, `overruledBy: "confidence"`); `minConfidence: 0` switches the gate off. +- **The proposer can no longer raise the tier.** The "free raise" escalated on the model's own + assessment, which whitepaper §5.1 rules out (escalate "only if an external check on the + output fails … never by the model's self-assessment"). A higher-band vote is now recorded, + not applied: path `llm-raise-deferred`, with the would-be tier kept as `llm.escalateTo` for + the verifier-failure path — a prime finder with a 0.99 "premium" vote stays on haiku instead + of jumping to opus. Route provenance is now `deterministic` / `llm-agreed` / `llm-lowered` / + `llm-raise-deferred` / `llm-overruled` (+ `overruledBy`); `llm-raised` is gone. - **MCP targets address their server bucket by dotted key path.** `emit/mcp.js` resolved a single top-level key (`mcpServers`, `servers`, `context_servers`); OpenClaw nests its registry under `mcp.servers`. The resolver now walks a path, creating missing objects only diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 8c1bec9..efb1998 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -1445,11 +1445,18 @@ model never decides: each proposal is verified against the rubric, the code grap before it can move a verdict. The reconcile is **bidirectional but rail-guarded** by default — a verified reading can _clear_ a false ask or route a task _down_ a tier, not only add caution, but never past a hard floor (no concrete anchor, unresolved repo entities, or a strong-signal -routing floor). Impact edges must be real + grep-confirmed; goal-drift moves off→on only. Any -failure falls back to the deterministic path, so the flag is safe to leave off or on. `--json` -exposes `llm.provenance` per faculty (`llm-cleared` / `llm-tightened` / `llm-raised` / -`llm-lowered` / …). Set `llm.bidirectional: false` in `source/substrate.json` for the -conservative tighten-/raise-only mode. Each faculty pairs a pure `*LLM` proposer with a +routing floor). Routing compares **bands, not points**: a vote for the band the deterministic +score already sits in leaves it alone; a vote for a lower band moves the score to that band's +ceiling only when the vote's p(band) reaches `llm.minConfidence` (an a-priori 0.8 — choose it on +fresh labelled data; a text-model vote reports no probability and so cannot move the tier unless +you set it to 0); a vote for a **higher** band is never applied — it is recorded as +`llm.escalateTo`, because the tier escalates only when a verifier fails, never on the model's +own assessment (whitepaper §5.1). Impact edges must be real + grep-confirmed; goal-drift moves +off→on only. Any failure falls back to the deterministic path, so the flag is safe to leave off +or on. `--json` exposes `llm.provenance` per faculty (`llm-cleared` / `llm-tightened` / +`llm-lowered` / `llm-raise-deferred` / `llm-overruled` / …). Set `llm.bidirectional: false` in +`source/substrate.json` for the conservative mode (the gate can only tighten, the tier never +moves). Each faculty pairs a pure `*LLM` proposer with a `reconcile` step — extend by adding both, never by trusting the model's answer directly. **TypeSafe System One (Jev) is the preferred proposer when configured.** Where the judgment diff --git a/docs/cognitive-substrate/README.md b/docs/cognitive-substrate/README.md index 62e94ae..d7c5f79 100644 --- a/docs/cognitive-substrate/README.md +++ b/docs/cognitive-substrate/README.md @@ -207,22 +207,26 @@ move a verdict, in the direction of the paper's _tabayyun_ gate (49:6). By defau is **bidirectional but rail-guarded** — a verified reading can lower caution as well as raise it, but never past a hard floor: -- **routing** — a _raise_ is free (spotting hidden complexity costs at most a bigger model); a - _lower_ is bounded to one band and never drops below a strong-signal (algorithmic/architectural) - floor, so a "distributed rate-limiter" can't be talked down to the cheap tier; +- **routing** — the proposer votes a band and the reconcile compares it with the band the + deterministic score is already in. A vote for a _lower_ band moves the score to that band's + ceiling only when the vote's p(band) reaches `llm.minConfidence`, and never below a + strong-signal (algorithmic/architectural) floor, so a "distributed rate-limiter" can't be + talked down to the cheap tier. A vote for a _higher_ band is **not applied** — escalation + follows a verifier failure, never the model's self-assessment (§5.1) — and is kept as + `llm.escalateTo`; - **the assumption gate** — can _clear_ a false ask **or** _add_ one, but never clears a task with no concrete anchor, or one naming symbols/files the repo doesn't define; - **impact edges** — kept only if the file is real _and_ a grep confirms the reference; - **goal-drift** — rescues an off-goal file only with a goal-referencing reason (off→on only). > **Note** — set `llm.bidirectional: false` in -> [`source/substrate.json`](../../source/substrate.json) for the conservative tighten-/raise-only -> mode (caution can only ever increase). +> [`source/substrate.json`](../../source/substrate.json) for the conservative mode (caution can +> only ever increase: the gate can only tighten, the tier never moves). It is **fail-safe**: any error, timeout, or unparseable reply falls back to the deterministic path (behaviour is byte-identical with the flag off), and it **never blocks**. `--json` output carries an `llm.provenance` map (`deterministic` / `llm-cleared` / `llm-tightened` / -`llm-raised` / `llm-lowered` / `llm-verified`) per faculty so every model-touched decision is +`llm-lowered` / `llm-raise-deferred` / `llm-overruled` / `llm-verified`) per faculty so every model-touched decision is auditable. Off by default; the ambient Claude Code hook stays deterministic unless you also set `FORGE_LLM_AMBIENT=1`. Config lives in [`source/substrate.json`](../../source/substrate.json) → `llm`. diff --git a/source/substrate.json b/source/substrate.json index fee67d8..3754ecb 100644 --- a/source/substrate.json +++ b/source/substrate.json @@ -23,9 +23,9 @@ "timeoutMs": 20000, "bidirectional": true, "band": 0.25, - "routingBand": 0.2, + "minConfidence": 0.8, "signalFloor": 0.4, - "note": "Opt-in (FORGE_LLM=1). The model PROPOSES; the deterministic rubric + repo grounding + tests VERIFY. Any failure falls back to the deterministic path — never blind, never blocking. `bidirectional` (default true) lets a verified reading also clear a false ask / route a tier down, within `band`/`routingBand` and never past the hard floors (`signalFloor`, repo grounding, no-anchor). Set false for the conservative tighten/raise-only mode." + "note": "Opt-in (FORGE_LLM=1). The model PROPOSES; the deterministic rubric + repo grounding + tests VERIFY. Any failure falls back to the deterministic path — never blind, never blocking. `bidirectional` (default true) lets a verified reading also clear a false ask (within `band`) / route a tier down (to the voted band, only when the vote's p(band) >= `minConfidence`), never past the hard floors (`signalFloor`, repo grounding, no-anchor). A model vote for a HIGHER tier is never applied (escalate only on a verifier failure). `minConfidence` is an a-priori conservative default, not fit to data - choose it on fresh labelled tasks. Set `bidirectional` false for the conservative mode (the model can only tighten the gate and never moves the tier)." }, "policies": { "preAction": "Run substrate before ambiguous, expensive, multi-file, or mutating work.", diff --git a/src/route.js b/src/route.js index 46c7f50..a9c4009 100644 --- a/src/route.js +++ b/src/route.js @@ -352,21 +352,126 @@ export function complexity(s = {}) { return { score: clamp01(score), norm }; } +/** recommend()'s tier cutoffs — the ONE complexity scale every routing input is read on. */ +export const TIER_CUTOFFS = { haiku: 0.25, sonnet: 0.55, opus: 0.8 }; + +/** Tier used when the score is not a finite number: unknown complexity is routed to the + * default tier (model_tiers: sonnet is "the default"), never to the most expensive one. */ +const UNKNOWN_SCORE_KEY = "sonnet"; + /** Pure: score → recommended model + the reasons that drove it. */ export function recommend(score, norm = {}) { - const key = score < 0.25 ? "haiku" : score < 0.55 ? "sonnet" : score < 0.8 ? "opus" : "fable"; const reasons = Object.entries(norm) .filter(([, v]) => v >= 0.5) .map(([k]) => k) .sort(); + // Fail safe on a non-finite score (NaN from a garbled signal, ±Infinity): every comparison + // below is false for NaN, which used to fall through to fable — the most expensive tier. + if (typeof score !== "number" || !Number.isFinite(score)) { + if (process.env.FORGE_DEBUG === "1") + process.stderr.write(`forge route: non-finite complexity score (${score}) → default tier\n`); + const key = UNKNOWN_SCORE_KEY; + return { + key, + model: MODELS[key], + tier: MODELS[key].tier, + reasons: [...reasons, "unknown-score"], + }; + } + const key = + score < TIER_CUTOFFS.haiku + ? "haiku" + : score < TIER_CUTOFFS.sonnet + ? "sonnet" + : score < TIER_CUTOFFS.opus + ? "opus" + : "fable"; return { key, model: MODELS[key], tier: MODELS[key].tier, reasons }; } // M1 routing — LLM proposer. Estimates task complexity c(x) as a coarse band. PROPOSER ONLY: -// the reconcile in routeTask() lets a RAISE through freely but bounds any LOWER (within one band -// and never below a strong-signal floor), so the model can escalate on hidden complexity yet can -// never under-provision a genuinely hard task; escalation still gates on a verified failure. -const BAND_FLOOR = { cheap: 0.15, mid: 0.4, premium: 0.65 }; +// reconcileRoute() compares the proposer's band with the band the deterministic score already +// sits in — band to band, never band floor against point score. The three bands are intervals +// on recommend()'s scale (cheap = haiku, mid = sonnet, premium = opus/fable); each ceiling sits +// just under the next band's floor so a score moved onto it stays inside the band. +const BAND_ORDER = ["cheap", "mid", "premium"]; +export const BANDS = { + cheap: { floor: 0, ceiling: TIER_CUTOFFS.haiku - 0.01 }, + mid: { floor: TIER_CUTOFFS.haiku, ceiling: TIER_CUTOFFS.sonnet - 0.01 }, + premium: { floor: TIER_CUTOFFS.sonnet, ceiling: 1 }, +}; + +/** The band a complexity score falls in (same cutoffs as recommend()). */ +export const bandOf = (score) => + score < TIER_CUTOFFS.haiku ? "cheap" : score < TIER_CUTOFFS.sonnet ? "mid" : "premium"; + +/** + * Minimum probability a proposer must put on its band before the vote may move the tier. + * An a-priori conservative default, NOT fit to any data: the right value has to be chosen on + * fresh labelled tasks (the frozen held-out set is spent). A proposal that reports no + * probability at all (the text-LLM proposer) cannot clear it; configurable per call and via + * `llm.minConfidence` in source/substrate.json (0 disables the gate). + */ +export const ROUTE_MIN_CONFIDENCE = 0.8; + +/** p(band) for a proposal: Jev's probability on the voted band, else its confidence, else null. */ +function proposalConfidence(proposal) { + const p = proposal?.probabilities?.[proposal.band]; + if (typeof p === "number" && Number.isFinite(p)) return p; + return typeof proposal?.confidence === "number" && Number.isFinite(proposal.confidence) + ? proposal.confidence + : null; +} + +/** + * Pure: reconcile the deterministic complexity score with a proposer's band vote. + * - same band → the deterministic score stands ("llm-agreed"); + * - higher band → NOT applied (whitepaper §5.1: spend more only when an external check + * on the output fails, never on a model's self-assessment). The target + * is kept as `escalateTo` for that verifier-failure path + * ("llm-raise-deferred"); + * - lower band → lowered to that band's ceiling — only when bidirectional, only when + * the vote clears `minConfidence`, and never below `signalFloor` when + * the rubric has a strong topic signal ("llm-lowered"); otherwise the + * deterministic score stands and `overruledBy` says why ("llm-overruled"). + * @param {number} detScore + * @param {{band:string, confidence?:number|null, probabilities?:Record|null}|null} proposal + * @param {{bidirectional?:boolean, minConfidence?:number, strongSignal?:boolean, signalFloor?:number}} [opts] + * @returns {{score:number, path:string, escalateTo?:string, overruledBy?:string, floored?:boolean}} + */ +export function reconcileRoute( + detScore, + proposal, + { + bidirectional = true, + minConfidence = ROUTE_MIN_CONFIDENCE, + strongSignal = false, + signalFloor = 0.4, + } = {}, +) { + if (!proposal || !(proposal.band in BANDS)) return { score: detScore, path: "deterministic" }; + const detBand = bandOf(detScore); + const vote = proposal.band; + if (vote === detBand) return { score: detScore, path: "llm-agreed" }; + if (BAND_ORDER.indexOf(vote) > BAND_ORDER.indexOf(detBand)) { + return { + score: detScore, + path: "llm-raise-deferred", + escalateTo: recommend(BANDS[vote].floor).key, + }; + } + if (!bidirectional) + return { score: detScore, path: "llm-overruled", overruledBy: "bidirectional-off" }; + const p = proposalConfidence(proposal); + if (minConfidence > 0 && (p == null || p < minConfidence)) + return { score: detScore, path: "llm-overruled", overruledBy: "confidence" }; + const ceiling = BANDS[vote].ceiling; + const floored = strongSignal && signalFloor > ceiling; + const target = floored ? signalFloor : ceiling; + if (bandOf(target) === detBand) + return { score: detScore, path: "llm-overruled", overruledBy: "signal-floor" }; + return { score: target, path: "llm-lowered", ...(floored ? { floored: true } : {}) }; +} export function buildComplexityPrompt(task) { return `Judge the intrinsic complexity of this coding task for model selection (not how to do it). @@ -381,8 +486,8 @@ export function parseComplexityProposal(obj) { const band = String(obj.band ?? "") .trim() .toLowerCase(); - if (!(band in BAND_FLOOR)) return null; - return { band, score: BAND_FLOOR[band], reason: asText(obj.reason) }; + if (!(band in BANDS)) return null; + return { band, score: BANDS[band].floor, reason: asText(obj.reason) }; } /** Ask the model for a complexity band (proposer). Returns null when off/unavailable. */ @@ -409,9 +514,10 @@ export function buildComplexityChoice(task) { /** * Ask Jev (TypeSafe System One) for a complexity band. Same proposal contract as - * complexityLLM — the band still floors at BAND_FLOOR and the deterministic rubric still - * judges — but the answer is typed and carries the probability distribution Jev computed, - * in ~150ms instead of a text round-trip. Returns null when off/unavailable. + * complexityLLM — reconcileRoute() still judges the band against the deterministic score — + * but the answer is typed and carries the probability distribution Jev computed (which the + * reconcile's confidence gate reads), in ~150ms instead of a text round-trip. Returns null + * when off/unavailable. * @param {string} task * @param {object} [opts] * @param {boolean} [opts.llm] @@ -424,10 +530,10 @@ export function complexityJev(task, { llm, call } = {}) { const ans = res?.answers?.band; if (!ans) return null; const band = ans.choice.toLowerCase(); - if (!(band in BAND_FLOOR)) return null; + if (!(band in BANDS)) return null; return { band, - score: BAND_FLOOR[band], + score: BANDS[band].floor, reason: ans.confidence != null ? `jev confidence ${ans.confidence.toFixed(2)}` : "jev choice", provider: "jev", confidence: ans.confidence ?? null, @@ -445,8 +551,8 @@ export function complexityJev(task, { llm, call } = {}) { * @param {number} [opts.timeoutMs] * @param {(p:string)=>string} [opts.run] * @param {(payload:object)=>object} [opts.jevCall] injectable Jev transport (tests) - * @param {boolean} [opts.bidirectional] - * @param {number} [opts.routingBand] + * @param {boolean} [opts.bidirectional] may a proposer LOWER the tier (false: it never moves it) + * @param {number} [opts.minConfidence] p(band) a vote needs before it may move the tier * @param {number} [opts.signalFloor] * @param {number} [opts.ambiguity] precomputed information-gap (skips a duplicate preflight pass) */ @@ -460,7 +566,7 @@ export function routeTask( run, jevCall, bidirectional = true, - routingBand = 0.2, + minConfidence = ROUTE_MIN_CONFIDENCE, signalFloor = 0.4, ambiguity, } = {}, @@ -494,36 +600,23 @@ export function routeTask( // Upper envelope, not an average: text and repo signals measure DIFFERENT facets // of complexity, and under-provisioning is the expensive failure (an escalation // retry costs more than a one-tier overshoot). Whichever facet detects difficulty - // sets the tier — same philosophy as the LLM proposer's "free raise" below. + // sets the tier. const detScore = Math.max(repoScore, rubric.score); - // M1 proposer (opt-in): the model PROPOSES a complexity band. A RAISE is free (spotting hidden - // complexity costs at most a bigger model). A LOWER is bounded — never more than one `band` - // below the rubric, and never below `signalFloor` when the rubric confidently matched an - // algorithmic/architectural exemplar, so a "distributed rate-limiter" can't be talked down - // to the cheap tier. - // Jev (typed, ~150ms) is the preferred proposer when its key is configured; the text-LLM - // runner is the fallback, and a null from either is ignored (fail-safe). - // With `bidirectional:false` it stays raise-only. + // M1 proposer (opt-in): the model PROPOSES a complexity band; reconcileRoute() decides what it + // may change (band-to-band, confidence-gated, lower-only — see its doc). Jev (typed, ~150ms) + // is the preferred proposer when its key is configured; the text-LLM runner is the fallback, + // and a null from either is ignored (fail-safe). const proposal = llmEnabled({ llm }) ? (complexityJev(task, { llm, call: jevCall }) ?? complexityLLM(task, { run: run || buildRunner({ model, timeoutMs }) })) : null; - const strongSignal = rubric.strongTopicSignal; - let score = detScore; - let path = proposal ? "llm-agreed" : "deterministic"; - if (proposal) { - if (proposal.score > detScore) { - score = proposal.score; // free raise - path = "llm-raised"; - } else if (bidirectional && proposal.score < detScore) { - const floor = Math.max(detScore - routingBand, strongSignal ? signalFloor : 0); - const lowered = Math.max(floor, proposal.score); - if (lowered < detScore) { - score = lowered; // bounded lower - path = "llm-lowered"; - } - } - } + const verdict = reconcileRoute(detScore, proposal, { + bidirectional, + minConfidence, + strongSignal: rubric.strongTopicSignal, + signalFloor, + }); + const { score, path } = verdict; const recommended = recommend(score, norm); const modelOvr = envModelOverride(); return { @@ -538,6 +631,9 @@ export function routeTask( direction: path.replace("llm-", ""), provider: proposal.provider ?? "text", ...(proposal.confidence != null ? { confidence: proposal.confidence } : {}), + ...(verdict.escalateTo ? { escalateTo: verdict.escalateTo } : {}), + ...(verdict.overruledBy ? { overruledBy: verdict.overruledBy } : {}), + ...(verdict.floored ? { floored: true } : {}), } : null, provenance: { path }, @@ -547,8 +643,13 @@ export function routeTask( ...new Set([ ...(recommended.reasons || []), ...rubric.reasons.filter((r) => r.weight > 0).map((r) => r.reason), - ...(path === "llm-raised" || path === "llm-lowered" - ? [`model judged ${proposal.band} (${path.replace("llm-", "")}): ${proposal.reason}`] + ...(path === "llm-lowered" + ? [`model judged ${proposal.band} (lowered): ${proposal.reason}`] + : []), + ...(path === "llm-raise-deferred" + ? [ + `model judged ${proposal.band} — not applied; escalate to ${verdict.escalateTo} only if a verifier fails`, + ] : []), ]), ], diff --git a/src/substrate.js b/src/substrate.js index b532481..0744a28 100644 --- a/src/substrate.js +++ b/src/substrate.js @@ -231,7 +231,7 @@ export function substrateCheck( timeoutMs, bidirectional: bi, band: spec?.llm?.band, - routingBand: spec?.llm?.routingBand, + minConfidence: spec?.llm?.minConfidence, signalFloor: spec?.llm?.signalFloor, }; const entities = referencedEntities(text); @@ -393,8 +393,9 @@ export function substrateCheck( verification: { checklist: verificationChecklist(root) }, substrate: loadSubstrateSpec(), // Which faculties, if any, had a model proposal survive external verification this run, and - // which direction it moved (…-cleared / …-tightened for the gate, …-raised / …-lowered for - // routing). Every non-deterministic value was checked before it counted. + // which direction it moved (…-cleared / …-tightened for the gate, …-lowered for routing; a + // routing …-raise-deferred is recorded but never applied). Every non-deterministic value was + // checked before it counted. llm: { enabled: useLLM, bidirectional: bi, @@ -419,7 +420,7 @@ export function substrateCheck( // verdict — safe to surface, never blindly trusted (whitepaper tabayyun gate). llmVerified: [ "assumption refinement (bounded ±band; clears a false ask only past the no-anchor + repo-grounding floors)", - "routing (free raise; bounded lower, never below strong-signal floor)", + "routing (band-to-band; a confident lower vote only, never below the strong-signal floor; raises deferred to a verifier failure)", "impact edges (graph + grep verified)", "goal-drift rescue (off→on, goal-referenced)", ], diff --git a/test/jev.test.js b/test/jev.test.js index 76ee323..efa6304 100644 --- a/test/jev.test.js +++ b/test/jev.test.js @@ -181,10 +181,11 @@ test("routeTask (llm on): Jev is the preferred proposer when its key is configur }), run: () => '{"band":"cheap","reason":"should not be used"}', }); - assert.ok(["opus", "fable"].includes(up.key), `raised to ${up.key}`); - assert.equal(up.provenance.path, "llm-raised"); + assert.equal(up.key, "haiku", "a higher-band vote is deferred to the verifier, not applied"); + assert.equal(up.provenance.path, "llm-raise-deferred"); assert.equal(up.llm.provider, "jev"); assert.equal(up.llm.confidence, 0.9); + assert.equal(up.llm.escalateTo, "opus"); }); test("routeTask (llm on): a Jev miss falls back to the text proposer, then to deterministic", () => { @@ -197,7 +198,7 @@ test("routeTask (llm on): a Jev miss falls back to the text proposer, then to de run: () => '{"band":"premium","reason":"text fallback"}', }); assert.equal(viaText.llm.provider, "text"); - assert.equal(viaText.provenance.path, "llm-raised"); + assert.equal(viaText.provenance.path, "llm-raise-deferred"); const det = routeTask(root, "fix a typo", { llm: true, jevCall: () => { @@ -273,3 +274,98 @@ test("preflightRepo (llm on): the Jev reading flows through reconcileAssumption "but never beyond the reconcile band", ); }); + +// --- routing reconcile through a typed Jev vote (deep review D2–D5) --- +// Built on long-standing exports only, so the same assertions ran against the pre-fix +// reconcile (all failed there). Every LLM-on call injects the transport; the text runner +// throws so a fallback can never hide a Jev-path regression. + +const jev = (band, confidence) => () => ({ + answers: { band: { type: "choice", choice: band, confidence } }, +}); +const noText = () => { + throw new Error("text proposer must not be reached"); +}; +const bandOfKey = (key) => (key === "haiku" ? "cheap" : key === "sonnet" ? "mid" : "premium"); +const route = (r, task, band, confidence = 0.99, extra = {}) => + routeTask(r, task, { llm: true, jevCall: jev(band, confidence), run: noText, ...extra }); + +const PRIME = "write a function to check if a number is prime"; +const CACHE = "add a small in-memory cache with get and set"; +const LIMITER = "implement a rate limiter with a token bucket algorithm"; +const HARD = + "Design the architecture of a new service and design a locking strategy across services.\n```\nsketch\n```\n" + + Array.from( + { length: 6 }, + (_, i) => + `${i + 1}. The service must ensure consistency guarantees and trade-offs for step ${i + 1}, and then after that verify it.`, + ).join("\n"); + +test("D2: a vote for the band the deterministic score is already in never moves it", () => { + const r = fixture(); + for (const task of [PRIME, CACHE, LIMITER, HARD]) { + const det = routeTask(r, task, { llm: false }); + const on = route(r, task, bandOfKey(det.key)); + assert.equal(on.score, det.score, `${task.slice(0, 30)}: agreement keeps the score`); + assert.equal(on.key, det.key, `${task.slice(0, 30)}: agreement keeps the tier`); + assert.equal(on.provenance.path, "llm-agreed", "and is logged as agreement, not a move"); + } +}); + +test("D3: the vote's confidence decides whether a down-vote moves the tier", () => { + const r = fixture(); + const det = routeTask(r, CACHE, { llm: false }); + assert.equal(bandOfKey(det.key), "mid", "precondition: the cache task routes mid"); + const unsure = route(r, CACHE, "cheap", 0.34); + const sure = route(r, CACHE, "cheap", 1.0); + assert.equal(unsure.key, det.key, "a 0.34-confidence vote leaves the tier alone"); + assert.equal(unsure.provenance.path, "llm-overruled"); + assert.equal(unsure.llm.overruledBy, "confidence"); + assert.equal(sure.key, "haiku", "a 1.00-confidence vote lowers it"); + assert.equal(sure.provenance.path, "llm-lowered"); +}); + +test("D4: a higher-band vote is never applied — escalation waits for a verifier failure", () => { + const r = fixture(); + const det = routeTask(r, PRIME, { llm: false }); + assert.equal(det.key, "haiku", "precondition: the prime finder routes cheap"); + const up = route(r, PRIME, "premium", 0.99); + assert.equal(up.key, "haiku", "the proposer's self-assessment does not buy a bigger model"); + assert.equal(up.score, det.score); + assert.equal(up.provenance.path, "llm-raise-deferred"); + assert.equal(up.llm.escalateTo, "opus", "the target is kept for the verifier-failure path"); +}); + +test("D5: a confident down-vote reaches the voted band from anywhere in the band above", () => { + const r = fixture(); + const upperMid = + "write a regex to extract fields from a line and parse a csv or json file into objects\n```\na,b\n```\n1. read file\n2. parse\n3. validate"; + const det = routeTask(r, upperMid, { llm: false }); + assert.equal(bandOfKey(det.key), "mid", "precondition: routes mid"); + const down = route(r, upperMid, "cheap", 0.95); + assert.equal(down.key, "haiku", `a 0.95 cheap vote lowers mid → haiku (det ${det.score})`); + assert.equal(down.provenance.path, "llm-lowered"); +}); + +test("D5: the strong-signal floor still holds a confidently-hard topic at mid", () => { + const r = fixture(); + const det = routeTask(r, LIMITER, { llm: false }); + assert.ok(det.rubric.strongTopicSignal, "precondition: strong topic signal"); + assert.equal(bandOfKey(det.key), "premium", "precondition: routes premium"); + const down = route(r, LIMITER, "cheap", 0.99); + assert.equal(down.key, "sonnet", "lowered, but never below the strong-signal floor"); + assert.equal(down.provenance.path, "llm-lowered"); + assert.equal(down.llm.floored, true); +}); + +test("D3: a proposer that reports no confidence (the text LLM) cannot move the tier by default", () => { + const r = fixture(); + const det = routeTask(r, CACHE, { llm: false }); + const text = () => '{"band":"cheap","reason":"trivial"}'; + const gated = routeTask(r, CACHE, { llm: true, jevCall: () => null, run: text }); + assert.equal(gated.llm.provider, "text"); + assert.equal(gated.key, det.key, "no reported p(band) → the deterministic tier stands"); + assert.equal(gated.provenance.path, "llm-overruled"); + const open = routeTask(r, CACHE, { llm: true, jevCall: () => null, run: text, minConfidence: 0 }); + assert.equal(open.key, "haiku", "minConfidence 0 switches the gate off"); +}); diff --git a/test/route.test.js b/test/route.test.js index bbd7cde..c96bf5b 100644 --- a/test/route.test.js +++ b/test/route.test.js @@ -5,6 +5,8 @@ import { join } from "node:path"; import { test } from "node:test"; import { read as readMetrics } from "../src/metrics.js"; import { + BANDS, + bandOf, complexity, complexityLLM, contentGrams, @@ -13,6 +15,7 @@ import { meterRoute, RUBRIC, recommend, + reconcileRoute, routeTask, rubricComplexity, } from "../src/route.js"; @@ -133,27 +136,30 @@ test("complexityLLM: parses a band into a score floor, rejects junk", () => { assert.equal(complexityLLM("x", { run: () => "not json" }), null); }); -test("routeTask (llm on): a RAISE is free — the model can escalate a trivial-looking task", () => { +test("routeTask (llm on): a higher-band vote is deferred to the verifier, never applied (§5.1)", () => { const root = mkdtempSync(join(tmpdir(), "forge-route-")); const task = "write a function to check if a number is prime"; + const base = routeTask(root, task); const up = routeTask(root, task, { llm: true, run: () => '{"band":"premium","reason":"x"}' }); - assert.ok(["opus", "fable"].includes(up.key), `raised to ${up.key}`); - assert.equal(up.provenance.path, "llm-raised"); - assert.equal(up.llm.direction, "raised"); + assert.equal(up.key, base.key, "the model's own judgment does not buy a bigger tier"); + assert.equal(up.provenance.path, "llm-raise-deferred"); + assert.equal(up.llm.direction, "raise-deferred"); + assert.equal(up.llm.escalateTo, "opus", "the premium floor is kept as the escalation target"); }); -test("routeTask (bidirectional): the model can LOWER an over-provisioned generic task, bounded", () => { +test("routeTask (bidirectional): a text vote carries no p(band), so it lowers only with the gate off", () => { const root = mkdtempSync(join(tmpdir(), "forge-route-")); // A moderate task with no strong algorithmic/architectural signal — safe to route down. const task = "add a small in-memory cache with get and set"; const base = routeTask(root, task); - const down = routeTask(root, task, { - llm: true, - run: () => '{"band":"cheap","reason":"trivial"}', - }); - assert.ok(down.score <= base.score, "cheap band pulls the score down"); - assert.ok(down.score >= base.score - 0.2 - 1e-9, "but never more than one routing band"); - assert.ok(["llm-lowered", "llm-agreed"].includes(down.provenance.path)); + const run = () => '{"band":"cheap","reason":"trivial"}'; + const gated = routeTask(root, task, { llm: true, run }); + assert.equal(gated.score, base.score, "no reported confidence → the rubric stands"); + assert.equal(gated.llm.overruledBy, "confidence"); + const down = routeTask(root, task, { llm: true, run, minConfidence: 0 }); + assert.equal(down.provenance.path, "llm-lowered"); + assert.equal(down.score, BANDS.cheap.ceiling, "lowered to the voted band's ceiling"); + assert.equal(down.key, "haiku"); }); test("routeTask (bidirectional): a strong-signal task holds the floor even on a 'cheap' vote", () => { @@ -163,22 +169,96 @@ test("routeTask (bidirectional): a strong-signal task holds the floor even on a const base = routeTask(root, task); const down = routeTask(root, task, { llm: true, + minConfidence: 0, run: () => '{"band":"cheap","reason":"looks easy"}', }); assert.ok(down.score >= 0.4, "algorithmic/architectural floor keeps it off the cheap tier"); - assert.ok(["opus", "fable"].includes(down.key) || down.score >= base.score - 0.2); + assert.ok(down.score <= base.score); }); -test("routeTask (bidirectional:false): reverts to raise-only — a 'cheap' vote can't lower", () => { +test("routeTask (bidirectional:false): conservative mode — a 'cheap' vote can't lower", () => { const root = mkdtempSync(join(tmpdir(), "forge-route-")); const task = "add a small in-memory cache with get and set"; const base = routeTask(root, task); const down = routeTask(root, task, { llm: true, bidirectional: false, + minConfidence: 0, run: () => '{"band":"cheap","reason":"trivial"}', }); - assert.ok(down.score >= base.score, "raise-only mode never routes below deterministic"); + assert.equal(down.score, base.score, "conservative mode never routes below deterministic"); + assert.equal(down.llm.overruledBy, "bidirectional-off"); +}); + +// --- reconcileRoute: band-to-band, confidence-gated, lower-only (deep review D2–D5) --- + +test("bandOf reads recommend()'s cutoffs; each band ceiling stays inside its band", () => { + assert.equal(bandOf(0.24), "cheap"); + assert.equal(bandOf(0.25), "mid"); + assert.equal(bandOf(0.55), "premium"); + for (const [band, { floor, ceiling }] of Object.entries(BANDS)) { + assert.equal(bandOf(floor), band); + assert.equal(bandOf(ceiling), band); + } + assert.equal(recommend(BANDS.cheap.ceiling).key, "haiku"); + assert.equal(recommend(BANDS.mid.ceiling).key, "sonnet"); +}); + +test("reconcileRoute: agreement keeps the point score — a premium vote on fable stays fable", () => { + const fable = reconcileRoute(0.887, { band: "premium", confidence: 0.99 }); + assert.deepEqual(fable, { score: 0.887, path: "llm-agreed" }); + assert.equal(recommend(fable.score).key, "fable"); + const mid = reconcileRoute(0.431, { band: "mid", confidence: 0.99 }); + assert.deepEqual(mid, { score: 0.431, path: "llm-agreed" }); +}); + +test("reconcileRoute: a confident lower vote moves to that band's ceiling, any distance", () => { + assert.deepEqual(reconcileRoute(0.53, { band: "cheap", confidence: 0.95 }), { + score: BANDS.cheap.ceiling, + path: "llm-lowered", + }); + assert.equal(reconcileRoute(0.9, { band: "mid", confidence: 0.95 }).score, BANDS.mid.ceiling); + assert.equal(reconcileRoute(0.9, { band: "cheap", confidence: 0.95 }).score, BANDS.cheap.ceiling); +}); + +test("reconcileRoute: p(band) from the distribution outranks the scalar confidence", () => { + const r = reconcileRoute(0.5, { + band: "cheap", + confidence: 0.99, + probabilities: { cheap: 0.4, mid: 0.35, premium: 0.25 }, + }); + assert.equal(r.path, "llm-overruled"); + assert.equal(r.overruledBy, "confidence"); + assert.equal(r.score, 0.5); + assert.equal( + reconcileRoute(0.5, { band: "cheap", confidence: 0.99 }, { minConfidence: 0.995 }).overruledBy, + "confidence", + "the threshold is configurable", + ); +}); + +test("reconcileRoute: a higher vote is deferred with its escalation target", () => { + assert.deepEqual(reconcileRoute(0.1, { band: "premium", confidence: 1 }), { + score: 0.1, + path: "llm-raise-deferred", + escalateTo: "opus", + }); + assert.equal(reconcileRoute(0.1, { band: "mid", confidence: 1 }).escalateTo, "sonnet"); +}); + +test("reconcileRoute: the strong-signal floor bounds a lower, and can veto it inside the band", () => { + const opts = { strongSignal: true, signalFloor: 0.4 }; + assert.deepEqual(reconcileRoute(0.78, { band: "cheap", confidence: 0.99 }, opts), { + score: 0.4, + path: "llm-lowered", + floored: true, + }); + assert.deepEqual(reconcileRoute(0.45, { band: "cheap", confidence: 0.99 }, opts), { + score: 0.45, + path: "llm-overruled", + overruledBy: "signal-floor", + }); + assert.equal(reconcileRoute(0.45, null).path, "deterministic"); }); test("routeTask (llm on): a failing model call falls back to deterministic", () => { @@ -229,3 +309,12 @@ test("routeTask: a precomputed ambiguity matches computing it internally", () => assert.equal(typeof a, "number"); assert.equal(typeof b, "number"); }); + +test("E5: a non-finite complexity score routes to the default tier, never to fable", () => { + for (const s of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, undefined]) { + const r = recommend(/** @type {number} */ (s)); + assert.equal(r.key, "sonnet", `recommend(${s}) → default tier`); + assert.ok(r.reasons.includes("unknown-score"), "and says why"); + } + assert.equal(recommend(0.9).key, "fable", "finite scores are unaffected"); +}); From 6ffdbc6699837669c602ae032a4dd44ff4f19a07 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:31:18 +0200 Subject: [PATCH 09/72] fix(radar): score rings with a noisy-OR so clean signals stop diluting risk The ring score was a weighted mean in which clean signals (deprecated:false at weight 1.0, "no advisories" at 0.9) counted as zeros and diluted the rest: a dependency 4 majors behind and 3 years stale scored 0.221 (adopt), currency risk was capped at 0.255 so "assess" was unreachable from the score, and a high-severity advisory alone scored 0.247 (adopt). The score is now 1 - prod(1 - w_k * s_k), the same shape as the lesson and consensus scores. The same dependency scores 0.485 (trial); maximal currency risk (0.545) and a high advisory (0.630) reach assess. Absent evidence still lands in assess via the evidence-count gate. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 9 +++++++++ docs/GUIDE.md | 3 ++- src/radar.js | 24 ++++++++++++----------- test/radar.test.js | 47 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 421d488..94b1968 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,15 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). the exact chance baseline of a random ranking instead of a fixed 0.6 (pure noise at 80% positives passed 0.6 and let the learned model take over; a real 10%-prevalence signal at AP 0.33 was disabled). +- **`forge radar` rings reflect the risk they find.** The ring score was a weighted mean in + which clean signals counted as zeros with the heaviest weights (`deprecated: false` 1.0, + "no advisories" 0.9), so they diluted everything else: a dependency 4 majors behind with + a 3-year-stale latest release scored **0.221 → adopt**, currency risk could never exceed + 0.255 (so "assess" was unreachable from the score), and a high-severity advisory alone + scored 0.247 → adopt. The score is now a noisy-OR, `1 − ∏(1 − wₖ·sₖ)`, like the lesson + and consensus scores: the same dependency scores 0.485 (trial), maximal currency risk + 0.545 and a high advisory 0.630 (both assess). Absent evidence still lands in "assess" + through the evidence-count gate, never through the score. ### Documentation diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 63f33e5..f6ac299 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -662,7 +662,8 @@ $ forge radar Rings are a **formula over registry evidence** (_mizan_ — a philosophical/ethical framing of weighed judgment, not a technical authority; every ring ships the evidence that earned it): `staleness = 1 − 0.5^(daysSincePublish/540)` (a 540-day half-life), major-version -lag, open security advisories (severity-weighted), and maintainer deprecation. Repo _usage_ +lag, open security advisories (severity-weighted), and maintainer deprecation, combined as a +noisy-OR `score = 1 − ∏(1 − wₖ·sₖ)` — a clean signal adds no risk and dilutes nothing. Repo _usage_ (import-sites from the atlas) is **stakes, not risk** — it only sorts output, never the score. Hard rules: **deprecated or a critical advisory → `hold`** regardless of freshness; fewer than two verified evidence kinds → **`assess` (never `adopt` on absence)** — missing evidence never diff --git a/src/radar.js b/src/radar.js index ca2c2a9..c05f2c2 100644 --- a/src/radar.js +++ b/src/radar.js @@ -142,9 +142,10 @@ function majorOf(v) { /** * Classify one dependency into a ring from its evidence — a pure formula, no package lists. - * Absent evidence is never scored as zero risk (it is simply not averaged in), so a dep we - * could not verify degrades to "assess", never "adopt". Hard gates (deprecated / critical - * advisory) win over the score. Every returned ring carries its calibration `reasons`. + * The score is a noisy-OR over the verified risk signals (a clean signal neither adds risk + * nor dilutes the others); absent evidence is never scored as zero risk — too few verified + * kinds degrades to "assess", never "adopt". Hard gates (deprecated / critical advisory) + * win over the score. Every returned ring carries its calibration `reasons`. * @param {DepEvidence} evidence * @param {number} nowDay epoch-day of the scan * @returns {{ring:"adopt"|"trial"|"assess"|"hold", score:number, @@ -200,14 +201,15 @@ export function classifyDep(evidence, nowDay = epochDay()) { } const kinds = Object.keys(signals); - let num = 0; - let den = 0; - for (const k of kinds) { - const w = RADAR_WEIGHTS[k] ?? 0; - num += w * signals[k]; - den += w; - } - const score = den > 0 ? clamp01(num / den) : 0; + // Noisy-OR over the risk signals, score = 1 − ∏(1 − wₖ·sₖ) — the lessons.js/consensus.js + // shape. A weighted MEAN let every clean signal dilute the others: `deprecated:false` + // (w 1.0) and "no advisories" (w 0.9) pulled a dep 4 majors behind and 3 years stale down + // to 0.22 → "adopt", capped pure currency risk at 0.25, and even a HIGH advisory scored + // 0.25 → "adopt". A clean signal now contributes a factor of 1 (no risk, no dilution); + // absent evidence is handled by the evidence-count gate below, never by the score. + let clean = 1; + for (const k of kinds) clean *= 1 - (RADAR_WEIGHTS[k] ?? 0) * clamp01(signals[k]); + const score = clamp01(1 - clean); // Hard gates first: a deprecated or critically-vulnerable dep is "hold" regardless of freshness. /** @type {"adopt"|"trial"|"assess"|"hold"} */ diff --git a/test/radar.test.js b/test/radar.test.js index 56e15cd..a6d68c1 100644 --- a/test/radar.test.js +++ b/test/radar.test.js @@ -147,6 +147,53 @@ test("classifyDep: score bounded [0,1] and monotone in staleness", () => { assert.ok(stale.score > fresh.score, "older publish → higher staleness → higher score"); }); +test("classifyDep: clean signals don't dilute real risk — 4 majors behind + 3y stale ≠ adopt", () => { + // Regression (review E3): the weighted mean averaged deprecated:false (w 1.0) and "no + // advisories" (w 0.9) in as zeros, scoring this dep 0.22 → "adopt". + const c = classifyDep( + { + installed: "1.0.0", + latest: "5.0.0", + publishedAt: (NOW_DAY - 1095) * DAY, + deprecated: false, + advisories: [], + }, + NOW_DAY, + ); + assert.equal(c.evidenceKinds, 4); + assert.notEqual(c.ring, "adopt"); + assert.ok(c.score > 0.4, `score ${c.score}`); + // a clean signal is a factor of 1: dropping the two clean kinds changes nothing + const bare = classifyDep( + { installed: "1.0.0", latest: "5.0.0", publishedAt: (NOW_DAY - 1095) * DAY }, + NOW_DAY, + ); + assert.ok(Math.abs(bare.score - c.score) < 1e-12); +}); + +test("classifyDep: every ring is reachable, and 'assess' is reachable from the score", () => { + const ev = (over) => ({ + installed: "3.0.0", + latest: "3.0.0", + publishedAt: NOW_DAY * DAY, + deprecated: false, + advisories: [], + ...over, + }); + const ring = (over) => classifyDep(ev(over), NOW_DAY); + assert.equal(ring({}).ring, "adopt"); + assert.equal(ring({ advisories: [{ severity: "moderate", title: "x" }] }).ring, "trial"); + // score-driven assess (≥2 evidence kinds, no hard gate): a high advisory — it used to + // score 0.25 and rate "adopt" + const high = ring({ advisories: [{ severity: "high", title: "prototype pollution" }] }); + assert.equal(high.ring, "assess"); + assert.ok(high.score >= 0.5); + // and pure currency risk at its maximum (it was capped at 0.255 → "trial") + const ancient = ring({ installed: "1.0.0", latest: "9.0.0", publishedAt: 0 }); + assert.equal(ancient.ring, "assess"); + assert.equal(ring({ deprecated: true }).ring, "hold"); +}); + // --- depsFromManifests ----------------------------------------------------- test("depsFromManifests: package.json + lock v3 → names/ranges/installed", () => { From 99dd1544a6897a492538d865521221590ee10c18 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:34:39 +0200 Subject: [PATCH 10/72] fix(preflight): judge the proposer verdict-to-verdict; unresolved entities only floor clearing With the LLM layer on, reconcileAssumption put hasUnresolved in the ask condition itself, so a task the rubric proceeded on and the model judged complete was still asked (a grounded rename with a background URL -> path "llm-tightened"); tighten-only mode ignored it. It is now a floor on clearing a rubric ask only, identically in both modes. The reconcile also clipped the proposer's reading to det +/- 0.25, but the rubric's logistic saturates (median 0.983 on the held-out tasks) while Jev's mean noul centres on 0.5, so a stub at Jev's median pinned 74/80 values at det - 0.25. Each reading is now judged against its own threshold and only the verdicts are compared; the proposer flips the rubric only at p >= minConfidence (GATE_MIN_CONFIDENCE 0.8, a-priori, llm.minConfidence). The rubric's completeness is reported unchanged, the proposer's rides in provenance.proposalCompleteness, and the substrate `band` key is removed. Co-Authored-By: Claude Opus 5 --- ARCHITECTURE.md | 8 ++- CHANGELOG.md | 21 ++++++ docs/GUIDE.md | 6 +- docs/cognitive-substrate/README.md | 5 +- source/substrate.json | 3 +- src/preflight.js | 80 +++++++++++++---------- src/substrate.js | 3 +- test/jev.test.js | 18 +++--- test/preflight.test.js | 100 ++++++++++++++++++++++------- 9 files changed, 168 insertions(+), 76 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d982d61..ad679dc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -291,9 +291,11 @@ of text plus JSON parsing. The module reuses the adjudicate contract verbatim: o (the `llm.js` spawned-child pattern, key in child env as `_FORGE_JEV_KEY`), and secret-refusing on the outgoing state. Jev answers are validated against the questions asked — a choice naming an option we never offered is garble and fails safe. The reconciles judge Jev like any proposer: -`reconcileRoute` compares its band with the deterministic score's band and gates on p(band), the -assumption gate still bounds completeness to ±band, and clarifying free-text questions stay with -the deterministic rubric, because a System One model judges but does not author prose. +`reconcileRoute` compares its band with the deterministic score's band and gates on p(band); +`reconcileAssumption` compares Jev's ask/proceed verdict (mean noul vs 0.5) with the rubric's +and lets it flip the gate only at p ≥ `minConfidence` — the two completeness scales are never +blended; and clarifying free-text questions stay with the deterministic rubric, because a +System One model judges but does not author prose. Provenance records which proposer answered (`llm.provider: "jev"` in `forge route --json`, `assumption.provenance.provider` in preflight). diff --git a/CHANGELOG.md b/CHANGELOG.md index 4717487..991c717 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,14 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). comparison is false for NaN, so `recommend(NaN)` — and `±Infinity`/`undefined` — fell through to fable. A non-finite score now routes to the default tier (sonnet) with an `unknown-score` reason, logged under `FORGE_DEBUG=1`. +- **With the LLM layer on, the assumption gate no longer asks just because a task names + something the repo lacks.** In bidirectional mode `reconcileAssumption` put `hasUnresolved` in + the ask condition itself, so it forced an ask even when the rubric proceeded and the model + judged the task complete — a grounded rename (`clamp01` → `clampUnit`) with a background URL: + rubric proceeds, model 0.99 → asked, path `llm-tightened` — while tighten-only mode ignored it + entirely. The reviewer measured 63 of 64 well-specified held-out tasks tripping it. Unresolved + entities are now a floor on _clearing_ a rubric ask only, identically in both modes (a + rename's new name is unresolved by definition). - **CI is green again on Linux.** `global/guards/run.mjs` was committed without its executable bit, so `forge doctor`'s plugin-hook check (which `access(X_OK)`s every script a hook names) reported `warn` on Linux and failed `test/doctor.test.js` on Node 20 and 22 for @@ -100,6 +108,19 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). the verifier-failure path — a prime finder with a 0.99 "premium" vote stays on haiku instead of jumping to opus. Route provenance is now `deterministic` / `llm-agreed` / `llm-lowered` / `llm-raise-deferred` / `llm-overruled` (+ `overruledBy`); `llm-raised` is gone. +- **The assumption gate compares the proposer's verdict with the rubric's instead of clipping + one scale onto the other.** The rubric's logistic saturates on real issues (median + completeness 0.983 on the 80 held-out tasks) while Jev's mean noul is a probability centred on + 0.5, and the reconcile bounded Jev to det ± 0.25 — so Jev almost never had a say: with a stub + proposer at Jev's reported median (0.29), 74 of 80 reconciled values sat exactly at det − 0.25 + (the reviewer measured 71 of 79 with real Jev answers, which are not in the repo). Each reading + is now judged against its own threshold (the rubric's `askThreshold`, the proposer's 0.5); the + proposer flips the verdict only when it holds its own with probability ≥ `minConfidence` + (`GATE_MIN_CONFIDENCE` = 0.8 — a-priori, not fit to data; same `llm.minConfidence` key as + routing); tightening is always allowed, and clearing still stops at the no-anchor and + repo-grounding floors. The reported `completeness`/`risk` stay the rubric's, the proposer's + reading is `provenance.proposalCompleteness`, and a blocked flip is `llm-overruled` with + `overruledBy`. The `band` key is gone from `source/substrate.json`. - **MCP targets address their server bucket by dotted key path.** `emit/mcp.js` resolved a single top-level key (`mcpServers`, `servers`, `context_servers`); OpenClaw nests its registry under `mcp.servers`. The resolver now walks a path, creating missing objects only diff --git a/docs/GUIDE.md b/docs/GUIDE.md index efb1998..7c52b3f 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -1445,7 +1445,11 @@ model never decides: each proposal is verified against the rubric, the code grap before it can move a verdict. The reconcile is **bidirectional but rail-guarded** by default — a verified reading can _clear_ a false ask or route a task _down_ a tier, not only add caution, but never past a hard floor (no concrete anchor, unresolved repo entities, or a strong-signal -routing floor). Routing compares **bands, not points**: a vote for the band the deterministic +routing floor); the gate's floors only ever block a _clear_ — they never raise an ask the +rubric didn't. The gate compares **verdicts, not scales**: the rubric's completeness and the +proposer's are judged against their own thresholds, and the proposer flips the rubric's +ask/proceed only when it holds its own verdict with p ≥ `llm.minConfidence`. Routing compares +**bands, not points**: a vote for the band the deterministic score already sits in leaves it alone; a vote for a lower band moves the score to that band's ceiling only when the vote's p(band) reaches `llm.minConfidence` (an a-priori 0.8 — choose it on fresh labelled data; a text-model vote reports no probability and so cannot move the tier unless diff --git a/docs/cognitive-substrate/README.md b/docs/cognitive-substrate/README.md index d7c5f79..2c082d7 100644 --- a/docs/cognitive-substrate/README.md +++ b/docs/cognitive-substrate/README.md @@ -215,7 +215,10 @@ but never past a hard floor: follows a verifier failure, never the model's self-assessment (§5.1) — and is kept as `llm.escalateTo`; - **the assumption gate** — can _clear_ a false ask **or** _add_ one, but never clears a task - with no concrete anchor, or one naming symbols/files the repo doesn't define; + with no concrete anchor, or one naming symbols/files the repo doesn't define (those floors + guard clearing only — they never raise an ask the rubric didn't). The rubric's completeness + and the proposer's are on different scales, so only their ask/proceed verdicts are compared, + and the proposer flips the rubric only when it holds its verdict with p ≥ `llm.minConfidence`; - **impact edges** — kept only if the file is real _and_ a grep confirms the reference; - **goal-drift** — rescues an off-goal file only with a goal-referencing reason (off→on only). diff --git a/source/substrate.json b/source/substrate.json index 3754ecb..3e32707 100644 --- a/source/substrate.json +++ b/source/substrate.json @@ -22,10 +22,9 @@ "model": "haiku", "timeoutMs": 20000, "bidirectional": true, - "band": 0.25, "minConfidence": 0.8, "signalFloor": 0.4, - "note": "Opt-in (FORGE_LLM=1). The model PROPOSES; the deterministic rubric + repo grounding + tests VERIFY. Any failure falls back to the deterministic path — never blind, never blocking. `bidirectional` (default true) lets a verified reading also clear a false ask (within `band`) / route a tier down (to the voted band, only when the vote's p(band) >= `minConfidence`), never past the hard floors (`signalFloor`, repo grounding, no-anchor). A model vote for a HIGHER tier is never applied (escalate only on a verifier failure). `minConfidence` is an a-priori conservative default, not fit to data - choose it on fresh labelled tasks. Set `bidirectional` false for the conservative mode (the model can only tighten the gate and never moves the tier)." + "note": "Opt-in (FORGE_LLM=1). The model PROPOSES; the deterministic rubric + repo grounding + tests VERIFY. Any failure falls back to the deterministic path — never blind, never blocking. `bidirectional` (default true) lets a verified reading also clear a false ask / route a tier down (to the voted band), never past the hard floors (`signalFloor`, repo grounding, no-anchor). Readings are compared verdict to verdict (ask vs proceed, band vs band), and a proposer flips the rubric only when it holds its own verdict with probability >= `minConfidence`. A model vote for a HIGHER tier is never applied (escalate only on a verifier failure). `minConfidence` is an a-priori conservative default, not fit to data - choose it on fresh labelled tasks. Set `bidirectional` false for the conservative mode (the model can only tighten the gate and never moves the tier)." }, "policies": { "preAction": "Run substrate before ambiguous, expensive, multi-file, or mutating work.", diff --git a/src/preflight.js b/src/preflight.js index 098976b..fa7b620 100644 --- a/src/preflight.js +++ b/src/preflight.js @@ -345,62 +345,72 @@ export function assessTaskJev(task, { llm, call } = {}) { } /** - * Verify-don't-trust reconcile for M2. The model may only move completeness within ±band of the - * deterministic score, so a clearly-specified or clearly-vague task can never be flipped — only a - * borderline reading shifts. In `bidirectional` mode (default) the ask is recomputed purely from - * that bounded completeness, so a verified reading can also CLEAR a false ask — but two hard - * floors the model can never override still force the ask: a task with no concrete anchor - * (`hardUnderspecified`), or one naming symbols/files the repo doesn't define (`hasUnresolved`). - * With `bidirectional:false` the gate only ever tightens (the conservative pre-bidirectional - * behaviour). Extra questions survive only if they map to a rubric-flagged dimension or (via + * Minimum probability a proposer must put on its OWN verdict (ask vs proceed) before it may flip + * the rubric's. An a-priori conservative default, NOT fit to any data: the right value has to be + * chosen on fresh labelled tasks (the frozen held-out set is spent). Configurable per call and via + * `llm.minConfidence` in source/substrate.json (0 disables the gate). + */ +export const GATE_MIN_CONFIDENCE = 0.8; + +/** + * Verify-don't-trust reconcile for M2, band to band. The rubric's completeness and a proposer's + * are on different scales — the rubric's logistic saturates on real issues (median ≈ 0.98) while a + * proposer's reading is a probability that the task is specified, centred on 0.5 — so each is + * judged against its OWN threshold and only the two verdicts are compared (the old ±band clamp of + * one onto the other pinned the proposer to the edge of the band and left it no say). When they + * disagree, the proposer's verdict wins only if it holds it with probability ≥ `minConfidence`: + * - TIGHTEN (rubric proceeds, proposer asks) — always allowed; caution can only grow. + * - CLEAR (rubric asks, proposer proceeds) — only in `bidirectional` mode, and never past two + * floors: a task with no concrete anchor (`hardUnderspecified`), or one naming symbols/files + * the repo doesn't define (`hasUnresolved`). The floors guard clearing ONLY — they never force + * an ask the rubric didn't raise (a rename's new name is unresolved by definition). + * The reported `completeness`/`risk` stay the rubric's; the proposer's reading rides along in + * provenance. Extra questions survive only if they map to a rubric-flagged dimension or (via * `grounded`) reference a real repo entity. * @param {object} det - assessTask() result * @param {{completeness:number, missing:string[], questions:string[], provider?:string}|null} proposal * @param {object} [opts] - * @param {number} [opts.askThreshold] - * @param {number} [opts.band] * @param {(q:string)=>boolean} [opts.grounded] * @param {boolean} [opts.bidirectional] * @param {boolean} [opts.hasUnresolved] + * @param {number} [opts.minConfidence] */ export function reconcileAssumption( det, proposal, { - askThreshold = 0.6, - band = 0.25, grounded = () => false, bidirectional = true, hasUnresolved = false, + minConfidence = GATE_MIN_CONFIDENCE, } = {}, ) { if (!proposal) return { ...det, provenance: { path: "deterministic" } }; - const bounded = Math.max( - det.completeness - band, - Math.min(det.completeness + band, proposal.completeness), - ); - const completeness = Math.max(0, Math.min(1, bounded)); + const p = proposal.completeness; // P(specified) on the proposer's own scale + const modelAsk = p < 0.5; + const confidence = modelAsk ? 1 - p : p; const flaggedDims = new Set(det.missing.map((m) => m.key)); const extraQuestions = proposal.questions.filter( (q) => proposal.missing.some((m) => flaggedDims.has(m)) || grounded(q), ); const questions = [...new Set([...det.questions, ...extraQuestions])].slice(0, 3); - // Bidirectional (default): the ask follows the bounded completeness, guarded by two floors the - // model can't override. Tighten-only: the rubric's ask always stands, the model can only add one. - const shouldAsk = bidirectional - ? det.hardUnderspecified || hasUnresolved || completeness < askThreshold - : det.shouldAsk || det.hardUnderspecified || completeness < askThreshold; - const risk = completeness < 0.45 ? "high" : completeness < 0.7 ? "medium" : "low"; - const moved = - Math.abs(completeness - det.completeness) > 1e-9 || questions.length !== det.questions.length; + let shouldAsk = det.shouldAsk; + let overruledBy = null; + if (modelAsk !== det.shouldAsk) { + if (minConfidence > 0 && confidence < minConfidence) overruledBy = "confidence"; + else if (modelAsk) shouldAsk = true; + else if (!bidirectional) overruledBy = "bidirectional-off"; + else if (det.hardUnderspecified) overruledBy = "no-anchor"; + else if (hasUnresolved) overruledBy = "unresolved-entities"; + else shouldAsk = false; + } let path; if (shouldAsk && !det.shouldAsk) path = "llm-tightened"; else if (!shouldAsk && det.shouldAsk) path = "llm-cleared"; - else path = moved ? "llm-verified" : "llm-agreed"; + else if (overruledBy) path = "llm-overruled"; + else path = questions.length !== det.questions.length ? "llm-verified" : "llm-agreed"; return { ...det, - completeness, - risk, shouldAsk, questions: shouldAsk && !questions.length @@ -409,6 +419,8 @@ export function reconcileAssumption( provenance: { path, detCompleteness: det.completeness, + proposalCompleteness: p, + ...(overruledBy ? { overruledBy } : {}), ...(proposal.provider ? { provider: proposal.provider } : {}), }, }; @@ -465,7 +477,7 @@ export function clarifyBlock(result, { threshold = 0.5 } = {}) { * @param {(p:string)=>string} [opts.run] * @param {(payload:object)=>object} [opts.jevCall] injectable Jev transport (tests) * @param {boolean} [opts.bidirectional] - * @param {number} [opts.band] + * @param {number} [opts.minConfidence] probability a proposer needs on its verdict to flip the rubric's */ export function preflightRepo( root, @@ -479,7 +491,7 @@ export function preflightRepo( run, jevCall, bidirectional = true, - band, + minConfidence, } = {}, ) { const atlas = loadAtlas(root) || (allowBuild ? buildAtlas({ root }) : null); @@ -507,17 +519,17 @@ export function preflightRepo( const { symbols, files } = referencedEntities(q); return symbols.some(hasSymbol) || files.some((f) => existsSync(join(root, f))); }; - // Repo grounding is a hard floor on clearing: if the task names entities the repo lacks, the - // model can never wave the gate through no matter how "complete" it judges the prose. + // Repo grounding is a hard floor on CLEARING: if the task names entities the repo lacks, the + // model can never wave a rubric ask through no matter how "complete" it judges the prose. It is + // not a reason to ask by itself — the rubric already weighed the task without it. const hasUnresolved = gap.unresolved.symbols.length + gap.unresolved.files.length > 0; return { ...gap, assumption: reconcileAssumption(det, proposal, { - askThreshold, grounded, bidirectional, hasUnresolved, - ...(typeof band === "number" ? { band } : {}), + ...(typeof minConfidence === "number" ? { minConfidence } : {}), }), }; } diff --git a/src/substrate.js b/src/substrate.js index 0744a28..5bba3d8 100644 --- a/src/substrate.js +++ b/src/substrate.js @@ -230,7 +230,6 @@ export function substrateCheck( model, timeoutMs, bidirectional: bi, - band: spec?.llm?.band, minConfidence: spec?.llm?.minConfidence, signalFloor: spec?.llm?.signalFloor, }; @@ -419,7 +418,7 @@ export function substrateCheck( // Proposed by a model, then checked against the repo/graph/tests before it could move a // verdict — safe to surface, never blindly trusted (whitepaper tabayyun gate). llmVerified: [ - "assumption refinement (bounded ±band; clears a false ask only past the no-anchor + repo-grounding floors)", + "assumption refinement (verdict vs verdict, confidence-gated; clears a false ask only past the no-anchor + repo-grounding floors)", "routing (band-to-band; a confident lower vote only, never below the strong-signal floor; raises deferred to a verifier failure)", "impact edges (graph + grep verified)", "goal-drift rescue (off→on, goal-referenced)", diff --git a/test/jev.test.js b/test/jev.test.js index efa6304..cf5e4b0 100644 --- a/test/jev.test.js +++ b/test/jev.test.js @@ -262,17 +262,15 @@ test("preflightRepo (llm on): the Jev reading flows through reconcileAssumption }), }); assert.equal(r.assumption.provenance.provider, "jev"); - // Verify-don't-trust: the Jev reading (0.9) is bounded to within ±band of the - // deterministic completeness — it lifts, but can never flip the gate on its own. + // Verify-don't-trust: the Jev reading (mean noul 0.9) is compared verdict to verdict — the + // rubric keeps its own completeness, Jev's rides along, and the no-anchor floor keeps a + // four-word "fix the login bug" asked however complete Jev judges it. const det = preflightRepo(root, "fix the login bug", { allowBuild: false, llm: false }); - assert.ok( - r.assumption.completeness > det.assumption.completeness, - `lifts past deterministic ${det.assumption.completeness}, got ${r.assumption.completeness}`, - ); - assert.ok( - r.assumption.completeness <= det.assumption.completeness + 0.25 + 1e-9, - "but never beyond the reconcile band", - ); + assert.equal(r.assumption.completeness, det.assumption.completeness); + assert.ok(Math.abs(r.assumption.provenance.proposalCompleteness - 0.9) < 1e-9); + assert.equal(r.assumption.shouldAsk, true); + assert.equal(r.assumption.provenance.path, "llm-overruled"); + assert.equal(r.assumption.provenance.overruledBy, "no-anchor"); }); // --- routing reconcile through a typed Jev vote (deep review D2–D5) --- diff --git a/test/preflight.test.js b/test/preflight.test.js index 8fd78df..95aa125 100644 --- a/test/preflight.test.js +++ b/test/preflight.test.js @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; @@ -153,16 +153,16 @@ test("assessTaskLLM: parses a completeness reading, rejects junk", () => { assert.equal(assessTaskLLM("x", { run: () => '{"completeness":"nope"}' }), null); }); -test("reconcileAssumption: model can only move completeness within ±band of the rubric", () => { - const det = assessTask("Fix the bug."); // under-specified, low completeness, shouldAsk - // Model claims fully specified — bounded, cannot flip a clearly-vague task to 1.0. - const r = reconcileAssumption( - det, - { completeness: 1, missing: [], questions: [] }, - { band: 0.25 }, - ); - assert.ok(r.completeness <= det.completeness + 0.25 + 1e-9, "clamped to +band"); - assert.equal(r.provenance.path, "llm-verified"); +test("reconcileAssumption: verdicts are compared, never the two scales blended", () => { + const det = assessTask("Fix the bug."); // under-specified, no anchor, shouldAsk + // Model claims fully specified — the no-anchor floor keeps the ask, and the reported + // completeness stays the rubric's own; the proposer's reading rides along in provenance. + const r = reconcileAssumption(det, { completeness: 1, missing: [], questions: [] }); + assert.equal(r.shouldAsk, true); + assert.equal(r.completeness, det.completeness); + assert.equal(r.provenance.proposalCompleteness, 1); + assert.equal(r.provenance.path, "llm-overruled"); + assert.equal(r.provenance.overruledBy, "no-anchor"); }); test("reconcileAssumption: never clears a deterministic / hard-underspecified ask", () => { @@ -269,28 +269,82 @@ test("bidirectional: an unresolved-entity task is NEVER cleared (repo grounding assert.equal(r.shouldAsk, true, "names symbols/files the repo lacks → still asks"); }); -test("bidirectional: a genuinely vague task can't be lifted over the line (band clamp)", () => { +test("bidirectional: an unconfident reading can't lift a vague task over the line", () => { const det = detStub({ completeness: 0.2, shouldAsk: true }); - const r = reconcileAssumption( + const r = reconcileAssumption(det, { completeness: 0.7, missing: [], questions: [] }); + assert.equal(r.shouldAsk, true, "p(proceed) 0.7 < minConfidence 0.8 → the rubric's ask stands"); + assert.equal(r.provenance.overruledBy, "confidence"); + const opened = reconcileAssumption( det, - { completeness: 1, missing: [], questions: [] }, - { band: 0.25 }, + { completeness: 0.7, missing: [], questions: [] }, + { minConfidence: 0.6 }, ); - assert.ok(r.completeness <= 0.45 + 1e-9, "clamped to det+band"); - assert.equal(r.shouldAsk, true, "0.45 < 0.6 threshold → still asks"); + assert.equal(opened.shouldAsk, false, "the threshold is configurable"); }); test("bidirectional: the model can still TIGHTEN a rubric-proceed task into an ask", () => { const det = detStub({ completeness: 0.7, shouldAsk: false, questions: [] }); - const r = reconcileAssumption( - det, - { completeness: 0.4, missing: [], questions: [] }, - { band: 0.25 }, - ); - assert.equal(r.shouldAsk, true, "lowered below threshold → now asks"); + const r = reconcileAssumption(det, { completeness: 0.1, missing: [], questions: [] }); + assert.equal(r.shouldAsk, true, "a confident 'unspecified' reading → now asks"); assert.equal(r.provenance.path, "llm-tightened"); }); +// --- deep review D6: repo grounding floors CLEARING only; it never forces an ask --- + +test("D6: unresolved entities never force an ask the rubric and the model both cleared", () => { + const det = detStub({ completeness: 0.9, shouldAsk: false, questions: [] }); + const complete = { completeness: 0.99, missing: [], questions: [] }; + for (const bidirectional of [true, false]) { + const r = reconcileAssumption(det, complete, { hasUnresolved: true, bidirectional }); + assert.equal(r.shouldAsk, false, `bidirectional:${bidirectional} — no ask out of thin air`); + assert.equal(r.provenance.path, "llm-agreed"); + } + // ...while it still blocks CLEARING a rubric ask, in both modes. + const asking = detStub({ completeness: 0.5, shouldAsk: true }); + for (const bidirectional of [true, false]) { + const r = reconcileAssumption(asking, complete, { hasUnresolved: true, bidirectional }); + assert.equal(r.shouldAsk, true); + } +}); + +test("D6 (integration): a grounded rename with a background URL is not asked when the model agrees", () => { + const root = mkdtempSync(join(tmpdir(), "forge-pre-d6-")); + mkdirSync(join(root, "src")); + writeFileSync(join(root, "src", "util.js"), "export function clamp01(x) { return x; }\n"); + // `clampUnit` is the rename TARGET — unresolved by definition — and the URL is background. + const task = + "Rename the helper `clamp01` in src/util.js to `clampUnit` and update every caller; " + + "the existing tests must pass unchanged. Background: https://example.com/issue/12"; + const off = preflightRepo(root, task, { llm: false }); + assert.equal(off.assumption.shouldAsk, false, "precondition: the rubric proceeds"); + const on = preflightRepo(root, task, { + llm: true, + run: () => '{"completeness":0.99,"missing":[],"questions":[]}', + }); + assert.equal(on.assumption.shouldAsk, false, "a unanimous proceed is not turned into an ask"); + assert.notEqual(on.assumption.provenance.path, "llm-tightened"); +}); + +// --- deep review D8: the proposer is judged on its own scale, not clipped to det±band --- + +test("D8: a saturated rubric no longer pins a confident proposer to det − band", () => { + const det = detStub({ completeness: 0.98, shouldAsk: false, questions: [] }); + const tight = reconcileAssumption(det, { completeness: 0.05, missing: [], questions: [] }); + assert.equal( + tight.shouldAsk, + true, + "a confident 'unspecified' reading tightens a saturated rubric", + ); + assert.equal(tight.completeness, 0.98, "and the rubric's number is not dragged onto its scale"); + const median = reconcileAssumption(det, { completeness: 0.29, missing: [], questions: [] }); + assert.equal(median.completeness, 0.98, "no clip to det − 0.25"); + assert.equal(median.provenance.proposalCompleteness, 0.29); + assert.equal(median.shouldAsk, false, "p(ask) 0.71 is below the confidence gate"); + const low = detStub({ completeness: 0.1, shouldAsk: true }); + const cleared = reconcileAssumption(low, { completeness: 0.95, missing: [], questions: [] }); + assert.equal(cleared.shouldAsk, false, "a confident reading can clear below det 0.35 too"); +}); + test("bidirectional:false — the model can never clear a deterministic ask", () => { const det = detStub({ completeness: 0.5, shouldAsk: true }); const r = reconcileAssumption( From d44d60d303ad66ba5d43facbb71e79098e1d2cb1 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:34:57 +0200 Subject: [PATCH 11/72] fix(cortex): hand the Stop payload to the detached hook (C1) `(node "$entry" stop &)` in a non-interactive shell gets /dev/null as stdin, so every Stop payload was empty, the session id fell back to "default", and the real session was never processed: no episodes, no lessons, no deja summary, and the session log was never cleared. Read the payload before detaching and pipe it into the background node. Add a shim-level test that drives `node run.mjs cortex.sh ...` the way Claude Code does (the existing tests piped into cortex_hook_main.js directly and could not see this). Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 10 ++++++++++ global/guards/cortex.sh | 7 ++++++- test/guards.test.js | 44 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 766f50b..d5f9236 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,16 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). were corrected: `doctor` asserted a global `failed === 0` to prove a local property about `na` rows, and a comment in `substrate` claimed no runner reaches the real CLI — the opposite of the truth, and the reason that file spent 85s on live calls. +- **The learning loop runs in real installs again.** `cortex.sh` runs the Stop hook detached, + as `(node … stop &)`, and a background job in a non-interactive shell gets `/dev/null` as + stdin. So every Stop payload arrived empty, the session id fell back to `"default"`, and + the real session was never processed. No correction episodes, lessons, contradictions or + deja summaries were written, and `.forge/sessions/.jsonl` was never cleared. The tests + missed it because they piped into `cortex_hook_main.js` directly. The shim now reads the + payload before detaching and pipes it into the background node. A new shim-level test + drives `node run.mjs cortex.sh …` the way Claude Code does: before the fix the session log + was still there 30 s after Stop; now it is consumed and `episodes.jsonl` is written in + about 3 s, on Linux and under Git Bash on Windows. - **Lockfile commits are no longer refused as leaking a secret.** The entropy leg flagged content-integrity digests as secrets: 100% of package-lock and yarn.lock `sha512-` hashes, 99% of SRI `sha384-` and 88% of go.sum `h1:` hashes. The real `left-pad@1.3.0` integrity diff --git a/global/guards/cortex.sh b/global/guards/cortex.sh index 1c463c7..5a2a8c7 100755 --- a/global/guards/cortex.sh +++ b/global/guards/cortex.sh @@ -9,8 +9,13 @@ DIR="$(cd "$(dirname "$0")" && pwd -P)" entry="$DIR/../../src/cortex_hook_main.js" # `stop` may distill lessons (an opt-in model call) — run it detached so session exit is # never delayed. Other modes are fast and (session-start) must return stdout synchronously. +# The payload is read HERE, before detaching: a background job in a non-interactive shell +# gets /dev/null as stdin (POSIX, job control off), so `(node … stop &)` used to see an +# empty payload, fall back to session "default", and never process the real session — +# no lessons, no episodes, and the session log was never cleared. if [ "$1" = "stop" ]; then - (node "$entry" stop >/dev/null 2>&1 &) + payload="$(cat)" + (printf '%s' "$payload" | node "$entry" stop >/dev/null 2>&1 &) else node "$entry" "$1" 2>/dev/null || true fi diff --git a/test/guards.test.js b/test/guards.test.js index b35855a..f7d8c54 100644 --- a/test/guards.test.js +++ b/test/guards.test.js @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { test } from "node:test"; @@ -244,3 +245,44 @@ test("lean-guard is non-blocking outside a git repo (exit 0)", () => { const r = runGuard("lean-guard.sh", {}, { cwd: tmpdir() }); assert.equal(r.code, 0); }); + +// ── The cortex hook SHIM (cortex.sh), driven exactly as Claude Code drives it: `node run.mjs +// cortex.sh ` with the hook JSON on stdin. The entrypoint tests pipe straight into +// node and so could never see a shim bug — and there was one: `stop` runs detached, and a +// background job in a non-interactive shell gets /dev/null as stdin, so the Stop payload was +// lost and the REAL session was never processed in any install (no episodes, no lessons, +// the session log never cleared). +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +test("cortex.sh stop (detached) processes the REAL session from the Stop payload (C1)", async () => { + const root = mkdtempSync(join(tmpdir(), "forge-shim-")); + execFileSync("git", ["init", "-q"], { cwd: root }); + const sid = "shim-c1"; + const hook = (mode, payload = {}) => + spawnSync("node", [join(guards, "run.mjs"), join(guards, "cortex.sh"), mode], { + input: JSON.stringify({ cwd: root, session_id: sid, ...payload }), + encoding: "utf8", + }); + for (let i = 0; i < 3; i++) + hook("capture", { tool_name: "Edit", tool_input: { file_path: "src/a.js" } }); + hook("prompt", { prompt: "that's wrong, undo it" }); + const sessions = join(root, ".forge", "sessions"); + const log = join(sessions, `${sid}.jsonl`); + assert.ok(existsSync(log), "capture/prompt logged the session through the shim"); + + const r = hook("stop"); + assert.equal(r.status, 0, "the Stop shim never fails the session"); + // Detached by design — poll for the background run to finish the real session. + const deadline = Date.now() + 30000; + while (existsSync(log) && Date.now() < deadline) await sleep(100); + assert.equal(existsSync(log), false, "the real session's log was consumed and cleared"); + assert.ok( + existsSync(join(root, ".forge", "lessons", "episodes.jsonl")), + "episodes were recorded for the real session", + ); + assert.equal( + existsSync(join(sessions, "default.jsonl")), + false, + "nothing fell back to the shared 'default' session", + ); +}); From f29fbb05f7c8dae295952f58102a0c8ce6502ff9 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:35:20 +0200 Subject: [PATCH 12/72] =?UTF-8?q?fix(ledger)!:=20evidence=20trust=20?= =?UTF-8?q?=E2=80=94=20only=20resolved=20refs=20lift=20val;=20serving=20an?= =?UTF-8?q?d=20MCP=20never=20confirm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security fixes from review C2: - refStrength: "resolved" now means forge re-derived the pointer — a git object id (resolved at append/import, re-resolved by verify) or a bridge pointer on its own bridge oracle (episode:/cortex.episode, legacy:/legacy.import). Untyped and unknown-prefix refs, ci:, human:, test:, file: and symbolic git:HEAD count at format strength and are capped below the serving floor. An agent: identity never supplies human-family evidence at resolved strength. - reuseQuery no longer appends a graph.reval confirm on every serve; only a failed revalidation is written back. mintArtifact reports `serves` from the val the proof actually earns. - MCP forge_ledger_ratify / forge_ledger_retract are stamped agent:mcp and only propose: ratify mints a distinct agent-proposed decision (no val change); retract requires one exact 64-char id and records a pending retraction proposal (claim stays live) surfaced by ledger query, stats and show. `forge ledger retract` (human, CLI) now also requires the full id. - getClaimByPrefix refuses an ambiguous prefix instead of returning the first sorted match. BREAKING CHANGE: evidence citing untyped/ci:/human:/session: refs no longer lifts a claim past 0.55; cite a git object id for full-strength evidence. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 31 +++++++++++ docs/GUIDE.md | 18 ++++--- src/cli.js | 29 +++++++++-- src/cortex_mcp.js | 41 +++++++++------ src/ledger.js | 82 ++++++++++++++++++----------- src/ledger_store.js | 93 +++++++++++++++++++++++++++------ src/mcp_tools.js | 17 ++++-- src/reuse.js | 22 ++++---- test/cli_ledger.test.js | 26 ++++++++++ test/context.test.js | 15 ++++-- test/cortex_mcp.test.js | 105 +++++++++++++++++++++++++++++++------- test/embed.test.js | 31 ++++++++--- test/ledger.test.js | 68 ++++++++++++++++++++---- test/ledger_read.test.js | 10 ++-- test/ledger_store.test.js | 16 ++++++ test/reuse.test.js | 54 +++++++++++++++----- 16 files changed, 516 insertions(+), 142 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9eb533..cc1e752 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,37 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `na` rows, and a comment in `substrate` claimed no runner reaches the real CLI — the opposite of the truth, and the reason that file spent 85s on live calls. +### Security + +- **Only evidence forge actually resolved can lift a claim into the trusted band.** Any + untyped or unknown-prefix ref counted as fully resolved: `lgtm`, `session:x`, `ci:1`, + `human:claude@yes` and `git:HEAD` each took one confirm to val 0.643, and + `forge reuse mint --ref lgtm` was served at tier exact. "Resolved" now means forge + re-derived the pointer — a `git:` object id, resolved at every append/import gate and + re-resolved by `verify` — plus the two bridge pointers on their own bridge oracle + (`episode:` ↔ `cortex.episode`, `legacy:` ↔ `legacy.import`). Everything else, including + `ci:`/`human:` locators and symbolic `git:HEAD`, counts at format strength and is capped at + 0.55, below the 0.6 serving floor; an `agent:` identity never supplies human-family evidence + at full strength. The review's three hand-written `human.accept` lines now reach 0.55 + instead of 0.787. What remains: a hand-written line citing a real commit sha still counts — + closing that needs signed evidence (key infrastructure), which this release does not add. +- **Serving a cached artifact no longer confirms it.** Every `forge reuse` hit appended a + passing `graph.reval` confirm, so ten daily serves moved val from 0.643 to 0.864 and an + artifact stayed served (0.710, tier exact) after two failing test runs. Only a failed + revalidation is written back (as a contradiction); ten serves now append nothing, and the + same two failing runs drop it to 0.427 — a miss. `mintArtifact` reports `serves` from the + confidence the proof actually earns instead of "some evidence was passed". +- **The MCP ledger write tools act as the agent and only propose.** `forge_ledger_ratify` and + `forge_ledger_retract` ran under the human's `gitAuthor()`; retract accepted any 2-character + prefix and permanently tombstoned the first sorted match, and ratify's description promised a + confidence change it never made. Both are now stamped `agent:mcp`. Ratify mints a distinct + agent-proposed decision (never deduped into, or counted as, a human ratification) and says it + changes no confidence. Retract requires one exact 64-character id and records a + pending-retraction proposal — the claim stays live, val unchanged — shown by `forge_ledger_query`, + `forge ledger stats` and `forge ledger show` until a human runs `forge ledger retract`, which + now also requires the full id. `getClaimByPrefix` refuses an ambiguous prefix instead of + returning the first sorted match. + ### Documentation - `CLAUDE.md`: Biome 2.5.2 → 2.5.5 (matching the pin), "600+ tests" → "1000+", and the lint diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 8c1bec9..ea8fd12 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -834,11 +834,15 @@ $ forge ledger diff 2026-07-01 The rest of the surface, briefly: `forge ledger merge ` folds in any other ledger tree (a teammate's checkout, a worktree, a backup) — `merged: 3 new claim(s), 5 new record(s) — conflict-free`, in any order; `query ""` ranks live claims by the -paper's Eq. 3; `show ` prints one claim with its computed `val`; `ratify ` and -`retract ` are the human oracle — a manual accept or revert that appends evidence and -moves confidence; `verify` recomputes every content hash (CI-friendly, exit 1 on -tampering); `import` back-fills legacy lessons/facts idempotently. Add `--personal` to -target the per-user ledger beside the global recall store, `--json` for scripts. +paper's Eq. 3; `show ` prints one claim with its computed `val`; `ratify ` records a +human ratification (a `decision` claim under your git identity — it does not change the +claim's `val`) and `retract --reason ""` tombstones exactly one claim (a +prefix is refused — a tombstone is permanent). The MCP twins only *propose*: they are +stamped `agent:mcp`, change no confidence, and a retraction proposal stays pending (shown +by `stats` and `show`) until a human runs `retract`; `verify` recomputes every content +hash (CI-friendly, exit 1 on tampering); `import` back-fills legacy lessons/facts +idempotently. Add `--personal` to target the per-user ledger beside the global recall store, +`--json` for scripts. `forge ledger sync` is `merge` without a path argument — a transport that moves the CRDT state between machines. Target precedence: `--dir ` (a shared folder, bidirectional @@ -1300,10 +1304,10 @@ emitted `.mcp.json`): | `forge_doctor` | Health check — verify installed tools, guards, MCP auth, config drift, and system state. | | `forge_provider_status` | Provider detection — which API provider is active (auto-detected or configured), env vars set, and health checks. | | `forge_remember` | Store a durable fact in this repo's portable memory (.forge/brain/). | -| `forge_ledger_ratify` | Promote a ledger claim's confidence — record an independent oracle ratification (the claim held under test). | +| `forge_ledger_ratify` | Propose a ratification of a ledger claim as agent:mcp (never as the human) — mints a decision claim and does NOT change the claim's confidence. | | `collide_check` | Parallel-session conflict radar — which recent teammate/agent sessions touched the files (or their import neighbors) you are about to edit, from the team-merged Forge ledger. | | `rank_code` | Which code is load-bearing and dangerous to touch — PageRank centrality over the Forge atlas graph joined with past-incident history from the evidence ledger, plus circular-dependency clusters and chokepoint files whose removal disconnects the import graph. | -| `forge_ledger_retract` | Tombstone a ledger claim with a reason — mark it as no longer valid so it stops influencing routing and memory. | +| `forge_ledger_retract` | Propose retracting one ledger claim, named by its full 64-char id, as agent:mcp — the claim stays live with unchanged confidence until a human runs `forge ledger retract --reason …`. | Forge never pretends it can force a hook into a tool that has none — **ambient on Claude diff --git a/src/cli.js b/src/cli.js index 368b8f3..81ddbdc 100755 --- a/src/cli.js +++ b/src/cli.js @@ -705,6 +705,13 @@ HANDLERS.ledger = async (argv) => { heading(`${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}`); + if (s.pendingRetractions) + console.log( + paint( + ` ${s.pendingRetractions} claim(s) with an agent-proposed retraction — review, then \`forge ledger retract --reason …\``, + "warn", + ), + ); console.log( ` val: ${paint(`trusted ${s.val.trusted}`, "ok")} · ${paint(`uncertain ${s.val.uncertain}`, "warn")} · ${paint(`dormant ${s.val.dormant}`, "dim")}`, ); @@ -732,7 +739,14 @@ HANDLERS.ledger = async (argv) => { return; } const { val } = await import("./ledger.js"); - return console.log(JSON.stringify({ ...hit, val: val(hit, nowDay) }, null, 2)); + const pending = ls.retractionProposals(ls.loadClaims(dir)).get(hit.id); + return console.log( + JSON.stringify( + { ...hit, val: val(hit, nowDay), ...(pending ? { pendingRetractions: pending } : {}) }, + null, + 2, + ), + ); } if (sub === "merge") { const src = args[2]; @@ -814,8 +828,17 @@ HANDLERS.ledger = async (argv) => { const id = args[2]; const ri = args.indexOf("--reason"); const reason = ri >= 0 ? (args[ri + 1] ?? "") : ""; - if (!id || id.length < 2 || id === "--reason" || !reason) { - console.error('usage: forge ledger retract --reason ""'); + if (!id || id === "--reason" || !reason) { + console.error('usage: forge ledger retract --reason ""'); + process.exitCode = 1; + return; + } + // A tombstone is permanent, so it must name exactly one claim: the full 64-char id, + // never a prefix (a short prefix used to retract the first sorted match). + if (!ls.FULL_ID_RE.test(id)) { + console.error( + ` refused: retract needs the full 64-character claim id (got "${id}") — see \`forge ledger query\` or \`forge ledger show \``, + ); process.exitCode = 1; return; } diff --git a/src/cortex_mcp.js b/src/cortex_mcp.js index 8c489cb..f039d4b 100644 --- a/src/cortex_mcp.js +++ b/src/cortex_mcp.js @@ -87,7 +87,7 @@ async function callTool(name, args = {}) { } if (name === "forge_ledger_query") { try { - const { loadClaims, repoLedger } = await import("./ledger_store.js"); + const { loadClaims, repoLedger, retractionProposals } = await import("./ledger_store.js"); const { retrieve, claimText } = await import("./ledger.js"); const { claimSim, simLabel } = await import("./embed.js"); const dir = repoLedger(root); @@ -95,6 +95,7 @@ async function callTool(name, args = {}) { const claims = loadClaims(dir); const sim = claimSim(root, q, claims, claimText); const ranked = retrieve(q, claims, { nowDay: today(), budget: 8, sim }); + const pending = retractionProposals(claims); return JSON.stringify( { sim: simLabel(sim), @@ -103,6 +104,10 @@ async function callTool(name, args = {}) { kind: r.claim.kind, score: r.score, text: claimText(r.claim).slice(0, 200), + // An agent proposed retracting this claim; it stays live until a human confirms. + ...(pending.has(r.claim.id) + ? { pendingRetraction: pending.get(r.claim.id).map((p) => p.reason) } + : {}), })), }, null, @@ -164,27 +169,31 @@ async function callTool(name, args = {}) { const { rankReport } = await import("./rank.js"); return JSON.stringify(rankReport(root, { top: Number(args.top ?? 15) || 15 }), null, 2); } + // The two agent-callable ledger WRITES act as the agent, never as the human: both are + // stamped `agent:mcp` (not gitAuthor()), neither changes any claim's confidence, and + // neither is final — a human ratifies or retracts with `forge ledger ratify|retract`. if (name === "forge_ledger_ratify") { - const { ratify, repoLedger, getClaimByPrefix } = await import("./ledger_store.js"); - const { gitAuthor } = await import("./util.js"); - const dir = repoLedger(root); - const claim = getClaimByPrefix(dir, String(args.id ?? "")); - if (!claim) return `No claim matching prefix "${args.id}".`; - ratify(dir, claim.id, { author: gitAuthor(), t: today() }); - return `Ratified claim ${claim.id}.`; + const { ratify, repoLedger, MCP_AUTHOR } = await import("./ledger_store.js"); + const r = ratify(repoLedger(root), String(args.id ?? ""), { + author: MCP_AUTHOR, + agent: "mcp", + note: "agent-proposed via MCP — not a human ratification", + t: today(), + }); + if (!r.ok) return `No claim matching "${args.id}" (unknown or ambiguous prefix).`; + return `Recorded an agent-proposed ratification of ${r.ratifies} as decision ${r.decisionId} (author ${MCP_AUTHOR}). This is not a human ratification and does not change the claim's confidence — a human ratifies with \`forge ledger ratify \`.`; } if (name === "forge_ledger_retract") { - const { tombstone, repoLedger, getClaimByPrefix } = await import("./ledger_store.js"); - const { gitAuthor } = await import("./util.js"); - const dir = repoLedger(root); - const claim = getClaimByPrefix(dir, String(args.id ?? "")); - if (!claim) return `No claim matching prefix "${args.id}".`; - tombstone(dir, claim.id, { - author: gitAuthor(), + const { proposeRetraction, repoLedger, FULL_ID_RE } = await import("./ledger_store.js"); + const id = String(args.id ?? "").trim(); + if (!FULL_ID_RE.test(id)) + return `Refused: forge_ledger_retract needs one full 64-character claim id (got "${id}") — prefixes are never accepted, so a retraction can't land on a claim nobody named. forge_ledger_query returns full ids.`; + const r = proposeRetraction(repoLedger(root), id, { reason: String(args.reason ?? ""), t: today(), }); - return `Retracted claim ${claim.id}: ${args.reason}`; + if (!r.ok) return `No claim matching "${id}".`; + return `Proposed retraction of claim ${id} (proposal ${r.proposalId}, author agent:mcp): ${args.reason}. The claim stays live until a human confirms with \`forge ledger retract ${id} --reason "…"\`.`; } return null; } diff --git a/src/ledger.js b/src/ledger.js index dd31706..79b1986 100644 --- a/src/ledger.js +++ b/src/ledger.js @@ -149,14 +149,14 @@ export function mintClaim({ kind, body, scope = {}, provenance = {}, t = 0 }) { }; } -// Typed evidence refs are `:`. Only these types are recognized; anything -// else (or a ref with no `type:` prefix) is treated as an untyped/legacy ref and accepted -// unchanged for back-compat. `git:` is the one type forge can cheaply AND soundly resolve — -// the object must exist in THIS repo — so it is ALWAYS resolved when a resolver is supplied. -// The rest now carry real FORMAT grammars (ME-05): `ci:` must be a CI locator, `human:` +// Typed evidence refs are `:`. Only these types are format-checked; anything +// else (or a ref with no `type:` prefix) is accepted for back-compat but counts only at +// FORMAT strength (see refStrength). `git:` is the one type forge can cheaply AND soundly +// resolve — the object must exist in THIS repo — so it is ALWAYS resolved when a resolver is +// supplied. The rest carry FORMAT grammars (ME-05): `ci:` must be a CI locator, `human:` // must be an explicit ratification, `file:` must resolve to an existing path when a repo -// root is available. A `test:` run id remains format-only — it is unverifiable — which is -// why it can never lift confidence into the trusted band (see refStrength/val). +// root is available. None of those proves the claim, so none lifts confidence into the +// trusted band on its own (see refStrength/val). export const REF_TYPES = new Set(["git", "file", "test", "ci", "human"]); // A `ci:` ref must be a real CI locator: an http(s) URL, an `owner/repo@run` reference, @@ -216,26 +216,48 @@ export function validateRef(ref, { resolveGit, resolveFile } = {}) { return { ok: true }; } -// The trust model (ME-05): record-integrity validity (validateRef/validOutcome) is NOT the -// same as evidence being RESOLVED. Only resolved evidence may lift confidence into the -// trusted/serving band. Two tiers, both re-derived PURELY from the ref so a forged log line -// can never buy a strength it isn't entitled to (same discipline as the ORACLES weights): -// - RESOLVED: untyped/legacy (historical trust), `git:` (resolved at the append gate — -// the one soundly-resolvable type), `ci:` (only well-formed CI locators pass validateRef), -// and `human:` (only explicit ratifications pass). These count at full weight. -// - FORMAT-ONLY: `file:` and `test:`. A path existing or a run id being well-formed is -// record integrity, NOT proof the claim is true, and pure val() cannot re-check existence. -// These count at a REDUCED weight and, on their own, are capped below the serving floor. -const RESOLVED_REF_TYPES = new Set(["git", "ci", "human"]); - -/** Resolution strength of a ref for confidence weighting: "resolved" or "format". - * Pure and total — never throws, never does I/O. */ -export function refStrength(ref) { - const parsed = parseRef(ref); - if (!parsed) return "resolved"; // untyped/legacy — historical full trust - return RESOLVED_REF_TYPES.has(parsed.type) ? "resolved" : "format"; +// The trust model (ME-05, tightened after review C2): record-integrity validity +// (validateRef/validOutcome) is NOT the same as evidence being RESOLVED. Only resolved +// evidence may lift confidence into the trusted/serving band, and "resolved" means FORGE +// re-derived the pointer — not that someone typed a plausible string. Two tiers, both +// re-derived PURELY from the record so a forged log line can never buy a strength it isn't +// entitled to (same discipline as the ORACLES weights): +// - RESOLVED: a `git:` ref naming an OBJECT ID (hex, 7–64 chars). It is resolved against +// this repo at every append/import gate and re-resolved by verify(). A symbolic revision +// (`git:HEAD`, `git:main`) names no fixed object — HEAD moves — so it is not resolved. +// Also the two bridge pointers, and only on the bridge oracle that mints them +// (`episode:` ↔ cortex.episode, `legacy:` ↔ legacy.import): forge's own observers, whose +// deliberately conservative table weight (0.5) already is the discount. +// - FORMAT-ONLY: everything else. Untyped refs (`lgtm`), unknown prefixes (`session:x`), +// and the typed-but-unverifiable `ci:`/`human:`/`test:`/`file:` — a CI locator, a named +// ratifier, a run id or an existing path is record integrity, not proof, and pure val() +// cannot check any of them. These count at a REDUCED weight and, on their own, are +// capped below the serving floor. +// A human-family oracle authored by an `agent:` identity (e.g. the MCP tools' `agent:mcp`) +// is never resolved either: an agent is not a human, whatever ref it cites. +const GIT_OID_RE = /^[0-9a-f]{7,64}$/i; +const BRIDGE_REF_ORACLE = { episode: "cortex.episode", legacy: "legacy.import" }; + +/** Resolution strength of a ref for confidence weighting: "resolved" or "format". `oracle` + * binds a bridge pointer to the one oracle allowed to cite it. Pure and total — never + * throws, never does I/O. + * @param {string} ref + * @param {string} [oracle] */ +export function refStrength(ref, oracle) { + const m = /^([a-z][a-z0-9-]*):(.+)$/.exec(String(ref ?? "")); + if (!m) return "format"; // untyped — nothing forge can re-derive + const [, type, value] = m; + if (type === "git") return GIT_OID_RE.test(value) ? "resolved" : "format"; + return oracle !== undefined && BRIDGE_REF_ORACLE[type] === oracle ? "resolved" : "format"; } +/** The strength val() actually applies to one evidence record: refStrength, except that an + * `agent:` identity can never supply HUMAN-family evidence at resolved strength. */ +const recordStrength = (e) => + ORACLES[e.oracle]?.family === "human" && /^agent:/.test(String(e.author ?? "")) + ? "format" + : refStrength(e.ref, e.oracle); + /** Weight multiplier applied to merely-format-valid (unresolved) evidence in val(). */ export const UNRESOLVED_WEIGHT = 0.5; /** A claim whose confirming evidence is ALL format-only may never be lifted to/above the @@ -316,10 +338,10 @@ const decayed = (outcome, nowDay, halfLife) => * appender's earned reliability. Pure function of (evidence set, trust map) ⇒ * identical after any merge order. * - * Resolution strength (ME-05): merely-format-valid evidence (`file:`/`test:` — a pointer, - * not a demonstration) counts at UNRESOLVED_WEIGHT, and a claim with NO resolved - * confirmation is capped at UNRESOLVED_VAL_CAP so `test:made-up-run` (and friends) can - * never lift confidence into the trusted/serving band. The cap only lowers — contradictions + * Resolution strength (ME-05/C2): evidence forge did not resolve (anything but a `git:` + * object id or a bridge pointer — see refStrength) counts at UNRESOLVED_WEIGHT, and a claim + * with NO resolved confirmation is capped at UNRESOLVED_VAL_CAP so `lgtm`, + * `test:made-up-run` (and friends) can never lift confidence into the trusted/serving band. The cap only lowers — contradictions * still sink val toward 0 as before. * @param {any} claim * @param {number} [nowDay] @@ -331,7 +353,7 @@ export function val(claim, nowDay = 0, { halfLife = DEFAULT_HALF_LIFE_DAYS, trus let resolvedConfirm = false; for (const e of claim.evidence ?? []) { if (!validOutcome(e)) continue; - const resolved = refStrength(e.ref) === "resolved"; + const resolved = recordStrength(e) === "resolved"; const strength = resolved ? 1 : UNRESOLVED_WEIGHT; const d = decayed(e, nowDay, halfLife) * (trust?.[e.author ?? ""] ?? 1) * strength; all += d; diff --git a/src/ledger_store.js b/src/ledger_store.js index 32edf66..ec77991 100644 --- a/src/ledger_store.js +++ b/src/ledger_store.js @@ -250,24 +250,34 @@ export function tombstone(dir, id, { author = "", reason = "", t = 0 } = {}) { return appendRecord(dir, "tombstones", id, sealRecord({ author, reason, t })); } +/** A full claim id — what an irreversible or agent-initiated write must name exactly. */ +export const FULL_ID_RE = /^[0-9a-f]{64}$/; + +/** The identity every agent-callable (MCP) ledger write is stamped with — never the human's + * git identity. val() never counts an `agent:` author as human evidence (ledger.js). */ +export const MCP_AUTHOR = "agent:mcp"; + /** * Ratify a claim — the fahm→ḥikma promotion (08-dashboard-ux.md §2): mint a `decision` - * claim pointing at the ratified claim's full id. Promotion is HUMAN-ONLY by design: - * the caller supplies the author (a person's identity, via gitAuthor()); nothing in the - * substrate ever calls this automatically. Append-only and content-addressed, so - * ratifying the same claim twice converges on the same decision ({existed:true}). + * claim pointing at the ratified claim's full id. A human ratification is the default + * (the CLI and dashboard pass the person's gitAuthor()). An agent may only PROPOSE one: + * the MCP tool passes `author: MCP_AUTHOR` plus a note, which makes it a distinct claim, so + * an agent proposal can never be mistaken for — or deduped into — a human's ratification. + * Neither changes the ratified claim's val: a decision is not evidence. Append-only and + * content-addressed, so ratifying the same claim twice converges ({existed:true}). * @param {string} dir - * @param {string} idPrefix - * @param {{author?: string, t?: number}} [opts] + * @param {string} idPrefix an unambiguous id prefix (≥2 chars) or the full id + * @param {{author?: string, t?: number, agent?: string, note?: string}} [opts] * @returns {{ok:boolean, reason?:string, decisionId?:string, ratifies?:string, existed?:boolean}} */ -export function ratify(dir, idPrefix, { author = "", t = 0 } = {}) { +export function ratify(dir, idPrefix, { author = "", t = 0, agent = "dash", note = "" } = {}) { const target = getClaimByPrefix(dir, idPrefix); - if (!target) return { ok: false, reason: `no claim matching ${idPrefix}` }; + if (!target) + return { ok: false, reason: `no claim matching ${idPrefix} (or the prefix is ambiguous)` }; const minted = mintClaim({ kind: "decision", - body: { ratifies: target.id, note: "" }, - provenance: { agent: "dash", author }, + body: { note, ratifies: target.id }, + provenance: { agent, author }, t, }); if (!minted.ok) @@ -289,6 +299,56 @@ export function ratify(dir, idPrefix, { author = "", t = 0 } = {}) { }; } +/** + * PROPOSE a retraction without making it: an agent-callable tool must not make a permanent + * change on its own (models propose; a human authorizes; corrections supersede rather than + * erase). Mints a `decision` claim {retracts, reason} stamped `agent:mcp` — append-only, + * content-addressed (the same proposal twice converges), synced like any claim, and visible + * via retractionProposals()/stats(). It lowers nothing: the target stays live until a human + * runs `forge ledger retract `, which writes the real tombstone. + * @param {string} dir + * @param {string} id the target's FULL 64-char claim id — a prefix is refused + * @param {{reason?: string, t?: number, author?: string}} [opts] + * @returns {{ok:boolean, reason?:string, proposalId?:string, retracts?:string, existed?:boolean}} + */ +export function proposeRetraction(dir, id, { reason = "", t = 0, author = MCP_AUTHOR } = {}) { + if (!FULL_ID_RE.test(String(id ?? ""))) + return { ok: false, reason: "a retraction must name one full 64-character claim id" }; + const target = getClaimByPrefix(dir, id); + if (!target || target.id !== id) return { ok: false, reason: `no claim matching ${id}` }; + const minted = mintClaim({ + kind: "decision", + body: { note: "proposed retraction — pending human confirmation", reason, retracts: id }, + provenance: { agent: "mcp", author }, + t, + }); + if (!minted.ok) return { ok: false, reason: "reason" in minted ? minted.reason : "mint failed" }; + const put = putClaim(dir, minted.claim); + if (!put.ok) return { ok: false, reason: put.reason ?? "could not persist the proposal" }; + return { ok: true, proposalId: minted.claim.id, retracts: id, existed: put.existed }; +} + +/** Pending retraction proposals by target id — proposals whose target is still live (a + * human retraction resolves them). Pure over a loadClaims() list. + * @param {any[]} claims + * @returns {Map} */ +export function retractionProposals(claims) { + const live = new Set(claims.filter((c) => !c.tombstone).map((c) => c.id)); + const out = new Map(); + for (const c of claims) { + const target = c.kind === "decision" && !c.tombstone ? c.body?.retracts : null; + if (!target || !live.has(target)) continue; + if (!out.has(target)) out.set(target, []); + out.get(target).push({ + proposalId: c.id, + reason: String(c.body?.reason ?? ""), + author: c.provenance?.author ?? "", + t: c.provenance?.t ?? 0, + }); + } + return out; +} + /** Load the full ledger state {claims, evidence, provenance, tombstones}. Log lines * are hash-verified on read (see readLog); `verifyHashes:false` is internal-only — * mergeDirs reads its SOURCE raw so bad records get quarantined, not silently lost. @@ -310,15 +370,16 @@ export function loadClaims(dir) { } /** Find one claim by id prefix without scanning the whole ledger (ids are sharded by - * their first two hex chars, so any prefix ≥ 2 chars pins the shard). */ + * their first two hex chars, so any prefix ≥ 2 chars pins the shard). An AMBIGUOUS prefix + * (≥2 claims match) returns null — silently picking the first sorted match let a short + * prefix ratify or retract a claim nobody named. */ export function getClaimByPrefix(dir, prefix) { if (!prefix || prefix.length < 2) return null; const shardDir = join(dir, "claims", prefix.slice(0, 2)); if (!existsSync(shardDir)) return null; - const f = readdirSync(shardDir) - .filter((f) => f.endsWith(".json") && f.startsWith(prefix)) - .sort()[0]; - if (!f) return null; + const matches = readdirSync(shardDir).filter((f) => f.endsWith(".json") && f.startsWith(prefix)); + if (matches.length !== 1) return null; + const f = matches[0]; const id = f.replace(/\.json$/, ""); const claim = readJsonSafe(join(shardDir, f)); if (!claim || claimId(claim.kind, claim.body, claim.scope) !== id) return null; @@ -569,6 +630,8 @@ export function stats(dir, nowDay = 0) { return { total: claims.length, tombstoned: claims.filter((c) => c.tombstone).length, + // Agent-proposed retractions awaiting a human `forge ledger retract `. + pendingRetractions: retractionProposals(claims).size, byKind, val: buckets, }; diff --git a/src/mcp_tools.js b/src/mcp_tools.js index 7792537..27a2520 100644 --- a/src/mcp_tools.js +++ b/src/mcp_tools.js @@ -183,11 +183,15 @@ export const TOOLS = [ { name: "forge_ledger_ratify", description: - "Promote a ledger claim's confidence — record an independent oracle ratification (the claim held under test).", + "Propose a ratification of a ledger claim as agent:mcp (never as the human) — mints a decision claim and does NOT change the claim's confidence. Only a human ratifies, with `forge ledger ratify `.", inputSchema: { type: "object", properties: { - id: { type: "string", description: "claim ID or unique prefix" }, + id: { + type: "string", + description: + "claim ID or an unambiguous prefix (≥2 chars; an ambiguous prefix is refused)", + }, }, required: ["id"], }, @@ -224,14 +228,17 @@ export const TOOLS = [ { name: "forge_ledger_retract", description: - "Tombstone a ledger claim with a reason — mark it as no longer valid so it stops influencing routing and memory.", + "Propose retracting one ledger claim, named by its full 64-char id, as agent:mcp — the claim stays live with unchanged confidence until a human runs `forge ledger retract --reason …`. Prefixes are refused; the pending proposal shows in ledger query/stats.", inputSchema: { type: "object", properties: { - id: { type: "string", description: "claim ID or unique prefix" }, + id: { + type: "string", + description: "the claim's full 64-character id (forge_ledger_query returns it)", + }, reason: { type: "string", - description: "why the claim is being retracted", + description: "why the claim should be retracted", }, }, required: ["id", "reason"], diff --git a/src/reuse.js b/src/reuse.js index 3e3ec04..03ae97d 100644 --- a/src/reuse.js +++ b/src/reuse.js @@ -10,7 +10,7 @@ import { join } from "node:path"; import { has as atlasHas } from "./atlas.js"; import { claimSim, simLabel } from "./embed.js"; import { isDormant, jaccard, mintClaim, outcomeRecord, SKETCH_K, sketch, val } from "./ledger.js"; -import { appendEvidence, loadClaims, putClaim, repoLedger } from "./ledger_store.js"; +import { appendEvidence, loadClaims, putClaim, readEvidence, repoLedger } from "./ledger_store.js"; import { record as recordMetric } from "./metrics.js"; import { contentHash, gitAuthor } from "./util.js"; @@ -136,7 +136,10 @@ export function mintArtifact(dir, fields, { evidence, t = 0 } = {}) { const a = appendEvidence(dir, minted.claim.id, o.outcome); if (!a.ok) return a; } - return { ok: true, id: minted.claim.id, existed: put.existed, serves: Boolean(evidence) }; + // `serves` is what the proof actually earns, not "some evidence was passed": a ref forge + // cannot resolve (`--ref lgtm`, `ci:1`) is recorded but capped below SERVE_FLOOR (C2). + const serves = val({ evidence: readEvidence(dir, minted.claim.id) }, t) >= SERVE_FLOOR; + return { ok: true, id: minted.claim.id, existed: put.existed, serves }; } // --------------------------------------------------------------------------- @@ -278,24 +281,25 @@ export function reuseQuery(root, spec, { slice = "", atlas = null, nowDay = 0 } const r = lookup(claims, spec, { slice, atlas, nowDay, sim }); r.sim = simLabel(sim); - // Revalidation results are themselves oracle outcomes (graph.reval): serving keeps - // evidence fresh, and an artifact whose deps vanished demotes itself — for everyone. - const structural = (c, ok, missing) => { + // A FAILED revalidation is an oracle outcome (graph.reval): an artifact whose deps + // vanished demotes itself — for everyone. A PASSING one is not written back: serving is + // never confirmation (review C2 — ten daily serves used to lift val 0.643 → 0.864 and + // kept an artifact served after two failing test runs). Only a real oracle raises val. + const contradict = (c, missing) => { const o = outcomeRecord({ oracle: "graph.reval", - result: ok ? "confirm" : "contradict", - ref: ok ? `atlas:ok:day${nowDay}` : `atlas:missing:${missing.slice(0, 3).join(",")}`, + result: "contradict", + ref: `atlas:missing:${missing.slice(0, 3).join(",")}`, author: gitAuthor(), t: nowDay, }); if (o.ok) appendEvidence(dir, c.id, o.outcome); }; - if (r.artifact && r.revalidation?.checked) structural(r.artifact, true, []); for (const reason of r.reasons) { const m = reason.match(/^(?:exact|near) ([0-9a-f]{8}) failed revalidation: missing (.+)$/); if (!m) continue; const c = claims.find((x) => x.id.startsWith(m[1])); - if (c) structural(c, false, m[2].split(", ")); + if (c) contradict(c, m[2].split(", ")); } recordMetric(root, { diff --git a/test/cli_ledger.test.js b/test/cli_ledger.test.js index a7e9e38..dd5905d 100644 --- a/test/cli_ledger.test.js +++ b/test/cli_ledger.test.js @@ -35,3 +35,29 @@ test("ledger at accepts a real date and a bare epoch-day equally", () => { assert.equal(run(["ledger", "at", "2026-08-01"], root).status, 0); assert.equal(run(["ledger", "at", "20666"], root).status, 0); }); + +test("ledger retract (C2): a prefix is refused; the full id tombstones under the human's identity", async () => { + const { mintClaim } = await import("../src/ledger.js"); + const { loadClaims, putClaim, repoLedger } = await import("../src/ledger_store.js"); + const root = mkdtempSync(join(tmpdir(), "forge-cliledger-")); + const dir = repoLedger(root); + const c = mintClaim({ kind: "fact", body: { name: "port", text: "api listens on 8080" } }).claim; + putClaim(dir, c); + const retract = (id) => + spawnSync("node", [CLI, "ledger", "retract", id, "--reason", "stale"], { + cwd: root, + encoding: "utf8", + env: { ...process.env, FORGE_NO_HINT: "1", FORGE_AUTHOR: "Alice " }, + }); + for (const prefix of [c.id.slice(0, 2), c.id.slice(0, 12), c.id.slice(0, 63)]) { + const r = retract(prefix); + assert.equal(r.status, 1, `prefix ${prefix.length} chars refused`); + assert.match(r.stderr, /full 64-character claim id/); + } + assert.equal(loadClaims(dir)[0].tombstone, undefined, "no prefix ever tombstones"); + const ok = retract(c.id); + assert.equal(ok.status, 0, ok.stderr); + const t = loadClaims(dir)[0].tombstone; + assert.equal(t.author, "Alice "); + assert.equal(t.reason, "stale"); +}); diff --git a/test/context.test.js b/test/context.test.js index 5600f04..a91252c 100644 --- a/test/context.test.js +++ b/test/context.test.js @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -45,8 +46,16 @@ const trustedLesson = (root) => { }); putClaim(dir, minted.claim); // Four confirmations: val = (1 + 4·0.9)/(2 + 4·0.9) ≈ 0.82 — past the 0.8 floor. - // (Three lands at 0.787 and is correctly NOT trusted enough to be required.) - for (const ref of ["run:1", "run:2", "pr:7", "pr:9"]) + // (Three lands at 0.787 and is correctly NOT trusted enough to be required.) The refs + // must be git objects that resolve in this repo — the only ref type forge re-derives. + const g = (...args) => execFileSync("git", args, { cwd: root, stdio: "ignore" }); + g("init"); + g("config", "user.email", "t@t.t"); + g("config", "user.name", "t"); + g("add", "-A"); + g("commit", "-m", "fixture"); + const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim(); + for (const ref of [7, 8, 9, 40].map((n) => `git:${head.slice(0, n)}`)) appendEvidence( dir, minted.claim.id, @@ -66,7 +75,7 @@ test("requiredSet: defs, hop-1 dependents, sibling tests, and trusted lessons evidence: ["h1", "h2", "h3", "h4"].map((h) => ({ oracle: "human.accept", result: "confirm", - ref: `r:${h}`, + ref: `git:${Buffer.from(h).toString("hex").repeat(4)}`, t: 0, w: 0.9, h, diff --git a/test/cortex_mcp.test.js b/test/cortex_mcp.test.js index 540c5cb..e4598d5 100644 --- a/test/cortex_mcp.test.js +++ b/test/cortex_mcp.test.js @@ -7,6 +7,9 @@ import { test } from "node:test"; import { fileURLToPath } from "node:url"; import { processSession } from "../src/cortex_hook.js"; import { handle } from "../src/cortex_mcp.js"; +import { mintClaim, val } from "../src/ledger.js"; +import { loadClaims, putClaim, repoLedger, stats } from "../src/ledger_store.js"; +import { TOOLS } from "../src/mcp_tools.js"; // Default is now ledger-only; these cases exercise the legacy FILE store (the // FORGE_LEDGER_ONLY=0 escape hatch). Pin it here so they test that path directly. @@ -181,31 +184,95 @@ test("handle: a tool handler that throws still gets a JSON-RPC error reply (no c ); }); -test("forge_ledger_retract returns error for missing claim via stdio", () => { - const root = mkdtempSync(join(tmpdir(), "forge-mcp-ret-")); - mkdirSync(join(root, ".forge", "ledger"), { recursive: true }); - const requests = [ - JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }), +/** Drive the live server with tools/call requests; returns {id → text|error}. */ +const callServer = (root, calls) => { + const requests = calls.map((c, i) => JSON.stringify({ jsonrpc: "2.0", - id: 2, + id: i + 1, method: "tools/call", - params: { - name: "forge_ledger_retract", - arguments: { id: "nonexistent", reason: "test" }, - }, + params: { name: c.name, arguments: c.arguments }, }), - ].join("\n"); + ); const r = spawnSync("node", [SERVER], { - input: `${requests}\n`, + input: `${requests.join("\n")}\n`, encoding: "utf8", - env: { ...process.env, FORGE_ROOT: root }, + env: { ...process.env, FORGE_ROOT: root, FORGE_AUTHOR: "Alice Human " }, timeout: 10000, }); - const responses = r.stdout - .trim() - .split("\n") - .map((l) => JSON.parse(l)); - const call = responses.find((x) => x.id === 2); - assert.match(call.result.content[0].text, /No claim matching/); + const out = {}; + for (const l of r.stdout.trim().split("\n").filter(Boolean)) { + const x = JSON.parse(l); + out[x.id] = x.result?.content?.[0]?.text ?? x.error; + } + return out; +}; + +/** A ledger with two fact claims whose ids share their first two hex chars. */ +const ledgerWithTwinPrefix = () => { + const root = mkdtempSync(join(tmpdir(), "forge-mcp-led-")); + const dir = repoLedger(root); + const first = mintClaim({ kind: "fact", body: { name: "a", text: "t0" } }).claim; + putClaim(dir, first); + for (let i = 1; ; i++) { + const c = mintClaim({ kind: "fact", body: { name: "a", text: `t${i}` } }).claim; + if (c.id.slice(0, 2) === first.id.slice(0, 2)) { + putClaim(dir, c); + return { root, dir, ids: [first.id, c.id].sort() }; + } + } +}; + +test("forge_ledger_retract refuses anything but one full claim id (C2)", () => { + const { root, dir, ids } = ledgerWithTwinPrefix(); + const out = callServer(root, [ + { name: "forge_ledger_retract", arguments: { id: "nonexistent", reason: "test" } }, + { name: "forge_ledger_retract", arguments: { id: ids[1].slice(0, 2), reason: "stale" } }, + { name: "forge_ledger_retract", arguments: { id: ids[1].slice(0, 12), reason: "stale" } }, + { name: "forge_ledger_retract", arguments: { id: "f".repeat(64), reason: "stale" } }, + ]); + for (const id of [1, 2, 3]) assert.match(out[id], /full 64-character claim id/, `call ${id}`); + assert.match(out[4], /No claim matching/); + assert.equal(loadClaims(dir).length, 2, "a refused call writes nothing at all"); +}); + +test("forge_ledger_retract only PROPOSES: the claim stays live and the proposal is visible", () => { + const { root, dir, ids } = ledgerWithTwinPrefix(); + const before = val(loadClaims(dir).find((c) => c.id === ids[1])); + const out = callServer(root, [ + { name: "forge_ledger_retract", arguments: { id: ids[1], reason: "stale value" } }, + { name: "forge_ledger_query", arguments: { query: "a t1" } }, + ]); + assert.match(out[1], /Proposed retraction/); + assert.match(out[1], /stays live until a human confirms/); + const target = loadClaims(dir).find((c) => c.id === ids[1]); + assert.equal(target.tombstone, undefined, "no permanent tombstone from an agent-callable tool"); + assert.equal(val(target), before, "a proposal lowers nothing"); + const proposal = loadClaims(dir).find((c) => c.body?.retracts === ids[1]); + assert.equal(proposal.provenance.author, "agent:mcp", "stamped as the agent, never the human"); + assert.equal(proposal.body.reason, "stale value"); + assert.equal(stats(dir).pendingRetractions, 1, "visible in ledger stats"); + const row = JSON.parse(out[2]).results.find((r) => r.id === ids[1]); + assert.deepEqual(row.pendingRetraction, ["stale value"], "visible in ledger query output"); +}); + +test("forge_ledger_ratify is stamped agent:mcp, changes no confidence, and says so (C2)", () => { + const { root, dir, ids } = ledgerWithTwinPrefix(); + const before = val(loadClaims(dir).find((c) => c.id === ids[0])); + const out = callServer(root, [ + { name: "forge_ledger_ratify", arguments: { id: ids[0].slice(0, 2) } }, + { name: "forge_ledger_ratify", arguments: { id: ids[0] } }, + ]); + assert.match(out[1], /No claim matching|ambiguous/i, "an ambiguous prefix is refused"); + assert.match(out[2], /not a human ratification/i); + const decision = loadClaims(dir).find((c) => c.kind === "decision"); + assert.equal(decision.body.ratifies, ids[0]); + assert.equal(decision.provenance.author, "agent:mcp"); + assert.ok( + decision.provenanceAll.every((p) => !/alice/i.test(p.author ?? "")), + "the human's identity is never used", + ); + assert.equal(val(loadClaims(dir).find((c) => c.id === ids[0])), before, "val unchanged"); + const tool = TOOLS.find((t) => t.name === "forge_ledger_ratify"); + assert.doesNotMatch(tool.description, /promote .*confidence/i, "no false promise in the schema"); }); diff --git a/test/embed.test.js b/test/embed.test.js index f415dc8..1441c4a 100644 --- a/test/embed.test.js +++ b/test/embed.test.js @@ -3,7 +3,7 @@ // NO network anywhere — the provider is test/fixtures/fake_embed.mjs (deterministic // hash-based pseudo-vectors that make two designated spec strings close). import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; +import { execFileSync, spawnSync } from "node:child_process"; import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -27,6 +27,20 @@ const CLI = fileURLToPath(new URL("../src/cli.js", import.meta.url)); const FAKE = fileURLToPath(new URL("./fixtures/fake_embed.mjs", import.meta.url)); const fakeCmd = (flags = "") => `cmd:node ${FAKE}${flags ? ` ${flags}` : ""}`; const tmp = () => mkdtempSync(join(tmpdir(), "forge-embed-")); +/** A one-commit git repo: store-level evidence must cite a git object that resolves here + * (the only ref type forge re-derives). Returns {root, head}. */ +const gitTmp = () => { + const root = tmp(); + const g = (...args) => execFileSync("git", args, { cwd: root, stdio: "ignore" }); + g("init"); + g("config", "user.email", "t@t.t"); + g("config", "user.name", "t"); + writeFileSync(join(root, "f.txt"), "x"); + g("add", "-A"); + g("commit", "-m", "init"); + const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim(); + return { root, head }; +}; /** Run fn with env vars set, restore after (getProvider re-resolves per env value). */ const withEnv = (vars, fn) => { @@ -132,11 +146,11 @@ const STORED = "delete a user account"; const REWORDED = "remove a user account"; const verifiedArtifactRoot = () => { - const root = tmp(); + const { root, head } = gitTmp(); const m = mintArtifact( repoLedger(root), { spec: STORED, code: { path: "src/users.js", sha256: "a".repeat(64) } }, - { evidence: { oracle: "test.run", result: "confirm", ref: "run:1" }, t: 0 }, + { evidence: { oracle: "test.run", result: "confirm", ref: `git:${head}` }, t: 0 }, ); assert.equal(m.ok, true); return root; @@ -184,7 +198,7 @@ test("lookup: per-candidate fallback — a candidate with no vector still ranks { oracle: "test.run", result: "confirm", - ref: "run:1", + ref: "git:c0ffee1", author: "ci", t: 0, w: 0.8, @@ -265,14 +279,14 @@ test("claimSim: one provider call covers query+candidates; unset/failure → nul // --- CLI: the backend line ------------------------------------------------------------------ test("forge ledger query / reuse query print which similarity backend served", () => { - const cwd = tmp(); + const { root: cwd, head } = gitTmp(); const dir = repoLedger(cwd); const claim = factClaim("users", STORED); putClaim(dir, claim); appendEvidence(dir, claim.id, { oracle: "test.run", result: "confirm", - ref: "run:1", + ref: `git:${head}`, t: 0, w: 0.8, h: "c".repeat(64), @@ -282,7 +296,10 @@ test("forge ledger query / reuse query print which similarity backend served", ( const m = mintArtifact( dir, { spec: STORED, code: { path: "src/users.js", sha256: "e".repeat(64) } }, - { evidence: { oracle: "test.run", result: "confirm", ref: "run:2" }, t: epochDay() }, + { + evidence: { oracle: "test.run", result: "confirm", ref: `git:${head.slice(0, 12)}` }, + t: epochDay(), + }, ); assert.equal(m.ok, true); diff --git a/test/ledger.test.js b/test/ledger.test.js index d436891..631575e 100644 --- a/test/ledger.test.js +++ b/test/ledger.test.js @@ -156,19 +156,63 @@ test("validateRef: ci: must be a locator, human: must be a ratification (ME-05 f assert.equal(okH("human:the-model-said-yes"), false, "self-assertion refused on format"); }); -test("refStrength: resolved (git/ci/human/legacy) vs format-only (file/test)", () => { - assert.equal(refStrength("run:1"), "resolved", "untyped/legacy keeps historical trust"); +test("refStrength: only a git object id (or a bridge pointer on its own bridge oracle) is resolved", () => { assert.equal(refStrength("git:cafebabe"), "resolved"); - assert.equal(refStrength("ci:42"), "resolved"); - assert.equal(refStrength("human:alice@d1"), "resolved"); - assert.equal(refStrength("test:made-up-run"), "format", "a run id is a pointer, not a proof"); - assert.equal(refStrength("file:/some/path"), "format"); + assert.equal(refStrength(`git:${"a1".repeat(20)}`), "resolved", "full sha1"); + assert.equal(refStrength("episode:ep_m0_x#n1", "cortex.episode"), "resolved"); + assert.equal(refStrength("legacy:lsn_a#confirm0", "legacy.import"), "resolved"); + // C2: none of these is something forge resolved — they are pointers anyone can type. + for (const ref of [ + "lgtm", + "run:1", + "session:x", + "foo:bar", + "ci:1", + "ci:https://ci.example.com/run/7", + "human:claude@yes", + "git:HEAD", + "git:main", + "git:HEAD~0", + "test:made-up-run", + "file:/some/path", + ]) + assert.equal(refStrength(ref, "test.run"), "format", ref); + assert.equal( + refStrength("episode:ep_m0_x#n1", "test.run"), + "format", + "a bridge pointer only counts on the bridge oracle that mints it", + ); +}); + +test("val (C2): made-up refs are capped below the serving floor — no prefix buys full trust", () => { + for (const ref of ["lgtm", "session:x", "ci:1", "human:claude@yes", "git:HEAD"]) { + const records = Array.from({ length: 3 }, (_, i) => + outcomeRecord({ oracle: "human.accept", result: "confirm", ref, t: i }), + ); + assert.ok( + records.every((r) => r.ok), + `${ref} passes the format check`, + ); + const c = mkClaim(records.map((r) => ("outcome" in r ? r.outcome : null))); + assert.ok(val(c, 0) <= UNRESOLVED_VAL_CAP + 1e-9, `${ref}: val ${val(c, 0)} is capped`); + assert.ok(val(c, 0) < SERVE_FLOOR, `${ref} never reaches the serving floor`); + } +}); + +test("val (C2): an agent identity never supplies human-family evidence at resolved strength", () => { + const human = (author) => + mkClaim([ + outcomeRecord({ oracle: "human.accept", result: "confirm", ref: "git:cafebabe", author }) + .outcome, + ]); + assert.ok(val(human("Alice "), 0) >= SERVE_FLOOR, "a person's git-anchored accept counts"); + assert.ok(val(human("agent:mcp"), 0) <= UNRESOLVED_VAL_CAP + 1e-9, "agent:mcp is not a human"); }); test("val: format-only evidence (test:/file:) cannot lift confidence into the serving band", () => { - // A single confirm on an UNTYPED (resolved-trust) ref clears the serving floor as before. + // A single confirm on a resolved (git object) ref clears the serving floor. const resolved = mkClaim([ - outcomeRecord({ oracle: "test.run", result: "confirm", ref: "run:legit" }).outcome, + outcomeRecord({ oracle: "test.run", result: "confirm", ref: "git:c0ffee1" }).outcome, ]); assert.ok(val(resolved, 0) >= SERVE_FLOOR, "resolved evidence still earns trust (no regression)"); @@ -232,8 +276,10 @@ const mkClaim = (evidence = []) => { }); return { ...m.claim, evidence }; }; +// A resolved (git object id) ref, unique per (result, t, oracle) so records never dedupe. +const gitRef = (s) => `git:${Buffer.from(String(s)).toString("hex").padEnd(8, "0").slice(0, 40)}`; const ev = (result, t, oracle = "test.run") => - outcomeRecord({ oracle, result, ref: `r:${result}:${t}:${oracle}`, t }).outcome; + outcomeRecord({ oracle, result, ref: gitRef(`${result}${t}${oracle}`), t }).outcome; test("val: fresh claim sits at the 0.5 prior; confirms raise; contradictions lower", () => { assert.equal(val(mkClaim(), 0), 0.5); @@ -250,7 +296,7 @@ test("val: monotone in confirmations (more independent evidence is never worse)" outcomeRecord({ oracle: "ci.run", result: "confirm", - ref: `r:${i}`, + ref: gitRef(`ci${i}`), t: 0, }).outcome, ); @@ -558,7 +604,7 @@ test("val with trust: a distrusted author's evidence moves confidence less", () outcomeRecord({ oracle: "test.run", result: "confirm", - ref: "r", + ref: gitRef("r"), author: "carol", t: 0, }).outcome, diff --git a/test/ledger_read.test.js b/test/ledger_read.test.js index 9660a28..54389fd 100644 --- a/test/ledger_read.test.js +++ b/test/ledger_read.test.js @@ -83,7 +83,7 @@ test("claimToLesson: fresh claim → candidate at the 0.5 prior, fields from bod test("claimToLesson: a fresh confirm crosses val ≥ 0.6 → active, lastConfirmedDay = confirm t", () => { const claim = mkClaim({ t: 1 }); - claim.evidence = [ev("confirm", "run:1", 5)]; // test.run w=0.8 → val 1.8/2.8 ≈ 0.64 + claim.evidence = [ev("confirm", "git:c0ffee1", 5)]; // test.run w=0.8 → val 1.8/2.8 ≈ 0.64 const l = claimToLesson(claim, 5); assert.equal(l.status, "active"); assert.equal(l.evidenceCount, 1); @@ -93,7 +93,7 @@ test("claimToLesson: a fresh confirm crosses val ≥ 0.6 → active, lastConfirm test("claimToLesson: net-negative evidence (val < 0.45, ≥1 contradiction) → quarantined", () => { const claim = mkClaim(); - claim.evidence = [ev("contradict", "revert:abc", 2, "human.revert")]; // val 1/3 ≈ 0.33 + claim.evidence = [ev("contradict", "git:abc1234", 2, "human.revert")]; // val 1/3 ≈ 0.33 const l = claimToLesson(claim, 2); assert.equal(l.status, "quarantined"); assert.equal(l.contradictionCount, 1); @@ -101,14 +101,14 @@ test("claimToLesson: net-negative evidence (val < 0.45, ≥1 contradiction) → test("claimToLesson: an old decayed confirm falls back to candidate, NOT quarantined", () => { const claim = mkClaim({ t: 0 }); - claim.evidence = [ev("confirm", "run:0", 0)]; + claim.evidence = [ev("confirm", "git:c0ffee0", 0)]; const l = claimToLesson(claim, 400); // decay pulls val back to ~0.5 (the prior) assert.equal(l.status, "candidate", "no contradiction → uncertainty, never quarantine"); }); test("claimToLesson: tombstoned → retired, regardless of evidence", () => { const claim = mkClaim(); - claim.evidence = [ev("confirm", "run:1", 1)]; + claim.evidence = [ev("confirm", "git:c0ffee1", 1)]; claim.tombstone = { author: "x", reason: "superseded", t: 2 }; assert.equal(claimToLesson(claim, 1).status, "retired"); }); @@ -117,7 +117,7 @@ test("claimToLesson: no provenance.task → deterministic lsn_ fallback; ju const claim = mkClaim({ task: "" }); claim.evidence = [ { oracle: "made.up", result: "confirm", ref: "x", h: "deadbeef", t: 1 }, // unknown oracle - ev("confirm", "run:1", 1), + ev("confirm", "git:c0ffee1", 1), ]; const l = claimToLesson(claim, 1); assert.equal(l.id, `lsn_${claim.id.slice(0, 8)}`); diff --git a/test/ledger_store.test.js b/test/ledger_store.test.js index f9b8476..4a73133 100644 --- a/test/ledger_store.test.js +++ b/test/ledger_store.test.js @@ -257,6 +257,22 @@ test("getClaimByPrefix: finds one claim via its shard without scanning the ledge assert.equal(getClaimByPrefix(dir, "a"), null, "sub-shard prefixes are refused"); }); +test("getClaimByPrefix: an ambiguous prefix finds nothing instead of the first sorted match (C2)", () => { + const dir = tmp(); + const a = fact("twin", "t0"); + putClaim(dir, a); + let b = null; + for (let i = 1; !b; i++) { + const c = fact("twin", `t${i}`); + if (c.id.slice(0, 2) === a.id.slice(0, 2)) b = c; + } + putClaim(dir, b); + assert.equal(getClaimByPrefix(dir, a.id.slice(0, 2)), null, "two matches → refuse"); + assert.equal(getClaimByPrefix(dir, a.id).id, a.id, "the full id is always unambiguous"); + assert.equal(getClaimByPrefix(dir, b.id).id, b.id); + assert.equal(ratify(dir, a.id.slice(0, 2), { author: "x" }).ok, false, "ratify refuses too"); +}); + test("importState: semilattice import is idempotent and merges evidence", () => { const a = tmp(); const b = tmp(); diff --git a/test/reuse.test.js b/test/reuse.test.js index 9199f5c..08a6721 100644 --- a/test/reuse.test.js +++ b/test/reuse.test.js @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -19,6 +20,20 @@ import { } from "../src/reuse.js"; const tmp = () => mkdtempSync(join(tmpdir(), "forge-reuse-")); +/** A real one-commit repo: store-level evidence must cite a git object that resolves in it + * (the only ref type forge re-derives — review C2). Returns {root, head}. */ +const gitRepo = () => { + const root = tmp(); + const g = (...args) => execFileSync("git", args, { cwd: root, stdio: "ignore" }); + g("init"); + g("config", "user.email", "t@t.t"); + g("config", "user.name", "t"); + writeFileSync(join(root, "f.txt"), "hello\n"); + g("add", "-A"); + g("commit", "-m", "init"); + const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim(); + return { root, head }; +}; // --- normalization ------------------------------------------------------------------- @@ -73,7 +88,7 @@ const verified = (spec, { slice = "", deps = [], evidence = 2 } = {}) => { c.evidence = Array.from({ length: evidence }, (_, i) => ({ oracle: "test.run", result: "confirm", - ref: `run:${i}`, + ref: `git:${String(i).repeat(8)}`, author: "ci", t: 0, w: 0.8, @@ -132,36 +147,37 @@ test("revalidate: a vanished dependency blocks serving (stale cache can't ship)" // --- store level: fill → hit → demote -------------------------------------------------- -test("mintArtifact + reuseQuery: verified fill serves; the serve refreshes structural evidence", () => { - const root = tmp(); +test("mintArtifact + reuseQuery: verified fill serves; serving adds NO evidence of its own (C2)", () => { + const { root, head } = gitRepo(); const dir = repoLedger(root); const m = mintArtifact( dir, { spec: SPEC, code: { path: "src/limit.js", sha256: "a".repeat(64) } }, - { evidence: { oracle: "test.run", result: "confirm", ref: "run:42" }, t: 0 }, + { evidence: { oracle: "test.run", result: "confirm", ref: `git:${head}` }, t: 0 }, ); assert.equal(m.ok, true); assert.equal(m.serves, true); const atlas = { symbols: [] }; - const r = reuseQuery(root, SPEC, { atlas, nowDay: 1 }); - assert.equal(r.tier, "exact"); + const before = val(loadClaims(dir)[0], 1); + for (let day = 1; day <= 10; day++) { + const r = reuseQuery(root, SPEC, { atlas, nowDay: day }); + assert.equal(r.tier, "exact"); + } const ev = readEvidence(dir, m.id); - assert.ok( - ev.some((e) => e.oracle === "graph.reval" && e.result === "confirm"), - "serving appended a structural confirmation", - ); + assert.equal(ev.length, 1, "ten serves appended nothing — a cache hit is not an oracle"); + assert.ok(val(loadClaims(dir)[0], 1) <= before, "serving never raises confidence"); const metric = readMetrics(root, { stage: "cache" }).pop(); assert.equal(metric.outcome, "hit_exact"); assert.ok(metric.savedEstimate > 0); }); test("reuseQuery: failed revalidation demotes the artifact in the ledger — for everyone", () => { - const root = tmp(); + const { root, head } = gitRepo(); const dir = repoLedger(root); const m = mintArtifact( dir, { spec: SPEC, deps: ["goneHelper"], code: { path: "src/limit.js", sha256: "b".repeat(64) } }, - { evidence: { oracle: "test.run", result: "confirm", ref: "run:1" }, t: 0 }, + { evidence: { oracle: "test.run", result: "confirm", ref: `git:${head}` }, t: 0 }, ); const before = val(loadClaims(dir)[0], 0); const r = reuseQuery(root, SPEC, { atlas: { symbols: [] }, nowDay: 0 }); @@ -175,6 +191,20 @@ test("reuseQuery: failed revalidation demotes the artifact in the ledger — for assert.equal(readMetrics(root, { stage: "cache" }).pop().outcome, "miss"); }); +test("mint with a made-up ref (C2): `--ref lgtm` is stored but honestly reported as not serving", () => { + for (const ref of ["lgtm", "session:x", "ci:1", "human:claude@yes"]) { + const root = tmp(); + const m = mintArtifact( + repoLedger(root), + { spec: `${SPEC} ${ref}`, code: {} }, + { evidence: { oracle: "test.run", result: "confirm", ref }, t: 0 }, + ); + assert.equal(m.ok, true, ref); + assert.equal(m.serves, false, `${ref}: an unresolved ref cannot earn the serving floor`); + assert.equal(reuseQuery(root, `${SPEC} ${ref}`, { nowDay: 0 }).tier, "miss", ref); + } +}); + test("mint without evidence is honest: stored but flagged as not serving", () => { const root = tmp(); const m = mintArtifact(repoLedger(root), { spec: SPEC, code: {} }, { t: 0 }); From 3ec18f5bc28b36667771cdc3806d67f57f2eac0f Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:37:01 +0200 Subject: [PATCH 13/72] docs(research): add a stdlib script that recomputes the review corrections research/recompute_corrections.py re-derives every number used in the 2026-09-21 corrections to the research papers: Theorem D and Eq. 5 arithmetic, the repo-cluster bootstrap (seed 1234, 20,000 resamples), the paired held-out comparison, cost per judged-correct output, the calibration bins and the label agreement. It is standard-library Python with a minimal Parquet reader, so it runs without numpy, scipy or pyarrow against the extracted replication package. The ARCHITECTURE.md repo-map block is re-rendered for the new file. Co-Authored-By: Claude Opus 5 --- ARCHITECTURE.md | 2 +- CHANGELOG.md | 4 + research/recompute_corrections.py | 501 ++++++++++++++++++++++++++++++ 3 files changed, 506 insertions(+), 1 deletion(-) create mode 100644 research/recompute_corrections.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f2267fd..5a807eb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -600,7 +600,7 @@ flowchart LR test["test
109 files"] src["src
98 files"] landing["landing
61 files"] - research["research
35 files"] + research["research
36 files"] global["global
3 files"] bench["bench
2 files"] scripts["scripts
2 files"] diff --git a/CHANGELOG.md b/CHANGELOG.md index 83d748f..e743616 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Documentation +- **`research/recompute_corrections.py` re-derives every corrected number.** Standard-library + Python (it includes a minimal Parquet reader), fixed seeds printed beside each result, run + against the extracted replication package. PDFs built from the corrected sources could not + be rebuilt here and are flagged as predating the corrections. - `CLAUDE.md`: Biome 2.5.2 → 2.5.5 (matching the pin), "600+ tests" → "1000+", and the lint command `npx biome check` → `npm run check` — the documented command fails outright, since the npx package is `@biomejs/biome`, not `biome`. diff --git a/research/recompute_corrections.py b/research/recompute_corrections.py new file mode 100644 index 0000000..8d2c6ba --- /dev/null +++ b/research/recompute_corrections.py @@ -0,0 +1,501 @@ +#!/usr/bin/env python3 +"""Recompute every number used in the 2026-09-21 corrections to the research papers. + +Pure Python 3 (standard library only: no numpy, scipy, pandas or pyarrow), so anyone can +re-run it. It reads the replication package shipped with the refutation paper: + + mkdir rp && tar -xzf research/empirical-refutation/replication_package.tar.gz -C rp + python research/recompute_corrections.py rp/repro + +Sections 1-3 need no data (they are arithmetic on the synthesis paper's own worked +examples). Sections 4-9 read `results/*.json` and `data/*.parquet` from the package. +Every random draw uses a fixed seed that is printed next to its result. + +The corrections these numbers support were prompted by an external deep review of the +repository (2026-09-21); see the "Corrections" sections of each paper. +""" + +import json +import math +import os +import random +import struct +import sys +from collections import Counter, defaultdict + +SEED = 1234 +B = 20000 # bootstrap resamples + + +def pct(a, q): + """Percentile with linear interpolation (numpy's default method).""" + a = sorted(a) + pos = (len(a) - 1) * q / 100.0 + lo = int(math.floor(pos)) + hi = min(lo + 1, len(a) - 1) + return a[lo] + (a[hi] - a[lo]) * (pos - lo) + + +def binom_cdf(k, n, p): + return sum(math.comb(n, i) * p**i * (1 - p) ** (n - i) for i in range(k + 1)) + + +def clopper_pearson(k, n, alpha=0.05): + """Exact binomial CI by bisection on the binomial tails.""" + + def solve(f, target): + # f is monotone on [0, 1]; find p with f(p) == target by bisection + lo, hi = 0.0, 1.0 + rising = f(1.0) > f(0.0) + for _ in range(200): + mid = (lo + hi) / 2 + if (f(mid) < target) == rising: + lo = mid + else: + hi = mid + return (lo + hi) / 2 + + lower = 0.0 if k == 0 else solve(lambda p: 1 - binom_cdf(k - 1, n, p), alpha / 2) + upper = 1.0 if k == n else solve(lambda p: binom_cdf(k, n, p), alpha / 2) + return lower, upper + + +def prf(tp, fp, fn): + p = tp / (tp + fp) if tp + fp else 0.0 + r = tp / (tp + fn) if tp + fn else 0.0 + f = 2 * tp / (2 * tp + fp + fn) if tp + fp + fn else 0.0 + return p, r, f + + +def header(title): + print() + print("=" * 78) + print(title) + print("=" * 78) + + +# -------------------------------------------------------------------------------------- +# 1-3. Formal synthesis: Theorem D and Eq. 5 (no data needed) +# -------------------------------------------------------------------------------------- + + +def theorem_d(): + header("1. Theorem D: P(>=1 miss) over n tasks, independent tasks") + for p, n in ((0.9, 10), (0.9, 30), (0.99, 30)): + print(f" instruction layer alone, p={p}: 1-p^{n} = {1 - p**n:.4f}") + r = (1 - 0.9) * (1 - 0.95) + for n in (30, 1000): + print(f" composed, p=0.9 c=0.95 (residual {r:.3f}): 1-(1-r)^{n} = {1 - (1 - r) ** n:.4f}") + r = (1 - 0.7) * (1 - 0.95) + print(f" composed, p=0.7 c=0.95 (residual {r:.3f}): 1-(1-r)^30 = {1 - (1 - r) ** 30:.4f}") + print(" dependence-free bound (union bound): P(>=1 miss in n) <= n * max per-task residual") + for eps, n in ((0.015, 30), (0.005, 30)): + print(f" residual {eps}, n={n}: <= {min(1, n * eps):.3f} (independent: {1 - (1 - eps) ** n:.4f})") + + header("2. Eq. 5 with three copies of one classifier (Stop hook, pre-commit, CI)") + p, c, k = 0.7, 0.95, 3 + eq5 = (1 - p) * (1 - c) ** k + nested = (1 - p) * (1 - c) + print(f" Eq. 5 (independence), k={k}: (1-p)(1-c)^k = {eq5:.3e}") + print(f" identical/nested checks: (1-p)(1-c_max) = {nested:.4f}") + print(f" understatement factor = {nested / eq5:.0f}x") + rng = random.Random(0) + n_sim, resid = 1_000_000, 0 + for _ in range(n_sim): + miss = rng.random() > p + z = rng.random() < c # one predicate, evaluated at all three points on the same diff + if miss and not (z or z or z): + resid += 1 + print(f" Monte Carlo (seed 0, N={n_sim}): residual = {resid / n_sim:.4f}") + print(" Frechet bounds on P(all k miss | miss): max(0, 1 - sum c_j) <= . <= 1 - max c_j") + + header("3. The gate can be satisfied by touching STATE.md (c depends on agent behaviour)") + for h in (0.0, 0.5, 0.9, 1.0): + c1 = 0.95 * (1 - h) + print(f" STATE-touch rate h={h:.1f}: c = 0.95(1-h) = {c1:.3f}, residual (p=0.7) = {(1 - 0.7) * (1 - c1):.4f}") + print(f" block-once-per-session: c=0 for later tasks in the session, residual = {1 - 0.7:.2f}") + print(f" T4: 150 lines x 80 bytes = {150 * 80} bytes > 8 KB cap (8192); 8192/150 = {8192 / 150:.1f} bytes/line") + + +# -------------------------------------------------------------------------------------- +# Minimal Parquet reader (flat schema; PLAIN/dictionary; UNCOMPRESSED or SNAPPY) +# -------------------------------------------------------------------------------------- + + +class _Buf: + def __init__(self, b, i=0): + self.b, self.i = b, i + + def byte(self): + v = self.b[self.i] + self.i += 1 + return v + + def varint(self): + s = r = 0 + while True: + x = self.byte() + r |= (x & 0x7F) << s + if not x & 0x80: + return r + s += 7 + + def zigzag(self): + v = self.varint() + return (v >> 1) ^ -(v & 1) + + def take(self, n): + v = self.b[self.i : self.i + n] + self.i += n + return v + + +def _thrift_value(r, t): + if t in (1, 2): + return t == 1 + if t == 3: + return r.byte() + if t in (4, 5, 6): + return r.zigzag() + if t == 7: + return struct.unpack("> 4, h & 0x0F + if n == 15: + n = r.varint() + if et in (1, 2): + return [r.byte() == 1 for _ in range(n)] + return [_thrift_value(r, et) for _ in range(n)] + if t == 11: + n = r.varint() + if n == 0: + return {} + kv = r.byte() + return {_thrift_value(r, kv >> 4): _thrift_value(r, kv & 0x0F) for _ in range(n)} + if t == 12: + return _thrift_struct(r) + raise ValueError(f"thrift type {t}") + + +def _thrift_struct(r): + out, fid = {}, 0 + while True: + h = r.byte() + if h == 0: + return out + delta, t = h >> 4, h & 0x0F + fid = fid + delta if delta else r.zigzag() + out[fid] = _thrift_value(r, t) + + +def _snappy(b): + r = _Buf(b) + n = r.varint() + out = bytearray() + while r.i < len(b): + tag = r.byte() + kind = tag & 3 + if kind == 0: + ln = tag >> 2 + if ln >= 60: + ln = int.from_bytes(r.take(ln - 59), "little") + out += r.take(ln + 1) + continue + if kind == 1: + ln, off = ((tag >> 2) & 7) + 4, ((tag >> 5) << 8) | r.byte() + elif kind == 2: + ln, off = (tag >> 2) + 1, int.from_bytes(r.take(2), "little") + else: + ln, off = (tag >> 2) + 1, int.from_bytes(r.take(4), "little") + for _ in range(ln): + out.append(out[-off]) + assert len(out) == n + return bytes(out) + + +def _rle_hybrid(r, bw, count, end=None): + vals, nbytes = [], (bw + 7) // 8 + while len(vals) < count and (end is None or r.i < end): + h = r.varint() + if h & 1: + groups = h >> 1 + acc = int.from_bytes(r.take(groups * bw), "little") + vals.extend((acc >> (j * bw)) & ((1 << bw) - 1) for j in range(groups * 8)) + else: + v = int.from_bytes(r.take(nbytes), "little") if nbytes else 0 + vals.extend([v] * (h >> 1)) + return vals[:count] + + +def _plain(r, ptype, n): + if ptype == 6: + out = [] + for _ in range(n): + out.append(bytes(r.take(struct.unpack(" prf(*g[r])[2] for r in repos) + print(f" sign test: {wins}/{len(repos)} repositories favour the oracle; one-sided p = {0.5 ** len(repos):.3f}") + ci_o, ci_g = h["ci_at_canonical_0.02"]["f1_ci95"], rr["e_baselines_unchanged"]["grep_heldout_ci"]["f1_ci95"] + print(f" reported file-level F1 CIs: oracle [{ci_o[0]:.3f}, {ci_o[1]:.3f}] vs grep [{ci_g[0]:.3f}, {ci_g[1]:.3f}]") + + +def cost(pkg): + header("7. Cost per judged-correct output (held-out, escalation-inclusive)") + d = json.load(open(os.path.join(pkg, "results", "heldout_results.json"), encoding="utf-8")) + tasks = [t for t in d["per_task"] if t.get("generation_routed_cost_usd") is not None] + routed = sum(t["generation_routed_cost_usd"] for t in tasks) + premium = sum(t["generation_premium_cost_usd"] for t in tasks) + ok_pipe = sum(bool(t["generation_final_success"]) for t in tasks) + ok_prem = sum(bool(t["generation_premium_baseline_correct"]) for t in tasks) + none_ok = sum(not t["generation_final_success"] and not t["generation_premium_baseline_correct"] for t in tasks) + print(f" tasks: {len(tasks)}; total routed ${routed:.4f}; total always-premium ${premium:.4f}") + print(f" raw total-spend saving: {100 * (1 - routed / premium):.2f}%") + print(f" judged correct: pipeline {ok_pipe}/{len(tasks)}, premium {ok_prem}/{len(tasks)}; neither: {none_ok}") + cpp, cpq = routed / ok_pipe, premium / ok_prem + print(f" cost per judged-correct output: pipeline ${cpp:.3f} premium ${cpq:.3f} ({100 * (1 - cpp / cpq):.1f}% lower)") + for k in (ok_pipe, ok_prem): + lo, hi = clopper_pearson(k, len(tasks)) + print(f" Clopper-Pearson 95% for {k}/{len(tasks)}: [{lo:.3f}, {hi:.3f}]") + + +def calibration(pkg): + header("8. Gate calibration bins") + d = json.load(open(os.path.join(pkg, "results", "heldout_results.json"), encoding="utf-8")) + tasks = d["per_task"] + c = [t["gate_completeness"] for t in tasks] + y = [0.0 if t["gold_ask"] else 1.0 for t in tasks] + n = len(c) + edges = [pct(c, q) for q in (0, 20, 40, 60, 80, 100)] + print(f" quantile edges: {[round(e, 3) for e in edges]}") + print(f" ties: {sum(v == 0.63 for v in c)} tasks at 0.63, {sum(v == 0.9 for v in c)} at 0.90, {sum(v == 1.0 for v in c)} at 1.00") + for label, right in (("(lo, hi] bins (as reported)", True), ("[lo, hi) bins", False)): + e, counts = 0.0, [] + for i in range(5): + lo, hi = edges[i], edges[i + 1] + if right: + m = [j for j in range(n) if (c[j] > lo or (i == 0 and c[j] >= lo)) and c[j] <= hi] + else: + m = [j for j in range(n) if c[j] >= lo and (c[j] < hi or (i == 4 and c[j] <= hi))] + counts.append(len(m)) + if m: + e += len(m) / n * abs(sum(c[j] for j in m) / len(m) - sum(y[j] for j in m) / len(m)) + print(f" {label:<28} counts {counts} ECE {e:.4f}") + cal = d["calibration"]["framing_A_reliability"] + b2, b3 = cal[2], cal[3] + k = round(b3["gate_decision_accuracy_in_bin"] * b3["n"]) + for p0, why in ((b2["gate_decision_accuracy_in_bin"], "middle-bin accuracy"), (b3["mean_predicted_completeness"], "bin's own mean score")): + print(f" bin 3: {k}/{b3['n']} correct; P(X<={k} | n={b3['n']}, p={p0:.4f} [{why}]) = {binom_cdf(k, b3['n'], p0):.4f}") + lo, hi = clopper_pearson(k, b3["n"]) + print(f" Clopper-Pearson 95% for {k}/{b3['n']}: [{lo:.3f}, {hi:.3f}]") + + +def kappa(pkg): + header("9. Label agreement (pass 1 vs pass 2, same model, reworded prompt)") + d = json.load(open(os.path.join(pkg, "results", "heldout_results.json"), encoding="utf-8")) + tasks = d["per_task"] + + def cohen(a, b, cats, quadratic=False): + k = len(cats) + ix = {v: i for i, v in enumerate(cats)} + m = [[0.0] * k for _ in range(k)] + for x, z in zip(a, b): + m[ix[x]][ix[z]] += 1 + tot = sum(map(sum, m)) + m = [[v / tot for v in row] for row in m] + pa = [sum(row) for row in m] + pb = [sum(m[i][j] for i in range(k)) for j in range(k)] + + def w(i, j): + return ((i - j) / (k - 1)) ** 2 if quadratic else float(i != j) + + obs = sum(w(i, j) * m[i][j] for i in range(k) for j in range(k)) + exp = sum(w(i, j) * pa[i] * pb[j] for i in range(k) for j in range(k)) + return 1 - obs / exp + + sub = [t for t in tasks if t["pass2_gold_ask"] is not None] + a, b = [t["gold_ask"] for t in sub], [t["pass2_gold_ask"] for t in sub] + print(f" n = {len(sub)}; halt label kappa = {cohen(a, b, [False, True]):.4f}") + a, b = [t["gold_tier"] for t in sub], [t["pass2_gold_tier"] for t in sub] + tiers = ["cheap", "mid", "premium"] + print(f" tier kappa unweighted = {cohen(a, b, tiers):.4f}, quadratic = {cohen(a, b, tiers, True):.4f}") + ca = d["cost_analysis"] + print(f" judge model: {ca['judge_model']}; judge is the mid-tier executor: {ca['judge_is_same_as_mid_tier_executor']}") + + +def main(): + theorem_d() + if len(sys.argv) < 2: + print("\n(pass the extracted replication package's repro/ directory to recompute sections 4-9)") + return + pkg = sys.argv[1] + ground_truth(pkg) + cluster_bootstrap(pkg) + repaired_vs_grep(pkg) + cost(pkg) + calibration(pkg) + kappa(pkg) + + +if __name__ == "__main__": + main() From 572afd832f7cf93c4ceccd95d9bc5da5b4cc496a Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:37:27 +0200 Subject: [PATCH 14/72] docs(research): restate Theorem D as a bound and fix the synthesis's definitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An external deep review (2026-09-21, findings F1-F4, F6) found the formal synthesis's central theorem circular as stated and its Eq. 5 dependent on an independence the design contradicts. The HTML edition now: - states the residual as (1-p)·P(no check fires | miss) with Frechet bounds, and Theorem D as residual <= eps over an explicit (p, q) region with the task-dependence assumption stated (union bound); - corrects the lattice of gates: one classifier at Stop, pre-commit and CI gives (1-p)(1-c_max) = 0.015, not the product's 3.75e-5; - shows c_j depends on the agent (a STATE.md touch passes the gate; c = 0.95(1-h), residual 0.27 at h = 0.9; c = 0 after the one block); - fixes lfp(X -> X u N(X)) = empty set, downgrades the oracle-is-Delta* identity, and corrects T3-T6, A1's type error, A3's definitional I1, the B = 150 lines vs 8 KB cap, and the use of Rice's theorem; - reconciles the faculty table with the whitepaper and Eq. 1 with the amnesia equation, concedes priority, marks the section 10 prototype results as refuted, says the convergence was not independent, and fixes the Appendix A tally (9 confirmed). Every change is marked in place and listed with the original wording in a dated Corrections section. crosswalk.json/.md and the README follow; the audit report gets a dated editorial note. The PDF predates this. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 15 ++ research/formal-synthesis/README.md | 109 ++++++++---- .../audits/review_wisdomlens_hikmah.md | 7 + research/formal-synthesis/crosswalk.json | 16 +- research/formal-synthesis/crosswalk.md | 18 +- .../formal-synthesis/substrate_synthesis.html | 161 +++++++++++------- 6 files changed, 219 insertions(+), 107 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e743616..909a211 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,21 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Documentation +- **The formal synthesis's Theorem D is restated as a bound, and its definitions are fixed.** + An external deep review (2026-09-21) found the theorem circular as stated (its criterion, + `P(≥1 miss) → 1`, also condemns the composed system) and its Eq. 5 dependent on an + independence the design contradicts — the same classifier at Stop, pre-commit and CI fires + together, so the product understates the residual 400× in the paper's own example. The + synthesis and the extended preprint now state the residual as `(1 − p)·P(no check fires | + miss)` with Fréchet bounds, bound it by `ε` over an explicit `(p, q)` region, show that the + gate's catch rate depends on agent behaviour (a STATE.md touch passes it), and fix the `lfp` + definition, the oracle-vs-`Δ*` "identity", T4, T5, T6 (A7 gains the catch-all arm + `src/knowledge_router.js` already has), A1's type error, A3's definitional I1, the use of + Rice's theorem, the faculty table (now matching the whitepaper), Eq. 1 vs the amnesia + equation, the Appendix A tally (9 confirmed, not 8), and the "independent" convergence. + Priority is conceded in both, as the refutation paper already did. Each paper ends with a + dated Corrections section quoting the original wording; `crosswalk.json`/`.md` and the + formal-synthesis README follow. - **`research/recompute_corrections.py` re-derives every corrected number.** Standard-library Python (it includes a minimal Parquet reader), fixed seeds printed beside each result, run against the extracted replication package. PDFs built from the corrected sources could not diff --git a/research/formal-synthesis/README.md b/research/formal-synthesis/README.md index 0cc2909..c44eecb 100644 --- a/research/formal-synthesis/README.md +++ b/research/formal-synthesis/README.md @@ -1,6 +1,15 @@ # Formal Synthesis — A Theory of the Cognitive Substrate for Coding Agents -This directory contains a formal, mathematical unification of three independently +> **Corrected 2026-09-21.** An external deep review found that Theorem D was circular as +> stated, that its composition law (Eq. 5) assumed an independence the design contradicts, +> that several definitions and proofs were wrong, and that this README still reported two +> prototype results the refutation had already overturned. `substrate_synthesis.html` is +> corrected in place and ends with a dated **Corrections** section that quotes each original +> statement. **`substrate_synthesis.pdf` predates these corrections** (it was built with +> WeasyPrint, which was not available to rebuild it); read the HTML. The numbers are +> recomputed by [`../recompute_corrections.py`](../recompute_corrections.py). + +This directory contains a formal, mathematical unification of three separately developed bodies of work that all describe the **same architecture** for making a frozen language model reliable at coding: @@ -13,36 +22,65 @@ frozen language model reliable at coding: 3. **forgekit / claude-e2e-kit** — the deployed implementation: committed-file memory, deterministic lifecycle hooks, and auto-invoked skills. -The synthesis proves these are **one object in three vocabularies**. +The synthesis argues these are **one object in three vocabularies**. They are not +independent — claude-e2e-kit is forgekit's precursor, and forgekit was built as a binding of +the theory — so their agreement shows consistency, not independent confirmation. (The +synthesis's §14 "four independent arrivals" — the theory, forgekit, hikmah-stack and +wisdom-lens — are likewise one author's work.) ## The central result -> **Reliability = a probabilistic instruction layer × a deterministic interception -> layer. Neither layer alone suffices.** +> **Silent-miss residual = (1 − p) × P(no deterministic check fires | miss).** +> Where instructions cannot push `p` near 1 and decidable checks cannot catch the misses +> that matter, both layers are needed to reach a small residual. Instructions (`CLAUDE.md`, rules, skills) _raise_ the probability `p < 1` that the -model behaves correctly, but can never reach `p = 1` — so the residual silent-miss -rate over `n` tasks is `1 − pⁿ → 1`. A deterministic layer (hooks that execute -regardless of the model's choice) multiplies that residual down by a factor -`(1 − cⱼ)` per check — but cannot catch the _semantic_ class (undecidable, by Rice's -theorem), so it needs the soft layer to shrink what reaches it. The paper states this -as **Theorem D** and proves it. It is the formal content of the practitioner's rule: -_never trust the output of a probability engine; earn trust with an external check._ - -## Three anchor identities (not analogies — the same mathematics) - -| Substrate | Framework | Relationship | -| -------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------- | -| Impact-Oracle blast-radius | change-closure `Δ*` (least fixpoint of `X ↦ X ∪ N(X)`) | **identical** — reverse reachability, run to fixpoint ⇒ perfect recall | -| M2 assumption gate | amnesia equation `assumption ≈ argmax P(convention \| training)` | **identical** — the gate supplies missing context or halts, never guesses | -| substrate's two layers | design law "Π₃ probabilistic, Π₂ deterministic" | **identical** — Theorem D | +model behaves correctly. A deterministic layer (hooks that execute regardless of the +model's choice) multiplies the residual `1 − p` by the probability that no check fires on +a miss. The paper states this as **Theorem D**, restated on 2026-09-21 as a bound: the +residual is at most `ε` on an explicit region of `(p, q)`, where `q` is the chance that at +least one check fires on a miss, and over `n` tasks `P(≥1 miss) ≤ n·ε` whatever the +dependence between tasks. + +What the corrections changed, briefly: + +- **It is a bound, not an impossibility proof.** The old criterion, `P(≥1 miss) → 1`, + also condemns the composed system (0.993 over 1,000 tasks at a residual of 0.005), and + raising `p` bends the curve too (30-task `P(≥1 miss)` is 0.958 at `p = 0.9` and 0.260 at + `p = 0.99`). +- **Checks do not multiply unless they are independent.** The old Eq. 5, + `(1 − p)·∏(1 − cⱼ)`, assumed the checks fire independently given a miss. The same + classifier at the Stop hook, pre-commit and CI fires together, so the residual is + `(1 − p)(1 − c_max)`: 0.015, not the product's 3.75 × 10⁻⁵, in the paper's own example. +- **`cⱼ` belongs to the agent as well as the gate.** The gate detects its proxy exactly, + not the miss; an agent that touches `STATE.md` passes it. At a STATE-touch rate of 0.9 + the residual is 0.27, not 0.015. +- **Rice's theorem is a worst case over all programs**, not a probability bound, so the + claim that `cⱼ < 1` on semantic misses is an empirical premise. +- **Priority is conceded.** The law is standard layer-of-protection algebra, and two + concurrent preprints derived a more general Bayesian form first (see the refutation + paper's related work). The paper no longer says it "proves" the result. + +It is the formal content of the practitioner's rule: _never trust the output of a +probability engine; earn trust with an external check._ + +## Three anchor identities (two of them weaker than first claimed) + +| Substrate | Framework | Relationship | +| -------------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Impact-Oracle blast-radius | change-closure `Δ* = lfp(X ↦ Δ₀ ∪ N(X))` | **approximation**, not identity — the oracle is thresholded, depth-10 reachability over the AST relation; its real-repository recall was 0.022 | +| M2 assumption gate | amnesia equation `assumption ≈ argmax P(convention \| training)` | **identical** — the gate supplies missing context or halts, never guesses (argmax is greedy decoding's special case of sampling) | +| substrate's two layers | design law "Π₃ probabilistic, Π₂ deterministic" | **Theorem D**, as a bound over `(p, q)` with its dependence assumptions stated | + +Earlier versions wrote `Δ*` as the least fixpoint of `X ↦ X ∪ N(X)`, which is the empty +set, and said reverse reachability run to fixpoint implied perfect recall. ## Contents | File | What it is | | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `substrate_synthesis.pdf` | The formal synthesis paper (42 pp): definitions, Theorem D + proof, the unified A1–A7 TASK loop, invariants I1–I4, theorems T1–T6 with proofs, the 16-row crosswalk, the full 14-mapping Qur'anic epistemology, both prototypes with honest metrics. | -| `substrate_synthesis.html` | Same paper, self-contained HTML. | +| `substrate_synthesis.pdf` | The formal synthesis paper (42 pp): definitions, Theorem D + proof, the unified A1–A7 TASK loop, invariants I1–I4, theorems T1–T6 with proofs, the 16-row crosswalk, the full 14-mapping Qur'anic epistemology, both prototypes. **Predates the 2026-09-21 corrections.** | +| `substrate_synthesis.html` | Same paper, self-contained HTML, **with the 2026-09-21 corrections** and a Corrections section. | | `crosswalk.json` / `crosswalk.md` | The three-way term-by-term correspondence (substrate ↔ framework ↔ forgekit), with the P1/P2/P3 → Π₁/Π₂/Π₃ notation reconciliation. | | `graded_reference_set.json` / `.md` | The 15 new sources independently verified and graded (9 confirmed, 6 traceable, 0 unverifiable), including the disambiguation of the two future-dated arXiv IDs. | | `merged_references.json` | Full 47-entry bibliography (32 original + 15 new, deduped). | @@ -52,21 +90,28 @@ _never trust the output of a probability engine; earn trust with an external che The **two runnable prototypes** referenced throughout the paper already live in this repo and are not duplicated here: -- `../python-prototypes/impact_oracle/` — Prototype I, the impact oracle (A1 / Δ\*). - Runnable, 36 tests. Perfect recall on impacted files. +- `../python-prototypes/impact_oracle/` — Prototype I, the impact oracle (approximates + A1 / Δ\*). Runnable, 36 tests. Recall 1.00 on five mutations of its own demo package; + **refuted on real repositories: recall 0.022** on 759 files in nine repositories, where + grep scored F1 0.437 against the oracle's 0.042 (see + [`../empirical-refutation/`](../empirical-refutation/)). - `../python-prototypes/router_gate/` — Prototype II, complexity-router + - assumption-gate (A7 + A6 / M1 + M2). Runnable, 19 tests. 62.1% real cost saved, - live-measured. + assumption-gate (A7 + A6 / M1 + M2). Runnable, 19 tests. 62.1% cost saved on the 30 + tasks its thresholds were tuned on; **refuted on 80 held-out tasks: total spend was + 20.2% higher** than always-premium. Per output a judge accepted, it cost $1.06 against + always-premium's $1.76, but only 6 and 3 of 64 outputs were accepted. ## Honesty commitments (carried from the source work) -- **The prototypes are demonstrations, not benchmarks.** The router/gate's perfect - accuracy is on a 30-task hand-labelled set whose thresholds were tuned against it — - it shows the rubric _can separate_ the cases. The cost figures, by contrast, are - exact arithmetic on **real measured token counts** from live model calls, and the - correctness sub-experiment actually executed the cheaper models' code against tests. -- **The impact oracle does not win on F1** (0.75 vs grep's 0.79). Its property is - **perfect recall** — the safety guarantee for "what will my edit break?". +- **The prototypes are demonstrations, not benchmarks** — and when they were + benchmarked, both headline results failed. The router/gate's perfect accuracy was on a + 30-task hand-labelled set whose thresholds were tuned against it; on 80 held-out tasks + gate F1 was 0.37. Its cost figures were exact arithmetic on real measured token counts, + but on held-out tasks almost no output at any tier was judged correct, so escalation paid + for every tier and routing cost more than always-premium. +- **The impact oracle does not win on F1**, and its "perfect recall" was a property of its + demo package, not a guarantee: a closure is complete only relative to the relation it + walks, and on real repositories that relation missed 97.8% of the co-changed file pairs. - **Every future-dated / recent citation was verified by direct fetch, not inferred.** Both 2026 arXiv IDs resolve to real preprints; one (`2601.05111`) is a _different_ paper from the founding Agent-as-a-Judge work (`2410.10934`), and both are recorded. diff --git a/research/formal-synthesis/audits/review_wisdomlens_hikmah.md b/research/formal-synthesis/audits/review_wisdomlens_hikmah.md index d2a7cfe..8332f3c 100644 --- a/research/formal-synthesis/audits/review_wisdomlens_hikmah.md +++ b/research/formal-synthesis/audits/review_wisdomlens_hikmah.md @@ -1,5 +1,12 @@ # Review — `wisdom-lens` plugin and `hikmah-stack` v3.0.0 +> **Editorial note (2026-09-21).** This review is kept as written. Two of its framings were +> later corrected: the four layers in §1 are the same author's work (forgekit and hikmah-stack +> are both under the author's GitHub account), so their convergence is consistency, not +> "four independent efforts"; and Theorem D's product `(1-p)·∏(1-c_j)` holds only when the +> checks fire independently given a miss. See the Corrections section of +> [`../substrate_synthesis.html`](../substrate_synthesis.html). + **Reviewer:** Claude Science, on request. **Date:** 13 August 2026. **Audited:** `wisdomlens.zip` (plugin v1.0.0, 15 files); `CodeWithJuber/hikmah-stack` at commit `4827966f` (main, Rust kernel v3.0.0); the two source manuscripts *The New Lens* and diff --git a/research/formal-synthesis/crosswalk.json b/research/formal-synthesis/crosswalk.json index e2d427c..4c50ccd 100644 --- a/research/formal-synthesis/crosswalk.json +++ b/research/formal-synthesis/crosswalk.json @@ -24,9 +24,9 @@ { "concept": "Impact-awareness / partial-work failure", "substrate": "Faculty: impact-awareness gap; the developer silently simulates 'what will this edit break'. Prototype I = Impact Oracle (reverse-dependency blast radius with confidence decay)", - "framework": "F1 partial work; dependents operator N(X); required change closure Delta* = least fixpoint of X |-> X \u222a N(X); Done predicate; Algorithm A1 IMPACT-CLOSURE", + "framework": "F1 partial work; dependents operator N(X); required change closure Delta* = lfp(X |-> Delta_0 \u222a N(X)) (the lfp of X |-> X \u222a N(X) alone is the empty set) [corrected 2026-09-21]; Done predicate; Algorithm A1 IMPACT-CLOSURE", "forgekit": "/impact skill; documentation-map.md (R_declared); grep identifier sweep (R_discovered)", - "relation": "IDENTICAL MATHEMATICS. The Impact Oracle computes N(X) by reverse reachability on the dependency graph; Delta* is exactly the oracle's transitive blast-radius set. The oracle adds a confidence-decay weight the framework's set-theoretic N(X) leaves boolean." + "relation": "SAME SHAPE, NOT IDENTICAL [corrected 2026-09-21]. The Impact Oracle computes a thresholded, depth-limited reverse reachability over the AST relation, which approximates Delta* over a different relation. It adds a confidence-decay weight the framework's set-theoretic N(X) leaves boolean. Measured on nine real repositories, its recall was 0.022." }, { "concept": "Memory / session-amnesia failure", @@ -38,7 +38,7 @@ { "concept": "Why assumptions happen (the root the user named)", "substrate": "M2 assumption/uncertainty gate: under-specified input -> the model confabulates a convention", - "framework": "amnesia equation: when f \u2208 essential(K) is missing from L(P), assumption \u2248 argmax P(convention | training data) \u2014 the mathematically EXPECTED result of missing context, not misbehavior. Invariant I3 (no fabrication)", + "framework": "amnesia equation: when f \u2208 essential(K) is missing from L(P), assumption \u2248 argmax P(convention | training data) under greedy decoding (a draw from that prior under sampling) \u2014 the mathematically EXPECTED result of missing context, not misbehavior. Invariant I3 (no fabrication)", "forgekit": "CLAUDE.md No-assumptions rule; intent-router SPEC card ('state assumptions explicitly')", "relation": "The framework gives the substrate's M2 gate its formal justification: assumption is argmax over the prior, so the fix is to supply context (raise what L loads) OR gate on missing context (halt), never to hope." }, @@ -47,7 +47,7 @@ "substrate": "M6 inline verification; Prototype-II verify step; self-correction faculty", "framework": "Algorithm A6 COMPLETION-GATE (deterministic Stop-hook floor); Hoare postcondition Done(t); the verification operator in A3 (verified-unaffected requires an actual grep, not an assumption)", "forgekit": "src/gate.js stopGate Stop hook via cortex.sh (blocks finish if code changed but no doc/state artifact did; kit: docs-guard.sh); reviewer agent verdict", - "relation": "Same verify-gate. Substrate framed it as a mechanism; framework proves it terminates (T1) and is sound (T3) and makes it the deterministic layer of the duality." + "relation": "Same verify-gate. Substrate framed it as a mechanism; framework proves it terminates (T1) and fires exactly on its proxy signal (T3) and makes it the deterministic layer of the duality." }, { "concept": "Complexity routing", @@ -108,16 +108,16 @@ { "concept": "The two-layer duality (THE central new insight)", "substrate": "implicit: the substrate wraps a probabilistic core with deterministic external structure, but v2 never states it as a law", - "framework": "DESIGN LAW: instructions (Pi3) are PROBABILISTIC (raise p); interception (Pi2) is DETERMINISTIC (guarantee a floor c->1). P(silent miss) = (1-p)\u00b7prod_j(1-c_j). Since p<1 always, neither layer alone suffices.", + "framework": "DESIGN LAW: instructions (Pi3) are PROBABILISTIC (raise p); interception (Pi2) is DETERMINISTIC (guarantee a floor c->1). P(silent miss) = (1-p)\u00b7P(no check fires | miss), which is (1-p)\u00b7prod_j(1-c_j) only for independent checks [corrected 2026-09-21]. Where p and c are each bounded below 1, both layers are needed to reach a small residual.", "forgekit": "the split itself: CLAUDE.md/rules/skills = soft layer; hooks (cortex.sh -> src/gate.js, src/session.js, src/intent.js; kit: docs-guard, session-context, intent-router) = hard layer", - "relation": "THIS is what the kit gives the substrate theory: the reason the substrate must be TWO layers, stated as a theorem. The user's own discipline ('never trust AI output \u2014 it is calculated probability') is exactly (1-p)>0, which forces the deterministic layer." + "relation": "THIS is what the kit gives the substrate theory: the reason the substrate must be TWO layers, stated as a bound over (p, q) [corrected 2026-09-21]. The user's own discipline ('never trust AI output \u2014 it is calculated probability') is exactly (1-p)>0, which forces the deterministic layer." }, { "concept": "The probabilistic failure model", "substrate": "eval honesty: perfect accuracy shows separation not a benchmark; p<1", - "framework": "P(>=1 miss) = 1 - p^n over n tasks (0.9 -> 65% at 10, 96% at 30); layered: P(silent miss)=(1-p)\u00b7prod(1-c_j)", + "framework": "P(>=1 miss) = 1 - p^n over n independent tasks (0.9 -> 65% at 10, 96% at 30); layered: P(silent miss)=(1-p)\u00b7P(no check fires | miss) [corrected 2026-09-21]", "forgekit": "the lattice of gates: turn-level (hook) \u2282 commit-level (pre-commit) \u2282 PR-level (CI)", - "relation": "The math that makes 'it works sometimes then forgets' a geometric certainty rather than bad luck \u2014 and quantifies why each added deterministic gate multiplies down the residual." + "relation": "The math that makes 'it works sometimes then forgets' a geometric certainty rather than bad luck \u2014 and quantifies when an added deterministic gate lowers the residual: only when it catches misses the earlier gates did not (a copy of the same check adds nothing) [corrected 2026-09-21]." }, { "concept": "Stewardship / governance boundary", diff --git a/research/formal-synthesis/crosswalk.md b/research/formal-synthesis/crosswalk.md index 37a4699..ee00591 100644 --- a/research/formal-synthesis/crosswalk.md +++ b/research/formal-synthesis/crosswalk.md @@ -17,10 +17,10 @@ This map is the spine of the formal synthesis: every unified concept, shown in a | # | Unified concept | Substrate (S) | E2E Framework (E) | forgekit / kit (K) | Why they are one object | | --- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 1 | **The frozen core** | y = f_theta(x): stateless map, fixed weights, bounded context (properties P1,P2,P3) | the agent/model whose behavior instructions can only raise the PROBABILITY of (p<1) | Claude / Codex / Cursor / Gemini / Aider — the model the kit wraps, never modifies | identical object: all three treat the model as a fixed probabilistic map to be wrapped, not retrained | -| 2 | **Impact-awareness / partial-work failure** | Faculty: impact-awareness gap; the developer silently simulates 'what will this edit break'. Prototype I = Impact Oracle (reverse-dependency blast radius with confidence decay) | F1 partial work; dependents operator N(X); required change closure Delta\* = least fixpoint of X \|-> X ∪ N(X); Done predicate; Algorithm A1 IMPACT-CLOSURE | /impact skill; documentation-map.md (R_declared); grep identifier sweep (R_discovered) | IDENTICAL MATHEMATICS. The Impact Oracle computes N(X) by reverse reachability on the dependency graph; Delta\* is exactly the oracle's transitive blast-radius set. The oracle adds a confidence-decay weight the framework's set-theoretic N(X) leaves boolean. | +| 2 | **Impact-awareness / partial-work failure** | Faculty: impact-awareness gap; the developer silently simulates 'what will this edit break'. Prototype I = Impact Oracle (reverse-dependency blast radius with confidence decay) | F1 partial work; dependents operator N(X); required change closure Delta\* = lfp(X \|-> Delta_0 ∪ N(X)) (the lfp of X \|-> X ∪ N(X) alone is the empty set) [corrected 2026-09-21]; Done predicate; Algorithm A1 IMPACT-CLOSURE | /impact skill; documentation-map.md (R_declared); grep identifier sweep (R_discovered) | SAME SHAPE, NOT IDENTICAL [corrected 2026-09-21]. The Impact Oracle computes a thresholded, depth-limited reverse reachability over the AST relation, which approximates Delta\* over a different relation. It adds a confidence-decay weight the framework's set-theoretic N(X) leaves boolean. Measured on nine real repositories, its recall was 0.022. | | 3 | **Memory / session-amnesia failure** | Faculty: persistent memory gap; each context window is ephemeral. Validity-anchored memory (facts carry confirmed/discredited state updated by verified outcomes) | F2 session amnesia; continuity invariant I2 (essential(K*i) ⊆ P and loader L must load it); handoff operator H; K*{i+1}(0)=L(P) | docs/STATE.md (bounded snapshot) + docs/DECISIONS.md (append-only log); /handoff writes it; SessionStart hook injects it | Same failure, same fix (external bounded-compression checkpoint). Substrate adds validity-anchoring (prune by whether a past prediction was externally confirmed); framework adds the snapshot+WAL split (STATE mutable vs DECISIONS append-only). | -| 4 | **Why assumptions happen (the root the user named)** | M2 assumption/uncertainty gate: under-specified input -> the model confabulates a convention | amnesia equation: when f ∈ essential(K) is missing from L(P), assumption ≈ argmax P(convention \| training data) — the mathematically EXPECTED result of missing context, not misbehavior. Invariant I3 (no fabrication) | CLAUDE.md No-assumptions rule; intent-router SPEC card ('state assumptions explicitly') | The framework gives the substrate's M2 gate its formal justification: assumption is argmax over the prior, so the fix is to supply context (raise what L loads) OR gate on missing context (halt), never to hope. | -| 5 | **Self-correction / verification** | M6 inline verification; Prototype-II verify step; self-correction faculty | Algorithm A6 COMPLETION-GATE (deterministic Stop-hook floor); Hoare postcondition Done(t); the verification operator in A3 (verified-unaffected requires an actual grep, not an assumption) | `src/gate.js` stopGate Stop hook via cortex.sh (blocks finish if code changed but no doc/state artifact did; kit: docs-guard.sh); reviewer agent verdict | Same verify-gate. Substrate framed it as a mechanism; framework proves it terminates (T1) and is sound (T3) and makes it the deterministic layer of the duality. | +| 4 | **Why assumptions happen (the root the user named)** | M2 assumption/uncertainty gate: under-specified input -> the model confabulates a convention | amnesia equation: when f ∈ essential(K) is missing from L(P), assumption ≈ argmax P(convention \| training data) under greedy decoding (a draw from that prior under sampling) — the mathematically EXPECTED result of missing context, not misbehavior. Invariant I3 (no fabrication) | CLAUDE.md No-assumptions rule; intent-router SPEC card ('state assumptions explicitly') | The framework gives the substrate's M2 gate its formal justification: assumption is argmax over the prior, so the fix is to supply context (raise what L loads) OR gate on missing context (halt), never to hope. | +| 5 | **Self-correction / verification** | M6 inline verification; Prototype-II verify step; self-correction faculty | Algorithm A6 COMPLETION-GATE (deterministic Stop-hook floor); Hoare postcondition Done(t); the verification operator in A3 (verified-unaffected requires an actual grep, not an assumption) | `src/gate.js` stopGate Stop hook via cortex.sh (blocks finish if code changed but no doc/state artifact did; kit: docs-guard.sh); reviewer agent verdict | Same verify-gate. Substrate framed it as a mechanism; framework proves it terminates (T1) and fires exactly on its proxy signal (T3) and makes it the deterministic layer of the duality. | | 6 | **Complexity routing** | M1 complexity-aware router (transparent additive rubric); Prototype-II router | Algorithm A7 KNOWLEDGE-ROUTER (where every fact lives, keeps always-loaded context bounded); System 1/System 2 effort routing; intent DFA | `src/intent.js` exemplar k-NN intent classifier (UserPromptSubmit via cortex.sh, zero-token; kit: intent-router.sh keyword DFA); effort-routing rule in CLAUDE.md; per-agent model: fields | Two facets of one routing principle. Substrate M1 routes by TASK COMPLEXITY -> model tier (cost). Framework A7 routes by KNOWLEDGE TYPE -> storage home (context budget). Both are transparent/deterministic, both reject an opaque-LLM classifier for the same reason. | | 7 | **Task decomposition** | M3 task/session decomposition | HTN closure -> ordered task list (A1 output is the worklist); the meta-algorithm TASK(t) | sdlc-pilot 7-phase skill; subagents; git worktrees | Same: decompose the goal into the closure's ordered worklist before building. | | 8 | **Goal-anchoring** | M4 goal-anchoring (goal drift: to a text model a long story and long code are the same object) | BDI Desires = written goal + acceptance criteria in STATE.md; I2 keeps them across sessions; acceptance criteria written at SPEC, consumed at VERIFY | docs/STATE.md 'Current goal' + 'Acceptance criteria'; sdlc-pilot SPEC->VERIFY gate | Substrate names the drift; framework anchors it in a persisted, re-injected Desire so every session optimizes the same target. | @@ -29,14 +29,14 @@ This map is the spine of the formal synthesis: every unified concept, shown in a | 11 | **Continual learning from outcomes** | Faculty: learning without touching theta (non-parametric always-on + parametric LoRA/EWC) | Reflexion loop made cross-session (I3/I4); DECISIONS.md as precedent DB; A4 mirrors durable lessons | Reflexion rule in CLAUDE.md; STATE gotchas; agents' memory: project | Same 'learn without retraining' stance. Both store verbal lessons externally and re-inject; substrate adds the parametric option, framework keeps it purely non-parametric (committed files). | | 12 | **Rehydration (session start)** | closed-loop write-back/read-back band in the substrate architecture | Algorithm A5 CONTEXT-REHYDRATE; loader L; records git baseline for the gate | `src/session.js` rehydrationBlock SessionStart injection via cortex.sh (kit: session-context.sh); /catchup skill (deep variant) | The read side of the substrate's memory loop, made deterministic (inject, don't hope the agent reads). | | 13 | **Independent verification / judge** | self-correction faculty; the honest-negative-result caution (models correct poorly alone) | LLM-as-Judge applied: reviewer agent (fresh context, explicit criteria, adversarial); self-consistency for critical changes (majority of N) | reviewer.md agent; DoD item 7 | Substrate cited the finding that self-correction alone is weak; framework operationalizes the fix (an EXTERNAL judge), which is the same 'trust only an external check' principle as the gate. | -| 14 | **The two-layer duality (THE central new insight)** | implicit: the substrate wraps a probabilistic core with deterministic external structure, but v2 never states it as a law | DESIGN LAW: instructions (Pi3) are PROBABILISTIC (raise p); interception (Pi2) is DETERMINISTIC (guarantee a floor c->1). P(silent miss) = (1-p)·prod_j(1-c_j). Since p<1 always, neither layer alone suffices. | the split itself: CLAUDE.md/rules/skills = soft layer; hooks (cortex.sh -> src/gate.js, src/session.js, src/intent.js; kit: docs-guard, session-context, intent-router) = hard layer | THIS is what the kit gives the substrate theory: the reason the substrate must be TWO layers, stated as a theorem. The user's own discipline ('never trust AI output — it is calculated probability') is exactly (1-p)>0, which forces the deterministic layer. | -| 15 | **The probabilistic failure model** | eval honesty: perfect accuracy shows separation not a benchmark; p<1 | P(>=1 miss) = 1 - p^n over n tasks (0.9 -> 65% at 10, 96% at 30); layered: P(silent miss)=(1-p)·prod(1-c_j) | the lattice of gates: turn-level (hook) ⊂ commit-level (pre-commit) ⊂ PR-level (CI) | The math that makes 'it works sometimes then forgets' a geometric certainty rather than bad luck — and quantifies why each added deterministic gate multiplies down the residual. | +| 14 | **The two-layer duality (THE central new insight)** | implicit: the substrate wraps a probabilistic core with deterministic external structure, but v2 never states it as a law | DESIGN LAW: instructions (Pi3) are PROBABILISTIC (raise p); interception (Pi2) is DETERMINISTIC (guarantee a floor c->1). P(silent miss) = (1-p)·P(no check fires \| miss), which is (1-p)·prod_j(1-c_j) only for independent checks [corrected 2026-09-21]. Where p and c are each bounded below 1, both layers are needed to reach a small residual. | the split itself: CLAUDE.md/rules/skills = soft layer; hooks (cortex.sh -> src/gate.js, src/session.js, src/intent.js; kit: docs-guard, session-context, intent-router) = hard layer | THIS is what the kit gives the substrate theory: the reason the substrate must be TWO layers, stated as a bound over (p, q) [corrected 2026-09-21]. The user's own discipline ('never trust AI output — it is calculated probability') is exactly (1-p)>0, which forces the deterministic layer. | +| 15 | **The probabilistic failure model** | eval honesty: perfect accuracy shows separation not a benchmark; p<1 | P(>=1 miss) = 1 - p^n over n independent tasks (0.9 -> 65% at 10, 96% at 30); layered: P(silent miss)=(1-p)·P(no check fires \| miss) [corrected 2026-09-21] | the lattice of gates: turn-level (hook) ⊂ commit-level (pre-commit) ⊂ PR-level (CI) | The math that makes 'it works sometimes then forgets' a geometric certainty rather than bad luck — and quantifies when an added deterministic gate lowers the residual: only when it catches misses the earlier gates did not (a copy of the same check adds nothing) [corrected 2026-09-21]. | | 16 | **Stewardship / governance boundary** | STEWARDSHIP/amana wrapper (33:72) around the whole architecture | amana in I3/I4 as no-fabrication + verified-currency; least privilege, reversibility, logged rationale, scope-boundedness | committed-files-only (auditable), block-at-most-once (no nagging), fail-open safety (T2), DOCS_GUARD_DISABLE auditable escape hatch | The ethical frame the substrate draws from the lens is realized as concrete safety properties in the framework (fail-open, reversibility) and kit (auditable hooks). | ## The three anchor identities -Three rows are not analogies but _the same mathematics under two names_: +Three rows were presented as _the same mathematics under two names_. Two of them are weaker than that (corrected 2026-09-21): -1. **Impact Oracle blast-radius ≡ change-closure fixpoint Δ\*.** Reverse reachability on the dependency graph is the least fixpoint of `X ↦ X ∪ N(X)`. The oracle computes `N(X)` with a confidence-decay weight; the framework's `N(X)` is its boolean core. -2. **M2 assumption gate ≡ the amnesia equation.** `assumption ≈ argmax P(convention | training)` is _why_ an under-specified prompt is answered with a confabulated convention — so the gate either supplies the missing context or halts; it never hopes. -3. **The substrate's two layers ≡ the design law.** Instructions raise `p<1`; interception guarantees a floor `c→1`. `P(silent miss) = (1−p)·∏(1−cⱼ)`. This is the theorem that says the substrate _must_ be two layers — and it is the formal statement of the governing discipline, _never trust AI output_. +1. **Impact Oracle blast-radius ≈ change-closure fixpoint Δ\*.** Reverse reachability from the seed is the least fixpoint of `X ↦ Δ₀ ∪ N(X)` (the least fixpoint of `X ↦ X ∪ N(X)` alone is `∅`). The oracle approximates it: it weights paths with confidence decay, thresholds them, stops at depth 10, and walks the AST relation rather than the framework's `R`. On nine real repositories its recall was 0.022. +2. **M2 assumption gate ≡ the amnesia equation.** `assumption ≈ argmax P(convention | training)` (greedy decoding; under sampling, a draw from that prior) is _why_ an under-specified prompt is answered with a confabulated convention — so the gate either supplies the missing context or halts; it never hopes. +3. **The substrate's two layers ≡ the design law.** Instructions raise `p<1`; interception multiplies the residual by `P(no check fires | miss)`, so `P(silent miss) = (1−p)·P(no check fires | miss)`, which equals `(1−p)·∏(1−cⱼ)` only when the checks fire independently. Restated as a bound over `(p, q)`, it says the substrate needs both layers wherever each factor is bounded away from zero — the formal statement of the governing discipline, _never trust AI output_. diff --git a/research/formal-synthesis/substrate_synthesis.html b/research/formal-synthesis/substrate_synthesis.html index 05a05e9..4ce4d12 100644 --- a/research/formal-synthesis/substrate_synthesis.html +++ b/research/formal-synthesis/substrate_synthesis.html @@ -74,6 +74,7 @@ .refs{font-size:.86rem;line-height:1.5} .refs li{margin:6px 0} .small{font-size:.88rem;color:var(--muted)} sub{font-size:.72em} +.corr{font-size:.8em;color:var(--alarm);font-style:normal;white-space:nowrap} @@ -85,7 +86,12 @@

A Formal Theory of the Cognitive Substrate for Coding Agents

Abstract

-

A large language model used for coding is a fixed probabilistic map, y = fθ(x): stateless, frozen, and bounded in context. Three research efforts converged, independently, on the same conclusion — that the remedy is not a better prompt or a bigger model but an external, stateful architecture wrapped around the frozen core. This paper proves they are describing one object. We show that the substrate's impact-awareness faculty and the framework's change-closure fixpoint Δ* are the same mathematics; that the assumption gate and the amnesia equation assumption ≈ argmax P(convention | training) are the same phenomenon; and that both reduce to a single two-layer duality: a probabilistic instruction layer that raises the probability p<1 of correct behaviour, and a deterministic interception layer that guarantees a floor. The central theorem states that neither layer alone can make an agent reliable — a direct formalization of the discipline never trust the output of a probabilistic engine; earn trust with an external check. We give definitions, the duality theorem with proof, a unified seven-algorithm task loop, the probabilistic failure model P(≥1 miss)=1−pn, and carry through the six correctness theorems of the reliability framework. Two prototypes — an impact oracle and a complexity-router/assumption-gate — instantiate the deterministic layer and are evaluated honestly. The forgekit / claude-e2e-kit codebase is the deployed binding. The Qur'anic lens supplies the vocabulary of epistemic obligation (tabayyun, amāna, lā taqfu) that names why each safeguard is mandatory rather than optional.

+

A large language model used for coding is a fixed probabilistic map, y = fθ(x): stateless, frozen, and bounded in context. Three bodies of work, developed separately from different starting points, converged on the same conclusion — that the remedy is not a better prompt or a bigger model but an external, stateful architecture wrapped around the frozen core. This paper argues [corrected 2026-09-21] they are describing one object; their agreement is consistency rather than independent evidence, since forgekit was built as a binding of the other two [corrected 2026-09-21]. We show that the substrate's impact-awareness faculty and the framework's change-closure fixpoint Δ* have the same shape, the oracle approximating the fixpoint over a different relation [corrected 2026-09-21]; that the assumption gate and the amnesia equation assumption ≈ argmax P(convention | training) are the same phenomenon; and that both reduce to a single two-layer duality: a probabilistic instruction layer that raises the probability p<1 of correct behaviour, and a deterministic interception layer that multiplies down what escapes it. The central result, restated in the 2026-09-21 corrections as a bound on the residual over an explicit region of (instruction-following, catch) probabilities rather than as an impossibility theorem [corrected 2026-09-21], is a formalization of the discipline never trust the output of a probabilistic engine; earn trust with an external check. The composition law itself is standard layer-of-protection algebra, and two concurrent preprints derived a strictly more general Bayesian form of it first; we concede priority [corrected 2026-09-21]. We give definitions, the duality result, a unified seven-algorithm task loop, the probabilistic failure model P(≥1 miss)=1−pn (for independent tasks), and carry through the six correctness theorems of the reliability framework. Two prototypes — an impact oracle and a complexity-router/assumption-gate — instantiate the deterministic layer; both of their headline results were later refuted on data the authors did not build [corrected 2026-09-21]. The forgekit / claude-e2e-kit codebase is the deployed binding. The Qur'anic lens supplies the vocabulary of epistemic obligation (tabayyun, amāna, lā taqfu) that names why each safeguard is mandatory rather than optional.

+
+ +
+

Corrections (2026-09-21)

+

An external review found that Theorem D was circular as stated, that Eq. (5) assumed an independence the design contradicts, that several definitions and proofs were wrong, and that the prototype results in §10 had already been refuted. The corrections are made in place, marked [corrected 2026-09-21], and listed with the original wording in Corrections. The PDF edition predates them.

@@ -122,7 +129,8 @@

1 The convergence — three roads to one architecture

The claim of this paper

-

These are not three similar ideas. They are one architecture described in three vocabularies. The impact-awareness faculty is the change-closure fixpoint. The assumption gate is the amnesia equation. The substrate's external structure is a two-layer duality — and that duality, which the reliability framework states as a design law, is the theorem the whole thing turns on. What each road saw partially, the union sees whole.

+

These are not three similar ideas. They are one architecture described in three vocabularies. The impact-awareness faculty is the change-closure fixpoint. The assumption gate is the amnesia equation. The substrate's external structure is a two-layer duality — and that duality, which the reliability framework states as a design law, is the result the whole thing turns on. What each road saw partially, the union sees whole.

+

Two of these three “identities” are weaker than this callout says: the impact oracle approximates Δ* rather than computing it (§3.2), and the duality is a bound over a region of parameters, not a theorem for every p, c < 1 (§4). See the Corrections. [corrected 2026-09-21]

The synthesis also inherits a governing discipline, stated plainly by the practitioner who commissioned this work: AI output is a mathematically calculated probability; it must never be trusted blindly; for the same prompt it can give a different answer, so use only the capability it is genuinely best at, and earn trust with an external check. We will see that this sentence is not a slogan but the informal statement of the central theorem — the quantity (1−p)>0 that forces a deterministic layer to exist.

@@ -130,7 +138,8 @@

The claim of this paper

2 The object of study — the frozen map and its five lacks

Fix the model. Let the coding agent's core be a function

-
(1)y = fθ(x), θ fixed,  x the bounded context window,  y the sampled output.
+
(1)y ~ pθ(· | x), θ fixed,  x the bounded context window,  y a draw from the output distribution the frozen map defines.
+

We write y = fθ(x) as shorthand for this sampling map. Greedy decoding, which approximately returns argmaxy pθ(y | x), is its temperature-0 special case; the amnesia equation of §8 uses that special case. [corrected 2026-09-21]

Three properties of this map generate every problem the architecture must solve. To avoid a notation collision with the reliability framework's primitives (§3), we label these model properties P1–P3:

@@ -144,13 +153,15 @@

2 The object of study — the frozen map and its five la - - - - - + + + + +
Faculty the model lacksForced byExternal remedy (this architecture)
Persistent memoryP1A committed store re-injected each session (§6, A4/A5)
Learning from outcomesP2Non-parametric experience store; optional parametric adapters (§6)
Imagination / world-modelP1, P3A typed artifact graph the agent queries before acting (§3.2)
Self-correctionP3An external verifier and a deterministic completion gate (§4, A6)
Impact-awarenessP1, P3The change-closure Δ* computed on the graph (§3.2, A1)
Persistent memoryP1, P3A committed store re-injected each session (§6, A4/A5)
Learning from outcomesP2, P1Non-parametric experience store; optional parametric adapters (§6)
Imagination / world-modelP1 (and the token→token form of Eq. 1)A typed artifact graph the agent queries before acting (§3.2)
Self-correctionP2An external verifier and a deterministic completion gate (§4, A6)
Impact-awarenessP3The change-closure Δ* computed on the graph (§3.2, A1)
+

The “Forced by” column now matches the whitepaper's derivation, which argues each row separately; the earlier version of this table disagreed with it in all five rows. [corrected 2026-09-21]

+

The critical word is external. Because θ is frozen (P2) and context is bounded (P3), none of these can be fixed by prompting harder or by fine-tuning alone. The architecture must live around the model, hold state outside it, and enforce behaviour the model cannot be relied upon to produce on its own. The rest of this paper makes "cannot be relied upon" precise and shows what "enforce" must therefore mean.

@@ -177,7 +188,7 @@

3.1 The stateful substrate operator

The design law, stated early because everything depends on it

-

Π3 is probabilistic; Π2 is deterministic. Instructions (Π3) raise the probability that the model behaves correctly; interception (Π2) executes regardless of what the model decides. A reliable substrate needs both, and §4 proves it cannot be built from either alone.

+

Π3 is probabilistic; Π2 is deterministic. Instructions (Π3) raise the probability that the model behaves correctly; interception (Π2) executes regardless of what the model decides. A reliable substrate needs both wherever neither can reach the target residual alone; §4 states when that is. [corrected 2026-09-21]

3.2 The repository as a typed artifact graph (the world-model)

@@ -188,7 +199,7 @@

3.2 The repository as a typed artifact graph (the world-model)

Let the project be a finite set of artifacts A = {a1, …, an} with a type function τ : A → {code, test, doc, config, diagram} and a dependency relation R ⊆ A × A, where (a,b) ∈ R means "a describes, verifies, exercises, or references b". R = Rdeclared ∪ Rdiscovered: declared edges come from a curated documentation map (high precision, small); discovered edges are found mechanically — a mentions an identifier defined in b — by text search.

-

The substrate's Prototype I builds this graph from source: an AST parser extracts the code nodes and their edges, so Rdiscovered over {code, test} is computed exactly rather than by grep. The reliability framework's contribution is to widen τ beyond code, making documentation a first-class dependent so that a code change can be seen to obligate a doc change.

+

The substrate's Prototype I builds this graph from source: an AST parser extracts the code nodes and their edges, so Rdiscovered over {code, test} is computed from the AST rather than by grep (the refutation later found a construction defect in exactly this step, §10 [corrected 2026-09-21]). The reliability framework's contribution is to widen τ beyond code, making documentation a first-class dependent so that a code change can be seen to obligate a doc change.

Definition 3 (Dependents operator and change closure)

For a set X ⊆ A, the dependents operator is

@@ -196,11 +207,12 @@

3.2 The repository as a typed artifact graph (the world-model)

— everything that describes, verifies, or references anything in X. A task seeds a change set Δ0 ⊆ A (the files the request names or obviously touches). The required change closure is the least fixpoint

(4)Δk+1 = Δk ∪ N(Δk), Δ* = Δk where Δk+1 = Δk.

Since A is finite and the sequence is monotone (Δ0 ⊆ Δ1 ⊆ … ⊆ A), the fixpoint exists and is reached in at most |A| steps (Kleene's theorem on a finite lattice). In practice depth 2–3 suffices.

+

Equivalently, Δ* = lfp(X ↦ Δ0 ∪ N(X)), the least fixpoint above the seed (the two agree because N distributes over union). The seed matters: the least fixpoint of X ↦ X ∪ N(X) on its own is , which is what earlier versions of this paper wrote. [corrected 2026-09-21]

-

Anchor identity #1: the impact oracle is Δ*

-

The substrate's Impact Oracle computes a file's blast radius by reverse reachability on the dependency graph — which is exactly the closure (4). The oracle adds a real-valued confidence that decays with graph distance, where the framework's N(·) is boolean; thresholding the oracle's confidence recovers N. They are the same computation. This is why the prototype achieves perfect recall on impacted files (§10): reverse reachability, run to fixpoint, cannot miss a reachable dependent.

+

Anchor identity #1, downgraded: the impact oracle approximates Δ* [corrected 2026-09-21]

+

The substrate's Impact Oracle computes a file's blast radius by reverse reachability on the dependency graph, which has the same shape as the closure (4). It is not the same object. The oracle adds a real-valued confidence that decays with graph distance, keeps only what clears a threshold, stops at depth 10, and walks the AST import/call relation rather than the framework's R (which also carries documentation and declared edges). So it approximates Δ* over a different relation. Earlier versions said the two were the same computation and that this was why the prototype achieved perfect recall. That inference was wrong: completeness relative to a relation says nothing about whether the relation holds the edges that matter, and on nine real repositories the oracle's recall was 0.022 (§10).

3.3 The faculties as operators on the store

@@ -229,34 +241,38 @@

4 The central result — the two-layer duality theorem

A substrate's behaviour-shaping is partitioned into two layers over the frozen model:

  • The probabilistic layer3): standing instructions loaded into context — CLAUDE.md, rules, skills, protocol cards. Let p = P(the agent performs the required behaviour on a task — e.g. the full closure Δ* — under the instruction layer alone).
  • -
  • The deterministic layer2): code executed at fixed lifecycle points independent of the model's choices — hooks that inject state, or that block a turn. A deterministic check j catches a target miss with probability cj, and for a decidable structural signal cj → 1.
  • +
  • The deterministic layer2): code executed at fixed lifecycle points independent of the model's choices — hooks that inject state, or that block a turn. A deterministic check j fires with probability cj = P(check j fires | miss). A decidable check detects its proxy signal exactly, but cj is the probability that a miss shows that proxy. That depends on the agent's behaviour as well as on the check, so it is not a property of the gate alone (§5.2). [corrected 2026-09-21]
-
Theorem D (Two-layer duality — neither layer alone suffices)
-

Let a task require a behaviour whose omission is a silent miss. Under the instruction layer alone the per-task silent-miss probability is 1−p; under a deterministic layer of k checks the residual silent-miss probability is

-
(5)P(silent miss) = (1−p) · ∏j=1..k (1−cj).
-

Then, for any model whose instruction-following is imperfect (p<1) and any deterministic layer that is not omniscient (cj<1 for every j):

+
Theorem D (Two-layer duality, restated as a bound [corrected 2026-09-21])
+

Let a task require a behaviour whose omission is a silent miss. Let M be the event that the agent omits it under the instruction layer, so P(M) = 1−p, and let q = P(at least one of the k checks fires | M). The per-task residual is exactly

+
(5)r = P(silent miss) = (1−p) · (1−q) = (1−p) · P(no check fires | M).
+

With cj = P(check j fires | M), and no independence assumption, max(0, 1−Σjcj) ≤ 1−q ≤ 1−maxjcj (Fréchet bounds). The product (1−p)·∏j(1−cj), which earlier versions gave as Eq. (5), is the special case in which the checks fire independently given the miss. When the checks are nested, for example the same classifier run at several points on the same diff, r = (1−p)(1−cmax).

+

Then:

    -
  1. The probabilistic layer alone cannot reach reliability. With k=0, P(silent miss)=1−p>0, and over n tasks P(≥1 miss)=1−pn → 1. No amount of instruction-writing removes the residual, because instructions are context, not enforcement.
  2. -
  3. The deterministic layer alone cannot reach reliability either. A decidable check bounds only the structural signal it was built to detect; semantic correctness is undecidable (§12), so ∏(1−cj)>0 for the semantic class. Without the instruction layer raising p, the factor (1−p) stays near 1 and the product is dominated by it.
  4. -
  5. Their composition is strictly better than either factor. Because 0<(1−p)<1 and each 0<(1−cj)<1, the product (5) is strictly smaller than (1−p) and strictly smaller than any single (1−cj). Reliability is the product of a soft factor and hard factors, and needs both kinds present.
  6. +
  7. Bound. The per-task residual is at most ε exactly on the region Rε = {(p, q) : (1−p)(1−q) ≤ ε}. Over n tasks, P(≥1 miss) ≤ min(1, nε) whatever the dependence between tasks (union bound). It equals 1−(1−ε)n only if tasks fail independently, and tasks done by one model on one repository need not.
  8. +
  9. Instruction layer alone (q = 0): r = 1−p, so reaching ε needs p ≥ 1−ε from instructions. Raising p does bend the curve: for 30 independent tasks, P(≥1 miss) is 0.958 at p = 0.9 and 0.260 at p = 0.99.
  10. +
  11. Deterministic layer alone (the bare model's p0): reaching ε needs q ≥ 1 − ε/(1−p0).
  12. +
  13. Composition. Adding a check with P(it fires | M, no earlier check fired) > 0 strictly lowers r. Adding a copy of a check that is already present lowers nothing.
+

The design claim that survives is a statement about ranges, not an impossibility theorem. Let p0 be the bare model's rate, pmax the best rate instructions can reach, and qmax the best catch rate decidable checks can reach on the misses that matter. Instructions alone leave at least 1−pmax; checks alone leave at least (1−p0)(1−qmax); together they can reach (1−pmax)(1−qmax). So a target ε with (1−pmax)(1−qmax) ≤ ε < min(1−pmax, (1−p0)(1−qmax)) needs both layers and is reachable with them. Whether a real target falls in that range is an empirical question about p0, pmax and qmax, which this paper does not measure.

-Equation (5) is the probability that the behaviour is both omitted by the agent (the independent event of probability 1−p) and undetected by every one of the k checks (each failing to catch with probability 1−cj, taken as conditionally independent given the miss). Claim 1: set k=0, the empty product is 1, so P=1−p; the n-task bound is the complement of n independent successes, pn. Claim 2: for the semantic-miss class every decidable cj is bounded below 1 (Rice's theorem: non-trivial semantic properties of programs are undecidable), so the product cannot vanish; with p not raised, (1−p) is near 1. Claim 3: multiplying a number in (0,1) by further numbers in (0,1) strictly decreases it below every factor. □ +Equation (5) is the chain rule: a silent miss is a miss (1−p) on which no check fires (1−q). The Fréchet bounds hold for any joint distribution of the checks: no check fires with probability at most that of the most reliable single check missing, and at least 1−Σcj by the union bound. Claim 1: r ≤ ε is the definition of Rε, and P(∪i missi) ≤ Σi P(missi) needs no independence. Claims 2–3: set q = 0, or p = p0, and solve. Claim 4: 1−q falls by the probability that the new check fires on misses no earlier check caught, which is zero for a duplicate. □ +

What the earlier version got wrong [corrected 2026-09-21]. (i) Its criterion, P(≥1 miss) → 1, also condemns the composed system: whenever every cj < 1 the residual is positive and the same limit holds (0.993 over 1,000 tasks at a residual of 0.005), so it could not separate the layers. (ii) T3 asserts exact detection of its structural class, which, if that class were the target, gives the deterministic layer alone a residual of 0 and contradicts Claim 2. (iii) It said only a factor (1−cj) could bend the failure curve, but raising p does too. (iv) It assumed independent tasks and conditionally independent checks, which the design contradicts (§5.3). (v) Rice's theorem is a worst case over all programs: no single total procedure decides a non-trivial semantic property for every program. It is not a probability bound for a finite repository. That cj < 1 on semantic misses is an empirical premise, not a consequence of Rice.

The two-layer duality architecture -
Figure 1. The two-layer duality. The probabilistic instruction layer (Π3, purple) raises p by loading context but may drift (dashed arrows); the deterministic interception layer (Π2, teal) executes regardless of the model's choice and either passes the turn or blocks it (exit 2) back into the model for repair. The persistent store (Π1) feeds both. What escapes both layers is the residual (1−p)·∏(1−cj), handed to review or a later commit/CI gate. The whole sits inside a stewardship boundary (amāna, §9). Neither layer alone suffices — the formal content of the discipline never trust the output; earn trust with a check.
+
Figure 1. The two-layer duality. The probabilistic instruction layer (Π3, purple) raises p by loading context but may drift (dashed arrows); the deterministic interception layer (Π2, teal) executes regardless of the model's choice and either passes the turn or blocks it (exit 2) back into the model for repair. The persistent store (Π1) feeds both. What escapes both layers is the residual (1−p)·P(no check fires | miss), which equals (1−p)·∏(1−cj) only when the checks fire independently [corrected 2026-09-21]. It is handed to review or a later commit/CI gate. The whole sits inside a stewardship boundary (amāna, §9). Neither layer alone suffices — the formal content of the discipline never trust the output; earn trust with a check.

What the theorem says in one sentence

-

The practitioner's rule — never trust the probability engine's output; verify it — is the statement (1−p)>0. Theorem D turns that intuition into a design mandate: because the soft layer can never drive (1−p) to zero, a deterministic layer must exist to multiply it down; and because the hard layer can never catch the semantic class, the soft layer must exist to shrink what reaches it. The substrate is two-layered not by taste but by theorem.

+

The practitioner's rule — never trust the probability engine's output; verify it — is the statement (1−p)>0. Theorem D turns that intuition into a design argument: where the soft layer cannot drive (1−p) below the target, a deterministic layer is needed to multiply it down; and where decidable checks cannot catch the semantic class, the soft layer is needed to shrink what reaches them. The substrate is two-layered because of what p and q can reach in practice, not by a theorem that holds for every p, c < 1. [corrected 2026-09-21]

5 The probabilistic failure model

@@ -264,13 +280,14 @@

5 The probabilistic failure model

Theorem D's equation (5) is worth reading as an engineering instrument, because it explains a lived experience and prices every design choice.

5.1 Why "it works, then forgets" is a certainty, not bad luck

-

With the instruction layer alone, the chance of at least one partial-work incident over n tasks is 1−pn. Even an excellent p=0.9 gives 65% after 10 tasks and 96% after 30. The agent that "usually remembers the docs" is, over a project's lifetime, near-certain to forget them at least once. The failure is geometric, so no degree of prompt-polishing escapes it — only a factor (1−cj) below 1 can bend the curve.

+

With the instruction layer alone, the chance of at least one partial-work incident over n tasks is 1−pn. Even an excellent p=0.9 gives 65% after 10 tasks and 96% after 30. The agent that "usually remembers the docs" is, over a project's lifetime, near-certain to forget them at least once. These figures assume tasks fail independently; tasks done by one model on one repository need not, and the dependence-free statement is the union bound P(≥1 miss) ≤ n(1−p). Raising p bends the curve too: at p=0.99 the 30-task figure is 26%, not 96%. Earlier versions said only a factor (1−cj) could do that. [corrected 2026-09-21]

5.2 Why the deterministic gate is worth exactly one factor

-

Add one gate whose target is the decidable signal "code changed and no doc/state artifact changed." That signal is checkable in microseconds and c1≈0.95. With p=0.7, the per-task silent-miss rate falls from 30% to (1−0.7)·(1−0.95)=1.5% — a twentyfold reduction from a twenty-line hook. Crucially the class of surviving misses changes from "forgot the docs entirely" (structural, now caught) to "updated the docs imperfectly" (semantic, handed to review). The gate does not make the model think; it removes an entire failure mode from the model's shoulders.

+

Add one gate whose target is the decidable signal "code changed and no doc/state artifact changed." That signal is checkable in microseconds. Suppose, as an illustration rather than a measurement, that 95% of misses show it, so c1≈0.95. With p=0.7, the per-task silent-miss rate falls from 30% to (1−0.7)·(1−0.95)=1.5% — a twentyfold reduction from a twenty-line hook. Crucially the class of surviving misses changes from "forgot the docs entirely" (structural, now caught) to "updated the docs imperfectly" (semantic, handed to review). The gate does not make the model think; it removes an entire failure mode from the model's shoulders.

+

But c1 belongs to the agent as much as to the gate. [corrected 2026-09-21] The gate detects its proxy, “code changed and no doc/state artifact changed”, exactly; it does not detect the miss. An agent that edits code and STATE.md but not docs/api.md passes it. If agents touch STATE at rate h, independently of whether they missed a doc, then c1 = 0.95(1−h); at h = 0.9 the residual is 0.27, not 1.5%. The gate also blocks at most once per session (T1), so for every later task in that session c1 = 0.

5.3 The lattice of gates

-

The same classifier can run at three lifecycle points, and (5) shows their catches multiply: a turn-level Stop hook a commit-level pre-commit hook a PR-level CI job. Each later gate catches what earlier ones missed — the product (5) with k=3. This is also the answer to portability: where hooks are unavailable, the same deterministic check re-binds as a pre-commit hook or a CI step, moving the enforcement point without changing the mathematics.

+

The same classifier can run at three lifecycle points: a turn-level Stop hook, a commit-level pre-commit hook, and a PR-level CI job. Earlier versions said (5) made their catches multiply, the product with k=3. They do not. [corrected 2026-09-21] Run on the same diff, the three copies fire together or not at all, so the residual is (1−p)(1−c1), not (1−p)(1−c1)3. With p=0.7 and c1=0.95 the product predicts 3.75×10−5 against a true 0.015, which understates it 400-fold. A later gate adds catches only when it sees something the earlier one could not, such as edits made after the turn ended or a host where the Stop hook never ran. This is also the answer to portability: where hooks are unavailable, the same deterministic check re-binds as a pre-commit hook or a CI step, moving the enforcement point without changing the mathematics.

The honest cost side

@@ -290,10 +307,10 @@

6 The unified algorithm set — the TASK loop

E ← grep_edges(I(frontier)) # R_discovered ∪ map_edges(frontier) # R_declared ∪ interface_edges(frontier) # callers, overrides, migrations, DI wiring - new ← E \ W ; W ← W ∪ new ; frontier ← new + new ← sources(E) \ W ; W ← W ∪ new ; frontier ← new # sources(E) = {a : (a,b) ∈ E} check_constraints(W, DECISIONS) # flag conflicts, never override (I3) return { (a, τ(a), action(a), risk(a)) : a ∈ W } # the impact table -

Terminates in ≤|A| rounds (Thm. T5); on termination W ⊇ Δ* over discoverable+declared edges. This is simulate and impact of Def. 4, and it is exactly what the Impact Oracle prototype computes (§10).

+

Terminates in ≤|A| rounds (Thm. T5). On termination W is the closure of the seeds under whatever edges the three edge functions return. That it is complete for that relation holds by construction and says nothing about whether the relation contains the edges that matter. This is simulate and impact of Def. 4. The Impact Oracle prototype approximates it with a thresholded, depth-limited walk over the AST relation (§3.2, §10). Earlier versions subtracted the artifact set W from the edge set E, a type error. [corrected 2026-09-21]

A2 — EXECUTE-FULL (world-model · act on the whole closure)
@@ -313,6 +330,7 @@

6 The unified algorithm set — the TASK loop

if user_facing: append CHANGELOG update STATE ; return {updated[], verified_unaffected[], doubts[]}

The verify operator: no artifact is ever declared unaffected without the check running — invariant I1's "∨ verified-unaffected" made mechanical. The doubts[] channel enforces I3 (surface ambiguity, do not guess).

+

This makes I1 true by definition for anything the grep cannot see [corrected 2026-09-21]. An artifact is recorded as verified-unaffected exactly when no changed identifier appears in it. Counterexample: a doc says a request is “retried 3 times”, and the change raises the retry constant from 3 to 5. No identifier from the diff appears in the doc, so A3 certifies it unaffected, and the doc is now false. I1 is only as strong as the mention relation, which is why unnamed couplings belong in Rdeclared (§12).

A4 — HANDOFF (memory · write-back · session end)
@@ -323,7 +341,7 @@

6 The unified algorithm set — the TASK loop

mirror durable decisions → DECISIONS # append-only log if a convention was corrected → update CLAUDE.md / rules (self-maintenance) propose commit # committing = portable memory -

The bounded-compression checkpoint (|σ|≤B≈150) keeps the loader's cost O(B) forever — the snapshot+WAL pattern: STATE is the mutable snapshot, DECISIONS the durable log.

+

The bounded-compression checkpoint (|σ|≤B≈150 lines) keeps the loader's cost O(B) forever — the snapshot+WAL pattern: STATE is the mutable snapshot, DECISIONS the durable log. The two budgets are in different units: A4 bounds lines, A5 injects at most 8 KB. 150 lines of 80 bytes are 12,000 bytes, more than the 8,192-byte cap, so a full snapshot fits only if lines average under about 54 bytes; otherwise the loader truncates it. [corrected 2026-09-21]

A5 — REHYDRATE (memory · retrieve · session start)
@@ -340,7 +358,7 @@

6 The unified algorithm set — the TASK loop

docs ← C ∩ DocClass if code ≠ ∅ ∧ docs = ∅: exit 2 + repair checklist + set marker # BLOCK once else: exit 0 # ALLOW -

The hard factor c1 of Theorem D. STATE counts as a doc artifact, so the weakest way to satisfy the gate is to update session state — which is exactly the continuity invariant I2. One check enforces a floor for both F1 and F2. Full decision table and safety proofs in §7.

+

The hard factor c1 of Theorem D. STATE counts as a doc artifact, so the weakest way to satisfy the gate is to update session state — which is exactly the continuity invariant I2. The same fact means an agent that routinely touches STATE drives c1 toward zero for F1 (§5.2). [corrected 2026-09-21] One check enforces a floor for both F1 and F2 only when the agent does not satisfy it that way. Full decision table and safety proofs in §7.

A7 — KNOWLEDGE-ROUTER (M1 routing · where every fact lives)
@@ -350,14 +368,15 @@

6 The unified algorithm set — the TASK loop

a procedure / workflow → skill (loads on invocation) specialist's accumulated patterns → subagent memory current work status → STATE.md (rewritten, injected) - decision + rationale → DECISIONS.md (append-only) + decision + rationale → DECISIONS.md (append-only) + otherwise → ledger fact (unverified; decays unless confirmed) # catch-all, added 2026-09-21

Keeps always-loaded context O(bounded) while total persisted knowledge grows without limit — the mathematical reason the substrate scales. This is the second face of routing: M1's complexity-router chooses a model tier by task difficulty; A7 chooses a storage home by knowledge type. Both are transparent and deterministic, and both reject an opaque-LLM classifier for the same reason — it would reintroduce the very (1−p) the hard layer exists to remove.

6.1 The loop

The seven compose into one meta-algorithm that fits every task, from a one-line fix to a multi-file feature:

TASK(t):  A5 rehydrate → A1 impact-closure → A2 execute → verify → A3 sync → A4 handoff → A6 gate
-

Formally, iterate until the unsynced set U = { a ∈ Δ* : ¬updated(a) ∧ ¬verified(a) } = ∅ — a fixpoint loop whose progress is guaranteed by A1's explicit worklist and whose floor is guaranteed by A6. The soft stages (A1–A5, driven by instructions) raise p; the gate (A6, deterministic) guarantees the floor; persistence (A4/A5 over Π1) carries essential(K) across the session boundary.

+

Formally, iterate until the unsynced set U = { a ∈ Δ* : ¬updated(a) ∧ ¬verified(a) } = ∅ — a fixpoint loop whose progress is guaranteed by A1's explicit worklist and whose floor on the proxy signal is enforced by A6 (§5.2). The soft stages (A1–A5, driven by instructions) raise p; the gate (A6, deterministic) enforces a floor on its proxy signal; persistence (A4/A5 over Π1) carries essential(K) across the session boundary.

The unified TASK loop @@ -394,36 +413,40 @@

7.1 The correctness theorems

T3 (Soundness of the block signal — no false silence)
On the first stop of a session with usable git: C is the union of the baseline diff and an untracked-inclusive worktree scan, so every changed path is in C; classification is a total function of path; the row order reaches the block row exactly when code changed and no doc changed. Hence a silent code-only completion is impossible at the session's first completion — the agent must fix the docs or explicitly justify and update STATE, both visible to the developer. □
+

T3 is exact for the proxy event (code changed, no doc/state changed) at the session's first stop. It says nothing about the probability that a real miss shows that proxy, which is what Theorem D's c1 measures (§5.2). [corrected 2026-09-21]

-
T4 (Continuity under handoff)
+
T4 (Loader fidelity under handoff [corrected 2026-09-21])
If A4 ran and its commit is pushed/pulled, then for any next session on any machine the loader injects σ at start (A5 reads the committed file), so essential(Ki) ∩ σ ⊆ Ki+1(0). Residual risk is exactly selection error in the handoff (what it chose not to write), bounded by the priority order and by DECISIONS catching the durable class. □
+

As stated, T4 says only that what the handoff wrote is read back. That is close to vacuous: the substantive continuity claim, essential(Ki) ⊆ σ, is the selection error the proof sets aside. It also holds only if σ fits the loader's 8 KB cap, and a 150-line snapshot at 80 bytes a line does not (A4). Earlier versions called this “continuity under handoff”. [corrected 2026-09-21]

T5 (Closure termination, A1)
A monotone worklist on the finite set A: each round adds ≥1 artifact or stops, so ≤|A| rounds. This is the finite-lattice Kleene fixpoint of Def. 3. □
+

T5 gives termination only. That the result is complete relative to R is true by construction and transfers nothing about whether R contains the edges that matter. [corrected 2026-09-21]

T6 (Router totality, A7)
-
The routing chain ends in catch-alls per scope; every fact matches ≥1 arm; first-match makes the assignment unique. Hence route is a total function — every piece of knowledge has exactly one home, which is what keeps the always-loaded budget bounded. □
+
The routing chain ends in an explicit catch-all arm; every fact matches ≥1 arm; first-match makes the assignment unique. Hence route is a total function — every piece of knowledge has exactly one home, which is what keeps the always-loaded budget bounded. □
+

In earlier versions T6 was false: A7's six arms had no catch-all, so a fact that matched none of them (a transient observation, say) had no home. A7 now ends in the fallback arm forgekit's router actually uses (src/knowledge_router.js: an unmatched fact becomes an unverified ledger fact that decays unless confirmed), and the proof relies on it. [corrected 2026-09-21]

How the theorems earn Theorem D

-

Theorem D says reliability needs a deterministic factor with cj→1 on its target signal. T3 is precisely that guarantee (the block fires exactly on the target signal); T1 and T2 ensure the factor is safe to add (it never loops, never bricks); T5 and T6 ensure the soft-layer machinery it composes with is well-defined (the closure terminates, the router is total); T4 extends the guarantee across the session boundary that F2 attacks. The six local proofs are what make the one global theorem deployable rather than merely true.

+

Earlier versions said T3 was the guarantee cj→1 that Theorem D needs. It is narrower. [corrected 2026-09-21] T3 makes the block fire exactly on its proxy signal, but cj is the probability that a real miss shows that proxy, which T3 does not bound (§5.2). What the six results do give: T1 and T2 make the gate safe to add (it never loops, never bricks); T5 makes the closure terminate; T6, with its catch-all arm, makes the router total; T4 says what the handoff wrote is read back if it fits. They make the deterministic layer well-behaved. They do not make it reliable.

8 The crosswalk — one object, three vocabularies

-

The table below is the operational proof of the paper's claim: every concept appears in all three vocabularies, and the final column states the relationship that makes them one object. Three rows (marked ●) are not analogies but identities — the same mathematics under two names. The full machine-readable crosswalk is a companion artifact.

+

The table below is the operational proof of the paper's claim: every concept appears in all three vocabularies, and the final column states the relationship that makes them one object. Three rows (marked ●) were presented as identities — the same mathematics under two names. Two are weaker than that: row 2 is an approximation and row 14 a bound (§3.2, §4). [corrected 2026-09-21] The full machine-readable crosswalk is a companion artifact.

Notation reconciliation — the P1/P2/P3 collision

Both source frameworks independently use the labels P1/P2/P3. In the substrate paper they are model properties (P1 statelessness, P2 frozen weights, P3 bounded context, §2); in the reliability framework they are the three primitives (persistent store, lifecycle interception, instruction channel). This paper keeps P1–P3 for the model properties and renames the primitives Π123 throughout (§3.1). Every reference to a primitive in this paper is written Πn.

- + - - + + @@ -432,14 +455,14 @@

8 The crosswalk — one object, three vocabularies

- - + +
#Unified conceptSubstrate (S)E2E Framework (E)forgekit (K)Relationship
1The frozen corey = f_theta(x): stateless map, fixed weights, bounded context (properties P1,P2,P3)the agent/model whose behavior instructions can only raise the PROBABILITY of (p<1)Claude / Codex / Cursor / Gemini / Aider — the model the kit wraps, never modifiesidentical object: all three treat the model as a fixed probabilistic map to be wrapped, not retrained
2Impact-awareness / partial-work failureFaculty: impact-awareness gap; the developer silently simulates 'what will this edit break'. Prototype I = Impact Oracle (reverse-dependency blast radius with confidence decay)F1 partial work; dependents operator N(X); required change closure Delta* = least fixpoint of X |-> X ∪ N(X); Done predicate; Algorithm A1 IMPACT-CLOSURE/impact skill; documentation-map.md (R_declared); grep identifier sweep (R_discovered)IDENTICAL MATHEMATICS. The Impact Oracle computes N(X) by reverse reachability on the dependency graph; Delta* is exactly the oracle's transitive blast-radius set. The oracle adds a confidence-decay weight the framework's set-theoretic N(X) leaves boolean.
2Impact-awareness / partial-work failureFaculty: impact-awareness gap; the developer silently simulates 'what will this edit break'. Prototype I = Impact Oracle (reverse-dependency blast radius with confidence decay)F1 partial work; dependents operator N(X); required change closure Delta* = lfp(X |-> Delta_0 ∪ N(X)) [corrected 2026-09-21: the lfp of X |-> X ∪ N(X) alone is the empty set]; Done predicate; Algorithm A1 IMPACT-CLOSURE/impact skill; documentation-map.md (R_declared); grep identifier sweep (R_discovered)SAME SHAPE, NOT IDENTICAL [corrected 2026-09-21]. The Impact Oracle computes a thresholded, depth-limited reverse reachability over the AST relation, which approximates Delta* over a different relation. It adds a confidence-decay weight the framework's set-theoretic N(X) leaves boolean. Measured on nine real repositories, its recall was 0.022.
3Memory / session-amnesia failureFaculty: persistent memory gap; each context window is ephemeral. Validity-anchored memory (facts carry confirmed/discredited state updated by verified outcomes)F2 session amnesia; continuity invariant I2 (essential(K_i) ⊆ P and loader L must load it); handoff operator H; K_{i+1}(0)=L(P)docs/STATE.md (bounded snapshot) + docs/DECISIONS.md (append-only log); /handoff writes it; SessionStart hook injects itSame failure, same fix (external bounded-compression checkpoint). Substrate adds validity-anchoring (prune by whether a past prediction was externally confirmed); framework adds the snapshot+WAL split (STATE mutable vs DECISIONS append-only).
4Why assumptions happen (the root the user named)M2 assumption/uncertainty gate: under-specified input -> the model confabulates a conventionamnesia equation: when f ∈ essential(K) is missing from L(P), assumption ≈ argmax P(convention | training data) — the mathematically EXPECTED result of missing context, not misbehavior. Invariant I3 (no fabrication)CLAUDE.md No-assumptions rule; intent-router SPEC card ('state assumptions explicitly')The framework gives the substrate's M2 gate its formal justification: assumption is argmax over the prior, so the fix is to supply context (raise what L loads) OR gate on missing context (halt), never to hope.
5Self-correction / verificationM6 inline verification; Prototype-II verify step; self-correction facultyAlgorithm A6 COMPLETION-GATE (deterministic Stop-hook floor); Hoare postcondition Done(t); the verification operator in A3 (verified-unaffected requires an actual grep, not an assumption)docs-guard.sh Stop hook (blocks finish if code changed but no doc/state artifact did); reviewer agent verdictSame verify-gate. Substrate framed it as a mechanism; framework proves it terminates (T1) and is sound (T3) and makes it the deterministic layer of the duality.
4Why assumptions happen (the root the user named)M2 assumption/uncertainty gate: under-specified input -> the model confabulates a conventionamnesia equation: when f ∈ essential(K) is missing from L(P), assumption ≈ argmax P(convention | training data) under greedy decoding (a draw from that prior under sampling, Eq. 1) — the mathematically EXPECTED result of missing context, not misbehavior. Invariant I3 (no fabrication)CLAUDE.md No-assumptions rule; intent-router SPEC card ('state assumptions explicitly')The framework gives the substrate's M2 gate its formal justification: assumption is argmax over the prior, so the fix is to supply context (raise what L loads) OR gate on missing context (halt), never to hope.
5Self-correction / verificationM6 inline verification; Prototype-II verify step; self-correction facultyAlgorithm A6 COMPLETION-GATE (deterministic Stop-hook floor); Hoare postcondition Done(t); the verification operator in A3 (verified-unaffected requires an actual grep, not an assumption)docs-guard.sh Stop hook (blocks finish if code changed but no doc/state artifact did); reviewer agent verdictSame verify-gate. Substrate framed it as a mechanism; framework proves it terminates (T1) and fires exactly on its proxy signal (T3) and makes it the deterministic layer of the duality.
6Complexity routingM1 complexity-aware router (transparent additive rubric); Prototype-II routerAlgorithm A7 KNOWLEDGE-ROUTER (where every fact lives, keeps always-loaded context bounded); System 1/System 2 effort routing; intent DFAintent-router.sh (UserPromptSubmit hook, keyword DFA, <10ms, zero-token); effort-routing rule in CLAUDE.md; per-agent model: fieldsTwo facets of one routing principle. Substrate M1 routes by TASK COMPLEXITY -> model tier (cost). Framework A7 routes by KNOWLEDGE TYPE -> storage home (context budget). Both are transparent/deterministic, both reject an opaque-LLM classifier for the same reason.
7Task decompositionM3 task/session decompositionHTN closure -> ordered task list (A1 output is the worklist); the meta-algorithm TASK(t)sdlc-pilot 7-phase skill; subagents; git worktreesSame: decompose the goal into the closure's ordered worklist before building.
8Goal-anchoringM4 goal-anchoring (goal drift: to a text model a long story and long code are the same object)BDI Desires = written goal + acceptance criteria in STATE.md; I2 keeps them across sessions; acceptance criteria written at SPEC, consumed at VERIFYdocs/STATE.md 'Current goal' + 'Acceptance criteria'; sdlc-pilot SPEC->VERIFY gateSubstrate names the drift; framework anchors it in a persisted, re-injected Desire so every session optimizes the same target.
11Continual learning from outcomesFaculty: learning without touching theta (non-parametric always-on + parametric LoRA/EWC)Reflexion loop made cross-session (I3/I4); DECISIONS.md as precedent DB; A4 mirrors durable lessonsReflexion rule in CLAUDE.md; STATE gotchas; agents' memory: projectSame 'learn without retraining' stance. Both store verbal lessons externally and re-inject; substrate adds the parametric option, framework keeps it purely non-parametric (committed files).
12Rehydration (session start)closed-loop write-back/read-back band in the substrate architectureAlgorithm A5 CONTEXT-REHYDRATE; loader L; records git baseline for the gatesession-context.sh SessionStart hook; /catchup skill (deep variant)The read side of the substrate's memory loop, made deterministic (inject, don't hope the agent reads).
13Independent verification / judgeself-correction faculty; the honest-negative-result caution (models correct poorly alone)LLM-as-Judge applied: reviewer agent (fresh context, explicit criteria, adversarial); self-consistency for critical changes (majority of N)reviewer.md agent; DoD item 7Substrate cited the finding that self-correction alone is weak; framework operationalizes the fix (an EXTERNAL judge), which is the same 'trust only an external check' principle as the gate.
14The two-layer duality (THE central new insight)implicit: the substrate wraps a probabilistic core with deterministic external structure, but v2 never states it as a lawDESIGN LAW: instructions (Pi3) are PROBABILISTIC (raise p); interception (Pi2) is DETERMINISTIC (guarantee a floor c->1). P(silent miss) = (1-p)·prod_j(1-c_j). Since p<1 always, neither layer alone suffices.the split itself: CLAUDE.md/rules/skills = soft layer; hooks (docs-guard, session-context, intent-router) = hard layerTHIS is what the kit gives the substrate theory: the reason the substrate must be TWO layers, stated as a theorem. The user's own discipline ('never trust AI output — it is calculated probability') is exactly (1-p)>0, which forces the deterministic layer.
15The probabilistic failure modeleval honesty: perfect accuracy shows separation not a benchmark; p<1P(>=1 miss) = 1 - p^n over n tasks (0.9 -> 65% at 10, 96% at 30); layered: P(silent miss)=(1-p)·prod(1-c_j)the lattice of gates: turn-level (hook) ⊂ commit-level (pre-commit) ⊂ PR-level (CI)The math that makes 'it works sometimes then forgets' a geometric certainty rather than bad luck — and quantifies why each added deterministic gate multiplies down the residual.
14The two-layer duality (THE central new insight)implicit: the substrate wraps a probabilistic core with deterministic external structure, but v2 never states it as a lawDESIGN LAW: instructions (Pi3) are PROBABILISTIC (raise p); interception (Pi2) is DETERMINISTIC (guarantee a floor c->1). P(silent miss) = (1-p)·P(no check fires | miss), which is (1-p)·prod_j(1-c_j) only for independent checks [corrected 2026-09-21]. Where p and c are each bounded below 1, both layers are needed to reach a small residual.the split itself: CLAUDE.md/rules/skills = soft layer; hooks (docs-guard, session-context, intent-router) = hard layerTHIS is what the kit gives the substrate theory: the reason the substrate must be TWO layers, stated as a bound over (p, q) [corrected 2026-09-21]. The user's own discipline ('never trust AI output — it is calculated probability') is exactly (1-p)>0, which forces the deterministic layer.
15The probabilistic failure modeleval honesty: perfect accuracy shows separation not a benchmark; p<1P(>=1 miss) = 1 - p^n over n independent tasks (0.9 -> 65% at 10, 96% at 30); layered: P(silent miss)=(1-p)·P(no check fires | miss) [corrected 2026-09-21]the lattice of gates: turn-level (hook) ⊂ commit-level (pre-commit) ⊂ PR-level (CI)The math that makes 'it works sometimes then forgets' a geometric certainty rather than bad luck — and quantifies when an added deterministic gate lowers the residual: only when it catches misses the earlier gates did not (a copy of the same check adds nothing) [corrected 2026-09-21].
16Stewardship / governance boundarySTEWARDSHIP/amana wrapper (33:72) around the whole architectureamana in I3/I4 as no-fabrication + verified-currency; least privilege, reversibility, logged rationale, scope-boundednesscommitted-files-only (auditable), block-at-most-once (no nagging), fail-open safety (T2), DOCS_GUARD_DISABLE auditable escape hatchThe ethical frame the substrate draws from the lens is realized as concrete safety properties in the framework (fail-open, reversibility) and kit (auditable hooks).

The three anchor identities

    -
  1. Impact-Oracle blast-radius ≡ change-closure Δ*. Reverse reachability on the dependency graph is the least fixpoint of X ↦ X ∪ N(X). The oracle weights it with confidence decay; the framework’s N(·) is its boolean core.
  2. +
  3. Impact-Oracle blast-radius ≈ change-closure Δ*. Reverse reachability from the seed is the least fixpoint of X ↦ Δ0 ∪ N(X). The oracle approximates it: it weights paths with confidence decay, thresholds them, stops at depth 10, and walks a different relation. This was listed as an identity until 2026-09-21. [corrected 2026-09-21]
  4. M2 assumption gate ≡ the amnesia equation. assumption ≈ argmax P(convention | training) is why an under-specified prompt is answered with a confabulated convention — so the gate supplies the missing context or halts; it never hopes.
  5. -
  6. The substrate’s two layers ≡ the design law. Instructions raise p<1; interception guarantees a floor c→1. Theorem D. This is the formal statement of the governing discipline.
  7. +
  8. The substrate’s two layers ≡ the design law. Instructions raise p<1; interception multiplies the residual by P(no check fires | miss). Theorem D, restated as a bound [corrected 2026-09-21]. This is the formal statement of the governing discipline.

9 The Qur’anic epistemology — the full fourteen mappings

@@ -528,6 +551,11 @@

The lens in one line

10 The prototypes — instantiating the deterministic layer

+
+

Both results below were refuted

+

These are the demonstration numbers as first reported. A pre-registered evaluation on data the authors did not build overturned both headline claims: the impact oracle's recall was 0.022 on 759 files in nine real repositories, and on 80 held-out tasks the router's total spend was 20.2% higher than always using the premium tier. See research/empirical-refutation/ and the Corrections. [corrected 2026-09-21]

+
+

Theory earns its keep when it runs. Two prototypes instantiate the deterministic layer's two hard functions — the impact closure (A1) and the routing/gate pair (A7 + A6) — and both are evaluated with the honesty the governing discipline demands: a perfect score on a self-built set demonstrates that a rubric can separate cases, never that it is a field benchmark.

10.1 Prototype I — the impact oracle (A1 made real)

@@ -536,9 +564,9 @@

10.1 Prototype I — the impact oracle (A1 made real)

MethodPrecisionRecallF1Reading edited-file only1.000.530.65never over-warns, misses half the blast radius grep baseline0.730.940.79strong, but can still miss -graph oracle0.631.000.75perfect recall — never misses an impacted file +graph oracle0.631.000.75recall 1.00 on 5 self-built mutations; 0.022 on real repositories [corrected 2026-09-21] -

The oracle does not win on F1 — and the paper says so. Its distinguishing property is perfect recall, which is the safety property for the question "what will my edit break?": a reverse-reachability closure run to fixpoint cannot miss a reachable dependent (Thm. T5). It trades precision for that guarantee, tunable by the confidence threshold (best F1 = 0.79 at threshold 0.4). It scales: 303-node stdlib module in 18 ms, 1903-node module in 91 ms. This is anchor identity #1 confirmed on real code.

+

The oracle does not win on F1 — and the paper says so. This version of the paper called its distinguishing property perfect recall and argued from T5 that a reverse-reachability closure run to fixpoint cannot miss a reachable dependent. Both steps failed. T5 guarantees completeness only relative to the relation the closure walks, and on real repositories that relation missed the edges that mattered: recall 0.022 on 759 files in nine repositories, where a grep baseline reached F1 0.437 against the oracle's 0.042. [corrected 2026-09-21] Precision is tunable by the confidence threshold (best F1 = 0.79 at threshold 0.4 on the demo package). It scales: 303-node stdlib module in 18 ms, 1903-node module in 91 ms.

10.2 Prototype II — the complexity-router and assumption-gate (A7 + A6 + M1 + M2)

A transparent, feature-based rubric routes each task to a model tier (cheap/mid/premium) and gates under-specified requests before they reach a model at all — the assumption gate that answers the user's "biggest problem is assumption." The routing decision explains itself; it does not ask another opaque model, because that would reintroduce the (1−p) the deterministic layer exists to remove. Evaluated live on real models (haiku/sonnet/opus):

@@ -546,12 +574,13 @@

10.2 Prototype II — the complexity-router and assumption-gate (A7 MetricResult Gate accuracy (should-ask, 30 tasks)30/30, precision = recall = 1.00 Routing (well-specified tasks)21/21 exact tier -Real cost saved vs always-premium62.1% on measured tokens +Real cost saved vs always-premium62.1% on measured tokens, on the 30 tuning tasks; on 80 held-out tasks total spend was 20.2% higher [corrected 2026-09-21] Execution-verified routed-down outputs3/3 passed real test cases

The honest caveat, stated wherever the numbers appear

The 30-task set is hand-labelled and the thresholds were tuned against it, so these numbers show the rubric can separate the cases — they are not a field benchmark. The cost figures, by contrast, are exact arithmetic on real measured token counts from live calls, and the correctness sub-experiment actually executed the cheaper models' code against test cases (the honesty core: routing down is only a saving if the cheap output is correct). Both prototypes ship as runnable packages with passing test suites.

+

The caveat was accurate and not enough. On 80 held-out tasks from real issues, gate F1 fell to 0.37 and the cost saving inverted: counting every escalation the pipeline paid for, it spent 20.2% more than always-premium. Per output the judge accepted, it cost $1.06 against always-premium's $1.76, but only 6 and 3 of 64 outputs were accepted. [corrected 2026-09-21]

11 forgekit — the deployed binding

@@ -571,12 +600,12 @@

11 forgekit — the deployed binding

Why one binding matters for the theory

-

That an independent team, starting from production failures rather than from cognitive faculties, built the same seven algorithms and stated the same design law is the strongest available evidence that the architecture is discovered, not invented — a convergent solution to a structural problem, the way distributed systems converge on snapshot+WAL. The synthesis does not merge two guesses; it records a convergence.

+

A second body of work, starting from production failures rather than from cognitive faculties, built the same seven algorithms and stated the same design law. Earlier versions called this an independent team's work and “the strongest available evidence that the architecture is discovered, not invented”. It is not independent: claude-e2e-kit is forgekit's precursor, and forgekit was built as a binding of this theory, so the agreement shows the framings are consistent; it is not independent evidence that the architecture is right. [corrected 2026-09-21] The synthesis does not merge two guesses; it records that one project reached the same design from two directions.

12 Honest limits — what no architecture can guarantee

    -
  • Semantic correctness is undecidable. The gate proves "a doc artifact changed", not "the docs are now true"; A3's grep proves "mentions were visited", not "the prose is accurate". By Rice's theorem the last layer is unavoidable — tests for behaviour, human review for meaning. The architecture's job is to make that review cheap: everything arrives already-attempted, with an updated / verified / doubts report.
  • +
  • Semantic correctness is undecidable. The gate proves "a doc artifact changed", not "the docs are now true"; A3's grep proves "mentions were visited", not "the prose is accurate". Rice's theorem says no single procedure decides such properties for every program; that is a worst case, not a bound on how often checks miss in a given repository [corrected 2026-09-21]. In practice the last layer is still needed — tests for behaviour, human review for meaning. The architecture's job is to make that review cheap: everything arrives already-attempted, with an updated / verified / doubts report.
  • The soft layer's p is real but bounded. Theorem D quantifies the residual; it does not abolish it. A determined agent can satisfy the letter of a gate (touch STATE with one line) without its spirit — which is why no single layer is trusted, by design.
  • Rdiscovered misses unnamed coupling. A doc that describes behaviour without naming any identifier has no grep edge; such couplings must be lifted into Rdeclared (the documentation map) — exactly what that table is for.
  • One prototype is not five faculties. The substrate prototypes instantiate impact-awareness and routing/gating well; memory, learning, and imagination remain the harder research frontier, and the honest ecosystem map marks the assumption gate (M2) and outcome-based learning as genuine whitespace the current stack does not fill.
  • @@ -584,20 +613,20 @@

    12 Honest limits — what no architecture can guarantee

13 Conclusion

-

A language model that writes code is a fixed probabilistic map, and three independent efforts — one from cognition, one from production failures, one from a shipped codebase — converged on the same remedy: wrap it in an external, stateful architecture that supplies the faculties it structurally lacks. This paper showed they describe one object. The impact-awareness faculty is the change-closure fixpoint; the assumption gate is the amnesia equation; and both rest on a single theorem — reliability is the product of a probabilistic instruction layer that raises p<1 and a deterministic interception layer that guarantees a floor, with neither alone sufficient.

+

A language model that writes code is a fixed probabilistic map, and three efforts that were not independent of one another — one from cognition, one from production failures, one from a shipped codebase — converged on the same remedy: wrap it in an external, stateful architecture that supplies the faculties it structurally lacks. This paper argued they describe one object. The impact-awareness faculty approximates the change-closure fixpoint; the assumption gate is the amnesia equation; and both rest on one result — the residual silent-miss rate is the product of what a probabilistic instruction layer lets through, 1−p, and what a deterministic interception layer lets through, P(no check fires | miss). Where each factor is bounded away from zero, neither layer alone reaches a small residual. [corrected 2026-09-21]

That theorem is the formal content of a plain discipline: the output of a probability engine is never to be trusted on its own; trust is earned by an external check. The Qur'anic lens gives that discipline its oldest names — lā taqfu, do not pursue what you do not know; tabayyun, verify the report before you act; al-amāna, the weight of a trust accepted by one who may err. The mathematics says how to build the check. The tradition says why it is owed. The codebase shows it runs.

Companion artifacts: the three-way crosswalk (JSON + markdown), the graded reference set (Appendix A), and two runnable prototype packages (impact-oracle, router-gate). This synthesis consolidates and does not supersede the v2 Theory → Evidence → Build-Map edition, which carries the empirical evidence layer and the full ecosystem map.

-

14 Four independent arrivals — the convergence audited

+

14 Four arrivals by one author — the convergence audited

A theory that only its author believes is a hypothesis. This section reports what happened when -three further bodies of work, developed independently and in different languages, were audited -against the law of §4 — and against each other.

+three further bodies of work, developed separately and in different languages, were audited +against the law of §4 — and against each other. All four are the same author's work (forgekit and hikmah-stack are both published under the author's GitHub account), so this section tests their consistency with each other, not the law's truth. Earlier versions called them “four independent arrivals”. [corrected 2026-09-21]

14.1 The four layers

-

Four artifacts now state the same design law. They were not derived from one another: two +

Four artifacts now state the same design law. They were not derived from one another's text, but they share an author: two manuscripts were written as general operating principles, a Rust kernel was built as production infrastructure, and the formal theory was developed as mathematics.

@@ -615,11 +644,11 @@

14.1 The four layers

Four independent arrivals at one design law -
Figure 8. Four independent arrivals at one design law. Theory proves it, +
Figure 8. Four arrivals at one design law, by one author. Theory states it, forgekit binds it across agents, hikmah-stack makes it deterministic in compiled code, wisdom-lens makes it teachable. Each states P(silent miss) = (1−p)·∏j(1−cj) in its -own vocabulary. Convergence from four directions is the strongest available evidence that the law was -discovered rather than invented.
+own vocabulary (the product holds only for independent checks; see §4). Because all four share an author, their agreement is consistency, not evidence that the law was +discovered rather than invented. [corrected 2026-09-21]

14.2 The law violated inside the repository that states it

@@ -697,8 +726,24 @@

Where an audited artifact was more rigorous than ours

Compression toward usability is not free, and the cost falls precisely on the provenance that lets a reader check the claim.

+

Corrections (2026-09-21)

+

An external deep review of the forgekit repository (2026-09-21) checked this paper's mathematics against its own definitions, its claims about forgekit against the code, and its statistics against the replication package. Several statements were wrong as written. Each is corrected in place above, marked [corrected 2026-09-21], and listed here with the original wording, so nothing is silently rewritten. Every number below was recomputed by research/recompute_corrections.py (pure Python, fixed seeds, printed with each result) from this paper's own worked examples or from the refutation's replication package. The PDF edition predates these corrections.

+
    +
  1. Theorem D (§4) was circular as stated, and is restated as a bound. It claimed that for any p<1 and any cj<1 “neither layer alone suffices”, using P(≥1 miss) → 1 as the criterion. That criterion condemns the composed system too: with every cj<1 the residual is positive, and at a residual of 0.005 the chance of at least one miss in 1,000 independent tasks is 0.993. T3 also asserts exact detection of its structural class, which would give the deterministic layer alone a residual of 0 on that class and contradict Claim 2. The theorem now bounds the residual by ε on an explicit region of (p, q) and states its dependence assumptions. The word “proves” is withdrawn from the abstract.
  2. +
  3. Raising p bends the failure curve (§5.1). The original said “only a factor (1−cj) below 1 can bend the curve”. For 30 independent tasks, P(≥1 miss) is 0.958 at p = 0.9 and 0.260 at p = 0.99. The 1−pn figures also assume tasks fail independently; tasks done by one model on one repository are correlated, and the dependence-free statement is the union bound.
  4. +
  5. Eq. (5) assumed an independence the design contradicts (§4, §5.3, Fig. 1). The same classifier runs at the Stop hook, at pre-commit and in CI on the same diff, so the copies fire together. The residual is (1−p)·P(no check fires | miss), and (1−p)(1−cmax) for nested checks. With three copies, p = 0.7 and c = 0.95, the old product gives 3.75×10−5; the true residual is 0.015 (a Monte Carlo run with seed 0 and 106 draws also gives 0.0150). That is a 400-fold understatement.
  6. +
  7. cj is not a property of the gate (Def. 5, §5.2, T3, A6). T3 guarantees exact detection of the proxy event, not of the miss. An agent that edits code and STATE.md but not docs/api.md passes the gate. If agents touch STATE at rate h, then c = 0.95(1−h), and at h = 0.9 the residual is 0.27, not 0.015. The gate blocks once per session, so c = 0 for every later task in that session.
  8. +
  9. Definition and proof errors. (a) Anchor identity #1 wrote Δ* as the least fixpoint of X ↦ X ∪ N(X), which is ; it is lfp(X ↦ Δ0 ∪ N(X)). (b) The impact oracle is thresholded, depth-10 reachability over the AST relation, a different relation from R: it approximates Δ* and is not Δ*. “This is why the prototype achieves perfect recall” is withdrawn; on nine real repositories its recall was 0.022. (c) T5 gives termination only; completeness relative to R holds by construction. (d) A1 subtracted artifacts from edges (E \ W); it now subtracts from the edges' source artifacts. (e) A3 makes I1 true by definition for couplings grep cannot see (the “retried 3 times” counterexample under A3). (f) T4 was close to vacuous, and A4's 150-line budget does not fit A5's 8 KB cap at 80 bytes a line (12,000 bytes); it is restated as loader fidelity, with that condition. (g) T6 was false because A7 had no catch-all arm; A7 now ends in the fallback forgekit's router uses. (h) Rice's theorem was used as if it bounded cj; it is a worst case over all programs, not a probability bound for a finite repository.
  10. +
  11. The faculty table (§2) disagreed with the whitepaper in all five rows. We keep the whitepaper's derivation: memory from P1 and P3, learning from P2 and P1, imagination from P1 and the token→token form of Eq. 1, self-correction from P2, impact-awareness from P3. It argues each row separately, and it matches what forgekit's bindings address in source/substrate.json: lessons that change no weights (P2), tests trusted over model claims (self-correction's same-weights blind spot, P2), and repository facts surfaced from outside the window (P3). The earlier table had memory P1; learning P2; imagination P1, P3; self-correction P3; impact-awareness P1, P3.
  12. +
  13. Eq. (1) and the amnesia equation disagreed. Eq. 1 read as a deterministic function, its gloss said the output was sampled, and the amnesia equation used argmax. There is now one statement: y ~ pθ(· | x), and greedy decoding, which approximately returns the argmax, is the temperature-0 special case.
  14. +
  15. Priority is conceded here too. The composition law is standard layer-of-protection algebra, and two concurrent preprints (Odds Law and Maestro Order, cited in the refutation paper's related work) derive a strictly more general Bayesian form of it weeks before this paper. research/README.md said priority was conceded “in the paper”, but only the refutation paper's LaTeX source did so, while this paper still said it “proves” the result. What remains ours is narrower: deriving the split from the frozen-map architecture, and testing it against real repositories.
  16. +
  17. The convergence was not independent (abstract, §1, §11, §13, §14). The “four independent arrivals” of §14 (the formal theory, forgekit, hikmah-stack and wisdom-lens) are the same author's work; forgekit and hikmah-stack are both published under the author's GitHub account. The three roads of §1 are not independent either: claude-e2e-kit is forgekit's precursor, and forgekit was built as a binding of this theory. Their agreement shows consistency, not independent evidence that the design is right.
  18. +
  19. Appendix A tally. 9 confirmed, 6 traceable and 0 unverifiable of 15 new sources, not “8 confirmed”. The founding Agent-as-a-Judge paper was added on the citations track's recommendation after the count was written.
  20. +
  21. The prototype results in §10 were refuted before this correction, and the section now says so. The impact oracle's “perfect recall” was measured on five mutations of a ten-file package the authors wrote; on 759 evaluated files in nine real repositories its recall was 0.022, and a grep baseline scored F1 0.437 against its 0.042. The router's “62.1% real cost saved” was measured on the 30 tasks its thresholds were tuned on; on 80 held-out tasks the pipeline's total spend was 20.2% higher than always using the premium tier. Per output the judge accepted, it cost $1.06 against always-premium's $1.76, but only 6 and 3 of 64 outputs were accepted, so neither figure is stable. The details are in the extended preprint and the refutation paper.
  22. +
+

Appendix A — Graded reference set (new sources)

-

The synthesis draws in a body of cognitive-architecture and process literature beyond the substrate paper's original 32 references. Each new source was independently verified this pass — modern arXiv sources by direct metadata fetch, classical works by primary-host search or established secondary knowledge — and graded: confirmed (record retrieved, attribution matches), traceable (the work clearly exists and is correctly attributed, but rests on established secondary knowledge rather than a single retrievable record), unverifiable (could not confirm). The tally: 8 confirmed, 6 traceable, 0 unverifiable.

+

The synthesis draws in a body of cognitive-architecture and process literature beyond the substrate paper's original 32 references. Each new source was independently verified this pass — modern arXiv sources by direct metadata fetch, classical works by primary-host search or established secondary knowledge — and graded: confirmed (record retrieved, attribution matches), traceable (the work clearly exists and is correctly attributed, but rests on established secondary knowledge rather than a single retrievable record), unverifiable (could not confirm). The tally: 9 confirmed, 6 traceable, 0 unverifiable (15 sources: 8 confirmed by the citations track plus the founding Agent-as-a-Judge paper added on its recommendation). Earlier versions said 8 confirmed. [corrected 2026-09-21]

From 1a7cf0ebab8a2bf6d26cb04c267965d3ae05d3f9 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:37:51 +0200 Subject: [PATCH 15/72] docs(research): tighten the refutation's statistics and add errata The external review (2026-09-21, finding F5 and parts of F1-F4, F6) recomputed the refutation from its replication package. The counts reproduce exactly; some inferences did not. The LaTeX source and the extended preprint now: - report repository-cluster bootstrap intervals, because every ground-truth pair is mirrored (20,144 of 20,144) and files cluster in nine repositories: oracle precision [0.15, 0.91], recall [0.0005, 0.052] (seed 1234, 20,000 resamples); the refutation survives (oracle F1 [0.001, 0.093] vs grep [0.381, 0.539]); - stop claiming the repaired oracle beats grep: paired dF1 +0.044, 3/3 held-out repositories (sign test p = 0.125), pytest 71% of pairs, and the relation choice was made on all nine repositories; - say the gold labels and the second pass are one model (kappa measures prompt robustness), add cost per judged-correct output ($1.06 vs $1.76, from 6 and 3 of 64), and qualify the 96.8% ceiling; - fix the calibration paragraph (bins 27/5/28/4/16, ECE 0.103 or 0.078, p = 0.028) and explain 801 labelled vs 759 evaluated files. The extended preprint also receives the synthesis's theory corrections (its theory sections are shared) and drops its remaining "this is why the prototype achieves perfect recall". Both papers end with a dated Corrections section; paper.pdf and extended_preprint.pdf predate it. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 9 + research/empirical-refutation/README.md | 38 +++++ .../extended_preprint.html | 155 ++++++++++++------ research/empirical-refutation/paper/main.tex | 136 ++++++++++++--- 4 files changed, 259 insertions(+), 79 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 909a211..212861e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,15 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Priority is conceded in both, as the refutation paper already did. Each paper ends with a dated Corrections section quoting the original wording; `crosswalk.json`/`.md` and the formal-synthesis README follow. +- **The refutation paper's statistics are tightened without changing the refutation.** The + LaTeX source and the extended preprint now report repository-cluster bootstrap intervals + (every ground-truth pair is mirrored and files cluster in nine repositories: oracle + precision [0.15, 0.91], recall [0.0005, 0.052], seed 1234), no longer claim the repaired + oracle beats grep (3/3 repositories, sign-test p = 0.125, pytest 71% of pairs, relation + choice made on all nine repositories), say that the gold labels and the "independent" second + pass are one model, add cost per judged-correct output ($1.06 vs $1.76, from 6 and 3 of 64), + qualify the "96.8% fixable" ceiling, correct the calibration paragraph (bins 27/5/28/4/16, + ECE 0.103 or 0.078, p = 0.028), and explain 801 labelled vs 759 evaluated files. - **`research/recompute_corrections.py` re-derives every corrected number.** Standard-library Python (it includes a minimal Parquet reader), fixed seeds printed beside each result, run against the extracted replication package. PDFs built from the corrected sources could not diff --git a/research/empirical-refutation/README.md b/research/empirical-refutation/README.md index afe3e8b..2c64c6a 100644 --- a/research/empirical-refutation/README.md +++ b/research/empirical-refutation/README.md @@ -2,6 +2,9 @@ *Static Impact Analysis Does Not Transfer: A Pre-Registered Refutation of Two LLM-Agent Reliability Mechanisms* +> **Corrected 2026-09-21** — see [Corrections](#corrections-2026-09-21) at the end. The PDFs in +> this directory predate the corrections; the LaTeX and HTML sources carry them. + This package contains everything needed to check every number in the paper. It is organised so that a reviewer can start from the frozen protocol and work forward, in the order the work was actually done. @@ -94,3 +97,38 @@ static analysis can predict, and an over-warning may be a correct dependency tha co-changed. The 96.9% ceiling is measured on the graph the as-shipped oracle builds, and reachability in a dense graph is a weak property — it bounds what any static method could attain, and is not evidence that a reachable pair is causally related. + +## Corrections (2026-09-21) + +An external deep review of the repository recomputed every statistic from this package. The +counts reproduced exactly; some inferences did not. `paper/main.tex` (in this directory) and +`extended_preprint.html` are corrected in place, with dated Corrections sections that quote the +original wording. **`paper.pdf` and `extended_preprint.pdf` predate the corrections** (no TeX or +WeasyPrint toolchain was available to rebuild them), and the copies of the paper inside +`replication_package.tar.gz` are left exactly as published. + +- **Cluster the bootstrap.** Every ground-truth pair is mirrored (all 20,144 pairs among the 801 + labelled files are counted from both ends) and files cluster in nine repositories, so the + file-resampled intervals are too narrow. Resampling repositories (seed 1234, 20,000 resamples): + oracle precision [0.15, 0.91], recall [0.0005, 0.052]; the refutation survives, oracle F1 + [0.001, 0.093] against grep's [0.381, 0.539]. +- **801 vs 759.** 801 files are labelled; 759 are evaluated, because the pre-registered cap of 200 + files per repository cut pytest from 242 to 200. +- **The repair does not demonstrably beat grep.** Paired ΔF1 is +0.044 at t = 0.02; at t = 0.10 + all three held-out repositories favour the oracle, but three out of three is a one-sided + sign-test p of 0.125, pytest supplies 71% of the held-out pairs, and the choice of which + relations to add was made on all nine repositories. +- **One model made the labels.** The gold labels and the second pass are the same model with two + prompts (n = 30), and it is also the judge and the mid-tier executor, so κ measures prompt + robustness, not label validity. +- **Cost per accepted output.** $1.06 for the pipeline against $1.76 for always-premium, from 6 and + 3 accepted outputs of 64; 58 of 64 tasks failed at every tier, which is what makes the −20.2% + largely mechanical. +- **The ceiling** counts pairs with any static path, treats symmetric co-change as directional + impact, and had no random-pair control, so "96.8% fixable" is now read as a bound. +- **Calibration.** Bins held 27/5/28/4/16 tasks because of ties; ECE is 0.103 or 0.078 depending on + tie handling; the 2-of-4 bin has p = 0.028 against the middle bin's accuracy, so it is not "well + within sampling noise". + +Re-derive them with [`../recompute_corrections.py`](../recompute_corrections.py) (standard-library +Python): extract this package and run `python research/recompute_corrections.py /repro`. diff --git a/research/empirical-refutation/extended_preprint.html b/research/empirical-refutation/extended_preprint.html index 904831c..889e11f 100644 --- a/research/empirical-refutation/extended_preprint.html +++ b/research/empirical-refutation/extended_preprint.html @@ -74,6 +74,7 @@ .refs{font-size:.86rem;line-height:1.5} .refs li{margin:6px 0} .small{font-size:.88rem;color:var(--muted)} sub{font-size:.72em} +.corr{font-size:.8em;color:var(--alarm);font-style:normal;white-space:nowrap} @@ -94,9 +95,14 @@

Status of this edition

now the corrected ones.

+
+

Corrections (2026-09-21)

+

An external review found that Theorem D was circular as stated, that Eq. (5) assumed an independence the design contradicts, that several definitions and proofs were wrong, that one sentence still claimed the prototype’s perfect recall, and that some statistical inferences in §10 were stronger than the data support. The theory sections are therefore no longer unchanged from the synthesis edition. The corrections are made in place, marked [corrected 2026-09-21], and listed with the original wording in Corrections. The PDF edition predates them.

+
+

Abstract

-

A large language model used for coding is a fixed probabilistic map, y = fθ(x): stateless, frozen, and bounded in context. Three research efforts converged, independently, on the same conclusion — that the remedy is not a better prompt or a bigger model but an external, stateful architecture wrapped around the frozen core. This paper proves they are describing one object. We show that the substrate's impact-awareness faculty and the framework's change-closure fixpoint Δ* are the same mathematics; that the assumption gate and the amnesia equation assumption ≈ argmax P(convention | training) are the same phenomenon; and that both reduce to a single two-layer duality: a probabilistic instruction layer that raises the probability p<1 of correct behaviour, and a deterministic interception layer that guarantees a floor. The central theorem states that neither layer alone can make an agent reliable — a direct formalization of the discipline never trust the output of a probabilistic engine; earn trust with an external check. We give definitions, the duality theorem with proof, a unified seven-algorithm task loop, the probabilistic failure model P(≥1 miss)=1−pn, and carry through the six correctness theorems of the reliability framework. Two prototypes — an impact oracle and a complexity-router/assumption-gate — instantiate the deterministic layer and are evaluated honestly. The forgekit / claude-e2e-kit codebase is the deployed binding. The Qur'anic lens supplies the vocabulary of epistemic obligation (tabayyun, amāna, lā taqfu) that names why each safeguard is mandatory rather than optional.

+

A large language model used for coding is a fixed probabilistic map, y = fθ(x): stateless, frozen, and bounded in context. Three bodies of work, developed separately from different starting points, converged on the same conclusion — that the remedy is not a better prompt or a bigger model but an external, stateful architecture wrapped around the frozen core. This paper argues [corrected 2026-09-21] they are describing one object; their agreement is consistency rather than independent evidence, since forgekit was built as a binding of the other two [corrected 2026-09-21]. We show that the substrate's impact-awareness faculty and the framework's change-closure fixpoint Δ* have the same shape, the oracle approximating the fixpoint over a different relation [corrected 2026-09-21]; that the assumption gate and the amnesia equation assumption ≈ argmax P(convention | training) are the same phenomenon; and that both reduce to a single two-layer duality: a probabilistic instruction layer that raises the probability p<1 of correct behaviour, and a deterministic interception layer that multiplies down what escapes it. The central result, restated in the 2026-09-21 corrections as a bound on the residual over an explicit region of (instruction-following, catch) probabilities rather than as an impossibility theorem [corrected 2026-09-21], is a formalization of the discipline never trust the output of a probabilistic engine; earn trust with an external check. The composition law itself is standard layer-of-protection algebra, and two concurrent preprints derived a strictly more general Bayesian form of it first; we concede priority [corrected 2026-09-21]. We give definitions, the duality result, a unified seven-algorithm task loop, the probabilistic failure model P(≥1 miss)=1−pn (for independent tasks), and carry through the six correctness theorems of the reliability framework. Two prototypes — an impact oracle and a complexity-router/assumption-gate — instantiate the deterministic layer; both of their headline results were later refuted on data the authors did not build [corrected 2026-09-21]. The forgekit / claude-e2e-kit codebase is the deployed binding. The Qur'anic lens supplies the vocabulary of epistemic obligation (tabayyun, amāna, lā taqfu) that names why each safeguard is mandatory rather than optional.

@@ -115,6 +121,7 @@

Abstract

  • forgekit — the deployed binding
  • Honest limits — what no architecture can guarantee
  • Conclusion
  • +
  • Corrections (2026-09-21)
  • Appendix A: graded reference set  ·  Appendix B: crosswalk table  ·  References
    @@ -132,7 +139,8 @@

    1 The convergence — three roads to one architecture

    The claim of this paper

    -

    These are not three similar ideas. They are one architecture described in three vocabularies. The impact-awareness faculty is the change-closure fixpoint. The assumption gate is the amnesia equation. The substrate's external structure is a two-layer duality — and that duality, which the reliability framework states as a design law, is the theorem the whole thing turns on. What each road saw partially, the union sees whole.

    +

    These are not three similar ideas. They are one architecture described in three vocabularies. The impact-awareness faculty is the change-closure fixpoint. The assumption gate is the amnesia equation. The substrate's external structure is a two-layer duality — and that duality, which the reliability framework states as a design law, is the result the whole thing turns on. What each road saw partially, the union sees whole.

    +

    Two of these three “identities” are weaker than this callout says: the impact oracle approximates Δ* rather than computing it (§3.2), and the duality is a bound over a region of parameters, not a theorem for every p, c < 1 (§4). See the Corrections. [corrected 2026-09-21]

    The synthesis also inherits a governing discipline, stated plainly by the practitioner who commissioned this work: AI output is a mathematically calculated probability; it must never be trusted blindly; for the same prompt it can give a different answer, so use only the capability it is genuinely best at, and earn trust with an external check. We will see that this sentence is not a slogan but the informal statement of the central theorem — the quantity (1−p)>0 that forces a deterministic layer to exist.

    @@ -140,7 +148,8 @@

    The claim of this paper

    2 The object of study — the frozen map and its five lacks

    Fix the model. Let the coding agent's core be a function

    -
    (1)y = fθ(x), θ fixed,  x the bounded context window,  y the sampled output.
    +
    (1)y ~ pθ(· | x), θ fixed,  x the bounded context window,  y a draw from the output distribution the frozen map defines.
    +

    We write y = fθ(x) as shorthand for this sampling map. Greedy decoding, which approximately returns argmaxy pθ(y | x), is its temperature-0 special case; the amnesia equation of §8 uses that special case. [corrected 2026-09-21]

    Three properties of this map generate every problem the architecture must solve. To avoid a notation collision with the reliability framework's primitives (§3), we label these model properties P1–P3:

    @@ -154,13 +163,15 @@

    2 The object of study — the frozen map and its five la

    SourceIDGradeNote
    Cognitive Architectures for Language Agents
    Theodore R. Sumers, Shunyu Yao, Karthik Narasi, 2023
    2309.02427confirmedRetrieved via arXiv metadata API; title/authors match claim exactly. Unifies memory, planning/reasoning, action, and learning modules into a single CoALA framework for language agents, giving the cognitive-substrate work's memory/im…
    - - - - - + + + + +
    Faculty the model lacksForced byExternal remedy (this architecture)
    Persistent memoryP1A committed store re-injected each session (§6, A4/A5)
    Learning from outcomesP2Non-parametric experience store; optional parametric adapters (§6)
    Imagination / world-modelP1, P3A typed artifact graph the agent queries before acting (§3.2)
    Self-correctionP3An external verifier and a deterministic completion gate (§4, A6)
    Impact-awarenessP1, P3The change-closure Δ* computed on the graph (§3.2, A1)
    Persistent memoryP1, P3A committed store re-injected each session (§6, A4/A5)
    Learning from outcomesP2, P1Non-parametric experience store; optional parametric adapters (§6)
    Imagination / world-modelP1 (and the token→token form of Eq. 1)A typed artifact graph the agent queries before acting (§3.2)
    Self-correctionP2An external verifier and a deterministic completion gate (§4, A6)
    Impact-awarenessP3The change-closure Δ* computed on the graph (§3.2, A1)
    +

    The “Forced by” column now matches the whitepaper's derivation, which argues each row separately; the earlier version of this table disagreed with it in all five rows. [corrected 2026-09-21]

    +

    The critical word is external. Because θ is frozen (P2) and context is bounded (P3), none of these can be fixed by prompting harder or by fine-tuning alone. The architecture must live around the model, hold state outside it, and enforce behaviour the model cannot be relied upon to produce on its own. The rest of this paper makes "cannot be relied upon" precise and shows what "enforce" must therefore mean.

    @@ -187,7 +198,7 @@

    3.1 The stateful substrate operator

    The design law, stated early because everything depends on it

    -

    Π3 is probabilistic; Π2 is deterministic. Instructions (Π3) raise the probability that the model behaves correctly; interception (Π2) executes regardless of what the model decides. A reliable substrate needs both, and §4 proves it cannot be built from either alone.

    +

    Π3 is probabilistic; Π2 is deterministic. Instructions (Π3) raise the probability that the model behaves correctly; interception (Π2) executes regardless of what the model decides. A reliable substrate needs both wherever neither can reach the target residual alone; §4 states when that is. [corrected 2026-09-21]

    3.2 The repository as a typed artifact graph (the world-model)

    @@ -198,7 +209,7 @@

    3.2 The repository as a typed artifact graph (the world-model)

    Let the project be a finite set of artifacts A = {a1, …, an} with a type function τ : A → {code, test, doc, config, diagram} and a dependency relation R ⊆ A × A, where (a,b) ∈ R means "a describes, verifies, exercises, or references b". R = Rdeclared ∪ Rdiscovered: declared edges come from a curated documentation map (high precision, small); discovered edges are found mechanically — a mentions an identifier defined in b — by text search.

    -

    The substrate's Prototype I builds this graph from source: an AST parser extracts the code nodes and their edges, so Rdiscovered over {code, test} is computed exactly rather than by grep. The reliability framework's contribution is to widen τ beyond code, making documentation a first-class dependent so that a code change can be seen to obligate a doc change.

    +

    The substrate's Prototype I builds this graph from source: an AST parser extracts the code nodes and their edges, so Rdiscovered over {code, test} is computed from the AST rather than by grep (the refutation later found a construction defect in exactly this step, §10 [corrected 2026-09-21]). The reliability framework's contribution is to widen τ beyond code, making documentation a first-class dependent so that a code change can be seen to obligate a doc change.

    Definition 3 (Dependents operator and change closure)

    For a set X ⊆ A, the dependents operator is

    @@ -206,11 +217,12 @@

    3.2 The repository as a typed artifact graph (the world-model)

    — everything that describes, verifies, or references anything in X. A task seeds a change set Δ0 ⊆ A (the files the request names or obviously touches). The required change closure is the least fixpoint

    (4)Δk+1 = Δk ∪ N(Δk), Δ* = Δk where Δk+1 = Δk.

    Since A is finite and the sequence is monotone (Δ0 ⊆ Δ1 ⊆ … ⊆ A), the fixpoint exists and is reached in at most |A| steps (Kleene's theorem on a finite lattice). In practice depth 2–3 suffices.

    +

    Equivalently, Δ* = lfp(X ↦ Δ0 ∪ N(X)), the least fixpoint above the seed (the two agree because N distributes over union). The seed matters: the least fixpoint of X ↦ X ∪ N(X) on its own is , which is what earlier versions of this paper wrote. [corrected 2026-09-21]

    -

    Anchor identity #1: the impact oracle is Δ*

    -

    The substrate's Impact Oracle computes a file's blast radius by reverse reachability on the dependency graph — which is exactly the closure (4). The oracle adds a real-valued confidence that decays with graph distance, where the framework's N(·) is boolean; thresholding the oracle's confidence recovers N. They are the same computation. This is why the prototype achieves perfect recall on impacted files (§10): reverse reachability, run to fixpoint, cannot miss a reachable dependent.

    +

    Anchor identity #1, downgraded: the impact oracle approximates Δ* [corrected 2026-09-21]

    +

    The substrate's Impact Oracle computes a file's blast radius by reverse reachability on the dependency graph, which has the same shape as the closure (4). It is not the same object. The oracle adds a real-valued confidence that decays with graph distance, keeps only what clears a threshold, stops at depth 10, and walks the AST import/call relation rather than the framework's R (which also carries documentation and declared edges). So it approximates Δ* over a different relation. Earlier versions said the two were the same computation and that this was why the prototype achieved perfect recall. That inference was wrong: completeness relative to a relation says nothing about whether the relation holds the edges that matter, and on nine real repositories the oracle's recall was 0.022 (§10).

    3.3 The faculties as operators on the store

    @@ -239,34 +251,38 @@

    4 The central result — the two-layer duality theorem

    A substrate's behaviour-shaping is partitioned into two layers over the frozen model:

    • The probabilistic layer3): standing instructions loaded into context — CLAUDE.md, rules, skills, protocol cards. Let p = P(the agent performs the required behaviour on a task — e.g. the full closure Δ* — under the instruction layer alone).
    • -
    • The deterministic layer2): code executed at fixed lifecycle points independent of the model's choices — hooks that inject state, or that block a turn. A deterministic check j catches a target miss with probability cj, and for a decidable structural signal cj → 1.
    • +
    • The deterministic layer2): code executed at fixed lifecycle points independent of the model's choices — hooks that inject state, or that block a turn. A deterministic check j fires with probability cj = P(check j fires | miss). A decidable check detects its proxy signal exactly, but cj is the probability that a miss shows that proxy. That depends on the agent's behaviour as well as on the check, so it is not a property of the gate alone (§5.2). [corrected 2026-09-21]

    -
    Theorem D (Two-layer duality — neither layer alone suffices)
    -

    Let a task require a behaviour whose omission is a silent miss. Under the instruction layer alone the per-task silent-miss probability is 1−p; under a deterministic layer of k checks the residual silent-miss probability is

    -
    (5)P(silent miss) = (1−p) · ∏j=1..k (1−cj).
    -

    Then, for any model whose instruction-following is imperfect (p<1) and any deterministic layer that is not omniscient (cj<1 for every j):

    +
    Theorem D (Two-layer duality, restated as a bound [corrected 2026-09-21])
    +

    Let a task require a behaviour whose omission is a silent miss. Let M be the event that the agent omits it under the instruction layer, so P(M) = 1−p, and let q = P(at least one of the k checks fires | M). The per-task residual is exactly

    +
    (5)r = P(silent miss) = (1−p) · (1−q) = (1−p) · P(no check fires | M).
    +

    With cj = P(check j fires | M), and no independence assumption, max(0, 1−Σjcj) ≤ 1−q ≤ 1−maxjcj (Fréchet bounds). The product (1−p)·∏j(1−cj), which earlier versions gave as Eq. (5), is the special case in which the checks fire independently given the miss. When the checks are nested, for example the same classifier run at several points on the same diff, r = (1−p)(1−cmax).

    +

    Then:

      -
    1. The probabilistic layer alone cannot reach reliability. With k=0, P(silent miss)=1−p>0, and over n tasks P(≥1 miss)=1−pn → 1. No amount of instruction-writing removes the residual, because instructions are context, not enforcement.
    2. -
    3. The deterministic layer alone cannot reach reliability either. A decidable check bounds only the structural signal it was built to detect; semantic correctness is undecidable (§12), so ∏(1−cj)>0 for the semantic class. Without the instruction layer raising p, the factor (1−p) stays near 1 and the product is dominated by it.
    4. -
    5. Their composition is strictly better than either factor. Because 0<(1−p)<1 and each 0<(1−cj)<1, the product (5) is strictly smaller than (1−p) and strictly smaller than any single (1−cj). Reliability is the product of a soft factor and hard factors, and needs both kinds present.
    6. +
    7. Bound. The per-task residual is at most ε exactly on the region Rε = {(p, q) : (1−p)(1−q) ≤ ε}. Over n tasks, P(≥1 miss) ≤ min(1, nε) whatever the dependence between tasks (union bound). It equals 1−(1−ε)n only if tasks fail independently, and tasks done by one model on one repository need not.
    8. +
    9. Instruction layer alone (q = 0): r = 1−p, so reaching ε needs p ≥ 1−ε from instructions. Raising p does bend the curve: for 30 independent tasks, P(≥1 miss) is 0.958 at p = 0.9 and 0.260 at p = 0.99.
    10. +
    11. Deterministic layer alone (the bare model's p0): reaching ε needs q ≥ 1 − ε/(1−p0).
    12. +
    13. Composition. Adding a check with P(it fires | M, no earlier check fired) > 0 strictly lowers r. Adding a copy of a check that is already present lowers nothing.
    +

    The design claim that survives is a statement about ranges, not an impossibility theorem. Let p0 be the bare model's rate, pmax the best rate instructions can reach, and qmax the best catch rate decidable checks can reach on the misses that matter. Instructions alone leave at least 1−pmax; checks alone leave at least (1−p0)(1−qmax); together they can reach (1−pmax)(1−qmax). So a target ε with (1−pmax)(1−qmax) ≤ ε < min(1−pmax, (1−p0)(1−qmax)) needs both layers and is reachable with them. Whether a real target falls in that range is an empirical question about p0, pmax and qmax, which this paper does not measure.

    -Equation (5) is the probability that the behaviour is both omitted by the agent (the independent event of probability 1−p) and undetected by every one of the k checks (each failing to catch with probability 1−cj, taken as conditionally independent given the miss). Claim 1: set k=0, the empty product is 1, so P=1−p; the n-task bound is the complement of n independent successes, pn. Claim 2: for the semantic-miss class every decidable cj is bounded below 1 (Rice's theorem: non-trivial semantic properties of programs are undecidable), so the product cannot vanish; with p not raised, (1−p) is near 1. Claim 3: multiplying a number in (0,1) by further numbers in (0,1) strictly decreases it below every factor. □ +Equation (5) is the chain rule: a silent miss is a miss (1−p) on which no check fires (1−q). The Fréchet bounds hold for any joint distribution of the checks: no check fires with probability at most that of the most reliable single check missing, and at least 1−Σcj by the union bound. Claim 1: r ≤ ε is the definition of Rε, and P(∪i missi) ≤ Σi P(missi) needs no independence. Claims 2–3: set q = 0, or p = p0, and solve. Claim 4: 1−q falls by the probability that the new check fires on misses no earlier check caught, which is zero for a duplicate. □ +

    What the earlier version got wrong [corrected 2026-09-21]. (i) Its criterion, P(≥1 miss) → 1, also condemns the composed system: whenever every cj < 1 the residual is positive and the same limit holds (0.993 over 1,000 tasks at a residual of 0.005), so it could not separate the layers. (ii) T3 asserts exact detection of its structural class, which, if that class were the target, gives the deterministic layer alone a residual of 0 and contradicts Claim 2. (iii) It said only a factor (1−cj) could bend the failure curve, but raising p does too. (iv) It assumed independent tasks and conditionally independent checks, which the design contradicts (§5.3). (v) Rice's theorem is a worst case over all programs: no single total procedure decides a non-trivial semantic property for every program. It is not a probability bound for a finite repository. That cj < 1 on semantic misses is an empirical premise, not a consequence of Rice.

    The two-layer duality architecture -
    Figure 1. The two-layer duality. The probabilistic instruction layer (Π3, purple) raises p by loading context but may drift (dashed arrows); the deterministic interception layer (Π2, teal) executes regardless of the model's choice and either passes the turn or blocks it (exit 2) back into the model for repair. The persistent store (Π1) feeds both. What escapes both layers is the residual (1−p)·∏(1−cj), handed to review or a later commit/CI gate. The whole sits inside a stewardship boundary (amāna, §9). Neither layer alone suffices — the formal content of the discipline never trust the output; earn trust with a check.
    +
    Figure 1. The two-layer duality. The probabilistic instruction layer (Π3, purple) raises p by loading context but may drift (dashed arrows); the deterministic interception layer (Π2, teal) executes regardless of the model's choice and either passes the turn or blocks it (exit 2) back into the model for repair. The persistent store (Π1) feeds both. What escapes both layers is the residual (1−p)·P(no check fires | miss), which equals (1−p)·∏(1−cj) only when the checks fire independently [corrected 2026-09-21]. It is handed to review or a later commit/CI gate. The whole sits inside a stewardship boundary (amāna, §9). Neither layer alone suffices — the formal content of the discipline never trust the output; earn trust with a check.

    What the theorem says in one sentence

    -

    The practitioner's rule — never trust the probability engine's output; verify it — is the statement (1−p)>0. Theorem D turns that intuition into a design mandate: because the soft layer can never drive (1−p) to zero, a deterministic layer must exist to multiply it down; and because the hard layer can never catch the semantic class, the soft layer must exist to shrink what reaches it. The substrate is two-layered not by taste but by theorem.

    +

    The practitioner's rule — never trust the probability engine's output; verify it — is the statement (1−p)>0. Theorem D turns that intuition into a design argument: where the soft layer cannot drive (1−p) below the target, a deterministic layer is needed to multiply it down; and where decidable checks cannot catch the semantic class, the soft layer is needed to shrink what reaches them. The substrate is two-layered because of what p and q can reach in practice, not by a theorem that holds for every p, c < 1. [corrected 2026-09-21]

    5 The probabilistic failure model

    @@ -274,13 +290,14 @@

    5 The probabilistic failure model

    Theorem D's equation (5) is worth reading as an engineering instrument, because it explains a lived experience and prices every design choice.

    5.1 Why "it works, then forgets" is a certainty, not bad luck

    -

    With the instruction layer alone, the chance of at least one partial-work incident over n tasks is 1−pn. Even an excellent p=0.9 gives 65% after 10 tasks and 96% after 30. The agent that "usually remembers the docs" is, over a project's lifetime, near-certain to forget them at least once. The failure is geometric, so no degree of prompt-polishing escapes it — only a factor (1−cj) below 1 can bend the curve.

    +

    With the instruction layer alone, the chance of at least one partial-work incident over n tasks is 1−pn. Even an excellent p=0.9 gives 65% after 10 tasks and 96% after 30. The agent that "usually remembers the docs" is, over a project's lifetime, near-certain to forget them at least once. These figures assume tasks fail independently; tasks done by one model on one repository need not, and the dependence-free statement is the union bound P(≥1 miss) ≤ n(1−p). Raising p bends the curve too: at p=0.99 the 30-task figure is 26%, not 96%. Earlier versions said only a factor (1−cj) could do that. [corrected 2026-09-21]

    5.2 Why the deterministic gate is worth exactly one factor

    -

    Add one gate whose target is the decidable signal "code changed and no doc/state artifact changed." That signal is checkable in microseconds and c1≈0.95. With p=0.7, the per-task silent-miss rate falls from 30% to (1−0.7)·(1−0.95)=1.5% — a twentyfold reduction from a twenty-line hook. Crucially the class of surviving misses changes from "forgot the docs entirely" (structural, now caught) to "updated the docs imperfectly" (semantic, handed to review). The gate does not make the model think; it removes an entire failure mode from the model's shoulders.

    +

    Add one gate whose target is the decidable signal "code changed and no doc/state artifact changed." That signal is checkable in microseconds. Suppose, as an illustration rather than a measurement, that 95% of misses show it, so c1≈0.95. With p=0.7, the per-task silent-miss rate falls from 30% to (1−0.7)·(1−0.95)=1.5% — a twentyfold reduction from a twenty-line hook. Crucially the class of surviving misses changes from "forgot the docs entirely" (structural, now caught) to "updated the docs imperfectly" (semantic, handed to review). The gate does not make the model think; it removes an entire failure mode from the model's shoulders.

    +

    But c1 belongs to the agent as much as to the gate. [corrected 2026-09-21] The gate detects its proxy, “code changed and no doc/state artifact changed”, exactly; it does not detect the miss. An agent that edits code and STATE.md but not docs/api.md passes it. If agents touch STATE at rate h, independently of whether they missed a doc, then c1 = 0.95(1−h); at h = 0.9 the residual is 0.27, not 1.5%. The gate also blocks at most once per session (T1), so for every later task in that session c1 = 0.

    5.3 The lattice of gates

    -

    The same classifier can run at three lifecycle points, and (5) shows their catches multiply: a turn-level Stop hook a commit-level pre-commit hook a PR-level CI job. Each later gate catches what earlier ones missed — the product (5) with k=3. This is also the answer to portability: where hooks are unavailable, the same deterministic check re-binds as a pre-commit hook or a CI step, moving the enforcement point without changing the mathematics.

    +

    The same classifier can run at three lifecycle points: a turn-level Stop hook, a commit-level pre-commit hook, and a PR-level CI job. Earlier versions said (5) made their catches multiply, the product with k=3. They do not. [corrected 2026-09-21] Run on the same diff, the three copies fire together or not at all, so the residual is (1−p)(1−c1), not (1−p)(1−c1)3. With p=0.7 and c1=0.95 the product predicts 3.75×10−5 against a true 0.015, which understates it 400-fold. A later gate adds catches only when it sees something the earlier one could not, such as edits made after the turn ended or a host where the Stop hook never ran. This is also the answer to portability: where hooks are unavailable, the same deterministic check re-binds as a pre-commit hook or a CI step, moving the enforcement point without changing the mathematics.

    The honest cost side

    @@ -300,10 +317,10 @@

    6 The unified algorithm set — the TASK loop

    E ← grep_edges(I(frontier)) # R_discovered ∪ map_edges(frontier) # R_declared ∪ interface_edges(frontier) # callers, overrides, migrations, DI wiring - new ← E \ W ; W ← W ∪ new ; frontier ← new + new ← sources(E) \ W ; W ← W ∪ new ; frontier ← new # sources(E) = {a : (a,b) ∈ E} check_constraints(W, DECISIONS) # flag conflicts, never override (I3) return { (a, τ(a), action(a), risk(a)) : a ∈ W } # the impact table -

    Terminates in ≤|A| rounds (Thm. T5); on termination W ⊇ Δ* over discoverable+declared edges. This is simulate and impact of Def. 4, and it is exactly what the Impact Oracle prototype computes (§10).

    +

    Terminates in ≤|A| rounds (Thm. T5). On termination W is the closure of the seeds under whatever edges the three edge functions return. That it is complete for that relation holds by construction and says nothing about whether the relation contains the edges that matter. This is simulate and impact of Def. 4. The Impact Oracle prototype approximates it with a thresholded, depth-limited walk over the AST relation (§3.2, §10). Earlier versions subtracted the artifact set W from the edge set E, a type error. [corrected 2026-09-21]

    A2 — EXECUTE-FULL (world-model · act on the whole closure)
    @@ -323,6 +340,7 @@

    6 The unified algorithm set — the TASK loop

    if user_facing: append CHANGELOG update STATE ; return {updated[], verified_unaffected[], doubts[]}

    The verify operator: no artifact is ever declared unaffected without the check running — invariant I1's "∨ verified-unaffected" made mechanical. The doubts[] channel enforces I3 (surface ambiguity, do not guess).

    +

    This makes I1 true by definition for anything the grep cannot see [corrected 2026-09-21]. An artifact is recorded as verified-unaffected exactly when no changed identifier appears in it. Counterexample: a doc says a request is “retried 3 times”, and the change raises the retry constant from 3 to 5. No identifier from the diff appears in the doc, so A3 certifies it unaffected, and the doc is now false. I1 is only as strong as the mention relation, which is why unnamed couplings belong in Rdeclared (§12).

    A4 — HANDOFF (memory · write-back · session end)
    @@ -333,7 +351,7 @@

    6 The unified algorithm set — the TASK loop

    mirror durable decisions → DECISIONS # append-only log if a convention was corrected → update CLAUDE.md / rules (self-maintenance) propose commit # committing = portable memory -

    The bounded-compression checkpoint (|σ|≤B≈150) keeps the loader's cost O(B) forever — the snapshot+WAL pattern: STATE is the mutable snapshot, DECISIONS the durable log.

    +

    The bounded-compression checkpoint (|σ|≤B≈150 lines) keeps the loader's cost O(B) forever — the snapshot+WAL pattern: STATE is the mutable snapshot, DECISIONS the durable log. The two budgets are in different units: A4 bounds lines, A5 injects at most 8 KB. 150 lines of 80 bytes are 12,000 bytes, more than the 8,192-byte cap, so a full snapshot fits only if lines average under about 54 bytes; otherwise the loader truncates it. [corrected 2026-09-21]

    A5 — REHYDRATE (memory · retrieve · session start)
    @@ -350,7 +368,7 @@

    6 The unified algorithm set — the TASK loop

    docs ← C ∩ DocClass if code ≠ ∅ ∧ docs = ∅: exit 2 + repair checklist + set marker # BLOCK once else: exit 0 # ALLOW -

    The hard factor c1 of Theorem D. STATE counts as a doc artifact, so the weakest way to satisfy the gate is to update session state — which is exactly the continuity invariant I2. One check enforces a floor for both F1 and F2. Full decision table and safety proofs in §7.

    +

    The hard factor c1 of Theorem D. STATE counts as a doc artifact, so the weakest way to satisfy the gate is to update session state — which is exactly the continuity invariant I2. The same fact means an agent that routinely touches STATE drives c1 toward zero for F1 (§5.2). [corrected 2026-09-21] One check enforces a floor for both F1 and F2 only when the agent does not satisfy it that way. Full decision table and safety proofs in §7.

    A7 — KNOWLEDGE-ROUTER (M1 routing · where every fact lives)
    @@ -360,14 +378,15 @@

    6 The unified algorithm set — the TASK loop

    a procedure / workflow → skill (loads on invocation) specialist's accumulated patterns → subagent memory current work status → STATE.md (rewritten, injected) - decision + rationale → DECISIONS.md (append-only) + decision + rationale → DECISIONS.md (append-only) + otherwise → ledger fact (unverified; decays unless confirmed) # catch-all, added 2026-09-21

    Keeps always-loaded context O(bounded) while total persisted knowledge grows without limit — the mathematical reason the substrate scales. This is the second face of routing: M1's complexity-router chooses a model tier by task difficulty; A7 chooses a storage home by knowledge type. Both are transparent and deterministic, and both reject an opaque-LLM classifier for the same reason — it would reintroduce the very (1−p) the hard layer exists to remove.

    6.1 The loop

    The seven compose into one meta-algorithm that fits every task, from a one-line fix to a multi-file feature:

    TASK(t):  A5 rehydrate → A1 impact-closure → A2 execute → verify → A3 sync → A4 handoff → A6 gate
    -

    Formally, iterate until the unsynced set U = { a ∈ Δ* : ¬updated(a) ∧ ¬verified(a) } = ∅ — a fixpoint loop whose progress is guaranteed by A1's explicit worklist and whose floor is guaranteed by A6. The soft stages (A1–A5, driven by instructions) raise p; the gate (A6, deterministic) guarantees the floor; persistence (A4/A5 over Π1) carries essential(K) across the session boundary.

    +

    Formally, iterate until the unsynced set U = { a ∈ Δ* : ¬updated(a) ∧ ¬verified(a) } = ∅ — a fixpoint loop whose progress is guaranteed by A1's explicit worklist and whose floor on the proxy signal is enforced by A6 (§5.2). The soft stages (A1–A5, driven by instructions) raise p; the gate (A6, deterministic) enforces a floor on its proxy signal; persistence (A4/A5 over Π1) carries essential(K) across the session boundary.

    The unified TASK loop @@ -404,36 +423,40 @@

    7.1 The correctness theorems

    T3 (Soundness of the block signal — no false silence)
    On the first stop of a session with usable git: C is the union of the baseline diff and an untracked-inclusive worktree scan, so every changed path is in C; classification is a total function of path; the row order reaches the block row exactly when code changed and no doc changed. Hence a silent code-only completion is impossible at the session's first completion — the agent must fix the docs or explicitly justify and update STATE, both visible to the developer. □
    +

    T3 is exact for the proxy event (code changed, no doc/state changed) at the session's first stop. It says nothing about the probability that a real miss shows that proxy, which is what Theorem D's c1 measures (§5.2). [corrected 2026-09-21]

    -
    T4 (Continuity under handoff)
    +
    T4 (Loader fidelity under handoff [corrected 2026-09-21])
    If A4 ran and its commit is pushed/pulled, then for any next session on any machine the loader injects σ at start (A5 reads the committed file), so essential(Ki) ∩ σ ⊆ Ki+1(0). Residual risk is exactly selection error in the handoff (what it chose not to write), bounded by the priority order and by DECISIONS catching the durable class. □
    +

    As stated, T4 says only that what the handoff wrote is read back. That is close to vacuous: the substantive continuity claim, essential(Ki) ⊆ σ, is the selection error the proof sets aside. It also holds only if σ fits the loader's 8 KB cap, and a 150-line snapshot at 80 bytes a line does not (A4). Earlier versions called this “continuity under handoff”. [corrected 2026-09-21]

    T5 (Closure termination, A1)
    A monotone worklist on the finite set A: each round adds ≥1 artifact or stops, so ≤|A| rounds. This is the finite-lattice Kleene fixpoint of Def. 3. □
    +

    T5 gives termination only. That the result is complete relative to R is true by construction and transfers nothing about whether R contains the edges that matter. [corrected 2026-09-21]

    T6 (Router totality, A7)
    -
    The routing chain ends in catch-alls per scope; every fact matches ≥1 arm; first-match makes the assignment unique. Hence route is a total function — every piece of knowledge has exactly one home, which is what keeps the always-loaded budget bounded. □
    +
    The routing chain ends in an explicit catch-all arm; every fact matches ≥1 arm; first-match makes the assignment unique. Hence route is a total function — every piece of knowledge has exactly one home, which is what keeps the always-loaded budget bounded. □
    +

    In earlier versions T6 was false: A7's six arms had no catch-all, so a fact that matched none of them (a transient observation, say) had no home. A7 now ends in the fallback arm forgekit's router actually uses (src/knowledge_router.js: an unmatched fact becomes an unverified ledger fact that decays unless confirmed), and the proof relies on it. [corrected 2026-09-21]

    How the theorems earn Theorem D

    -

    Theorem D says reliability needs a deterministic factor with cj→1 on its target signal. T3 is precisely that guarantee (the block fires exactly on the target signal); T1 and T2 ensure the factor is safe to add (it never loops, never bricks); T5 and T6 ensure the soft-layer machinery it composes with is well-defined (the closure terminates, the router is total); T4 extends the guarantee across the session boundary that F2 attacks. The six local proofs are what make the one global theorem deployable rather than merely true.

    +

    Earlier versions said T3 was the guarantee cj→1 that Theorem D needs. It is narrower. [corrected 2026-09-21] T3 makes the block fire exactly on its proxy signal, but cj is the probability that a real miss shows that proxy, which T3 does not bound (§5.2). What the six results do give: T1 and T2 make the gate safe to add (it never loops, never bricks); T5 makes the closure terminate; T6, with its catch-all arm, makes the router total; T4 says what the handoff wrote is read back if it fits. They make the deterministic layer well-behaved. They do not make it reliable.

    8 The crosswalk — one object, three vocabularies

    -

    The table below is the operational proof of the paper's claim: every concept appears in all three vocabularies, and the final column states the relationship that makes them one object. Three rows (marked ●) are not analogies but identities — the same mathematics under two names. The full machine-readable crosswalk is a companion artifact.

    +

    The table below is the operational proof of the paper's claim: every concept appears in all three vocabularies, and the final column states the relationship that makes them one object. Three rows (marked ●) were presented as identities — the same mathematics under two names. Two are weaker than that: row 2 is an approximation and row 14 a bound (§3.2, §4). [corrected 2026-09-21] The full machine-readable crosswalk is a companion artifact.

    Notation reconciliation — the P1/P2/P3 collision

    Both source frameworks independently use the labels P1/P2/P3. In the substrate paper they are model properties (P1 statelessness, P2 frozen weights, P3 bounded context, §2); in the reliability framework they are the three primitives (persistent store, lifecycle interception, instruction channel). This paper keeps P1–P3 for the model properties and renames the primitives Π123 throughout (§3.1). Every reference to a primitive in this paper is written Πn.

    - + - - + + @@ -442,14 +465,14 @@

    8 The crosswalk — one object, three vocabularies

    - - + +
    #Unified conceptSubstrate (S)E2E Framework (E)forgekit (K)Relationship
    1The frozen corey = f_theta(x): stateless map, fixed weights, bounded context (properties P1,P2,P3)the agent/model whose behavior instructions can only raise the PROBABILITY of (p<1)Claude / Codex / Cursor / Gemini / Aider — the model the kit wraps, never modifiesidentical object: all three treat the model as a fixed probabilistic map to be wrapped, not retrained
    2Impact-awareness / partial-work failureFaculty: impact-awareness gap; the developer silently simulates 'what will this edit break'. Prototype I = Impact Oracle (reverse-dependency blast radius with confidence decay)F1 partial work; dependents operator N(X); required change closure Delta* = least fixpoint of X |-> X ∪ N(X); Done predicate; Algorithm A1 IMPACT-CLOSURE/impact skill; documentation-map.md (R_declared); grep identifier sweep (R_discovered)IDENTICAL MATHEMATICS. The Impact Oracle computes N(X) by reverse reachability on the dependency graph; Delta* is exactly the oracle's transitive blast-radius set. The oracle adds a confidence-decay weight the framework's set-theoretic N(X) leaves boolean.
    2Impact-awareness / partial-work failureFaculty: impact-awareness gap; the developer silently simulates 'what will this edit break'. Prototype I = Impact Oracle (reverse-dependency blast radius with confidence decay)F1 partial work; dependents operator N(X); required change closure Delta* = lfp(X |-> Delta_0 ∪ N(X)) [corrected 2026-09-21: the lfp of X |-> X ∪ N(X) alone is the empty set]; Done predicate; Algorithm A1 IMPACT-CLOSURE/impact skill; documentation-map.md (R_declared); grep identifier sweep (R_discovered)SAME SHAPE, NOT IDENTICAL [corrected 2026-09-21]. The Impact Oracle computes a thresholded, depth-limited reverse reachability over the AST relation, which approximates Delta* over a different relation. It adds a confidence-decay weight the framework's set-theoretic N(X) leaves boolean. Measured on nine real repositories, its recall was 0.022.
    3Memory / session-amnesia failureFaculty: persistent memory gap; each context window is ephemeral. Validity-anchored memory (facts carry confirmed/discredited state updated by verified outcomes)F2 session amnesia; continuity invariant I2 (essential(K_i) ⊆ P and loader L must load it); handoff operator H; K_{i+1}(0)=L(P)docs/STATE.md (bounded snapshot) + docs/DECISIONS.md (append-only log); /handoff writes it; SessionStart hook injects itSame failure, same fix (external bounded-compression checkpoint). Substrate adds validity-anchoring (prune by whether a past prediction was externally confirmed); framework adds the snapshot+WAL split (STATE mutable vs DECISIONS append-only).
    4Why assumptions happen (the root the user named)M2 assumption/uncertainty gate: under-specified input -> the model confabulates a conventionamnesia equation: when f ∈ essential(K) is missing from L(P), assumption ≈ argmax P(convention | training data) — the mathematically EXPECTED result of missing context, not misbehavior. Invariant I3 (no fabrication)CLAUDE.md No-assumptions rule; intent-router SPEC card ('state assumptions explicitly')The framework gives the substrate's M2 gate its formal justification: assumption is argmax over the prior, so the fix is to supply context (raise what L loads) OR gate on missing context (halt), never to hope.
    5Self-correction / verificationM6 inline verification; Prototype-II verify step; self-correction facultyAlgorithm A6 COMPLETION-GATE (deterministic Stop-hook floor); Hoare postcondition Done(t); the verification operator in A3 (verified-unaffected requires an actual grep, not an assumption)docs-guard.sh Stop hook (blocks finish if code changed but no doc/state artifact did); reviewer agent verdictSame verify-gate. Substrate framed it as a mechanism; framework proves it terminates (T1) and is sound (T3) and makes it the deterministic layer of the duality.
    4Why assumptions happen (the root the user named)M2 assumption/uncertainty gate: under-specified input -> the model confabulates a conventionamnesia equation: when f ∈ essential(K) is missing from L(P), assumption ≈ argmax P(convention | training data) under greedy decoding (a draw from that prior under sampling, Eq. 1) — the mathematically EXPECTED result of missing context, not misbehavior. Invariant I3 (no fabrication)CLAUDE.md No-assumptions rule; intent-router SPEC card ('state assumptions explicitly')The framework gives the substrate's M2 gate its formal justification: assumption is argmax over the prior, so the fix is to supply context (raise what L loads) OR gate on missing context (halt), never to hope.
    5Self-correction / verificationM6 inline verification; Prototype-II verify step; self-correction facultyAlgorithm A6 COMPLETION-GATE (deterministic Stop-hook floor); Hoare postcondition Done(t); the verification operator in A3 (verified-unaffected requires an actual grep, not an assumption)docs-guard.sh Stop hook (blocks finish if code changed but no doc/state artifact did); reviewer agent verdictSame verify-gate. Substrate framed it as a mechanism; framework proves it terminates (T1) and fires exactly on its proxy signal (T3) and makes it the deterministic layer of the duality.
    6Complexity routingM1 complexity-aware router (transparent additive rubric); Prototype-II routerAlgorithm A7 KNOWLEDGE-ROUTER (where every fact lives, keeps always-loaded context bounded); System 1/System 2 effort routing; intent DFAintent-router.sh (UserPromptSubmit hook, keyword DFA, <10ms, zero-token); effort-routing rule in CLAUDE.md; per-agent model: fieldsTwo facets of one routing principle. Substrate M1 routes by TASK COMPLEXITY -> model tier (cost). Framework A7 routes by KNOWLEDGE TYPE -> storage home (context budget). Both are transparent/deterministic, both reject an opaque-LLM classifier for the same reason.
    7Task decompositionM3 task/session decompositionHTN closure -> ordered task list (A1 output is the worklist); the meta-algorithm TASK(t)sdlc-pilot 7-phase skill; subagents; git worktreesSame: decompose the goal into the closure's ordered worklist before building.
    8Goal-anchoringM4 goal-anchoring (goal drift: to a text model a long story and long code are the same object)BDI Desires = written goal + acceptance criteria in STATE.md; I2 keeps them across sessions; acceptance criteria written at SPEC, consumed at VERIFYdocs/STATE.md 'Current goal' + 'Acceptance criteria'; sdlc-pilot SPEC->VERIFY gateSubstrate names the drift; framework anchors it in a persisted, re-injected Desire so every session optimizes the same target.
    11Continual learning from outcomesFaculty: learning without touching theta (non-parametric always-on + parametric LoRA/EWC)Reflexion loop made cross-session (I3/I4); DECISIONS.md as precedent DB; A4 mirrors durable lessonsReflexion rule in CLAUDE.md; STATE gotchas; agents' memory: projectSame 'learn without retraining' stance. Both store verbal lessons externally and re-inject; substrate adds the parametric option, framework keeps it purely non-parametric (committed files).
    12Rehydration (session start)closed-loop write-back/read-back band in the substrate architectureAlgorithm A5 CONTEXT-REHYDRATE; loader L; records git baseline for the gatesession-context.sh SessionStart hook; /catchup skill (deep variant)The read side of the substrate's memory loop, made deterministic (inject, don't hope the agent reads).
    13Independent verification / judgeself-correction faculty; the honest-negative-result caution (models correct poorly alone)LLM-as-Judge applied: reviewer agent (fresh context, explicit criteria, adversarial); self-consistency for critical changes (majority of N)reviewer.md agent; DoD item 7Substrate cited the finding that self-correction alone is weak; framework operationalizes the fix (an EXTERNAL judge), which is the same 'trust only an external check' principle as the gate.
    14The two-layer duality (THE central new insight)implicit: the substrate wraps a probabilistic core with deterministic external structure, but v2 never states it as a lawDESIGN LAW: instructions (Pi3) are PROBABILISTIC (raise p); interception (Pi2) is DETERMINISTIC (guarantee a floor c->1). P(silent miss) = (1-p)·prod_j(1-c_j). Since p<1 always, neither layer alone suffices.the split itself: CLAUDE.md/rules/skills = soft layer; hooks (docs-guard, session-context, intent-router) = hard layerTHIS is what the kit gives the substrate theory: the reason the substrate must be TWO layers, stated as a theorem. The user's own discipline ('never trust AI output — it is calculated probability') is exactly (1-p)>0, which forces the deterministic layer.
    15The probabilistic failure modeleval honesty: perfect accuracy shows separation not a benchmark; p<1P(>=1 miss) = 1 - p^n over n tasks (0.9 -> 65% at 10, 96% at 30); layered: P(silent miss)=(1-p)·prod(1-c_j)the lattice of gates: turn-level (hook) ⊂ commit-level (pre-commit) ⊂ PR-level (CI)The math that makes 'it works sometimes then forgets' a geometric certainty rather than bad luck — and quantifies why each added deterministic gate multiplies down the residual.
    14The two-layer duality (THE central new insight)implicit: the substrate wraps a probabilistic core with deterministic external structure, but v2 never states it as a lawDESIGN LAW: instructions (Pi3) are PROBABILISTIC (raise p); interception (Pi2) is DETERMINISTIC (guarantee a floor c->1). P(silent miss) = (1-p)·P(no check fires | miss), which is (1-p)·prod_j(1-c_j) only for independent checks [corrected 2026-09-21]. Where p and c are each bounded below 1, both layers are needed to reach a small residual.the split itself: CLAUDE.md/rules/skills = soft layer; hooks (docs-guard, session-context, intent-router) = hard layerTHIS is what the kit gives the substrate theory: the reason the substrate must be TWO layers, stated as a bound over (p, q) [corrected 2026-09-21]. The user's own discipline ('never trust AI output — it is calculated probability') is exactly (1-p)>0, which forces the deterministic layer.
    15The probabilistic failure modeleval honesty: perfect accuracy shows separation not a benchmark; p<1P(>=1 miss) = 1 - p^n over n independent tasks (0.9 -> 65% at 10, 96% at 30); layered: P(silent miss)=(1-p)·P(no check fires | miss) [corrected 2026-09-21]the lattice of gates: turn-level (hook) ⊂ commit-level (pre-commit) ⊂ PR-level (CI)The math that makes 'it works sometimes then forgets' a geometric certainty rather than bad luck — and quantifies when an added deterministic gate lowers the residual: only when it catches misses the earlier gates did not (a copy of the same check adds nothing) [corrected 2026-09-21].
    16Stewardship / governance boundarySTEWARDSHIP/amana wrapper (33:72) around the whole architectureamana in I3/I4 as no-fabrication + verified-currency; least privilege, reversibility, logged rationale, scope-boundednesscommitted-files-only (auditable), block-at-most-once (no nagging), fail-open safety (T2), DOCS_GUARD_DISABLE auditable escape hatchThe ethical frame the substrate draws from the lens is realized as concrete safety properties in the framework (fail-open, reversibility) and kit (auditable hooks).

    The three anchor identities

      -
    1. Impact-Oracle blast-radius ≡ change-closure Δ*. Reverse reachability on the dependency graph is the least fixpoint of X ↦ X ∪ N(X). The oracle weights it with confidence decay; the framework’s N(·) is its boolean core.
    2. +
    3. Impact-Oracle blast-radius ≈ change-closure Δ*. Reverse reachability from the seed is the least fixpoint of X ↦ Δ0 ∪ N(X). The oracle approximates it: it weights paths with confidence decay, thresholds them, stops at depth 10, and walks a different relation. This was listed as an identity until 2026-09-21. [corrected 2026-09-21]
    4. M2 assumption gate ≡ the amnesia equation. assumption ≈ argmax P(convention | training) is why an under-specified prompt is answered with a confabulated convention — so the gate supplies the missing context or halts; it never hopes.
    5. -
    6. The substrate’s two layers ≡ the design law. Instructions raise p<1; interception guarantees a floor c→1. Theorem D. This is the formal statement of the governing discipline.
    7. +
    8. The substrate’s two layers ≡ the design law. Instructions raise p<1; interception multiplies the residual by P(no check fires | miss). Theorem D, restated as a bound [corrected 2026-09-21]. This is the formal statement of the governing discipline.

    9 The Qur’anic epistemology — the full fourteen mappings

    @@ -550,7 +573,7 @@

    This section reports a refutation of the claims made in earlier editions of

    10.1 Prototype I — the impact oracle, refuted and repaired

    Ground truth was mined from real commit history: two files are impact-related if they changed in the -same non-merge commit, under filters fixed in advance. Against 801 labelled files in nine repositories:

    +same non-merge commit, under filters fixed in advance. Of 801 labelled files in nine repositories, 759 were evaluated (the pre-registered cap of 200 files per repository cut pytest from 242 to 200) [corrected 2026-09-21]:

    @@ -560,7 +583,7 @@

    10.1 Prototype I — the impact oracle, refuted and repaired

    Recall was 0.022, not 1.00 — a gap of roughly 45×. Ninety-one percent of files received exactly zero recall, and a textual baseline with no -dependency graph at all outperformed the graph-based oracle by an order of magnitude on F1.

    +dependency graph at all outperformed the graph-based oracle by an order of magnitude on F1. That conclusion survives a stricter bootstrap: resampling the nine repositories rather than the files, the oracle's F1 interval is [0.001, 0.093] against grep's [0.381, 0.539]. The file-level intervals first reported for the oracle's precision and recall were too narrow, because ground-truth pairs are mirrored and files cluster by repository (Corrections). [corrected 2026-09-21]

    What this does to Theorem T5 — the correction that matters

    @@ -579,7 +602,7 @@

    What this does to Theorem T5 — the correction that matters

    the R the theory assumed.

    -

    Because 96.8% of the missed impact was diagnosed as reachable in principle, we repaired both defects and +

    Because 96.8% of the missed pairs have some static path in the oracle's own graph, we repaired both defects and re-ran the same frozen protocol, with parameters fixed on six repositories and frozen before three held-out repositories were touched:

    @@ -591,8 +614,7 @@

    What this does to Theorem T5 — the correction that matters

    MethodPrecisionRecallF1
    grep baseline, held-out0.2690.6010.371
    -

    The repaired oracle beats the baseline for the first time, reaching 66.8% of the achievable ceiling. The -margin is narrow and the intervals are close. Two details are worth more than the headline. First, the +

    The repaired oracle's point estimate is above the baseline for the first time, reaching 66.8% of the static ceiling. Earlier versions said it beats the baseline; that is not established. [corrected 2026-09-21] F1 is higher by 0.044, and all three held-out repositories agree in sign, but three out of three gives a one-sided sign-test p of 0.125, pytest supplies 71% of the held-out pairs, and the file-level F1 intervals overlap. The choice of which relations to add also came from failure analysis pooled over all nine repositories, so the split was clean for the numeric parameters but not for that structural choice. Two details are worth more than the headline. First, the obvious repair of the construction defect is unsafe — it fabricates dependency edges through standard-library name collisions — so we applied a more conservative fix with a smaller gain (11.0× rather than 14.5×); a tool that invents edges to raise recall is worse than one that misses @@ -626,8 +648,8 @@

    10.2 Prototype II — the router and gate, refuted

    The gate missed roughly seven in ten under-specified requests. Routing retained partial signal — within-one-tier accuracy of 0.91 is well above chance, so the complexity rubric measures something -— but exact-tier accuracy fell to 0.53, and the cost saving did not merely shrink but inverted: routing does save 59.5% in raw dollars on first attempts alone, but almost none of that cheaper output is correct (3.6% once gated), and counting what the pipeline actually spent escalating up the tier ladder, it costs 20.2% more than always using the premium tier. Labelling noise is real and reported rather than hidden: inter-rater agreement on the -should-ask label was κ = 0.52, moderate, which bounds how well any gate could score here.

    +— but exact-tier accuracy fell to 0.53, and the cost saving did not merely shrink but inverted: routing does save 59.5% in raw dollars on first attempts alone, but almost none of that cheaper output is correct (3.6% once gated), and counting what the pipeline actually spent escalating up the tier ladder, it costs 20.2% more than always using the premium tier. Labelling noise is real and reported rather than hidden: agreement between two labelling passes on the +should-ask label was κ = 0.52, moderate, which bounds how well any gate could score here. Both passes were the same model with differently worded prompts, and that model also judged correctness, so κ measures robustness to prompt wording, not label validity. The cost inversion is also largely mechanical: 58 of 64 tasks failed at every tier and always-premium was judged correct on only 3, so escalation paid for every tier. Per output the judge accepted, the pipeline cost $1.06 against always-premium's $1.76, from 6 and 3 accepted outputs. [corrected 2026-09-21]

    What the earlier numbers actually measured

    @@ -654,12 +676,12 @@

    11 forgekit — the deployed binding

    Why one binding matters for the theory

    -

    That an independent team, starting from production failures rather than from cognitive faculties, built the same seven algorithms and stated the same design law is the strongest available evidence that the architecture is discovered, not invented — a convergent solution to a structural problem, the way distributed systems converge on snapshot+WAL. The synthesis does not merge two guesses; it records a convergence.

    +

    A second body of work, starting from production failures rather than from cognitive faculties, built the same seven algorithms and stated the same design law. Earlier versions called this an independent team's work and “the strongest available evidence that the architecture is discovered, not invented”. It is not independent: claude-e2e-kit is forgekit's precursor, and forgekit was built as a binding of this theory, so the agreement shows the framings are consistent; it is not independent evidence that the architecture is right. [corrected 2026-09-21] The synthesis does not merge two guesses; it records that one project reached the same design from two directions.

    12 Honest limits — what no architecture can guarantee

      -
    • Semantic correctness is undecidable. The gate proves "a doc artifact changed", not "the docs are now true"; A3's grep proves "mentions were visited", not "the prose is accurate". By Rice's theorem the last layer is unavoidable — tests for behaviour, human review for meaning. The architecture's job is to make that review cheap: everything arrives already-attempted, with an updated / verified / doubts report.
    • +
    • Semantic correctness is undecidable. The gate proves "a doc artifact changed", not "the docs are now true"; A3's grep proves "mentions were visited", not "the prose is accurate". Rice's theorem says no single procedure decides such properties for every program; that is a worst case, not a bound on how often checks miss in a given repository [corrected 2026-09-21]. In practice the last layer is still needed — tests for behaviour, human review for meaning. The architecture's job is to make that review cheap: everything arrives already-attempted, with an updated / verified / doubts report.
    • The soft layer's p is real but bounded. Theorem D quantifies the residual; it does not abolish it. A determined agent can satisfy the letter of a gate (touch STATE with one line) without its spirit — which is why no single layer is trusted, by design.
    • Rdiscovered misses unnamed coupling. A doc that describes behaviour without naming any identifier has no grep edge; such couplings must be lifted into Rdeclared (the documentation map) — exactly what that table is for.
    • One prototype is not five faculties. The substrate prototypes instantiate impact-awareness and routing/gating well; memory, learning, and imagination remain the harder research frontier, and the honest ecosystem map marks the assumption gate (M2) and outcome-based learning as genuine whitespace the current stack does not fill.
    • @@ -667,7 +689,7 @@

      12 Honest limits — what no architecture can guarantee

    13 Conclusion

    -

    A language model that writes code is a fixed probabilistic map, and three independent efforts — one from cognition, one from production failures, one from a shipped codebase — converged on the same remedy: wrap it in an external, stateful architecture that supplies the faculties it structurally lacks. This paper showed they describe one object. The impact-awareness faculty is the change-closure fixpoint; the assumption gate is the amnesia equation; and both rest on a single theorem — reliability is the product of a probabilistic instruction layer that raises p<1 and a deterministic interception layer that guarantees a floor, with neither alone sufficient.

    +

    A language model that writes code is a fixed probabilistic map, and three efforts that were not independent of one another — one from cognition, one from production failures, one from a shipped codebase — converged on the same remedy: wrap it in an external, stateful architecture that supplies the faculties it structurally lacks. This paper argued they describe one object. The impact-awareness faculty approximates the change-closure fixpoint; the assumption gate is the amnesia equation; and both rest on one result — the residual silent-miss rate is the product of what a probabilistic instruction layer lets through, 1−p, and what a deterministic interception layer lets through, P(no check fires | miss). Where each factor is bounded away from zero, neither layer alone reaches a small residual. [corrected 2026-09-21]

    The limit this edition discovered the hard way

    The honest limits listed below were all stated before any real-repository measurement existed. One more @@ -683,8 +705,33 @@

    The limit this edition discovered the hard way

    That theorem is the formal content of a plain discipline: the output of a probability engine is never to be trusted on its own; trust is earned by an external check. The Qur'anic lens gives that discipline its oldest names — lā taqfu, do not pursue what you do not know; tabayyun, verify the report before you act; al-amāna, the weight of a trust accepted by one who may err. The mathematics says how to build the check. The tradition says why it is owed. The codebase shows it runs.

    Companion artifacts: the three-way crosswalk (JSON + markdown), the graded reference set (Appendix A), and two runnable prototype packages (impact-oracle, router-gate). This synthesis consolidates and does not supersede the v2 Theory → Evidence → Build-Map edition, which carries the empirical evidence layer and the full ecosystem map.

    +

    Corrections (2026-09-21)

    +

    An external deep review of the forgekit repository (2026-09-21) checked this paper's mathematics against its own definitions, its claims about forgekit against the code, and its statistics against the replication package. Several statements were wrong as written. Each is corrected in place above, marked [corrected 2026-09-21], and listed here with the original wording, so nothing is silently rewritten. Every number below was recomputed by research/recompute_corrections.py (pure Python, fixed seeds, printed with each result) from this paper's own worked examples or from the refutation's replication package. The PDF edition predates these corrections.

    +
      +
    1. Theorem D (§4) was circular as stated, and is restated as a bound. It claimed that for any p<1 and any cj<1 “neither layer alone suffices”, using P(≥1 miss) → 1 as the criterion. That criterion condemns the composed system too: with every cj<1 the residual is positive, and at a residual of 0.005 the chance of at least one miss in 1,000 independent tasks is 0.993. T3 also asserts exact detection of its structural class, which would give the deterministic layer alone a residual of 0 on that class and contradict Claim 2. The theorem now bounds the residual by ε on an explicit region of (p, q) and states its dependence assumptions. The word “proves” is withdrawn from the abstract.
    2. +
    3. Raising p bends the failure curve (§5.1). The original said “only a factor (1−cj) below 1 can bend the curve”. For 30 independent tasks, P(≥1 miss) is 0.958 at p = 0.9 and 0.260 at p = 0.99. The 1−pn figures also assume tasks fail independently; tasks done by one model on one repository are correlated, and the dependence-free statement is the union bound.
    4. +
    5. Eq. (5) assumed an independence the design contradicts (§4, §5.3, Fig. 1). The same classifier runs at the Stop hook, at pre-commit and in CI on the same diff, so the copies fire together. The residual is (1−p)·P(no check fires | miss), and (1−p)(1−cmax) for nested checks. With three copies, p = 0.7 and c = 0.95, the old product gives 3.75×10−5; the true residual is 0.015 (a Monte Carlo run with seed 0 and 106 draws also gives 0.0150). That is a 400-fold understatement.
    6. +
    7. cj is not a property of the gate (Def. 5, §5.2, T3, A6). T3 guarantees exact detection of the proxy event, not of the miss. An agent that edits code and STATE.md but not docs/api.md passes the gate. If agents touch STATE at rate h, then c = 0.95(1−h), and at h = 0.9 the residual is 0.27, not 0.015. The gate blocks once per session, so c = 0 for every later task in that session.
    8. +
    9. Definition and proof errors. (a) Anchor identity #1 wrote Δ* as the least fixpoint of X ↦ X ∪ N(X), which is ; it is lfp(X ↦ Δ0 ∪ N(X)). (b) The impact oracle is thresholded, depth-10 reachability over the AST relation, a different relation from R: it approximates Δ* and is not Δ*. “This is why the prototype achieves perfect recall” is withdrawn; on nine real repositories its recall was 0.022. (c) T5 gives termination only; completeness relative to R holds by construction. (d) A1 subtracted artifacts from edges (E \ W); it now subtracts from the edges' source artifacts. (e) A3 makes I1 true by definition for couplings grep cannot see (the “retried 3 times” counterexample under A3). (f) T4 was close to vacuous, and A4's 150-line budget does not fit A5's 8 KB cap at 80 bytes a line (12,000 bytes); it is restated as loader fidelity, with that condition. (g) T6 was false because A7 had no catch-all arm; A7 now ends in the fallback forgekit's router uses. (h) Rice's theorem was used as if it bounded cj; it is a worst case over all programs, not a probability bound for a finite repository.
    10. +
    11. The faculty table (§2) disagreed with the whitepaper in all five rows. We keep the whitepaper's derivation: memory from P1 and P3, learning from P2 and P1, imagination from P1 and the token→token form of Eq. 1, self-correction from P2, impact-awareness from P3. It argues each row separately, and it matches what forgekit's bindings address in source/substrate.json: lessons that change no weights (P2), tests trusted over model claims (self-correction's same-weights blind spot, P2), and repository facts surfaced from outside the window (P3). The earlier table had memory P1; learning P2; imagination P1, P3; self-correction P3; impact-awareness P1, P3.
    12. +
    13. Eq. (1) and the amnesia equation disagreed. Eq. 1 read as a deterministic function, its gloss said the output was sampled, and the amnesia equation used argmax. There is now one statement: y ~ pθ(· | x), and greedy decoding, which approximately returns the argmax, is the temperature-0 special case.
    14. +
    15. Priority is conceded here too. The composition law is standard layer-of-protection algebra, and two concurrent preprints (Odds Law and Maestro Order, cited in the refutation paper's related work) derive a strictly more general Bayesian form of it weeks before this paper. research/README.md said priority was conceded “in the paper”, but only the refutation paper's LaTeX source did so, while this paper still said it “proves” the result. What remains ours is narrower: deriving the split from the frozen-map architecture, and testing it against real repositories.
    16. +
    17. The convergence was not independent (abstract, §1, §11, §13). Earlier versions called the three roads of §1 independent and the kit “an independent team”'s work. claude-e2e-kit is forgekit's precursor, and forgekit was built as a binding of this theory. Their agreement shows consistency, not independent evidence that the design is right.
    18. +
    19. Appendix A tally. 9 confirmed, 6 traceable and 0 unverifiable of 15 new sources, not “8 confirmed”. The founding Agent-as-a-Judge paper was added on the citations track's recommendation after the count was written.
    20. +
    21. Refutation statistics (§10). The reported counts recompute exactly. Some inferences drawn from them were too strong. +
        +
      • 801 vs 759. 801 files were labelled; the metrics are over 759 of them, because the pre-registered cap of 200 files per repository cut pytest from 242 to 200.
      • +
      • File-level intervals understate uncertainty. Every ground-truth pair is mirrored (all 20,144 pairs among the 801 labelled files have both ends labelled and appear from both sides), and files cluster in nine repositories. Resampling repositories instead of files (seed 1234, 20,000 resamples), the oracle's precision interval is [0.15, 0.91] (file-level: [0.27, 0.57]) and its recall interval [0.0005, 0.052] (file-level: [0.012, 0.033]). The refutation survives: oracle F1 [0.001, 0.093] against grep's [0.381, 0.539], and the paired difference in F1 (grep minus oracle) is [0.342, 0.517].
      • +
      • “The repaired oracle beats the baseline” is not established. On the three held-out repositories the point estimate favours it: F1 is higher by 0.044 at the canonical threshold 0.02 and by 0.057 at 0.10, and at 0.10, where per-repository counts exist, all three repositories agree in sign (+0.114 loguru, +0.131 requests, +0.033 pytest). But three out of three gives a one-sided sign-test p of 0.125, pytest supplies 71% of the held-out pairs, and the separately computed file-level F1 intervals overlap ([0.382, 0.446] and [0.332, 0.405]).
      • +
      • The split was not clean for structural choices. The eight numeric parameters were fit on the six tuning repositories only, but the choice of which relations to add (sibling, forward) came from the failure analysis pooled over all nine, held-out repositories included.
      • +
      • The labels come from one model. The gold labels and the “independent” second labelling pass were both produced by the same model (claude-sonnet-5) with differently worded prompts, on a 30-task subset; that model is also the correctness judge and the mid-tier executor. κ measures how robust the labels are to prompt wording, not whether they are valid.
      • +
      • The cost inversion is largely mechanical. 58 of 64 tasks fail at every tier, and always-premium is judged correct on only 3 of 64, so escalation pays for every tier on almost every task. Per output the judge accepted, the pipeline cost $1.06 against always-premium's $1.76 (about 40% lower), from 6 and 3 accepted outputs; neither figure is stable at those counts.
      • +
      • The ceiling does not show that 96.8% of misses are fixable. It counts pairs joined by some static path in the oracle's graph. Co-change is symmetric while impact is directional, and no control measured how many random pairs are connected in the same dense graph. It bounds what a static method could reach.
      • +
    22. +
    +

    Appendix A — Graded reference set (new sources)

    -

    The synthesis draws in a body of cognitive-architecture and process literature beyond the substrate paper's original 32 references. Each new source was independently verified this pass — modern arXiv sources by direct metadata fetch, classical works by primary-host search or established secondary knowledge — and graded: confirmed (record retrieved, attribution matches), traceable (the work clearly exists and is correctly attributed, but rests on established secondary knowledge rather than a single retrievable record), unverifiable (could not confirm). The tally: 8 confirmed, 6 traceable, 0 unverifiable.

    +

    The synthesis draws in a body of cognitive-architecture and process literature beyond the substrate paper's original 32 references. Each new source was independently verified this pass — modern arXiv sources by direct metadata fetch, classical works by primary-host search or established secondary knowledge — and graded: confirmed (record retrieved, attribution matches), traceable (the work clearly exists and is correctly attributed, but rests on established secondary knowledge rather than a single retrievable record), unverifiable (could not confirm). The tally: 9 confirmed, 6 traceable, 0 unverifiable (15 sources: 8 confirmed by the citations track plus the founding Agent-as-a-Judge paper added on its recommendation). Earlier versions said 8 confirmed. [corrected 2026-09-21]

    diff --git a/research/empirical-refutation/paper/main.tex b/research/empirical-refutation/paper/main.tex index 176ca85..f9e65c5 100644 --- a/research/empirical-refutation/paper/main.tex +++ b/research/empirical-refutation/paper/main.tex @@ -6,6 +6,9 @@ \pagestyle{plain} \usepackage{booktabs} \usepackage{graphicx} +% Passages changed by the 2026-09-21 corrections are marked with a dagger; see the +% Corrections section at the end. +\newcommand{\corr}{\textsuperscript{\dag}} \begin{document} @@ -34,13 +37,17 @@ pipeline's actual total spend, routing costs $20.2\%$ \emph{more} than always using the premium tier. We report three things beyond the refutation. First, a structural decomposition of all -$18{,}221$ false negatives showing that $96.8\%$ are \emph{not} a ground-truth ceiling -but an artifact of reverse-only graph traversal, together with a construction defect whose safe +$18{,}221$ false negatives showing that $96.8\%$ have a static path in the oracle's own graph +that its reverse-only traversal cannot take --- a bound on what static analysis could reach, not +evidence that those misses are fixable\corr{} --- together with a construction defect whose safe repair alone recovers an $11.0\times$ recall gap. Second, a repair of both defects evaluated under the same frozen protocol with a declared tuning/held-out repository split. Third, the observation that our central composition law is decades-old layer-of-protection algebra, and was independently and more generally derived weeks before our own write-up. We argue the transferable contribution is the protocol, not the mechanisms. +Corrections made on 2026-09-21 after an external review (listed at the end) tighten several +inferences --- the confidence intervals, the repaired oracle's comparison with grep, the labels, +and the ceiling --- and leave the refutation itself unchanged. \end{abstract} \begin{CCSXML} @@ -102,8 +109,8 @@ \section{Introduction} \item A \textbf{refutation} of both mechanisms' headline results on real data: oracle recall $1.000 \rightarrow 0.022$, gate F1 $1.00 \rightarrow 0.37$, and a cost saving that inverts to $-20.2\%$ once escalation retries are counted (\S\ref{sec:eval1}, \S\ref{sec:eval2}). -\item A \textbf{structural failure-mode taxonomy} attributing $96.8\%$ of missed impact to -a traversal-direction blind spot rather than to a limit of the ground truth, and isolating a +\item A \textbf{structural failure-mode taxonomy} showing that $96.8\%$ of missed impact has a +static path the traversal cannot take, which bounds what a static method could reach\corr{}, and isolating a graph-construction defect whose safe repair recovers an $11.0\times$ recall gap (\S\ref{sec:failure}, \S\ref{sec:repair}). \item A \textbf{repair and re-evaluation} of both defects under the same frozen protocol, @@ -147,7 +154,8 @@ \section{Protocol}\label{sec:protocol} Python files appearing in the same non-merge commit as $f$, aggregated over history. Merges, mega-commits above the pre-declared size threshold (formatting sweeps, vendored dependency bumps, mass renames), and excluded paths were dropped by the frozen filters. This yields -801 labelled files, of which 759 met the evaluation's inclusion condition. +801 labelled files, of which 759 were evaluated: the pre-registered cap of 200 files per +repository cut \texttt{pytest} from 242 to 200\corr{}. \paragraph{Ground truth is a proxy, and its ceiling is measurable.} Co-change is not semantic impact. Files co-change for reasons no static analysis can see (release chores, @@ -171,7 +179,8 @@ \section{Evaluation I: Impact Closure}\label{sec:eval1} \begin{table}[t] \caption{Pooled, micro-averaged over 759 files in 9 repositories, with 95\% bootstrap CIs ($B=2000$, file-resampled). The oracle is shown at $t=0.02$, the most favourable point of -the pre-registered grid for its recall.} +the pre-registered grid for its recall. File-resampled intervals are too narrow for this +design; repository-cluster intervals are given in the Corrections\protect\corr{}.} \label{tab:eval1} \small \begin{tabular}{lccc} @@ -189,7 +198,8 @@ \section{Evaluation I: Impact Closure}\label{sec:eval1} \end{table} Table~\ref{tab:eval1} states the result. The oracle's pooled recall is 0.0220, 95\% CI -[0.012, 0.033]. The claim under test was $1.000$. The oracle reaches 2.27\% of its own +[0.012, 0.033] when files are resampled, and [0.0005, 0.052] when the nine repositories are +resampled instead\corr{}. The claim under test was $1.000$. The oracle reaches 2.27\% of its own static ceiling. The per-file distribution shows this is not a uniform shortfall but close to a step @@ -225,9 +235,9 @@ \section{Failure Modes}\label{sec:failure} \toprule Category & Share & Interpretation \\ \midrule -Sibling (common dependency) & 94.68\% & Fixable: both files depend on a shared module; +Sibling (common dependency) & 94.68\% & Static path exists\corr{}: both files depend on a shared module; reachable only by a mixed-direction path \\ -Forward-only dependency & 2.13\% & Fixable: excluded by design \\ +Forward-only dependency & 2.13\% & Static path exists\corr{}: excluded by design \\ No static path at all & 3.19\% & True ceiling: ground-truth limit \\ \bottomrule \end{tabular} @@ -241,6 +251,12 @@ \section{Failure Modes}\label{sec:failure} from the other. The mechanism was not defeated by the messiness of real code; it was defeated by answering a narrower question than the one that matters. +Two cautions apply to this decomposition\corr{}. Co-change is symmetric while impact is +directional, so a pair with a static path in either direction is not thereby a pair whose impact a +static method should predict. And we ran no control measuring how many \emph{random} file pairs +are connected in the same dense graph, so the $96.8\%$ bounds what static analysis could reach; it +does not show that those misses are fixable. + \paragraph{A construction defect.} Separately, and reported as a labelled diagnostic rather than substituted into the numbers above, we found a bug. The world-model's module-qualified-name derivation collides with absolute imports in \texttt{src/}-layout packages, creating @@ -258,9 +274,12 @@ \section{Evaluation II: Routing and Gating}\label{sec:eval2} with independent gold labels for cost tier and for whether the request is under-specified enough that an agent should halt. \textbf{No threshold, weight, or pattern was modified.} -\paragraph{Label noise first.} Agreement between two independent labelling passes was +\paragraph{Label noise first.} Agreement between two labelling passes was $\kappa = 0.952$ (quadratic-weighted) for tier and $\kappa = 0.516$ for the halt -decision. Tier labels are near-perfect; halt labels are moderate. We report this before the +decision. Tier labels are near-perfect; halt labels are moderate. Both passes were produced by +the same model (\texttt{claude-sonnet-5}) with differently worded prompts, on a 30-task subset, +and that model is also the correctness judge and the mid-tier executor, so $\kappa$ measures how +robust the labels are to prompt wording, not whether they are valid\corr{}. We report this before the accuracy numbers because it bounds how much of the degradation below can be attributed to label ambiguity rather than to the mechanism --- and for the halt decision, some of it can. @@ -320,11 +339,21 @@ \section{Evaluation II: Routing and Gating}\label{sec:eval2} complexity router is a net cost \emph{increase} once its retries are counted, and the single headline number the demonstration produced ($62.1\%$) turns out to be the most favourable of four available cells. +The inversion is also largely mechanical\corr{}. On 58 of the 64 tasks no tier produced an output +the judge accepted, and always-premium was accepted on only 3 of 64, so on almost every task the +pipeline paid for every tier it tried. Measured per accepted output, the pipeline cost \$1.06 +against always-premium's \$1.76, about 40\% less --- but from 6 and 3 accepted outputs, so neither +figure is stable, and we report it beside the $-20.2\%$ rather than instead of it. + \paragraph{The gate's confidence score is non-monotonic.} Binned calibration is not monotone in the score: decision accuracy is $0.93$ in the middle bin ($n{=}28$) and $0.94$ in the highest -($n{=}16$), but dips to $0.50$ in the second-highest bin ($n{=}4$). We flag this as a caution -rather than a finding: with four tasks in the affected bin the dip is well within sampling noise, -and the top bin is in fact well calibrated. What it does establish is that the score is not +($n{=}16$), but dips to $0.50$ in the second-highest bin ($n{=}4$). The bins were meant to be +equal-frequency, but scores tie at 0.63, 0.90 and 1.00, so they hold 27, 5, 28, 4 and 16 tasks; +the expected calibration error is 0.103 with half-open $(lo, hi]$ bins and 0.078 with $[lo, hi)$ +bins. We earlier called the dip ``well within sampling noise''. It is not, at the nominal level: if +the bin's accuracy matched the middle bin's $0.93$, two or fewer correct of four has probability +$0.028$\corr{}. With four tasks and no correction for the bins we looked at, it is weak evidence +either way. What it does establish is that the score is not reliably ordered across its range, so choosing an operating point from it requires more evidence than this sample provides. @@ -387,8 +416,9 @@ \section{Repair and Re-Evaluation}\label{sec:repair} \paragraph{Result.} We headline row (d) at the canonical $t{=}0.02$ used for the as-shipped baseline, so the comparison is like-for-like. On repositories never seen during parameter selection the repaired oracle reaches -$\mathrm{F1}=0.416$ (95\% CI $[0.382, 0.446]$) against grep's 0.371 (CI $[0.332, 0.405]$). This reverses the -as-shipped ordering, in which grep won by roughly an order of magnitude. Recall rises from 0.022 to 0.653, +$\mathrm{F1}=0.416$ (95\% CI $[0.382, 0.446]$) against grep's 0.371 (CI $[0.332, 0.405]$). The point +estimate reverses the as-shipped ordering, in which grep won by roughly an order of magnitude; whether +the repaired oracle actually beats grep is not established (below)\corr{}. Recall rises from 0.022 to 0.653, reaching $66.8\%$ of the held-out repositories' own static ceiling ($0.977$), and the share of files with exactly zero recall falls from $91.0\%$ to $19.3\%$. Row (d$'$) reports the same configuration at $t{=}0.10$, the F1-peak of the tuning grid; it scores marginally higher, and we decline to headline it @@ -397,9 +427,15 @@ \section{Repair and Re-Evaluation}\label{sec:repair} both fixes and the stdlib-collision safety case. Both fixes are necessary: neither alone crosses the baseline. -\paragraph{The win is narrow, and precision generalised worse than recall.} The two confidence -intervals are close, and the honest reading is a real but modest advantage, not a decisive one. More -instructive is the asymmetry: recall transferred almost perfectly from tuning to held-out repositories +\paragraph{The advantage is not established, and precision generalised worse than recall.} We +earlier read the comparison as a real but modest win\corr{}. The evidence does not support that. +The two file-level intervals overlap. The paired difference in F1 is $+0.044$ at $t{=}0.02$, and +$+0.057$ at $t{=}0.10$, where per-repository counts exist; there all three held-out repositories +favour the oracle ($+0.114$ \texttt{loguru}, $+0.131$ \texttt{requests}, $+0.033$ \texttt{pytest}), +but three out of three gives a one-sided sign-test $p$ of $0.125$, and \texttt{pytest} supplies +71\% of the held-out pairs. The split was also clean only for the eight numeric parameters: the +choice of which relations to add came from the failure analysis of \S\ref{sec:failure}, pooled +over all nine repositories, held-out ones included. More instructive is the asymmetry: recall transferred almost perfectly from tuning to held-out repositories ($-0.021$), while precision fell by $-0.141$. Some overfitting to the tuning set occurred during parameter selection --- visible \emph{only} because the split was declared in advance. Had we tuned on all nine repositories we would have reported the better number and never known the gap existed. @@ -439,7 +475,8 @@ \section{Threats to Validity}\label{sec:threats} self-administered within one continuous working session, with no external timestamping authority: a reader must take the ordering on trust, evidenced only by the artifact store's own version timestamps and the append-only amendment trail. We consider this the central weakness of a self-evaluation and state it plainly -rather than resting on the protocol's authority. The held-out task labels come from a single labelling pair; halt-decision +rather than resting on the protocol's authority. The held-out task labels come from a single model, +queried twice with differently worded prompts\corr{}; halt-decision agreement is only moderate ($\kappa = 0.52$), so part of the gate's measured degradation is label ambiguity rather than mechanism failure. Tier agreement is near-perfect ($\kappa = 0.95$), so the routing result is on firmer ground. No held-out task admitted execution-based @@ -452,6 +489,9 @@ \section{Threats to Validity}\label{sec:threats} prompts a developer actually gives an agent. \paragraph{Conclusion.} All intervals are 95\% file- or task-resampled bootstrap intervals. +Files are not independent units here --- every ground-truth pair is counted from both of its +files, and files cluster in nine repositories --- so the file-resampled intervals are too narrow; +repository-cluster intervals are in the Corrections\corr{}. We report them for every headline number and make no claim that survives only at a point estimate. We apply no multiple-comparison correction and claim no significance test; the comparisons are descriptive. @@ -466,8 +506,10 @@ \section{Related Work}\label{sec:related} back to von Neumann and to N-version programming~\cite{avizienis1985}. The literature also supplies the caveat any layered argument must confront: Knight and Leveson showed experimentally that independently written versions do \emph{not} fail independently~\cite{knight1986}, so an -independence assumption between layers must be defended rather than asserted. We claim no novelty -for the arithmetic. +independence assumption between layers must be defended rather than asserted. Our own formal +synthesis asserted it: it multiplied the catch probabilities of one classifier run at three +lifecycle points on the same diff, which understates the residual 400-fold in its own worked +example\corr{}. We claim no novelty for the arithmetic. \paragraph{And it was independently derived, more generally, before ours.} More consequentially, two concurrent preprints derive a strictly more general Bayesian form of the same law weeks before @@ -525,8 +567,9 @@ \section{Discussion}\label{sec:discussion} where an operating point would be chosen. The failure is qualitative, not a matter of degree. \textbf{Report the ceiling, not just the score.} Decomposing false negatives structurally was what -turned an uninterpretable $2.2\%$ into an actionable finding: $96.8\%$ fixable, $3.2\%$ a genuine -proxy limit. Without the ceiling, the number is merely discouraging; with it, it is a specification +turned an uninterpretable $2.2\%$ into an actionable finding: $96.8\%$ with a static path the +traversal could not take, $3.2\%$ with none. The first figure bounds what a repair could reach; it +does not show those misses are fixable\corr{}. Without the ceiling, the number is merely discouraging; with it, it is a specification for the repair. \section{Conclusion}\label{sec:conclusion} @@ -539,9 +582,52 @@ \section{Conclusion}\label{sec:conclusion} and re-evaluated under the same frozen protocol with a declared held-out split. We think the protocol is the transferable contribution. The mechanisms may yet be made to work --- -$96.8\%$ of the missed impact is reachable in a graph the tool already builds --- but the evidence +$96.8\%$ of the missed impact has a static path in a graph the tool already builds\corr{} --- but the evidence standard that told us they already worked was the thing most in need of repair. +\section*{Corrections (2026-09-21)} + +An external deep review of the repository that hosts this paper (2026-09-21) recomputed its +statistics from the replication package. Every reported count reproduced exactly. Some inferences +drawn from them were too strong. The passages marked \dag{} above were changed; this list gives +each change. All figures were recomputed with \texttt{research/recompute\_corrections.py} +(standard-library Python, fixed seeds). + +\begin{enumerate} +\item \textbf{Confidence intervals.} Every ground-truth pair is mirrored --- all 20,144 pairs among +the 801 labelled files have both ends labelled and are counted from both sides --- and files +cluster in nine repositories, so file-level resampling understates uncertainty. Resampling +repositories (seed 1234, 20,000 resamples), the oracle's pooled precision interval is +$[0.15, 0.91]$ (file-level $[0.27, 0.57]$) and its recall interval $[0.0005, 0.052]$ (file-level +$[0.012, 0.033]$). The refutation survives: oracle F1 $[0.001, 0.093]$ against grep's +$[0.381, 0.539]$, and the paired difference (grep minus oracle) is $[0.342, 0.517]$. With nine +clusters the interval ends move in the third decimal from seed to seed. +\item \textbf{801 and 759.} 801 files were labelled; 759 were evaluated, because the pre-registered +cap of 200 files per repository cut \texttt{pytest} from 242 to 200. The text had said 759 ``met +the evaluation's inclusion condition''. +\item \textbf{The repaired oracle does not demonstrably beat grep.} The original text read the +held-out comparison as ``a real but modest advantage''. See \S\ref{sec:repair} for the paired +differences, the sign test ($p = 0.125$), the 71\% \texttt{pytest} share, and the structural +choice that was made on all nine repositories. +\item \textbf{Labels.} The gold labels and the ``independent'' second pass were the same model with +two prompts, on $n = 30$; the same model is the judge and the mid-tier executor. $\kappa$ measures +prompt robustness, not label validity. +\item \textbf{Cost per accepted output.} The paper reported the $-20.2\%$ inversion without the cost +per output the judge accepted: \$1.06 for the pipeline against \$1.76 for always-premium, from 6 +and 3 accepted outputs of 64. 58 of 64 tasks failed at every tier. +\item \textbf{The ceiling.} ``$96.8\%$ fixable'' treated symmetric co-change as directional impact +and had no random-pair control. It now reads as a bound on what static analysis could reach. +\item \textbf{Calibration.} The bins held 27, 5, 28, 4 and 16 tasks, not equal numbers; ECE is +0.103 or 0.078 depending on how ties at the bin edges are assigned; and the dip in the +second-highest bin is not ``well within sampling noise'' ($p = 0.028$ against the middle bin's +accuracy). +\item \textbf{The composition law.} Related work now notes that our own synthesis asserted the +independence between layers that Knight and Leveson warn against. +\end{enumerate} + +The compiled PDF and the copy of this source inside \texttt{replication\_package.tar.gz} predate +these corrections. + \section*{Artifact availability} All materials are available: the pre-registration with its append-only amendment trail, the corpus manifest From 32623dfc7c11badd93f7dc7efde44fce96364d4e Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:38:17 +0200 Subject: [PATCH 16/72] docs(research): mark the whitepaper's refuted claims and fix its errors The cognitive-substrate whitepaper was written before its prototypes were tested on real data. Following the external review (2026-09-21), its HTML edition (and the identical docs/ copy) now carries a status banner, inline markers and a dated Corrections section for: - the impact oracle's "never misses an affected file" / "guaranteed recall" (recall 0.022 on 759 files in nine repositories), including the "sound over-approximation" wording in section 7.1; - the 62.1% routing saving (tuning set only; -20.2% on 80 held-out tasks, $1.06 vs $1.76 per judged-correct output); - M1's worst-case cost, which is the sum over every tier escalated through, not a cheap plus a premium attempt; - Eq. 1 (deterministic) vs its gloss (sampled) vs M2 (argmax), now one statement: y ~ p_theta(.|x), greedy decoding as the argmax case; - the misquoted Faros figure: "31.3% more PRs merged with no review". Its faculty table is kept as the canonical one. The executive summary's banner no longer calls the repair a win; docs/cognitive-substrate/ deliverable-package.md, which had no banner at all, gets one; the Python impact oracle's README no longer claims perfect recall. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 6 ++ docs/cognitive-substrate/README.md | 9 ++- .../cognitive_substrate_whitepaper.html | 58 +++++++++++++------ .../deliverable-package.md | 24 +++++++- .../cognitive-substrate/EXECUTIVE_SUMMARY.md | 19 +++--- .../cognitive_substrate_whitepaper.html | 58 +++++++++++++------ .../python-prototypes/impact_oracle/README.md | 12 +++- 7 files changed, 133 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 212861e..ad5ecdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,12 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). pass are one model, add cost per judged-correct output ($1.06 vs $1.76, from 6 and 3 of 64), qualify the "96.8% fixable" ceiling, correct the calibration paragraph (bins 27/5/28/4/16, ECE 0.103 or 0.078, p = 0.028), and explain 801 labelled vs 759 evaluated files. +- **The whitepaper marks its refuted prototype claims in place.** A status banner, inline + markers and a Corrections section cover the impact oracle's "never misses an affected file" + (recall 0.022 on real repositories), the 62.1% routing saving (−20.2% held out), M1's + worst-case cost (the sum over every tier, not cheap + premium), Eq. 1 vs M2, and a misquoted + Faros figure ("31.3% _more_ PRs merged with no review"). The docs copy and + `docs/cognitive-substrate/deliverable-package.md` (which had no refutation banner) match. - **`research/recompute_corrections.py` re-derives every corrected number.** Standard-library Python (it includes a minimal Parquet reader), fixed seeds printed beside each result, run against the extracted replication package. PDFs built from the corrected sources could not diff --git a/docs/cognitive-substrate/README.md b/docs/cognitive-substrate/README.md index 62e94ae..a2c6e1c 100644 --- a/docs/cognitive-substrate/README.md +++ b/docs/cognitive-substrate/README.md @@ -241,9 +241,12 @@ needs a hook surface. Tests and human corrections always win. The full, canonica ## Learn more -- **White paper** — the full argument: [PDF](./cognitive_substrate_whitepaper.pdf) · - [HTML](./cognitive_substrate_whitepaper.html) -- **[Package overview](./deliverable-package.md)** — headline results and prototypes +- **White paper** — the full argument: [HTML](./cognitive_substrate_whitepaper.html) (with the + 2026-09-21 corrections) · [PDF](./cognitive_substrate_whitepaper.pdf) (predates them). Its two + prototype results were refuted on real data — see + [research/empirical-refutation/](../../research/empirical-refutation/). +- **[Package overview](./deliverable-package.md)** — headline results (refuted; see its banner) + and prototypes - **[Evidence map](./evidence_map.md)** — every load-bearing statistic re-graded against primary sources (5 confirmed, 5 vendor-reported, 2 dropped) - **[Ecosystem map](./ecosystem_map.md)** — each capability vs. the real 2026 tool stack diff --git a/docs/cognitive-substrate/cognitive_substrate_whitepaper.html b/docs/cognitive-substrate/cognitive_substrate_whitepaper.html index 940e8d8..95460e9 100644 --- a/docs/cognitive-substrate/cognitive_substrate_whitepaper.html +++ b/docs/cognitive-substrate/cognitive_substrate_whitepaper.html @@ -80,6 +80,7 @@ ol.refs li{margin:.45em 0;} ol.refs li b{color:var(--ink); font-weight:600;} hr.sec{border:none; border-top:1px solid var(--rule); margin:2.4em 0;} .foot{font-size:.82rem; color:var(--faint); margin-top:3em; border-top:1px solid var(--rule); padding-top:16px;} + .corr{font-size:.8em; color:var(--alarm); font-style:normal; white-space:nowrap;} @media print{ body{background:#fff;} .page{box-shadow:none; max-width:none; padding:0;} .toc{columns:2;} } @media (max-width:720px){ .page{padding:32px 22px;} .toc{columns:1;} h1{font-size:1.6rem;} } @@ -94,9 +95,14 @@

    A Cognitive Substrate for Coding Agents

    A first‑principles design study, grounded in field evidence. The five faculties are argued from the shape of a frozen model and grounded in 32 primary sources (memory‑augmentation, continual‑learning, self‑correction, world‑model, and program‑analysis literatures). This edition adds an evidence layer — twelve load‑bearing pain‑point statistics independently re‑grounded from primary sources and graded confirmed / vendor‑reported / unverifiablesix mechanisms (routing, assumption gate, decomposition, goal‑anchoring, anti‑over‑engineering, inline verification), a build‑opportunity map against the real 2026 Claude‑Code stack, and a second prototype (a complexity‑aware router + assumption gate, evaluated live on real models). An epistemological and ethical lens drawn from Qur'anic sources runs throughout, used as design framing, never as technical authority.  |  Companion artifacts: evidence map, ecosystem map, gap‑map, reference list, Qur'anic‑lens table, and two runnable packages (impact‑oracle, router‑gate). +
    +
    Status: both prototype results in this edition were refuted · corrections 2026-09-21
    +

    This edition was written before any real-repository evaluation existed. A later pre-registered evaluation (research/empirical-refutation/) overturned both prototype claims: the impact oracle’s recall was 0.022, not 1.00, on 759 files in nine open-source repositories, and on 80 held-out tasks the router’s total spend was 20.2% higher than always using the premium tier, not 62.1% lower. An external review (2026-09-21) also found a misquoted statistic, a wrong worst-case cost, and an inconsistency between Eq. (1) and M2. Corrections are made in place, marked [corrected 2026-09-21] or [refuted], and listed with the original wording in Corrections. The theory sections remain the programme’s working framework; the prototype numbers do not. The PDF edition predates these corrections.

    +
    +

    Abstract

    -

    A large language model at inference time is, mathematically, a fixed function y = fθ(x) with frozen parameters θ and a bounded input window. From this single fact, five apparent “cognitive” deficits of a coding agent follow as structural consequences, not incidental weaknesses: it cannot remember across sessions, cannot learn from outcomes, cannot imagine the consequences of an action before taking it, cannot reliably correct itself, and does not know what already exists in a codebase or what an edit will affect. We show that neither better prompting nor additional tools (skills, MCP servers) remove these deficits, because they leave fθ and the open‑loop pipeline intact. We then specify a cognitive substrate: an external architecture that keeps the LLM frozen but re‑wraps its input→process→output loop into a closed, stateful cycle over persistent stores — an episodic/semantic memory, an online‑updatable learning layer, a consequence simulator, a metacognitive verification gate, and a persistent structural model of the codebase — all under an explicit stewardship boundary. For each faculty we identify precisely what the existing literature solves and what residual gap remains for a coding agent. To turn the weakest‑evidenced claim into something testable, we build and evaluate the impact‑awareness faculty as a runnable prototype: a Codebase World‑Model that parses a repository into a persistent dependency graph, and an Impact Oracle that predicts the blast radius of a proposed edit. Against mutation‑derived ground truth, the oracle is the only method that never misses an affected file (recall = 1.00 across five tested edits), where a text‑search baseline misses transitive dependents and an edited‑file‑only baseline misses 47% of impact. Throughout, a Qur'anic epistemic lens supplies the design's vocabulary of obligation — know what exists before acting (2:31–32), verify before you act (49:6), pursue not that of which you have no knowledge (17:36), and hold what you can damage as a trust (33:72).

    +

    A large language model at inference time is, mathematically, a fixed function y = fθ(x) with frozen parameters θ and a bounded input window. From this single fact, five apparent “cognitive” deficits of a coding agent follow as structural consequences, not incidental weaknesses: it cannot remember across sessions, cannot learn from outcomes, cannot imagine the consequences of an action before taking it, cannot reliably correct itself, and does not know what already exists in a codebase or what an edit will affect. We show that neither better prompting nor additional tools (skills, MCP servers) remove these deficits, because they leave fθ and the open‑loop pipeline intact. We then specify a cognitive substrate: an external architecture that keeps the LLM frozen but re‑wraps its input→process→output loop into a closed, stateful cycle over persistent stores — an episodic/semantic memory, an online‑updatable learning layer, a consequence simulator, a metacognitive verification gate, and a persistent structural model of the codebase — all under an explicit stewardship boundary. For each faculty we identify precisely what the existing literature solves and what residual gap remains for a coding agent. To turn the weakest‑evidenced claim into something testable, we build and evaluate the impact‑awareness faculty as a runnable prototype: a Codebase World‑Model that parses a repository into a persistent dependency graph, and an Impact Oracle that predicts the blast radius of a proposed edit. Against mutation‑derived ground truth on a ten‑file package we wrote, the oracle was the only method that missed no affected file (recall = 1.00 across five tested edits), where a text‑search baseline missed transitive dependents and an edited‑file‑only baseline missed 47% of impact. On nine real repositories its recall was 0.022, and text search beat it by an order of magnitude on F1. [refuted — see Corrections] Throughout, a Qur'anic epistemic lens supplies the design's vocabulary of obligation — know what exists before acting (2:31–32), verify before you act (49:6), pursue not that of which you have no knowledge (17:36), and hold what you can damage as a trust (33:72).

    @@ -132,6 +139,8 @@

    2 The root cause, formally

    y = fθ(x),  x ∈ 𝕏≤W,  θ fixed for all inferences. +

    Read fθ as the sampling map: it defines a distribution pθ(· | x) and returns a draw y ~ pθ(· | x). Greedy decoding, which approximately returns arg maxy pθ(y | x), is the temperature‑0 special case, and it is the case M2 (§6) reasons about. [corrected 2026-09-21]

    +

    Three properties of Equation (1) are the source of everything that follows. They are not bugs; they are what a deployed transformer is.

    (P1) Statelessness. The function has no argument that carries information from one call to the next except x itself. Whatever the agent “knew” during turn t is gone at turn t+1 unless it was serialized back into the token string. There is no hidden variable st that persists; formally, the map is memoryless: yt depends on xt alone, not on the history (x1,…,xt−1).

    @@ -268,8 +277,8 @@

    4.2 What is real but vendor‑reported — and what did not sur remedy for the problem the figure describes; we use them as corroboration, not proof, and we say so. Veracode reports 45 % of AI‑generated samples introduce an OWASP‑Top‑10 vulnerability [C3 vendor]; Faros' telemetry across 22,000 developers -reports median PR‑review time up 441.5 % and 31.3 % of PRs merged with no -review [C7 vendor]; Sonar reports the sharpest single number in the +reports median PR‑review time up 441.5 % and 31.3 % more PRs merged with no +review [corrected 2026-09-21] [C7 vendor]; Sonar reports the sharpest single number in the whole discourse — 96 % of developers do not fully trust AI code, yet only 48 % always verify it, a 48‑point “verification gap” [C9 vendor]. These are consistent with the confirmed evidence and with each @@ -376,8 +385,7 @@

    5.1 M1 — Complexity‑aware routing

    route(x) = min{ Ti : capable(Ti, c(x)) }, then escalate Ti → Ti+1 only if an external check on the output fails. -

    The escalation clause is what makes this safe: the worst case is a cheap attempt plus a premium attempt, -the common case is cheap alone, and the decision to spend more is driven by a verified failure, +

    The escalation clause bounds the damage but does not make routing free. Escalating one tier at a time, the worst case pays for every tier from the routed one up, κi + … + κk, which is more than always using the top tier. Earlier versions gave the worst case as a cheap attempt plus a premium attempt. [corrected 2026-09-21] The saving therefore depends on how often the cheap attempt verifies: on 80 held-out tasks where almost no attempt at any tier verified, routing cost 20.2% more than always‑premium (§9). The decision to spend more is driven by a verified failure, never by the model's self‑assessment. Ecosystem status: largely solved. Model tiering and gateways (LiteLLM, OpenRouter, per‑agent model: fields) already route by cost. What they do not give is a transparent, per‑task, auditable complexity judgment the user can @@ -387,9 +395,9 @@

    5.1 M1 — Complexity‑aware routing

    5.2 M2 — The assumption / uncertainty gate root failure

    The deficit. This is the failure the project named as central: “the biggest problem is -Assumption. If it doesn't have enough context it will assume many things.” A model that computes -arg maxy P(y \mid x) always returns some most‑likely -continuation, even when x under‑determines the task — there is no term in +Assumption. If it doesn't have enough context it will assume many things.” A model that samples from +P(y \mid x), or under greedy decoding approximately computes its arg max (Eq. 1), always returns some high‑probability +continuation [corrected 2026-09-21], even when x under‑determines the task — there is no term in the objective that fires when the input is insufficient. The model cannot distinguish “I know this” from “this is merely the least‑improbable guess.” This is precisely what the METR trial measured from the outside: confident forecasts, slower reality @@ -512,7 +520,7 @@

    7.1 The five faculties, formally

    (4) ĉ​  = g(a, C)  →  predicted { broken call sites, type errors, failing tests }
    -

    Unlike World Models or MuZero22,23, g here need not be learned from pixels or reward: for code it can be partly exact — static analysis and the dependency graph give sound over‑approximations of “what breaks” — and partly cheap simulation (run the affected tests in a sandbox). This is the faculty the literature has not built for the symbolic domain (§3); the impact oracle of §8 is a first, exact‑analysis instance of g.

    +

    Unlike World Models or MuZero22,23, g here need not be learned from pixels or reward: for code it can be partly static — static analysis and the dependency graph aim at an over‑approximation of “what breaks”, though our prototype’s graph turned out to under‑approximate badly on real repositories (recall 0.022) [corrected 2026-09-21] — and partly cheap simulation (run the affected tests in a sandbox). This is the faculty the literature has not built for the symbolic domain (§3); the impact oracle of §8 is a first static‑analysis instance of g.

    Metacognition — a structured self‑consistency pass. Following the honest lesson of §3—§5 (self‑critique on the same weights is unreliable21; a real external check is required), the controller does not ask the model “are you sure?” It runs a tadabbur trace‑forward: it checks the proposed action against (i) the model's own stated plan, (ii) the type system, and (iii) the existing test suite — three signals at least two of which are external to fθ. Inconsistency halts and backtracks rather than proceeds.

    @@ -541,7 +549,7 @@

    7.2 Learning without touching θ

    8 Prototype: a Codebase World‑Model & Impact Oracle

    -

    Design papers are cheap; the discipline is to build the one faculty where a claim can be tested in a single sitting. That faculty is impact‑awareness (§3: mature tools, missing bridge). We implemented two of the substrate's components as a runnable Python package: the Codebase World‑Model (the “perceive” store, 2:31–32) and the Impact Oracle (an exact instance of the “imagine→verify” simulator g, 17:36). The full source, demo, and tests are the companion artifact impact_oracle_src.zip.

    +

    Design papers are cheap; the discipline is to build the one faculty where a claim can be tested in a single sitting. That faculty is impact‑awareness (§3: mature tools, missing bridge). We implemented two of the substrate's components as a runnable Python package: the Codebase World‑Model (the “perceive” store, 2:31–32) and the Impact Oracle (a static‑analysis instance of the “imagine→verify” simulator g, 17:36). The full source, demo, and tests are the companion artifact impact_oracle_src.zip.

    8.1 What it does

    World‑model. A parser walks a repository with Python's ast module and builds a directed graph whose nodes are symbols — modules, classes, functions, methods, module‑level names — with stable qualified IDs (pkg.mod.Class.method) and metadata (file, line, kind, signature). Edges capture five structural relations: imports, calls, inherits, references, contains. The graph is persisted to disk keyed by a per‑file content hash, so an unchanged file is never re‑parsed — the “persistent memory of what already exists,” incremental by construction.

    @@ -569,17 +577,17 @@

    8.2 How we evaluated it — against ground truth, not self‑gr
    Two panels. Left: grouped bars of precision, recall, F1 for Graph Oracle, Grep, and Edited-file-only. Oracle has recall 1.00; edited-file-only has precision 1.00 but recall 0.53. Right: precision, recall, F1 vs confidence threshold, with best F1 0.79 at threshold 0.4. -
    Figure 5. Honest evaluation against mutation‑derived ground truth. (left) The oracle is the only method with perfect recall (1.00) — it never misses an affected file — at a precision cost (0.63) versus grep (0.73). Edited‑file‑only achieves perfect precision but catastrophic recall (0.53), missing nearly half of all real impact. (right) Precision and recall trade off with the confidence threshold; best F1 = 0.79 at threshold 0.4. The chart title states the result plainly, including where grep leads on F1 — we do not oversell.
    +
    Figure 5. Honest evaluation against mutation‑derived ground truth. (left) On this demo package the oracle is the only method with perfect recall (1.00) — it missed no affected file in these five edits — at a precision cost (0.63) versus grep (0.73). On real repositories its recall was 0.022. [refuted — see Corrections] Edited‑file‑only achieves perfect precision but catastrophic recall (0.53), missing nearly half of all real impact. (right) Precision and recall trade off with the confidence threshold; best F1 = 0.79 at threshold 0.4. The chart title states the result plainly, including where grep leads on F1 — we do not oversell.

    8.3 Reading the result honestly

    -

    The oracle does not dominate every metric, and the paper is stronger for saying so. On F1 at the default threshold, the grep baseline (0.79) slightly edges the oracle (0.75). What the oracle uniquely provides is guaranteed recall: across all five mutations it never once missed a file that actually broke, whereas grep missed transitive dependents (a caller that never mentions the symbol by name) and edited‑file‑only missed 47% of real impact. For the question that matters when you are about to change production code — “show me everything that could break” — a false negative (a silent breakage) is far more costly than a false positive (an extra file to glance at), and only the structural oracle drives false negatives to zero. Its precision, moreover, is tunable: Figure 5(right) shows the operating point can be moved along the curve, where neither text baseline offers such a control.

    +

    The oracle does not dominate every metric, and the paper is stronger for saying so. On F1 at the default threshold, the grep baseline (0.79) slightly edges the oracle (0.75). What the oracle appeared to provide is guaranteed recall [refuted — see Corrections]: across all five mutations it never once missed a file that actually broke, whereas grep missed transitive dependents (a caller that never mentions the symbol by name) and edited‑file‑only missed 47% of real impact. For the question that matters when you are about to change production code — “show me everything that could break” — a false negative (a silent breakage) is far more costly than a false positive (an extra file to glance at), and on this package only the structural oracle drove false negatives to zero. It did not on real code: on 759 files in nine open‑source repositories, 91% of files received zero recall, because the graph walked only reverse edges and mis‑built one package layout. Its precision, moreover, is tunable: Figure 5(right) shows the operating point can be moved along the curve, where neither text baseline offers such a control.

    It also scales. Beyond the toy package, the same parser handled real standard‑library code: it built a 303‑node graph of the json package in 18 ms and a 1,903‑node graph of the http package in 91 ms, answering impact queries in under a millisecond. The approach is not a toy that only works on toys; it is a mature technique that simply had not been placed in the agent's loop.

    What this proves, and what it does not
    -

    Proves: the impact‑awareness faculty of the substrate is buildable today, runs on a laptop, persists its world‑model, and beats the two strategies a context‑bounded LLM agent actually uses — on the metric (recall) that governs safety — against independent ground truth. Does not prove: that the other four faculties are equally easy (they are not — §3), that this Python‑ast analysis handles dynamic dispatch, reflection, or cross‑language repos (it does not yet — §12), or that the whole substrate has been built end‑to‑end (it has not). This is one faculty, demonstrated; the rest is specified and argued.

    +

    Proves: the impact‑awareness faculty of the substrate is buildable today, runs on a laptop, persists its world‑model, and, on this package, beat the two strategies a context‑bounded LLM agent actually uses on the metric (recall) that governs safety. It did not transfer: on real repositories a grep baseline beat it by an order of magnitude. [refuted — see Corrections] Does not prove: that the other four faculties are equally easy (they are not — §3), that this Python‑ast analysis handles dynamic dispatch, reflection, or cross‑language repos (it does not yet — §12), or that the whole substrate has been built end‑to‑end (it has not). This is one faculty, demonstrated; the rest is specified and argued.

    9 Prototype II: a complexity‑aware router and an assumption gate

    @@ -625,7 +633,7 @@

    9.2 How we evaluated it — live, on real models

    Router and gate live evaluation
    Figure 7. Live evaluation. (a) Real cost for the 21 executed tasks: -complexity‑routing spends 62 % less than always‑premium on the same measured tokens, +complexity‑routing spends 62 % less than always‑premium on the same measured tokens (on these 30 tuning tasks; on 80 held‑out tasks it spent 20.2% more [refuted — see Corrections]), with most tasks served by the cheap and mid tiers. (b) All 30 decisions: every well‑specified task routed to its gold tier (green/orange/red by tier), every under‑specified task halted by the gate (purple ×). No errors on either axis.

    @@ -633,7 +641,7 @@

    9.2 How we evaluated it — live, on real models

    30/30
    gate accuracy
    (P = R = 1.00)
    21/21
    routing exact‑tier
    (well‑specified)
    -
    62.1%
    real cost saved
    vs always‑premium
    +
    62.1%
    real cost saved
    vs always‑premium
    (tuning set; held‑out: −20.2%)
    3/3
    routed‑down outputs
    passed execution tests
    @@ -641,7 +649,7 @@

    9.2 How we evaluated it — live, on real models

    “Optimize it.”) with concrete clarifying questions and spent zero generation tokens on them; it passed all 21 well‑specified tasks through. The router placed every one of those 21 on its gold tier. Across the executed workload the loop spent $0.54 versus $1.42 for -always‑premium — a genuine 62.1 % reduction on real tokens.

    +always‑premium — a genuine 62.1 % reduction on real tokens, on the tasks the thresholds were tuned on. It did not survive held‑out data: on 80 tasks drawn from real issues, counting every escalation the pipeline paid for, it spent 20.2% more than always‑premium. Per output a judge accepted, it cost $1.06 against always‑premium’s $1.76, but only 6 and 3 of 64 outputs were accepted, so neither figure is stable. [refuted — see Corrections]

    9.3 Reading this result honestly

    @@ -750,14 +758,26 @@

    11 Genuinely new vs. reinvented

    12 Limitations & threats to validity

    • One faculty, not five. Only impact‑awareness is prototyped. Memory, learning, and imagination for code remain specified‑but‑unbuilt, and §3 is explicit that three of them are open research problems, not weekend engineering.
    • -
    • Static analysis has known blind spots. The ast‑based world‑model is sound for static Python structure but conservative‑to‑wrong on dynamic dispatch, monkey‑patching, reflection, eval, and dependency injection. It is single‑language. Precision (0.63) reflects partly that it over‑approximates; recall is its strength precisely because it errs toward inclusion.
    • -
    • Small, self‑built evaluation. Five mutations on a 10‑module package plus two stdlib scale checks is a demonstration, not a benchmark. The mutation‑derived ground truth is only as complete as the test suite (an untested consequence is invisible to it) — so true recall could be lower than measured. A convincing evaluation needs many real repositories with rich test suites and real historical edits.
    • +
    • Static analysis has known blind spots. The ast‑based world‑model was meant to be sound for static Python structure (a construction defect later broke src/‑layout packages) and is conservative‑to‑wrong on dynamic dispatch, monkey‑patching, reflection, eval, and dependency injection. It is single‑language. On the demo package, precision (0.63) reflected over‑approximation and recall looked like its strength. On real repositories the graph under‑approximated: it walked only reverse edges, missing the sibling pairs that make up 94.7% of missed impact, and a construction defect broke one standard package layout. [corrected 2026-09-21]
    • +
    • Small, self‑built evaluation. Five mutations on a 10‑module package plus two stdlib scale checks is a demonstration, not a benchmark. The mutation‑derived ground truth is only as complete as the test suite (an untested consequence is invisible to it) — so true recall could be lower than measured. A convincing evaluation needs many real repositories with rich test suites and real historical edits. That evaluation was later run, and true recall was far lower: 0.022.
    • The hard faculties may not yield to this architecture. The substrate assumes that anchoring memory/learning to external oracles is sufficient; for domains or tasks without a cheap oracle, the whole premise weakens. Code is favorable; much agent work is not.
    • The lens is framing, not proof. As stated throughout, no verse validates an engineering claim. A reader who rejects the lens loses the organizing vocabulary but none of the technical content, which stands on §3 and §8 alone.

    13 Conclusion

    -

    The faculties a coding agent seems to lack — memory, learning, imagination, self‑correction, impact‑awareness — are not deficiencies of knowledge that scale will cure. They are structural consequences of what a frozen transformer is: a stateless map with fixed weights and a bounded window (Eq. 1, P1–P3). Because they follow from the shape of the loop, they cannot be prompted or tooled away; they can only be removed by re‑wrapping the loop into a closed, stateful cycle over persistent stores, with the model left frozen inside it (Eq. 2, Fig. 1–2). We specified that substrate faculty by faculty, said honestly which parts are open research and which are engineering, and — for the one faculty that is buildable today — shipped a running impact oracle that, against independent ground truth, never misses an affected file where the strategies a context‑bounded agent actually uses miss up to half. The Qur'anic lens gave the work its spine of obligation: know what exists before you act, verify what you are told, and hold what you can damage as a trust. Those are not just good engineering defaults; here they are the architecture. The next step is to build the memory and learning layers against the same discipline — anchored to what can be verified, not to what the model says of itself — and to evaluate the whole loop on real repositories with real histories.

    +

    The faculties a coding agent seems to lack — memory, learning, imagination, self‑correction, impact‑awareness — are not deficiencies of knowledge that scale will cure. They are structural consequences of what a frozen transformer is: a stateless map with fixed weights and a bounded window (Eq. 1, P1–P3). Because they follow from the shape of the loop, they cannot be prompted or tooled away; they can only be removed by re‑wrapping the loop into a closed, stateful cycle over persistent stores, with the model left frozen inside it (Eq. 2, Fig. 1–2). We specified that substrate faculty by faculty, said honestly which parts are open research and which are engineering, and — for the one faculty that is buildable today — shipped a running impact oracle that, on a package we built, missed no affected file where the strategies a context‑bounded agent actually uses missed up to half. On real repositories it missed almost everything (recall 0.022), which is the refutation’s subject. [refuted — see Corrections] The Qur'anic lens gave the work its spine of obligation: know what exists before you act, verify what you are told, and hold what you can damage as a trust. Those are not just good engineering defaults; here they are the architecture. The next step is to build the memory and learning layers against the same discipline — anchored to what can be verified, not to what the model says of itself — and to evaluate the whole loop on real repositories with real histories.

    + +
    +

    Corrections (2026-09-21)

    +

    This edition was published before its prototypes were tested on data we did not build. The refutation (research/empirical-refutation/) reports that test; an external deep review of the forgekit repository (2026-09-21) found the further errors below. Each is corrected in place above and listed here with the original wording, so nothing is silently rewritten. Numbers were recomputed by research/recompute_corrections.py from the refutation’s replication package. The PDF edition predates these corrections.

    +
      +
    1. The impact oracle’s recall (abstract, §8, §12, §13). The edition said the oracle “never misses an affected file” and offered “guaranteed recall”. That held on five mutations of a ten‑file package we wrote. On 759 files in nine open‑source repositories its recall was 0.022, 91% of files got zero recall, and a grep baseline scored F1 0.437 against its 0.042. The graph under‑approximated rather than erring toward inclusion, so §7.1’s “sound over‑approximations” and “exact‑analysis instance” are withdrawn.
    2. +
    3. The routing cost saving (§9). The 62.1% saving was measured on the 30 tasks the thresholds were tuned on. On 80 held‑out tasks, counting every escalation, total spend was 20.2% higher than always‑premium. Per output a judge accepted, the pipeline cost $1.06 and always‑premium $1.76, from 6 and 3 accepted outputs of 64; 58 of the 64 tasks failed at every tier.
    4. +
    5. M1’s worst case (§6). “The worst case is a cheap attempt plus a premium attempt” is wrong for a ladder that escalates one tier at a time: the worst case is the sum over every tier from the routed one up, which exceeds always‑premium. That is the mechanism behind the −20.2%.
    6. +
    7. Eq. (1) and M2 disagreed. Eq. (1) wrote y as a deterministic function, the text said y is sampled, and M2 said the model computes the argmax. There is now one statement: y is a draw from pθ(· | x), and greedy decoding, approximately the argmax, is the temperature‑0 special case.
    8. +
    9. A misquoted statistic (§4.2). Faros reports “31.3% more PRs merged with no review” (a relative increase between low‑ and high‑adoption quarters), as the evidence appendix (C7) already says. The body said “31.3% of PRs merged with no review”.
    10. +
    11. The faculty table (§2) is unchanged here, and is now the canonical one. The formal synthesis had a different “follows from” column in all five rows; it has been brought into line with this table, which argues each row and matches what forgekit’s bindings address.
    12. +

    References

    diff --git a/docs/cognitive-substrate/deliverable-package.md b/docs/cognitive-substrate/deliverable-package.md index c8e4475..a8b6786 100644 --- a/docs/cognitive-substrate/deliverable-package.md +++ b/docs/cognitive-substrate/deliverable-package.md @@ -1,3 +1,21 @@ +> **Status: the headline prototype claims in this document are REFUTED.** +> +> This package describes the _Theory → Evidence → Build-Map_ edition (v2), written before any +> real-repository evaluation existed. A later pre-registered evaluation — see +> [`research/empirical-refutation/`](../../research/empirical-refutation/) — overturned both +> prototype claims reported below: +> +> | Claim below | Measured on real data | +> | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +> | Impact oracle recall **1.00** | **0.022** (9 OSS repos; 801 labelled files, 759 evaluated); `grep` beats it ~10× on F1 | +> | Router/gate F1 **1.00**, cost saving **+62.1%** | F1 **0.37**; cost saving **−20.2%** (routing costs _more_ than always-premium; per judged-correct output $1.06 vs $1.76, from only 6 and 3 correct outputs of 64) | +> +> The theory remains forgekit's working framework; the _numbers_ here do not. This banner was +> added on 2026-09-21 after an external review; the white paper's HTML edition marks each refuted +> claim in place, and its PDF predates those corrections. + +--- + # A Cognitive Substrate for Coding Agents — Deliverable Package ### Theory → Evidence → Build-Map edition (v2) @@ -53,7 +71,7 @@ The seven figures (the frozen loop; the substrate; the six-mechanism control lay | Grep baseline (what agents do today) | 0.73 | 0.94 | **0.79** | | Edited-file-only | 1.00 | 0.53 | 0.65 | -The oracle does **not** dominate F1 — grep edges it at the default threshold, and we say so. What the oracle uniquely provides is **guaranteed recall**: for "show me everything my edit could break," a silent miss costs far more than an extra file to check, and only the structural oracle drives false negatives to zero (precision tunable, best F1 = 0.79 at threshold 0.4). +The oracle does **not** dominate F1 — grep edges it at the default threshold, and we say so. What the oracle appeared to provide is **guaranteed recall**: for "show me everything my edit could break," a silent miss costs far more than an extra file to check, and on this package only the structural oracle drove false negatives to zero (precision tunable, best F1 = 0.79 at threshold 0.4). **Refuted:** on real repositories its recall was 0.022 (see the banner). ### Prototype II — Router + Gate (live, on real models: haiku / sonnet / opus) @@ -61,7 +79,7 @@ The oracle does **not** dominate F1 — grep edges it at the default threshold, | --------------------------------------- | ---------------------------------------------- | | Gate accuracy (should-ask) | 30/30 · precision 1.00 · recall 1.00 | | Routing accuracy (well-specified tasks) | 21/21 exact tier | -| **Real cost saved vs always-premium** | **62.1%** (same measured tokens) | +| **Real cost saved vs always-premium** | **62.1%** (same measured tokens; tuning set only — **−20.2%** on 80 held-out tasks) | | Execution-verified sub-experiment | 3/3 routed-down outputs passed real test cases | **Honest caveat (both prototypes):** these are **demonstrations, not benchmarks**. The router's 30-task set is hand-labeled and the rubric thresholds were tuned against it, so perfect separation shows the rubric _can_ distinguish these cases — not field accuracy. The oracle's evaluation is 5 mutations + 2 stdlib scale checks. We apply the "retired SWE-bench Verified" caution (§4, confirmed) to our own numbers. @@ -88,4 +106,4 @@ Most components are borrowed (external memory, fast/slow learning, code graphs, ## Scope & limitations (stated honestly) -Two faculties/mechanisms are prototyped, not eleven. The impact oracle's static analysis is single-language (Python) and conservative on dynamic dispatch. The router/gate rubrics are keyword heuristics tuned on a small hand-labeled set. Memory validity, outcome learning, and doom-loop diagnosis remain _specified but unbuilt_ — the harder research gaps, marked as such rather than gestured at with a demo. The lens is framing: reject it and you lose the organizing vocabulary but none of the technical content. +Two faculties/mechanisms are prototyped, not eleven. The impact oracle's static analysis is single-language (Python) and misses dynamic dispatch; on real repositories it under-approximated badly (reverse-only traversal and a package-layout defect). The router/gate rubrics are keyword heuristics tuned on a small hand-labeled set. Memory validity, outcome learning, and doom-loop diagnosis remain _specified but unbuilt_ — the harder research gaps, marked as such rather than gestured at with a demo. The lens is framing: reject it and you lose the organizing vocabulary but none of the technical content. diff --git a/research/cognitive-substrate/EXECUTIVE_SUMMARY.md b/research/cognitive-substrate/EXECUTIVE_SUMMARY.md index 689de21..0938a0a 100644 --- a/research/cognitive-substrate/EXECUTIVE_SUMMARY.md +++ b/research/cognitive-substrate/EXECUTIVE_SUMMARY.md @@ -6,12 +6,17 @@ > > | Claim below | Measured on real data | > |---|---| -> | Impact oracle recall **1.00** | **0.022** (9 OSS repos, 801 files); `grep` beats it ~10× on F1 | -> | Router/gate F1 **1.00**, cost saving **+62.1%** | F1 **0.37**; cost saving **−20.2%** (routing costs *more* than always-premium) | +> | Impact oracle recall **1.00** | **0.022** (9 OSS repos; 801 labelled files, 759 evaluated); `grep` beats it ~10× on F1 | +> | Router/gate F1 **1.00**, cost saving **+62.1%** | F1 **0.37**; cost saving **−20.2%** (routing costs *more* than always-premium; per judged-correct output $1.06 vs $1.76, from only 6 and 3 correct outputs of 64) | > > The theory sections remain the programme's working framework. The *numbers* here do not. A repair -> recovered a narrow win (recall 0.653, F1 0.416, beating grep's 0.371), documented in the refutation -> paper. Read this document for the architecture; read the refutation for what is actually true. +> raised recall to 0.653 and F1 to 0.416, a point estimate above grep's 0.371, documented in the +> refutation paper; that it beats grep is not established (three held-out repositories, sign-test +> p = 0.125). Read this document for the architecture; read the refutation for what is actually true. +> +> *Corrected 2026-09-21* after an external review: the repair was previously called "a narrow win", +> and the whitepaper's HTML edition now also marks each refuted claim in place. The whitepaper PDF +> predates those corrections. --- @@ -66,14 +71,14 @@ | Grep baseline (what agents do today) | 0.73 | 0.94 | **0.79** | | Edited-file-only | 1.00 | 0.53 | 0.65 | -The oracle does **not** dominate F1 — grep edges it at the default threshold, and we say so. What the oracle uniquely provides is **guaranteed recall**: for "show me everything my edit could break," a silent miss costs far more than an extra file to check, and only the structural oracle drives false negatives to zero (precision tunable, best F1 = 0.79 at threshold 0.4). +The oracle does **not** dominate F1 — grep edges it at the default threshold, and we say so. What the oracle appeared to provide is **guaranteed recall**: for "show me everything my edit could break," a silent miss costs far more than an extra file to check, and on this package only the structural oracle drove false negatives to zero (precision tunable, best F1 = 0.79 at threshold 0.4). **Refuted:** on real repositories its recall was 0.022 (see the banner). ### Prototype II — Router + Gate (live, on real models: haiku / sonnet / opus) | Metric | Result | |---|---| | Gate accuracy (should-ask) | 30/30 · precision 1.00 · recall 1.00 | | Routing accuracy (well-specified tasks) | 21/21 exact tier | -| **Real cost saved vs always-premium** | **62.1%** (same measured tokens) | +| **Real cost saved vs always-premium** | **62.1%** (same measured tokens; tuning set only — **−20.2%** on 80 held-out tasks) | | Execution-verified sub-experiment | 3/3 routed-down outputs passed real test cases | **Honest caveat (both prototypes):** these are **demonstrations, not benchmarks**. The router's 30-task set is hand-labeled and the rubric thresholds were tuned against it, so perfect separation shows the rubric *can* distinguish these cases — not field accuracy. The oracle's evaluation is 5 mutations + 2 stdlib scale checks. We apply the "retired SWE-bench Verified" caution (§4, confirmed) to our own numbers. @@ -99,4 +104,4 @@ Most components are borrowed (external memory, fast/slow learning, code graphs, ## Scope & limitations (stated honestly) -Two faculties/mechanisms are prototyped, not eleven. The impact oracle's static analysis is single-language (Python) and conservative on dynamic dispatch. The router/gate rubrics are keyword heuristics tuned on a small hand-labeled set. Memory validity, outcome learning, and doom-loop diagnosis remain *specified but unbuilt* — the harder research gaps, marked as such rather than gestured at with a demo. The lens is framing: reject it and you lose the organizing vocabulary but none of the technical content. +Two faculties/mechanisms are prototyped, not eleven. The impact oracle's static analysis is single-language (Python) and misses dynamic dispatch; on real repositories it under-approximated badly (reverse-only traversal and a package-layout defect). The router/gate rubrics are keyword heuristics tuned on a small hand-labeled set. Memory validity, outcome learning, and doom-loop diagnosis remain *specified but unbuilt* — the harder research gaps, marked as such rather than gestured at with a demo. The lens is framing: reject it and you lose the organizing vocabulary but none of the technical content. diff --git a/research/cognitive-substrate/cognitive_substrate_whitepaper.html b/research/cognitive-substrate/cognitive_substrate_whitepaper.html index 940e8d8..95460e9 100644 --- a/research/cognitive-substrate/cognitive_substrate_whitepaper.html +++ b/research/cognitive-substrate/cognitive_substrate_whitepaper.html @@ -80,6 +80,7 @@ ol.refs li{margin:.45em 0;} ol.refs li b{color:var(--ink); font-weight:600;} hr.sec{border:none; border-top:1px solid var(--rule); margin:2.4em 0;} .foot{font-size:.82rem; color:var(--faint); margin-top:3em; border-top:1px solid var(--rule); padding-top:16px;} + .corr{font-size:.8em; color:var(--alarm); font-style:normal; white-space:nowrap;} @media print{ body{background:#fff;} .page{box-shadow:none; max-width:none; padding:0;} .toc{columns:2;} } @media (max-width:720px){ .page{padding:32px 22px;} .toc{columns:1;} h1{font-size:1.6rem;} } @@ -94,9 +95,14 @@

    A Cognitive Substrate for Coding Agents

    A first‑principles design study, grounded in field evidence. The five faculties are argued from the shape of a frozen model and grounded in 32 primary sources (memory‑augmentation, continual‑learning, self‑correction, world‑model, and program‑analysis literatures). This edition adds an evidence layer — twelve load‑bearing pain‑point statistics independently re‑grounded from primary sources and graded confirmed / vendor‑reported / unverifiablesix mechanisms (routing, assumption gate, decomposition, goal‑anchoring, anti‑over‑engineering, inline verification), a build‑opportunity map against the real 2026 Claude‑Code stack, and a second prototype (a complexity‑aware router + assumption gate, evaluated live on real models). An epistemological and ethical lens drawn from Qur'anic sources runs throughout, used as design framing, never as technical authority.  |  Companion artifacts: evidence map, ecosystem map, gap‑map, reference list, Qur'anic‑lens table, and two runnable packages (impact‑oracle, router‑gate).
    +
    +
    Status: both prototype results in this edition were refuted · corrections 2026-09-21
    +

    This edition was written before any real-repository evaluation existed. A later pre-registered evaluation (research/empirical-refutation/) overturned both prototype claims: the impact oracle’s recall was 0.022, not 1.00, on 759 files in nine open-source repositories, and on 80 held-out tasks the router’s total spend was 20.2% higher than always using the premium tier, not 62.1% lower. An external review (2026-09-21) also found a misquoted statistic, a wrong worst-case cost, and an inconsistency between Eq. (1) and M2. Corrections are made in place, marked [corrected 2026-09-21] or [refuted], and listed with the original wording in Corrections. The theory sections remain the programme’s working framework; the prototype numbers do not. The PDF edition predates these corrections.

    +
    +

    Abstract

    -

    A large language model at inference time is, mathematically, a fixed function y = fθ(x) with frozen parameters θ and a bounded input window. From this single fact, five apparent “cognitive” deficits of a coding agent follow as structural consequences, not incidental weaknesses: it cannot remember across sessions, cannot learn from outcomes, cannot imagine the consequences of an action before taking it, cannot reliably correct itself, and does not know what already exists in a codebase or what an edit will affect. We show that neither better prompting nor additional tools (skills, MCP servers) remove these deficits, because they leave fθ and the open‑loop pipeline intact. We then specify a cognitive substrate: an external architecture that keeps the LLM frozen but re‑wraps its input→process→output loop into a closed, stateful cycle over persistent stores — an episodic/semantic memory, an online‑updatable learning layer, a consequence simulator, a metacognitive verification gate, and a persistent structural model of the codebase — all under an explicit stewardship boundary. For each faculty we identify precisely what the existing literature solves and what residual gap remains for a coding agent. To turn the weakest‑evidenced claim into something testable, we build and evaluate the impact‑awareness faculty as a runnable prototype: a Codebase World‑Model that parses a repository into a persistent dependency graph, and an Impact Oracle that predicts the blast radius of a proposed edit. Against mutation‑derived ground truth, the oracle is the only method that never misses an affected file (recall = 1.00 across five tested edits), where a text‑search baseline misses transitive dependents and an edited‑file‑only baseline misses 47% of impact. Throughout, a Qur'anic epistemic lens supplies the design's vocabulary of obligation — know what exists before acting (2:31–32), verify before you act (49:6), pursue not that of which you have no knowledge (17:36), and hold what you can damage as a trust (33:72).

    +

    A large language model at inference time is, mathematically, a fixed function y = fθ(x) with frozen parameters θ and a bounded input window. From this single fact, five apparent “cognitive” deficits of a coding agent follow as structural consequences, not incidental weaknesses: it cannot remember across sessions, cannot learn from outcomes, cannot imagine the consequences of an action before taking it, cannot reliably correct itself, and does not know what already exists in a codebase or what an edit will affect. We show that neither better prompting nor additional tools (skills, MCP servers) remove these deficits, because they leave fθ and the open‑loop pipeline intact. We then specify a cognitive substrate: an external architecture that keeps the LLM frozen but re‑wraps its input→process→output loop into a closed, stateful cycle over persistent stores — an episodic/semantic memory, an online‑updatable learning layer, a consequence simulator, a metacognitive verification gate, and a persistent structural model of the codebase — all under an explicit stewardship boundary. For each faculty we identify precisely what the existing literature solves and what residual gap remains for a coding agent. To turn the weakest‑evidenced claim into something testable, we build and evaluate the impact‑awareness faculty as a runnable prototype: a Codebase World‑Model that parses a repository into a persistent dependency graph, and an Impact Oracle that predicts the blast radius of a proposed edit. Against mutation‑derived ground truth on a ten‑file package we wrote, the oracle was the only method that missed no affected file (recall = 1.00 across five tested edits), where a text‑search baseline missed transitive dependents and an edited‑file‑only baseline missed 47% of impact. On nine real repositories its recall was 0.022, and text search beat it by an order of magnitude on F1. [refuted — see Corrections] Throughout, a Qur'anic epistemic lens supplies the design's vocabulary of obligation — know what exists before acting (2:31–32), verify before you act (49:6), pursue not that of which you have no knowledge (17:36), and hold what you can damage as a trust (33:72).

    @@ -132,6 +139,8 @@

    2 The root cause, formally

    y = fθ(x),  x ∈ 𝕏≤W,  θ fixed for all inferences. +

    Read fθ as the sampling map: it defines a distribution pθ(· | x) and returns a draw y ~ pθ(· | x). Greedy decoding, which approximately returns arg maxy pθ(y | x), is the temperature‑0 special case, and it is the case M2 (§6) reasons about. [corrected 2026-09-21]

    +

    Three properties of Equation (1) are the source of everything that follows. They are not bugs; they are what a deployed transformer is.

    (P1) Statelessness. The function has no argument that carries information from one call to the next except x itself. Whatever the agent “knew” during turn t is gone at turn t+1 unless it was serialized back into the token string. There is no hidden variable st that persists; formally, the map is memoryless: yt depends on xt alone, not on the history (x1,…,xt−1).

    @@ -268,8 +277,8 @@

    4.2 What is real but vendor‑reported — and what did not sur remedy for the problem the figure describes; we use them as corroboration, not proof, and we say so. Veracode reports 45 % of AI‑generated samples introduce an OWASP‑Top‑10 vulnerability [C3 vendor]; Faros' telemetry across 22,000 developers -reports median PR‑review time up 441.5 % and 31.3 % of PRs merged with no -review [C7 vendor]; Sonar reports the sharpest single number in the +reports median PR‑review time up 441.5 % and 31.3 % more PRs merged with no +review [corrected 2026-09-21] [C7 vendor]; Sonar reports the sharpest single number in the whole discourse — 96 % of developers do not fully trust AI code, yet only 48 % always verify it, a 48‑point “verification gap” [C9 vendor]. These are consistent with the confirmed evidence and with each @@ -376,8 +385,7 @@

    5.1 M1 — Complexity‑aware routing

    route(x) = min{ Ti : capable(Ti, c(x)) }, then escalate Ti → Ti+1 only if an external check on the output fails. -

    The escalation clause is what makes this safe: the worst case is a cheap attempt plus a premium attempt, -the common case is cheap alone, and the decision to spend more is driven by a verified failure, +

    The escalation clause bounds the damage but does not make routing free. Escalating one tier at a time, the worst case pays for every tier from the routed one up, κi + … + κk, which is more than always using the top tier. Earlier versions gave the worst case as a cheap attempt plus a premium attempt. [corrected 2026-09-21] The saving therefore depends on how often the cheap attempt verifies: on 80 held-out tasks where almost no attempt at any tier verified, routing cost 20.2% more than always‑premium (§9). The decision to spend more is driven by a verified failure, never by the model's self‑assessment. Ecosystem status: largely solved. Model tiering and gateways (LiteLLM, OpenRouter, per‑agent model: fields) already route by cost. What they do not give is a transparent, per‑task, auditable complexity judgment the user can @@ -387,9 +395,9 @@

    5.1 M1 — Complexity‑aware routing

    5.2 M2 — The assumption / uncertainty gate root failure

    The deficit. This is the failure the project named as central: “the biggest problem is -Assumption. If it doesn't have enough context it will assume many things.” A model that computes -arg maxy P(y \mid x) always returns some most‑likely -continuation, even when x under‑determines the task — there is no term in +Assumption. If it doesn't have enough context it will assume many things.” A model that samples from +P(y \mid x), or under greedy decoding approximately computes its arg max (Eq. 1), always returns some high‑probability +continuation [corrected 2026-09-21], even when x under‑determines the task — there is no term in the objective that fires when the input is insufficient. The model cannot distinguish “I know this” from “this is merely the least‑improbable guess.” This is precisely what the METR trial measured from the outside: confident forecasts, slower reality @@ -512,7 +520,7 @@

    7.1 The five faculties, formally

    (4) ĉ​  = g(a, C)  →  predicted { broken call sites, type errors, failing tests }
    -

    Unlike World Models or MuZero22,23, g here need not be learned from pixels or reward: for code it can be partly exact — static analysis and the dependency graph give sound over‑approximations of “what breaks” — and partly cheap simulation (run the affected tests in a sandbox). This is the faculty the literature has not built for the symbolic domain (§3); the impact oracle of §8 is a first, exact‑analysis instance of g.

    +

    Unlike World Models or MuZero22,23, g here need not be learned from pixels or reward: for code it can be partly static — static analysis and the dependency graph aim at an over‑approximation of “what breaks”, though our prototype’s graph turned out to under‑approximate badly on real repositories (recall 0.022) [corrected 2026-09-21] — and partly cheap simulation (run the affected tests in a sandbox). This is the faculty the literature has not built for the symbolic domain (§3); the impact oracle of §8 is a first static‑analysis instance of g.

    Metacognition — a structured self‑consistency pass. Following the honest lesson of §3—§5 (self‑critique on the same weights is unreliable21; a real external check is required), the controller does not ask the model “are you sure?” It runs a tadabbur trace‑forward: it checks the proposed action against (i) the model's own stated plan, (ii) the type system, and (iii) the existing test suite — three signals at least two of which are external to fθ. Inconsistency halts and backtracks rather than proceeds.

    @@ -541,7 +549,7 @@

    7.2 Learning without touching θ

    8 Prototype: a Codebase World‑Model & Impact Oracle

    -

    Design papers are cheap; the discipline is to build the one faculty where a claim can be tested in a single sitting. That faculty is impact‑awareness (§3: mature tools, missing bridge). We implemented two of the substrate's components as a runnable Python package: the Codebase World‑Model (the “perceive” store, 2:31–32) and the Impact Oracle (an exact instance of the “imagine→verify” simulator g, 17:36). The full source, demo, and tests are the companion artifact impact_oracle_src.zip.

    +

    Design papers are cheap; the discipline is to build the one faculty where a claim can be tested in a single sitting. That faculty is impact‑awareness (§3: mature tools, missing bridge). We implemented two of the substrate's components as a runnable Python package: the Codebase World‑Model (the “perceive” store, 2:31–32) and the Impact Oracle (a static‑analysis instance of the “imagine→verify” simulator g, 17:36). The full source, demo, and tests are the companion artifact impact_oracle_src.zip.

    8.1 What it does

    World‑model. A parser walks a repository with Python's ast module and builds a directed graph whose nodes are symbols — modules, classes, functions, methods, module‑level names — with stable qualified IDs (pkg.mod.Class.method) and metadata (file, line, kind, signature). Edges capture five structural relations: imports, calls, inherits, references, contains. The graph is persisted to disk keyed by a per‑file content hash, so an unchanged file is never re‑parsed — the “persistent memory of what already exists,” incremental by construction.

    @@ -569,17 +577,17 @@

    8.2 How we evaluated it — against ground truth, not self‑gr
    Two panels. Left: grouped bars of precision, recall, F1 for Graph Oracle, Grep, and Edited-file-only. Oracle has recall 1.00; edited-file-only has precision 1.00 but recall 0.53. Right: precision, recall, F1 vs confidence threshold, with best F1 0.79 at threshold 0.4. -
    Figure 5. Honest evaluation against mutation‑derived ground truth. (left) The oracle is the only method with perfect recall (1.00) — it never misses an affected file — at a precision cost (0.63) versus grep (0.73). Edited‑file‑only achieves perfect precision but catastrophic recall (0.53), missing nearly half of all real impact. (right) Precision and recall trade off with the confidence threshold; best F1 = 0.79 at threshold 0.4. The chart title states the result plainly, including where grep leads on F1 — we do not oversell.
    +
    Figure 5. Honest evaluation against mutation‑derived ground truth. (left) On this demo package the oracle is the only method with perfect recall (1.00) — it missed no affected file in these five edits — at a precision cost (0.63) versus grep (0.73). On real repositories its recall was 0.022. [refuted — see Corrections] Edited‑file‑only achieves perfect precision but catastrophic recall (0.53), missing nearly half of all real impact. (right) Precision and recall trade off with the confidence threshold; best F1 = 0.79 at threshold 0.4. The chart title states the result plainly, including where grep leads on F1 — we do not oversell.

    8.3 Reading the result honestly

    -

    The oracle does not dominate every metric, and the paper is stronger for saying so. On F1 at the default threshold, the grep baseline (0.79) slightly edges the oracle (0.75). What the oracle uniquely provides is guaranteed recall: across all five mutations it never once missed a file that actually broke, whereas grep missed transitive dependents (a caller that never mentions the symbol by name) and edited‑file‑only missed 47% of real impact. For the question that matters when you are about to change production code — “show me everything that could break” — a false negative (a silent breakage) is far more costly than a false positive (an extra file to glance at), and only the structural oracle drives false negatives to zero. Its precision, moreover, is tunable: Figure 5(right) shows the operating point can be moved along the curve, where neither text baseline offers such a control.

    +

    The oracle does not dominate every metric, and the paper is stronger for saying so. On F1 at the default threshold, the grep baseline (0.79) slightly edges the oracle (0.75). What the oracle appeared to provide is guaranteed recall [refuted — see Corrections]: across all five mutations it never once missed a file that actually broke, whereas grep missed transitive dependents (a caller that never mentions the symbol by name) and edited‑file‑only missed 47% of real impact. For the question that matters when you are about to change production code — “show me everything that could break” — a false negative (a silent breakage) is far more costly than a false positive (an extra file to glance at), and on this package only the structural oracle drove false negatives to zero. It did not on real code: on 759 files in nine open‑source repositories, 91% of files received zero recall, because the graph walked only reverse edges and mis‑built one package layout. Its precision, moreover, is tunable: Figure 5(right) shows the operating point can be moved along the curve, where neither text baseline offers such a control.

    It also scales. Beyond the toy package, the same parser handled real standard‑library code: it built a 303‑node graph of the json package in 18 ms and a 1,903‑node graph of the http package in 91 ms, answering impact queries in under a millisecond. The approach is not a toy that only works on toys; it is a mature technique that simply had not been placed in the agent's loop.

    What this proves, and what it does not
    -

    Proves: the impact‑awareness faculty of the substrate is buildable today, runs on a laptop, persists its world‑model, and beats the two strategies a context‑bounded LLM agent actually uses — on the metric (recall) that governs safety — against independent ground truth. Does not prove: that the other four faculties are equally easy (they are not — §3), that this Python‑ast analysis handles dynamic dispatch, reflection, or cross‑language repos (it does not yet — §12), or that the whole substrate has been built end‑to‑end (it has not). This is one faculty, demonstrated; the rest is specified and argued.

    +

    Proves: the impact‑awareness faculty of the substrate is buildable today, runs on a laptop, persists its world‑model, and, on this package, beat the two strategies a context‑bounded LLM agent actually uses on the metric (recall) that governs safety. It did not transfer: on real repositories a grep baseline beat it by an order of magnitude. [refuted — see Corrections] Does not prove: that the other four faculties are equally easy (they are not — §3), that this Python‑ast analysis handles dynamic dispatch, reflection, or cross‑language repos (it does not yet — §12), or that the whole substrate has been built end‑to‑end (it has not). This is one faculty, demonstrated; the rest is specified and argued.

    9 Prototype II: a complexity‑aware router and an assumption gate

    @@ -625,7 +633,7 @@

    9.2 How we evaluated it — live, on real models

    Router and gate live evaluation
    Figure 7. Live evaluation. (a) Real cost for the 21 executed tasks: -complexity‑routing spends 62 % less than always‑premium on the same measured tokens, +complexity‑routing spends 62 % less than always‑premium on the same measured tokens (on these 30 tuning tasks; on 80 held‑out tasks it spent 20.2% more [refuted — see Corrections]), with most tasks served by the cheap and mid tiers. (b) All 30 decisions: every well‑specified task routed to its gold tier (green/orange/red by tier), every under‑specified task halted by the gate (purple ×). No errors on either axis.

    @@ -633,7 +641,7 @@

    9.2 How we evaluated it — live, on real models

    30/30
    gate accuracy
    (P = R = 1.00)
    21/21
    routing exact‑tier
    (well‑specified)
    -
    62.1%
    real cost saved
    vs always‑premium
    +
    62.1%
    real cost saved
    vs always‑premium
    (tuning set; held‑out: −20.2%)
    3/3
    routed‑down outputs
    passed execution tests
    @@ -641,7 +649,7 @@

    9.2 How we evaluated it — live, on real models

    “Optimize it.”) with concrete clarifying questions and spent zero generation tokens on them; it passed all 21 well‑specified tasks through. The router placed every one of those 21 on its gold tier. Across the executed workload the loop spent $0.54 versus $1.42 for -always‑premium — a genuine 62.1 % reduction on real tokens.

    +always‑premium — a genuine 62.1 % reduction on real tokens, on the tasks the thresholds were tuned on. It did not survive held‑out data: on 80 tasks drawn from real issues, counting every escalation the pipeline paid for, it spent 20.2% more than always‑premium. Per output a judge accepted, it cost $1.06 against always‑premium’s $1.76, but only 6 and 3 of 64 outputs were accepted, so neither figure is stable. [refuted — see Corrections]

    9.3 Reading this result honestly

    @@ -750,14 +758,26 @@

    11 Genuinely new vs. reinvented

    12 Limitations & threats to validity

    • One faculty, not five. Only impact‑awareness is prototyped. Memory, learning, and imagination for code remain specified‑but‑unbuilt, and §3 is explicit that three of them are open research problems, not weekend engineering.
    • -
    • Static analysis has known blind spots. The ast‑based world‑model is sound for static Python structure but conservative‑to‑wrong on dynamic dispatch, monkey‑patching, reflection, eval, and dependency injection. It is single‑language. Precision (0.63) reflects partly that it over‑approximates; recall is its strength precisely because it errs toward inclusion.
    • -
    • Small, self‑built evaluation. Five mutations on a 10‑module package plus two stdlib scale checks is a demonstration, not a benchmark. The mutation‑derived ground truth is only as complete as the test suite (an untested consequence is invisible to it) — so true recall could be lower than measured. A convincing evaluation needs many real repositories with rich test suites and real historical edits.
    • +
    • Static analysis has known blind spots. The ast‑based world‑model was meant to be sound for static Python structure (a construction defect later broke src/‑layout packages) and is conservative‑to‑wrong on dynamic dispatch, monkey‑patching, reflection, eval, and dependency injection. It is single‑language. On the demo package, precision (0.63) reflected over‑approximation and recall looked like its strength. On real repositories the graph under‑approximated: it walked only reverse edges, missing the sibling pairs that make up 94.7% of missed impact, and a construction defect broke one standard package layout. [corrected 2026-09-21]
    • +
    • Small, self‑built evaluation. Five mutations on a 10‑module package plus two stdlib scale checks is a demonstration, not a benchmark. The mutation‑derived ground truth is only as complete as the test suite (an untested consequence is invisible to it) — so true recall could be lower than measured. A convincing evaluation needs many real repositories with rich test suites and real historical edits. That evaluation was later run, and true recall was far lower: 0.022.
    • The hard faculties may not yield to this architecture. The substrate assumes that anchoring memory/learning to external oracles is sufficient; for domains or tasks without a cheap oracle, the whole premise weakens. Code is favorable; much agent work is not.
    • The lens is framing, not proof. As stated throughout, no verse validates an engineering claim. A reader who rejects the lens loses the organizing vocabulary but none of the technical content, which stands on §3 and §8 alone.

    13 Conclusion

    -

    The faculties a coding agent seems to lack — memory, learning, imagination, self‑correction, impact‑awareness — are not deficiencies of knowledge that scale will cure. They are structural consequences of what a frozen transformer is: a stateless map with fixed weights and a bounded window (Eq. 1, P1–P3). Because they follow from the shape of the loop, they cannot be prompted or tooled away; they can only be removed by re‑wrapping the loop into a closed, stateful cycle over persistent stores, with the model left frozen inside it (Eq. 2, Fig. 1–2). We specified that substrate faculty by faculty, said honestly which parts are open research and which are engineering, and — for the one faculty that is buildable today — shipped a running impact oracle that, against independent ground truth, never misses an affected file where the strategies a context‑bounded agent actually uses miss up to half. The Qur'anic lens gave the work its spine of obligation: know what exists before you act, verify what you are told, and hold what you can damage as a trust. Those are not just good engineering defaults; here they are the architecture. The next step is to build the memory and learning layers against the same discipline — anchored to what can be verified, not to what the model says of itself — and to evaluate the whole loop on real repositories with real histories.

    +

    The faculties a coding agent seems to lack — memory, learning, imagination, self‑correction, impact‑awareness — are not deficiencies of knowledge that scale will cure. They are structural consequences of what a frozen transformer is: a stateless map with fixed weights and a bounded window (Eq. 1, P1–P3). Because they follow from the shape of the loop, they cannot be prompted or tooled away; they can only be removed by re‑wrapping the loop into a closed, stateful cycle over persistent stores, with the model left frozen inside it (Eq. 2, Fig. 1–2). We specified that substrate faculty by faculty, said honestly which parts are open research and which are engineering, and — for the one faculty that is buildable today — shipped a running impact oracle that, on a package we built, missed no affected file where the strategies a context‑bounded agent actually uses missed up to half. On real repositories it missed almost everything (recall 0.022), which is the refutation’s subject. [refuted — see Corrections] The Qur'anic lens gave the work its spine of obligation: know what exists before you act, verify what you are told, and hold what you can damage as a trust. Those are not just good engineering defaults; here they are the architecture. The next step is to build the memory and learning layers against the same discipline — anchored to what can be verified, not to what the model says of itself — and to evaluate the whole loop on real repositories with real histories.

    + +
    +

    Corrections (2026-09-21)

    +

    This edition was published before its prototypes were tested on data we did not build. The refutation (research/empirical-refutation/) reports that test; an external deep review of the forgekit repository (2026-09-21) found the further errors below. Each is corrected in place above and listed here with the original wording, so nothing is silently rewritten. Numbers were recomputed by research/recompute_corrections.py from the refutation’s replication package. The PDF edition predates these corrections.

    +
      +
    1. The impact oracle’s recall (abstract, §8, §12, §13). The edition said the oracle “never misses an affected file” and offered “guaranteed recall”. That held on five mutations of a ten‑file package we wrote. On 759 files in nine open‑source repositories its recall was 0.022, 91% of files got zero recall, and a grep baseline scored F1 0.437 against its 0.042. The graph under‑approximated rather than erring toward inclusion, so §7.1’s “sound over‑approximations” and “exact‑analysis instance” are withdrawn.
    2. +
    3. The routing cost saving (§9). The 62.1% saving was measured on the 30 tasks the thresholds were tuned on. On 80 held‑out tasks, counting every escalation, total spend was 20.2% higher than always‑premium. Per output a judge accepted, the pipeline cost $1.06 and always‑premium $1.76, from 6 and 3 accepted outputs of 64; 58 of the 64 tasks failed at every tier.
    4. +
    5. M1’s worst case (§6). “The worst case is a cheap attempt plus a premium attempt” is wrong for a ladder that escalates one tier at a time: the worst case is the sum over every tier from the routed one up, which exceeds always‑premium. That is the mechanism behind the −20.2%.
    6. +
    7. Eq. (1) and M2 disagreed. Eq. (1) wrote y as a deterministic function, the text said y is sampled, and M2 said the model computes the argmax. There is now one statement: y is a draw from pθ(· | x), and greedy decoding, approximately the argmax, is the temperature‑0 special case.
    8. +
    9. A misquoted statistic (§4.2). Faros reports “31.3% more PRs merged with no review” (a relative increase between low‑ and high‑adoption quarters), as the evidence appendix (C7) already says. The body said “31.3% of PRs merged with no review”.
    10. +
    11. The faculty table (§2) is unchanged here, and is now the canonical one. The formal synthesis had a different “follows from” column in all five rows; it has been brought into line with this table, which argues each row and matches what forgekit’s bindings address.
    12. +

    References

    diff --git a/research/python-prototypes/impact_oracle/README.md b/research/python-prototypes/impact_oracle/README.md index 5269fad..259293e 100644 --- a/research/python-prototypes/impact_oracle/README.md +++ b/research/python-prototypes/impact_oracle/README.md @@ -87,8 +87,16 @@ The evaluation uses **mutation testing** as ground truth: | Grep baseline | 0.733 | 0.943 | 0.787 | | Edited-file only | **1.000** | 0.529 | 0.650 | -The oracle achieves **perfect recall** (never misses a truly affected module), -with its best F1 of 0.79 at the optimal threshold (t=0.4). +On this demo package the oracle reached recall 1.000 (it missed no affected module in +these five mutations), with its best F1 of 0.79 at the optimal threshold (t=0.4). + +> **Refuted on real code.** That recall did not transfer. On 759 files in nine open-source +> Python repositories, with co-change ground truth, this version's recall was **0.022** and a +> grep baseline scored F1 0.437 against its 0.042: the traversal walks only reverse edges, and a +> construction defect breaks `src/`-layout packages. A repaired version ships in +> [`../../empirical-refutation/replication_package.tar.gz`](../../empirical-refutation/). Earlier +> versions of this README said the oracle "achieves perfect recall (never misses a truly affected +> module)". (Corrected 2026-09-21.) ## File structure From 81d75fa263c1f8ec0a48fd27c1a5e795c43067e1 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:38:41 +0200 Subject: [PATCH 17/72] docs: stop calling the impact graph conservative or 62% a saving Documentation side of review items A7 and E4 (2026-09-21): - The impact graph under-approximates, so README, GUIDE, the Mintlify intro, the capability map, the substrate-v2 plan and source/substrate.json's limits no longer call it conservative; an empty impact set is "unknown", not "safe". - The README's impact-quality row (precision 0.90, F1 0.92) does not reproduce: evalImpact on the same six cases gives precision 0.34, recall 0.97, F1 0.50 at 1a82388. The README and a note beside the generated block in reports/benchmarks.md say so and carry TODO(impact-numbers) markers for the post-fix re-measurement. - The prototype rows (recall 1.000; 62.1% saved) now sit beside their real-data refutations (recall 0.022; total spend 20.2% higher), and the README, GUIDE, reports/cost-eval.md and the plan stop presenting 62% as a result. The README keeps the "**62.1% cost saved**" label the status-page generator parses, now marked as refuted. - research/README.md gains a Corrections section (what changed, how to recompute it, which PDFs are stale) and stops calling the repaired oracle "a real but narrow win". Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 8 +++ README.md | 35 ++++++---- docs/GUIDE.md | 11 ++- docs/plans/substrate-v2/00-overview.md | 10 +-- docs/plans/substrate-v2/05-cost-model.md | 6 +- .../06-faculties-and-mechanisms.md | 2 +- .../references/capability-map.md | 2 +- mintlify/introduction.mdx | 3 +- reports/benchmarks.md | 10 +++ reports/cost-eval.md | 8 ++- research/README.md | 67 ++++++++++++++++--- source/substrate.json | 2 +- 12 files changed, 129 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad5ecdf..364e128 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,14 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). worst-case cost (the sum over every tier, not cheap + premium), Eq. 1 vs M2, and a misquoted Faros figure ("31.3% _more_ PRs merged with no review"). The docs copy and `docs/cognitive-substrate/deliverable-package.md` (which had no refutation banner) match. +- **The README and docs stop calling the impact graph conservative and stop presenting 62% as + a saving.** The graph can miss affected files, so it is now described as approximate and + an empty impact set as "unknown". The README's impact-quality row (precision 0.90, F1 0.92) + did not reproduce — `evalImpact` gives precision 0.34, recall 0.97, F1 0.50 at `1a82388` — + and is marked for re-measurement after the impact-graph fix; the prototype rows now sit + beside their real-data refutations (recall 0.022; −20.2%). `docs/GUIDE.md`, + `reports/cost-eval.md`, `reports/benchmarks.md`, the substrate-v2 plan, the Mintlify intro, + the capability map and `source/substrate.json`'s limits say the same. - **`research/recompute_corrections.py` re-derives every corrected number.** Standard-library Python (it includes a minimal Parquet reader), fixed seeds printed beside each result, run against the extracted replication package. PDFs built from the corrected sources could not diff --git a/README.md b/README.md index f53d182..fd58632 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,9 @@ delivers them into every tool you use. > package as a compatible Codex bundle loads its skills and bundle-scoped MCP server. > Neither path provides ambient hooks (see > [OpenClaw in ARCHITECTURE](ARCHITECTURE.md#openclaw-what-is-automatic-and-what-is-not)). -> - **Impact/blast-radius analysis is heuristic** — a regex-approximate, conservative code -> graph, not a sound call graph. Treat its output as advisory. +> - **Impact/blast-radius analysis is heuristic** — a regex-approximate code graph, not a sound +> call graph. It is not conservative: it can miss affected files as well as flag unaffected +> ones, so treat its output as advisory and an empty result as "unknown", not "safe". > - **"Proof-carrying memory" is a name, not a formal proof.** Claims are content-addressed and > carry evidence references; confidence moves only when independent oracles (tests, CI, a > human) raise it. There is no theorem-prover in the loop. @@ -175,8 +176,8 @@ The day-to-day value first — the substrate gives a frozen model what it can't evidence trail, not a formal proof). Wrong lessons decay out instead of ossifying. - **Foresight before you break things.** _[Heuristic]_ Ask "what does changing `verifyToken` break?" and get the _blast radius_ — the set of files an edit is predicted to impact, read - from a regex-approximate (conservative, not sound) code graph, including coupled files you - never named. + from a regex-approximate code graph (not sound, and it can miss affected files), including + coupled files you never named. - **Guardrails that can't be forgotten.** _[Implemented on Claude Code]_ Deterministic hooks check the rules a model shouldn't break (protected paths, cost budget, doom loops) — they survive a context compaction the way `CLAUDE.md` prose does not. They reduce risk as @@ -198,13 +199,19 @@ block in [`reports/benchmarks.md`](reports/benchmarks.md) — the project rule i an assumption until measured_. - **Blast radius in 0.43 ms** (warm code-graph). On 6 hand-labeled cases from this repo's - real import graph: recall **0.97** vs **0.33** for looking at the edited file alone. + real import graph, recall is 0.97 against 0.33 for looking at the edited file alone, but + precision is 0.34 at commit `1a82388`, not the 0.90 reported earlier, and on nine real Python + repositories the research prototype's impact oracle reached recall 0.022 + ([refutation](research/empirical-refutation/)). - **A full pre-action gate in 118 ms** (median on this repo, warm) — assumption check, routing, reuse lookup, context assembly, blast radius, scope, and goal anchor in one deterministic pass, no LLM call. On Claude Code it runs on **every prompt, automatically**. -- **62.1% cost saved vs always-premium** — from the white paper's live routing prototype on - real models (paper §9; that's the paper's measurement, not this repo's — `forge cost ---stages` reports only _your_ measured stages). +- **The white paper's 62.1% routing saving is refuted.** It was measured on the 30 tasks the + prototype's thresholds were tuned on. On 80 held-out tasks from real issues, counting every + escalation, the pipeline spent **20.2% more** than always using the premium tier; per output a + judge accepted it cost $1.06 against $1.76, but only 6 and 3 of 64 outputs were accepted + ([refutation](research/empirical-refutation/)). `forge cost --stages` reports only _your_ + measured stages. - **Conflict-free team memory** — merging two 500-claim ledger replicas takes **158 ms**; the merge is order-independent and property-tested, so teammate ledgers converge to the same state no matter who syncs first, over plain git. @@ -220,8 +227,9 @@ from a fresh repository graph. - **Git-native team merge.** Claims and append-only logs merge by set union. The join is property-tested for commutativity, associativity, and idempotence. - **Heuristic impact prediction.** Forgekit builds a regex-derived code graph and walks - reverse dependencies to estimate affected files and tests. It is conservative and may - produce false positives or miss language constructs its parser does not recognize. + reverse dependencies to estimate affected files and tests. It is not conservative: it can + miss affected files (including constructs its parser does not recognize) as well as produce + false positives. - **Budgeted context assembly.** Definitions, direct dependants, sibling tests, and trusted lessons are selected under a token budget. Missing required context becomes a question rather than invented context. @@ -317,7 +325,8 @@ Parser-stable snapshot labels used by the generated project pages are: - **A full pre-action gate in 118 ms median** — deterministic, warm repository graph, LLM disabled; - **Blast radius in 0.43 ms median** — warm impact query; and -- **62.1% cost saved** — the 30-task Python routing demonstration. +- **62.1% cost saved** — the 30-task Python routing demonstration, tuned on those tasks and + **refuted** on 80 held-out tasks, where routing cost 20.2% more than always-premium (table below). The boundaries in the table below are part of each result. @@ -325,10 +334,12 @@ The boundaries in the table below are part of each result. | --- | ---: | --- | | Warm impact query | 0.43 ms median | 30 runs on one JavaScript repository with a memoized adjacency index; not model latency | | Deterministic substrate check | 118 ms median | 3 runs on one repository, warm graph, LLM disabled | -| Impact quality | precision 0.90, recall 0.97, F1 0.92 | 6 hand-labelled symbols in this repository; edited-file-only baseline recall 0.33 | +| Impact quality | precision 0.34, recall 0.97, F1 0.50 at commit `1a82388` (the precision 0.90 and F1 0.92 reported earlier do not reproduce) | 6 hand-labelled symbols in this repository, scored by `evalImpact`; edited-file-only baseline recall 0.33 | | Ledger replica merge | 158 ms median | 3 runs merging two synthetic 500-claim replicas with 250 claims shared | | Python router live demonstration | 62.1% calculated cost reduction versus always-premium | 30 hand-labelled tasks, thresholds tuned to the set, real measured LLM tokens, approximate public prices; demonstration, not field benchmark | +| Python router, held-out evaluation | total spend 20.2% **higher** than always-premium; gate F1 0.37 | 80 tasks from real GitHub issues and PRs, thresholds frozen, pre-registered; refutes the row above | | Python impact oracle | precision 0.633, recall 1.000, F1 0.753 | 5 mutations in the bundled demo package; mutation-derived test failures as ground truth | +| Python impact oracle, real repositories | precision 0.398, recall 0.022, F1 0.042 (grep baseline F1 0.437) | 759 files in 9 open-source repositories, co-change ground truth, pre-registered; refutes the row above | The current audited CI run at commit `3d9be37` completed successfully for Node 20, Node 22, and Windows Git Bash, plus the reusable quality gate. The quality gate ran the Node unit suite, diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 8c1bec9..b43c3be 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -1142,6 +1142,12 @@ Forge cost — measured stage factors (.forge/metrics.jsonl) target (unmet until measured): the plan's composed target is ~90% (docs/plans/substrate-v2/05-cost-model.md) ``` +Read the `context` line with care: that 62% is the white paper's 30-task routing +demonstration, measured on the tasks its thresholds were tuned on. A pre-registered +evaluation on 80 held-out tasks refuted it — counting every escalation, routing cost +20.2% _more_ than always-premium ([research/empirical-refutation/](../research/empirical-refutation/)). +The line is quoted here as the CLI currently prints it. + Plain `forge cost` remains the per-day spend view via `ccusage`. ### The rest @@ -1531,8 +1537,9 @@ code reads but this table misses fails CI on the forge repo): - **Guards reduce, don't eliminate** the "ignored my rules" problem — semantic rules still live in prose. - **`recall` / `cortex` are file + prompt memory**, not weight-level learning. -- **The atlas graph is regex-approximate** — conservative, not a sound call graph; - dynamic dispatch and generated code can be missed. +- **The atlas graph is regex-approximate** — not a sound call graph, and not conservative: + it can miss affected files (dynamic dispatch and generated code among them), so an empty + impact set means "unknown", not "safe". - **`forge reuse`'s MinHash near-match is weak on very short specs** — a few words hash to too few shingles to rank reliably; write a sentence, not a keyword — or configure the optional `FORGE_EMBED` embeddings tier, which replaces exactly this term. diff --git a/docs/plans/substrate-v2/00-overview.md b/docs/plans/substrate-v2/00-overview.md index 96aa52d..4853fda 100644 --- a/docs/plans/substrate-v2/00-overview.md +++ b/docs/plans/substrate-v2/00-overview.md @@ -27,7 +27,7 @@ the storage, trust, and wire protocol for the whole substrate. | Paper capability (§10 map) | ForgeKit v0.4 | Gap | Closed by | | ------------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------ | -| M1 routing + M2 assumption gate (opp. #1) | `src/route.js`, `src/preflight.js` — 62 % measured saving | ✅ shipped; residue: outcome-calibrated weights | [06](./06-faculties-and-mechanisms.md) §7 | +| M1 routing + M2 assumption gate (opp. #1) | `src/route.js`, `src/preflight.js` — 62 % demo saving, refuted (−20.2 % held out) | ✅ shipped; residue: outcome-calibrated weights | [06](./06-faculties-and-mechanisms.md) §7 | | Impact oracle, mandatory gate (opp. #3) | `src/atlas.js` regex graph; gate opt-in (`FORGE_ENFORCE=1`) | precision; gate not mandatory | [06](./06-faculties-and-mechanisms.md) §1 | | Validity-anchored memory (opp. #2, Eq. 3) | cortex confidence exists (`src/lessons.js` already keeps α/β evidence) | no Eq. 3 retrieval, no forget/consolidate policy, flat store | [01](./01-pcm-protocol.md) | | Outcome-validated learning (opp. #4, Eq. 2) | outcomes update lessons, but not _the memories that informed an action_ | write-back band incomplete | [01](./01-pcm-protocol.md) §6, [06](./06-faculties-and-mechanisms.md) §6 | @@ -38,7 +38,7 @@ the storage, trust, and wire protocol for the whole substrate. | Imagination (faculty, §3) | atlas traversal only — no dry-run of consequences | test selection + sandbox | [06](./06-faculties-and-mechanisms.md) §2 | | M3/M4/M5/M6 (decomposition, drift, lean, inline verify) | `scope.js`/`anchor.js`/`lean.js`/`verify.js` heuristics | each gets its algorithm | [06](./06-faculties-and-mechanisms.md) §3–§6 | | Generated-UI quality (owner pain; M5-shaped) | `src/uicheck.js` WCAG contrast only; taste is prose | anti-template gate | [07](./07-ui-quality-gate.md) | -| Cost to ~90 % (owner target) | routing alone: 62 % measured | cache + context + gate stages unmeasured | [05](./05-cost-model.md) | +| Cost to ~90 % (owner target) | routing alone: 62 % on a tuned demo, refuted (−20.2 % held out) | cache + context + gate stages unmeasured | [05](./05-cost-model.md) | | ForgeKit's own UX | CLI only | `forge dash` dashboard | [08](./08-dashboard-ux.md) | ## 2. The 11-capability master table @@ -99,8 +99,10 @@ flowchart LR ## 4. Honesty register (the paper's own discipline, applied to this plan) -- **Measured:** 62 % routing saving (paper §9, live tokens); atlas recall/precision method - (paper §8). Everything else in [05-cost-model.md](./05-cost-model.md) is a **target** until +- **Measured, then refuted:** the 62 % routing saving (paper §9, live tokens) was measured on + the 30 tasks its thresholds were tuned on; on 80 held-out tasks routing cost 20.2 % more than + always-premium ([research/empirical-refutation/](../../../research/empirical-refutation/)). + The atlas recall/precision method (paper §8) is a 6-case self-labelled benchmark. Everything else in [05-cost-model.md](./05-cost-model.md) is a **target** until P8 measures it — the ~90 % figure is a composition argument, not a result. - **Solved-elsewhere, not rebuilt:** subagent orchestration (M3's mechanics), model gateways (M1's plumbing) — per paper §10 "do not rebuild". diff --git a/docs/plans/substrate-v2/05-cost-model.md b/docs/plans/substrate-v2/05-cost-model.md index 404e4cc..0905b85 100644 --- a/docs/plans/substrate-v2/05-cost-model.md +++ b/docs/plans/substrate-v2/05-cost-model.md @@ -74,8 +74,10 @@ stage tags), `substrateCheck()`, and the reuse/context modules. `forge cost` lea entry). 4. **Report:** per-stage factors with confidence intervals → `reports/cost-eval.md`; the README claim gets updated to whatever the harness measured, with the workload - caveat attached. Until then the README may say "62.1 % measured (routing); ~90 % - composed target" — never "90 % achieved". + caveat attached. Until then the README may say "~90 % composed target" — never "90 % + achieved". (It used to also say "62.1 % measured (routing)"; that figure came from the 30 + tasks the router was tuned on and was refuted on 80 held-out tasks, where routing cost + 20.2 % more than always-premium — see `research/empirical-refutation/`.) ## 4. Cost of the substrate itself diff --git a/docs/plans/substrate-v2/06-faculties-and-mechanisms.md b/docs/plans/substrate-v2/06-faculties-and-mechanisms.md index 1d28cb3..18326f7 100644 --- a/docs/plans/substrate-v2/06-faculties-and-mechanisms.md +++ b/docs/plans/substrate-v2/06-faculties-and-mechanisms.md @@ -7,7 +7,7 @@ ## §1 Impact-awareness — harden the atlas, make the gate mandatory -**Today:** `src/atlas.js` builds a regex-approximate symbol graph (honest, conservative); +**Today:** `src/atlas.js` builds a regex-approximate symbol graph (not conservative: it can miss dependents); `impact()` traverses reverse edges with hop-decay; the gate only blocks under `FORGE_ENFORCE=1`. diff --git a/global/tools/cognitive-substrate/references/capability-map.md b/global/tools/cognitive-substrate/references/capability-map.md index 1eca342..e6b9ca9 100644 --- a/global/tools/cognitive-substrate/references/capability-map.md +++ b/global/tools/cognitive-substrate/references/capability-map.md @@ -14,4 +14,4 @@ | M5 anti-over-engineering | `forge lean`, `forge uicheck design` | Footprint vs ask; UI slop-distance + fingerprint conformance gate. | | M6 inline verification | `forge verify` | External checks are required before done. | -Limits: static graph edges are conservative; memory relevance and model routing are advisory; non-hook tools cannot be forcibly blocked. +Limits: static graph edges are approximate and can miss dependents (an empty impact set means "unknown", not "safe"); memory relevance and model routing are advisory; non-hook tools cannot be forcibly blocked. diff --git a/mintlify/introduction.mdx b/mintlify/introduction.mdx index cc17e1f..31cc7dd 100644 --- a/mintlify/introduction.mdx +++ b/mintlify/introduction.mdx @@ -111,7 +111,8 @@ Forge states its own ceiling everywhere. flag cut review burden; they do not prove the code correct. - **No weight-level learning.** `recall` / `cortex` are file-and-prompt memory only — no RL, no fine-tuning. -- **The impact graph is regex-approximate** — conservative, not a sound call graph. +- **The impact graph is regex-approximate** — not a sound call graph, and not conservative: it + can miss affected files, so an empty result means "unknown", not "safe". - **Tests and human corrections always win.** diff --git a/reports/benchmarks.md b/reports/benchmarks.md index 1534400..4fc2622 100644 --- a/reports/benchmarks.md +++ b/reports/benchmarks.md @@ -80,6 +80,16 @@ limitation). They are not comparable to the paper's numbers, which came from mut testing a Python codebase against a real test suite. The two appear side by side below, labeled, and are never blended. +> **The impact-quality table in the generated section below is stale (noted 2026-09-21).** +> Re-running `evalImpact` on the same six cases at commit `1a82388` gives precision **0.34**, +> recall **0.97**, F1 **0.50** (edited-file-only baseline recall 0.33), not the precision 0.90 / +> F1 0.92 recorded below; the atlas now predicts several unlabelled files per case. Neither +> figure is field evidence: on nine real Python repositories the paper's prototype oracle reached +> recall **0.022** ([research/empirical-refutation/](../research/empirical-refutation/)), and its +> "recall 1.00" row below comes from five mutations of its own demo package. The generated block +> is rewritten only by `npm run bench`. +> + ### Environment (machine section) diff --git a/reports/cost-eval.md b/reports/cost-eval.md index 9bfe203..a5c9d0e 100644 --- a/reports/cost-eval.md +++ b/reports/cost-eval.md @@ -2,9 +2,11 @@ > Status: **no data yet.** This document is the artifact the P8 harness > ([docs/plans/substrate-v2/05-cost-model.md](../docs/plans/substrate-v2/05-cost-model.md) §3) -> fills with measurements. Until a cell below holds a measured number, the only claimable -> figures are the paper's: 62 % routing saving on live tokens (paper §9). The plan's ~90 % -> composed figure is a **target**, not a result, and does not appear in this table. +> fills with measurements. Until a cell below holds a measured number, there is no claimable +> saving. The paper's 62 % routing saving (paper §9) was measured on the 30 tasks its +> thresholds were tuned on and is **refuted**: on 80 held-out tasks, counting every escalation, +> routing cost 20.2 % _more_ than always-premium ([research/empirical-refutation/](../research/empirical-refutation/)). +> The plan's ~90 % composed figure is a **target**, not a result, and does not appear in this table. ## Methodology diff --git a/research/README.md b/research/README.md index 48cdb61..6cac916 100644 --- a/research/README.md +++ b/research/README.md @@ -16,9 +16,17 @@ most useful part. | Router/gate F1 | 1.00 | **0.37** on 80 real GitHub issues/PRs | | Cost saving | +62.1% | **−20.2%** — routing costs *more* than always-premium | -After diagnosing and repairing two defects, with parameters frozen before the held-out -repositories were touched: recall **0.653**, F1 **0.416**, beating `grep` (0.371) for the -first time — a real but narrow win. +Per output a judge accepted, the router cost $1.06 against always-premium's $1.76, but +only 6 and 3 of 64 outputs were accepted, so that comparison is not stable; 58 of the 64 +tasks failed at every tier, which is why escalation made routing cost more overall. + +After diagnosing and repairing two defects, with numeric parameters frozen before the +held-out repositories were touched: recall **0.653**, F1 **0.416**, a point estimate above +`grep`'s 0.371 for the first time. That the repaired oracle *beats* grep is **not +established**: the three held-out repositories all favour it, but three out of three is a +one-sided sign-test p of 0.125, pytest supplies 71% of the held-out pairs, the file-level +intervals overlap, and the choice of which relations to add was made on all nine +repositories. (Corrected 2026-09-21; earlier versions called it "a real but narrow win".) The general lesson, demonstrated on our own work: **a self-built demonstration can overstate field performance by more than an order of magnitude, and careful caveating does not convert @@ -32,7 +40,8 @@ statelessness, frozen parameters, bounded context — which structurally deny it (memory, learning, imagination, self-correction, impact-awareness). The remedy is an external stateful architecture, not better prompting. -- `cognitive_substrate_whitepaper.pdf` — the *Theory → Evidence → Build-Map* edition (48pp) +- `cognitive_substrate_whitepaper.pdf` — the *Theory → Evidence → Build-Map* edition (48pp); + the `.html` edition carries the 2026-09-21 corrections, the PDF predates them - `EXECUTIVE_SUMMARY.md` — one-page entry point, **carries a status banner: its prototype numbers are refuted** - `literature/` — the gap map and 32 graded references behind each faculty claim - `evidence/` — twelve load-bearing industry statistics independently re-grounded and graded @@ -47,13 +56,19 @@ stateful architecture, not better prompting. ### 2. [`formal-synthesis/`](formal-synthesis/) — the mathematics Unifies the substrate theory, the end-to-end reliability framework (F1/F2, Δ*, I1–I4, A1–A7, -T1–T6), and the forgekit implementation, proving they are one object in three vocabularies. -Central result is a two-layer duality: reliability = a probabilistic instruction layer × a -deterministic interception layer, neither alone sufficient. +T1–T6), and the forgekit implementation, arguing they are one object in three vocabularies +(not independently: forgekit was built as a binding of the other two). Central result is a +two-layer duality: the silent-miss residual is +`(1 − p) × P(no deterministic check fires | miss)`, so where each factor is bounded away from +zero, neither layer alone reaches a small residual. Since the 2026-09-21 +corrections this is stated as a bound over an explicit `(p, q)` region, not as a proof that +neither layer suffices, and the checks multiply only if they fire independently. **Priority note:** prior-art review found this composition law is standard protection-layer algebra, and two concurrent preprints derive a strictly more general Bayesian form weeks -earlier. Priority is conceded in the paper. What survives is that both are simulation-only. +earlier. Priority is conceded in the refutation paper's related work and, since the +2026-09-21 corrections, in the synthesis and the extended preprint as well (before that they +still said "this paper proves"). What survives is that both preprints are simulation-only. ### 3. [`empirical-refutation/`](empirical-refutation/) — the measurement The pre-registered evaluation that overturned the claims above, the diagnosis of *why*, and @@ -82,3 +97,39 @@ here, so swapping it in stays a deliberate decision. Where this falls short is stated too: the pre-registration and parameter freezes were self-administered with no external timestamping authority, so a reader can verify internal consistency and the amendment trail but must take the ordering on trust. + +## Corrections (2026-09-21) + +An external deep review of this repository (2026-09-21) recomputed the research statistics +from the replication package and checked the mathematics against its own definitions. The +refutation's counts reproduced exactly; several inferences and several theorems did not hold as +written. Each paper is corrected in place and ends with a dated Corrections section that quotes +the original wording: + +- **Formal synthesis and extended preprint** — Theorem D restated as a bound; Eq. 5's + independence assumption removed (identical checks at Stop, pre-commit and CI gave a 400× + understatement); `cⱼ` shown to depend on agent behaviour; the `lfp` definition, the oracle-vs-Δ* + identity, T4, T5, T6, A1, A3 and the use of Rice's theorem corrected; the faculty table + reconciled with the whitepaper; priority conceded; the "independent" convergence shown not to + be independent (the "four arrivals" are one author's work). +- **Refutation paper** (LaTeX source) — repository-cluster bootstrap intervals, the repaired + oracle's comparison with grep softened, the labels identified as one model's, cost per accepted + output added, the 96.8% ceiling qualified, the calibration statement fixed, 801 vs 759 explained. +- **Whitepaper** — status banner, refuted prototype claims marked, M1's worst-case cost, Eq. 1 vs + M2, and a misquoted statistic ("31.3% *more* PRs merged with no review"). + +Every corrected number can be re-derived with the standard-library script +[`recompute_corrections.py`](recompute_corrections.py): + +```bash +mkdir rp && tar -xzf research/empirical-refutation/replication_package.tar.gz -C rp +python research/recompute_corrections.py rp/repro +``` + +**Stale PDFs.** These PDFs predate the corrections and could not be rebuilt here (the HTML +editions were rendered with WeasyPrint, the paper with a TeX Live toolchain; neither was +available): `formal-synthesis/substrate_synthesis.pdf`, +`empirical-refutation/extended_preprint.pdf`, `empirical-refutation/paper.pdf`, +`cognitive-substrate/cognitive_substrate_whitepaper.pdf`, and the copy in +`docs/cognitive-substrate/`. The copies of `paper/main.tex` and `paper.pdf` inside +`replication_package.tar.gz` are left as published. Read the HTML and LaTeX sources. diff --git a/source/substrate.json b/source/substrate.json index fee67d8..1b96d15 100644 --- a/source/substrate.json +++ b/source/substrate.json @@ -34,7 +34,7 @@ "honesty": "Label memory relevance, routing fit, and minimality as advisory unless verified by tests, graph membership, or explicit user scope." }, "limits": [ - "Graph edges are static and conservative; dynamic dispatch and generated code may be missed.", + "Graph edges are static and approximate, not conservative: dependents can be missed (dynamic dispatch, generated code, unresolved imports), so an empty impact set means unknown, not safe.", "Memory and learning are external file-backed lessons, not weight-level learning.", "Non-hook editors receive advisory MCP/context only; Forge cannot enforce hooks where the host has no hook surface." ] From 797adbb3b3d3a21f33504e2ff154a9e7daae4a00 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:40:11 +0200 Subject: [PATCH 18/72] fix(cost): price cache tokens, dedupe log lines, drop the lower-bound and 62% claims The ccusage-less spend estimate ignored cache_creation_input_tokens and cache_read_input_tokens (most of Claude Code's input) and summed every log line, although one response is logged on several lines sharing a message id: a one-message fixture logged three times estimated $0.038 against $0.228. Cache writes are now priced at 1.25x input (2x for 1-hour writes), reads at 0.1x (Anthropic's caching multipliers; the price table carries base rates only), and each message id counts once across all log files. forge cost --stages called its composition a lower bound that can only grow, but the route factor goes negative when routing prices above the always-premium baseline; it is now "measured stages only, not a bound". The paper's 62% routing saving is printed only as refuted, next to the measured -20.2% on total spend. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 15 +++++++ docs/GUIDE.md | 12 +++--- reports/cost-eval.md | 10 +++-- src/cost_report.js | 93 +++++++++++++++++++++++++++++++--------- test/cost_report.test.js | 93 ++++++++++++++++++++++++++++++++++++++-- 5 files changed, 191 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94b1968..0edbfc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,21 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and consensus scores: the same dependency scores 0.485 (trial), maximal currency risk 0.545 and a high advisory 0.630 (both assess). Absent evidence still lands in "assess" through the evidence-count gate, never through the score. +- **`forge cost` counts what a session actually cost.** Without `ccusage`, the fallback + estimate from Claude's session logs priced only `input_tokens` and `output_tokens`, + ignoring `cache_creation_input_tokens` and `cache_read_input_tokens` (most of Claude + Code's input), and summed every log line although Claude Code writes one response on + several lines with the same message id. A one-message fixture logged three times + estimated **$0.038 against $0.228**. Cache writes are now priced at 1.25× the model's + input rate (2× for 1-hour writes) and reads at 0.1× (Anthropic's caching multipliers; + the price table carries base rates only), and each message id counts once across all + log files; the fixture now estimates $0.228. `forge cost --stages` stopped calling its + composed figure a "lower bound" that "can only grow": the route factor goes negative + when routing prices above the always-premium baseline (measuring one such stage took the + composition from 50% to 0%). It is now labeled "measured stages only, not a bound". And it + no longer prints "the paper measured a 62% routing saving" as context: the line marks + the figure as refuted next to the measured −20.2% on total spend + (`research/empirical-refutation`). ### Documentation diff --git a/docs/GUIDE.md b/docs/GUIDE.md index f6ac299..a018da1 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -1128,8 +1128,9 @@ $ forge report ### `forge cost --stages` — the measured cost report Per-stage cost factors as pure arithmetic over `.forge/metrics.jsonl`. A stage with no -events says **no data** — never a default; the composed figure is a lower bound over -measured stages only. +events says **no data** — never a default; the composed figure covers measured stages +only and is not a bound (a stage can be negative — routing that priced above the +always-premium baseline raises cost — so a newly measured stage can lower it). ```console $ forge cost --stages @@ -1141,10 +1142,10 @@ Forge cost — measured stage factors (.forge/metrics.jsonl) route no data 0 context no data 0 - composed measured reduction: 6.2% (from: gate) — lower bound, measured stages only + composed measured reduction: 6.2% (from: gate) — measured stages only, not a bound (a stage can raise cost) totals: 16 metric event(s) · ~0 tokens saved (stage self-estimates) - context (not a local measurement): the paper measured a 62% routing saving on live tokens (paper §9) + context (not a local measurement): the paper's 62% routing saving (§9) is REFUTED — the held-out replication measured −20.2% on total spend: routing cost more than always-premium (research/empirical-refutation) target (unmet until measured): the plan's composed target is ~90% (docs/plans/substrate-v2/05-cost-model.md) ``` @@ -1545,7 +1546,8 @@ code reads but this table misses fails CI on the forge repo): - **The UI fingerprint doesn't resolve CSS `var()` indirection yet** — a fully tokenized palette is partially invisible to the design gate. - **`forge cost --stages` reports measured stages only** — a stage with no events says - "no data", never a default; the composed figure is a lower bound and ~90 % is a + "no data", never a default; the composed figure is not a bound (a stage can raise + cost) and ~90 % is a labeled _target_, not a claim. - **The substrate's rubrics are heuristic, not benchmarked** — judge them after real use. What's _asserted_ (safe to gate on): repo grounding, graph traversal, scope diff --git a/reports/cost-eval.md b/reports/cost-eval.md index 9bfe203..1bfb7c6 100644 --- a/reports/cost-eval.md +++ b/reports/cost-eval.md @@ -2,8 +2,9 @@ > Status: **no data yet.** This document is the artifact the P8 harness > ([docs/plans/substrate-v2/05-cost-model.md](../docs/plans/substrate-v2/05-cost-model.md) §3) -> fills with measurements. Until a cell below holds a measured number, the only claimable -> figures are the paper's: 62 % routing saving on live tokens (paper §9). The plan's ~90 % +> fills with measurements. Until a cell below holds a measured number, nothing here is +> claimable. The paper's 62 % routing saving (§9) is refuted: the held-out replication +> (research/empirical-refutation) measured −20.2 % on total spend. The plan's ~90 % > composed figure is a **target**, not a result, and does not appear in this table. ## Methodology @@ -60,7 +61,8 @@ Route and context events are emitted via `recordRoute` / a future context-assemb - Stage rates are **workload-dependent**: factors describe the recorded traffic of one repo, not a general claim (spec §2 — repeat-heavy warm-ledger workloads differ from cold starts). -- The composed reduction is a **lower bound from measured stages only**; unmeasured stages - contribute nothing rather than a target. +- The composed reduction covers **measured stages only** and is **not a bound**: unmeasured + stages contribute nothing rather than a target, and a measured stage can be negative (it + raised cost), so measuring another stage can lower the figure. - Until the paired-run harness with the correctness guard has run, per-stage factors from live metrics are unguarded observational numbers, not eval results. diff --git a/src/cost_report.js b/src/cost_report.js index 4d02a43..62434ae 100644 --- a/src/cost_report.js +++ b/src/cost_report.js @@ -4,8 +4,8 @@ // factor here is ARITHMETIC over .forge/metrics.jsonl lines that stages actually emitted; a // stage with no events reports measured:false and value:null — it is never guessed, defaulted, // or backfilled from a target. The ~90 % figure in the plan stays a TARGET everywhere in this -// module's output; the only measured external figure (62 % routing, paper §9) is cited as -// context, clearly labeled as not-local. +// module's output. The white paper's 62 % routing saving (§9) is cited only as REFUTED: the +// held-out replication (research/empirical-refutation) measured −20.2 % on total spend. import { existsSync, readdirSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -107,9 +107,11 @@ export function stageFactors(root) { /** * The multiplicative composition C = C₀ · Π(1 − fᵢ) over ONLY the measured factors. - * Honest framing: because unmeasured stages contribute exactly nothing (factor 0, not a - * target), the result is a LOWER BOUND built from measured stages only — it can only grow - * as more stages start emitting metrics, and it is never the plan's ~90 % target restated. + * Honest framing: unmeasured stages contribute exactly nothing (factor 0, not a target), + * and it is never the plan's ~90 % target restated. It is NOT a bound in either direction: + * a measured factor can be negative (routing that priced above the always-premium baseline + * raises cost), so adding a measured stage can LOWER the figure, and an unmeasured stage + * could move it either way once measured. * @param {ReturnType} factors * @returns {{measuredReduction:number, stagesIncluded:string[], stagesMissing:string[]}} */ @@ -149,7 +151,7 @@ export function report(root) { ); caveats.push( "stage rates are workload-dependent: these factors describe this repo's recorded traffic, not a general claim (05-cost-model.md §2)", - "the composed figure is a lower bound from measured stages only; savings are not correctness-guarded until the P8 paired harness runs (05-cost-model.md §3)", + "the composed figure covers measured stages only and is not a bound — a stage can be negative (it raised cost), so a newly measured stage can lower it; savings are not correctness-guarded until the P8 paired harness runs (05-cost-model.md §3)", ); return { factors, composed, totals, caveats }; } @@ -158,9 +160,9 @@ const pct = (v) => `${(v * 100).toFixed(1)}%`; /** * Human rendering. Register matters as much as arithmetic: measured numbers print as - * measurements, the paper's 62 % routing figure prints as CONTEXT (a citation, not a local - * result), and the ~90 % figure appears only with the word "target" in front of it — this - * report never claims it as achieved. + * measurements, the paper's 62 % routing figure prints only as REFUTED next to the measured + * −20.2 % (never as a result), and the ~90 % figure appears only with the word "target" in + * front of it — this report never claims it as achieved. * @param {ReturnType} r */ export function renderCostReport(r) { @@ -173,7 +175,7 @@ export function renderCostReport(r) { lines.push(""); lines.push( r.composed.stagesIncluded.length - ? ` composed measured reduction: ${pct(r.composed.measuredReduction)} (from: ${r.composed.stagesIncluded.join(", ")}) — lower bound, measured stages only` + ? ` composed measured reduction: ${pct(r.composed.measuredReduction)} (from: ${r.composed.stagesIncluded.join(", ")}) — measured stages only, not a bound (a stage can raise cost)` : " composed measured reduction: 0.0% — no stage has recorded events yet", ); lines.push( @@ -181,7 +183,7 @@ export function renderCostReport(r) { ); lines.push( "", - " context (not a local measurement): the paper measured a 62% routing saving on live tokens (paper §9)", + " context (not a local measurement): the paper's 62% routing saving (§9) is REFUTED — the held-out replication measured −20.2% on total spend: routing cost more than always-premium (research/empirical-refutation)", " target (unmet until measured): the plan's composed target is ~90% (docs/plans/substrate-v2/05-cost-model.md)", ); lines.push("", " caveats:"); @@ -209,10 +211,39 @@ export function recordRoute(root, { tier, tokensIn, tokensOut, ref } = {}) { return record(root, { stage: "route", tier, tokensIn, tokensOut, ref }); } +/** + * Anthropic prompt-caching prices as ratios of a model's BASE input price — the + * model_tiers table carries base input/output only. A 5-minute cache write costs 1.25×, + * a 1-hour write 2×, and a cache read 0.1× (Anthropic's published caching multipliers; + * model-specific exceptions are not modeled). In Claude Code logs cache reads and writes + * are most of the input, so leaving them out undercounts spend several-fold. + */ +export const CACHE_PRICE_RATIO = Object.freeze({ write5m: 1.25, write1h: 2, read: 0.1 }); + +/** Token counts of one `usage` object, cache writes split by TTL. Missing → 0. */ +function usageTokens(usage) { + const n = (x) => (Number.isFinite(Number(x)) && Number(x) > 0 ? Number(x) : 0); + const w1h = n(usage.cache_creation?.ephemeral_1h_input_tokens); + const w5m = n(usage.cache_creation?.ephemeral_5m_input_tokens); + // The total is authoritative; without a TTL breakdown every write is the 5-minute kind. + const writes = Math.max(n(usage.cache_creation_input_tokens), w5m + w1h); + return { + inTokens: n(usage.input_tokens), + outTokens: n(usage.output_tokens), + cacheWrite5mTokens: writes - w1h, + cacheWrite1hTokens: w1h, + cacheReadTokens: n(usage.cache_read_input_tokens), + }; +} + /** * Fallback spend estimation from Claude's native JSONL session logs when ccusage * is unavailable. Scans ~/.claude/projects/ for session files and computes cost - * from token counts x model_tiers pricing. Best-effort, never throws. + * from token counts x model_tiers pricing — uncached input and output at the model's + * rates, cache writes and reads at CACHE_PRICE_RATIO of its input rate. Claude Code + * writes one API response on several lines (one per content block), each repeating the + * same message id and usage, and a resumed session re-logs its history into a new file, + * so each message id is counted once across every file. Best-effort, never throws. */ export function estimateSpendFromLogs() { try { @@ -223,6 +254,7 @@ export function estimateSpendFromLogs() { pricingPerM[m.id] = { inCost: m.inCost, outCost: m.outCost }; } const byModel = {}; + const seen = new Set(); let sessions = 0; for (const project of readdirSync(projectsDir)) { const pDir = join(projectsDir, project); @@ -244,11 +276,20 @@ export function estimateSpendFromLogs() { const usage = entry.usage || entry.message?.usage; const model = entry.model || entry.message?.model || ""; if (!usage) continue; - const inTok = usage.input_tokens || 0; - const outTok = usage.output_tokens || 0; - if (!byModel[model]) byModel[model] = { inTokens: 0, outTokens: 0 }; - byModel[model].inTokens += inTok; - byModel[model].outTokens += outTok; + const id = entry.message?.id ?? entry.requestId ?? null; + if (id) { + if (seen.has(id)) continue; // the same response, logged again + seen.add(id); + } + const t = usageTokens(usage); + const acc = (byModel[model] ??= { + inTokens: 0, + outTokens: 0, + cacheWrite5mTokens: 0, + cacheWrite1hTokens: 0, + cacheReadTokens: 0, + }); + for (const k of Object.keys(acc)) acc[k] += t[k]; } catch {} } } catch {} @@ -256,12 +297,24 @@ export function estimateSpendFromLogs() { } let totalCost = 0; const modelBreakdown = []; - for (const [model, usage] of Object.entries(byModel)) { + for (const [model, u] of Object.entries(byModel)) { const pricing = pricingPerM[model] || { inCost: 3, outCost: 15 }; const cost = - (usage.inTokens * pricing.inCost + usage.outTokens * pricing.outCost) / 1_000_000; + (u.inTokens * pricing.inCost + + u.outTokens * pricing.outCost + + u.cacheWrite5mTokens * pricing.inCost * CACHE_PRICE_RATIO.write5m + + u.cacheWrite1hTokens * pricing.inCost * CACHE_PRICE_RATIO.write1h + + u.cacheReadTokens * pricing.inCost * CACHE_PRICE_RATIO.read) / + 1_000_000; totalCost += cost; - modelBreakdown.push({ model, cost, inTokens: usage.inTokens, outTokens: usage.outTokens }); + modelBreakdown.push({ + model, + cost, + inTokens: u.inTokens, + outTokens: u.outTokens, + cacheWriteTokens: u.cacheWrite5mTokens + u.cacheWrite1hTokens, + cacheReadTokens: u.cacheReadTokens, + }); } modelBreakdown.sort((a, b) => b.cost - a.cost); return { totalCost, sessions, byModel: modelBreakdown }; diff --git a/test/cost_report.test.js b/test/cost_report.test.js index c641b5e..e08de59 100644 --- a/test/cost_report.test.js +++ b/test/cost_report.test.js @@ -1,10 +1,12 @@ import assert from "node:assert/strict"; import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; import { + CACHE_PRICE_RATIO, composedReduction, + estimateSpendFromLogs, recordGate, recordRoute, renderCostReport, @@ -159,14 +161,99 @@ test('renderCostReport: "90" appears ONLY behind the word "target" — never as assert90OnlyAsTarget(renderCostReport(report(mixed))); }); -test("renderCostReport: unmeasured stages print as no-data; the 62% figure is labeled as paper context", () => { +test("renderCostReport: unmeasured stages print as no-data; the 62% figure prints only as REFUTED", () => { const out = renderCostReport(report(tmp())); assert.ok(out.includes("gate")); assert.ok((out.match(/no data/g) || []).length === 4, "all four stages show no data"); - assert.ok(/context \(not a local measurement\).*62%.*paper §9/.test(out)); + // Regression (review E4): "the paper measured a 62% routing saving" printed as a result + // although the empirical refutation measured −20.2% on total spend. + const line = out.split("\n").find((l) => l.includes("62%")); + assert.ok(line, "the paper figure is still cited"); + assert.match(line, /REFUTED/); + assert.match(line, /−20\.2%/); + assert.ok(!/measured a 62%/.test(out)); assert.ok(out.includes("caveats:")); }); +test("composedReduction: a cost-raising stage LOWERS the figure — it is not a lower bound", () => { + // Regression (review E4): the report called the composition a "lower bound" that "can only + // grow" as stages are measured, but the route factor goes negative when routing prices + // above the always-premium baseline (every event on the extreme tier here). + const root = tmp(); + seed(root, [ + { stage: "cache", outcome: "hit_exact" }, + { stage: "cache", outcome: "miss" }, + ]); + const before = composedReduction(stageFactors(root)).measuredReduction; + seed(root, [ + { stage: "cache", outcome: "hit_exact" }, + { stage: "cache", outcome: "miss" }, + { stage: "route", tier: "fable", tokensIn: 1000, tokensOut: 1000 }, + ]); + const f = stageFactors(root); + assert.ok(f.route.value < 0, "routing cost more than the baseline"); + assert.ok(composedReduction(f).measuredReduction < before, "a measured stage lowered it"); + const r = report(root); + const text = `${renderCostReport(r)}\n${r.caveats.join("\n")}`; + assert.ok(!/lower bound/i.test(text), "no lower-bound claim survives"); + assert.match(text, /not a bound/); +}); + +// --- estimateSpendFromLogs: the ccusage-less fallback ---------------------------------- + +test("estimateSpendFromLogs: prices cache tokens and counts a repeated response once (E4)", () => { + // test/_setup.js sandboxes $HOME, so this writes into a throwaway ~/.claude/projects. + const dir = join(homedir(), ".claude", "projects", "cost-fixture"); + mkdirSync(dir, { recursive: true }); + // Claude Code logs ONE API response on several lines (one per content block) sharing + // message.id + usage — and most of the input is cache reads/writes. + const msg = { + type: "assistant", + requestId: "req_1", + message: { + id: "msg_1", + model: "claude-opus-4-8", + usage: { + input_tokens: 10, + cache_creation_input_tokens: 20000, + cache_read_input_tokens: 180000, + output_tokens: 500, + }, + }, + }; + const hourly = { + type: "assistant", + message: { + id: "msg_2", + model: "claude-opus-4-8", + usage: { + input_tokens: 0, + cache_creation_input_tokens: 1000, + cache_creation: { ephemeral_5m_input_tokens: 0, ephemeral_1h_input_tokens: 1000 }, + output_tokens: 0, + }, + }, + }; + const lines = (...xs) => `${xs.map((x) => JSON.stringify(x)).join("\n")}\n`; + writeFileSync(join(dir, "s1.jsonl"), lines(msg, msg, msg, hourly)); + writeFileSync(join(dir, "s2.jsonl"), lines(msg)); // a resumed session re-logs history + const est = estimateSpendFromLogs(); + const opus = est.byModel.find((m) => m.model === "claude-opus-4-8"); + assert.equal(opus.inTokens, 10, "msg_1 counted once across lines and files"); + assert.equal(opus.cacheReadTokens, 180000); + assert.equal(opus.cacheWriteTokens, 21000); + // Opus 4.8: $5 in / $25 out per MTok; writes 1.25× (5m) / 2× (1h), reads 0.1× of input + const expected = + (10 * 5 + + 500 * 25 + + 20000 * 5 * CACHE_PRICE_RATIO.write5m + + 1000 * 5 * CACHE_PRICE_RATIO.write1h + + 180000 * 5 * CACHE_PRICE_RATIO.read) / + 1e6; + assert.ok(Math.abs(opus.cost - expected) < 1e-12, `${opus.cost} vs ${expected}`); + assert.ok(Math.abs(opus.cost - 0.23755) < 1e-9, "was $0.038 before (input+output only)"); +}); + test("renderCostReport: measured factors print as percentages with event counts", () => { const root = tmp(); seed(root, [ From 34ab86728eee36cf1494df7d0a5b0febe1b2e19f Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:40:36 +0200 Subject: [PATCH 19/72] docs(route): say plainly that llm.escalateTo is advisory and nothing acts on it A deferred higher-band vote is reported as llm.escalateTo, but no code path consumes it: nothing escalates automatically after a verifier failure. The route reason text, reconcileRoute JSDoc, substrate guarantees, GUIDE, cognitive-substrate README, substrate.json note and CHANGELOG now call it an advisory recommendation instead of implying a wired escalation path. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 7 ++++--- docs/GUIDE.md | 8 +++++--- docs/cognitive-substrate/README.md | 5 +++-- source/substrate.json | 2 +- src/route.js | 9 +++++---- src/substrate.js | 4 ++-- 6 files changed, 20 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 991c717..8eccf13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,9 +104,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **The proposer can no longer raise the tier.** The "free raise" escalated on the model's own assessment, which whitepaper §5.1 rules out (escalate "only if an external check on the output fails … never by the model's self-assessment"). A higher-band vote is now recorded, - not applied: path `llm-raise-deferred`, with the would-be tier kept as `llm.escalateTo` for - the verifier-failure path — a prime finder with a 0.99 "premium" vote stays on haiku instead - of jumping to opus. Route provenance is now `deterministic` / `llm-agreed` / `llm-lowered` / + not applied: path `llm-raise-deferred` — a prime finder with a 0.99 "premium" vote stays on + haiku instead of jumping to opus. The would-be tier is reported as `llm.escalateTo`, an + **advisory recommendation only**: nothing in forge acts on it automatically (no + verifier-failure path consumes it yet). Route provenance is now `deterministic` / `llm-agreed` / `llm-lowered` / `llm-raise-deferred` / `llm-overruled` (+ `overruledBy`); `llm-raised` is gone. - **The assumption gate compares the proposer's verdict with the rubric's instead of clipping one scale onto the other.** The rubric's logistic saturates on real issues (median diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 7c52b3f..9223e90 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -1453,9 +1453,11 @@ ask/proceed only when it holds its own verdict with p ≥ `llm.minConfidence`. R score already sits in leaves it alone; a vote for a lower band moves the score to that band's ceiling only when the vote's p(band) reaches `llm.minConfidence` (an a-priori 0.8 — choose it on fresh labelled data; a text-model vote reports no probability and so cannot move the tier unless -you set it to 0); a vote for a **higher** band is never applied — it is recorded as -`llm.escalateTo`, because the tier escalates only when a verifier fails, never on the model's -own assessment (whitepaper §5.1). Impact edges must be real + grep-confirmed; goal-drift moves +you set it to 0); a vote for a **higher** band is never applied, because the tier may escalate +only when a verifier fails, never on the model's own assessment (whitepaper §5.1). The tier it +would have picked is reported in `--json` as `llm.escalateTo` — an **advisory recommendation +only**: nothing in Forge acts on it automatically; escalating after a verifier failure is still +yours (or the doom-loop diagnosis's) to do. Impact edges must be real + grep-confirmed; goal-drift moves off→on only. Any failure falls back to the deterministic path, so the flag is safe to leave off or on. `--json` exposes `llm.provenance` per faculty (`llm-cleared` / `llm-tightened` / `llm-lowered` / `llm-raise-deferred` / `llm-overruled` / …). Set `llm.bidirectional: false` in diff --git a/docs/cognitive-substrate/README.md b/docs/cognitive-substrate/README.md index 2c082d7..882c532 100644 --- a/docs/cognitive-substrate/README.md +++ b/docs/cognitive-substrate/README.md @@ -212,8 +212,9 @@ but never past a hard floor: ceiling only when the vote's p(band) reaches `llm.minConfidence`, and never below a strong-signal (algorithmic/architectural) floor, so a "distributed rate-limiter" can't be talked down to the cheap tier. A vote for a _higher_ band is **not applied** — escalation - follows a verifier failure, never the model's self-assessment (§5.1) — and is kept as - `llm.escalateTo`; + follows a verifier failure, never the model's self-assessment (§5.1). The tier it would have + picked is reported as `llm.escalateTo`, an advisory recommendation that nothing acts on + automatically; - **the assumption gate** — can _clear_ a false ask **or** _add_ one, but never clears a task with no concrete anchor, or one naming symbols/files the repo doesn't define (those floors guard clearing only — they never raise an ask the rubric didn't). The rubric's completeness diff --git a/source/substrate.json b/source/substrate.json index 3e32707..b0c1840 100644 --- a/source/substrate.json +++ b/source/substrate.json @@ -24,7 +24,7 @@ "bidirectional": true, "minConfidence": 0.8, "signalFloor": 0.4, - "note": "Opt-in (FORGE_LLM=1). The model PROPOSES; the deterministic rubric + repo grounding + tests VERIFY. Any failure falls back to the deterministic path — never blind, never blocking. `bidirectional` (default true) lets a verified reading also clear a false ask / route a tier down (to the voted band), never past the hard floors (`signalFloor`, repo grounding, no-anchor). Readings are compared verdict to verdict (ask vs proceed, band vs band), and a proposer flips the rubric only when it holds its own verdict with probability >= `minConfidence`. A model vote for a HIGHER tier is never applied (escalate only on a verifier failure). `minConfidence` is an a-priori conservative default, not fit to data - choose it on fresh labelled tasks. Set `bidirectional` false for the conservative mode (the model can only tighten the gate and never moves the tier)." + "note": "Opt-in (FORGE_LLM=1). The model PROPOSES; the deterministic rubric + repo grounding + tests VERIFY. Any failure falls back to the deterministic path — never blind, never blocking. `bidirectional` (default true) lets a verified reading also clear a false ask / route a tier down (to the voted band), never past the hard floors (`signalFloor`, repo grounding, no-anchor). Readings are compared verdict to verdict (ask vs proceed, band vs band), and a proposer flips the rubric only when it holds its own verdict with probability >= `minConfidence`. A model vote for a HIGHER tier is never applied (escalate only on a verifier failure); it is reported as an advisory `llm.escalateTo` that nothing acts on automatically. `minConfidence` is an a-priori conservative default, not fit to data - choose it on fresh labelled tasks. Set `bidirectional` false for the conservative mode (the model can only tighten the gate and never moves the tier)." }, "policies": { "preAction": "Run substrate before ambiguous, expensive, multi-file, or mutating work.", diff --git a/src/route.js b/src/route.js index a9c4009..47e20c5 100644 --- a/src/route.js +++ b/src/route.js @@ -427,9 +427,10 @@ function proposalConfidence(proposal) { * Pure: reconcile the deterministic complexity score with a proposer's band vote. * - same band → the deterministic score stands ("llm-agreed"); * - higher band → NOT applied (whitepaper §5.1: spend more only when an external check - * on the output fails, never on a model's self-assessment). The target - * is kept as `escalateTo` for that verifier-failure path - * ("llm-raise-deferred"); + * on the output fails, never on a model's self-assessment). The tier the + * vote would have picked is returned as `escalateTo` — an ADVISORY + * recommendation only: nothing in forge acts on it automatically (no + * verifier-failure path consumes it yet) ("llm-raise-deferred"); * - lower band → lowered to that band's ceiling — only when bidirectional, only when * the vote clears `minConfidence`, and never below `signalFloor` when * the rubric has a strong topic signal ("llm-lowered"); otherwise the @@ -648,7 +649,7 @@ export function routeTask( : []), ...(path === "llm-raise-deferred" ? [ - `model judged ${proposal.band} — not applied; escalate to ${verdict.escalateTo} only if a verifier fails`, + `model judged ${proposal.band} — not applied; advisory only: consider ${verdict.escalateTo} if a verifier fails (nothing escalates automatically)`, ] : []), ]), diff --git a/src/substrate.js b/src/substrate.js index 5bba3d8..26bd72a 100644 --- a/src/substrate.js +++ b/src/substrate.js @@ -393,7 +393,7 @@ export function substrateCheck( substrate: loadSubstrateSpec(), // Which faculties, if any, had a model proposal survive external verification this run, and // which direction it moved (…-cleared / …-tightened for the gate, …-lowered for routing; a - // routing …-raise-deferred is recorded but never applied). Every non-deterministic value was + // routing …-raise-deferred is recorded as advisory only and never applied). Every non-deterministic value was // checked before it counted. llm: { enabled: useLLM, @@ -419,7 +419,7 @@ export function substrateCheck( // verdict — safe to surface, never blindly trusted (whitepaper tabayyun gate). llmVerified: [ "assumption refinement (verdict vs verdict, confidence-gated; clears a false ask only past the no-anchor + repo-grounding floors)", - "routing (band-to-band; a confident lower vote only, never below the strong-signal floor; raises deferred to a verifier failure)", + "routing (band-to-band; a confident lower vote only, never below the strong-signal floor; a higher vote is never applied, only reported as an advisory escalateTo that nothing acts on)", "impact edges (graph + grep verified)", "goal-drift rescue (off→on, goal-referenced)", ], From 5eb330af77588cedd519e807b4cff9939a79c375 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:44:00 +0200 Subject: [PATCH 20/72] fix(cortex): redact session hook logs and gitignore .forge/sessions (B5) The prompt and capture hooks wrote raw prompts and Bash commands to .forge/sessions/.jsonl, so a pasted GITHUB_TOKEN=... or a curl with an Authorization: Bearer header sat on disk verbatim, and `forge init` never gitignored the directory. Redact every string of an event before it is appended, and have init write a nested .forge/.gitignore listing sessions/ (the user's root .gitignore is left alone, so a committed ledger stays committable). Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 8 ++++++++ src/cortex_hook.js | 19 +++++++++++++++++-- src/gitignore.js | 32 +++++++++++++++++++++++++++++++- src/init.js | 3 +++ test/cortex_hook.test.js | 27 ++++++++++++++++++++++++++- test/gitignore.test.js | 32 +++++++++++++++++++++++++++++++- test/init.test.js | 8 ++++++++ 7 files changed, 124 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5f9236..62f2289 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,14 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). whole, up from 1,038. The rest (about 0.2%) start with `/`, so they are read as a path. Ordinary URLs, `$VAR` references, kwargs like `f(password=pw)` and counters like `MAX_TOKENS=4096` are still left alone. +- **Session hook logs no longer store raw secrets, and `init` keeps them out of git.** The + `prompt` and `capture` hooks appended the user's prompt and every Bash command verbatim to + `/.forge/sessions/.jsonl`. A pasted `GITHUB_TOKEN=ghp_…` or an `Authorization: + Bearer ghp_…` curl landed on disk in the repo, and `forge init` did not gitignore the + directory. Every string in a session event is now passed through `redactSecrets` before + it is written (the file now holds `GITHUB_TOKEN=[REDACTED]`). `init` also writes a nested + `.forge/.gitignore` that ignores `sessions/` without touching the user's root + `.gitignore`, so a deliberately committed ledger or `decisions.md` stays committable. - **The commit gate's secret scan now fails closed.** It read `git diff --cached` with the default 1 MiB `execFileSync` buffer. On overflow the diff became `""`, so a `ghp_` leak alone was refused (exit 1), but the same leak staged next to a 1.5 MB file was "allowed" diff --git a/src/cortex_hook.js b/src/cortex_hook.js index 68db267..d27457f 100644 --- a/src/cortex_hook.js +++ b/src/cortex_hook.js @@ -5,6 +5,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { recordContradiction, recordMistake } from "./cortex.js"; +import { redactSecrets } from "./secrets.js"; import { contentHash, slug } from "./util.js"; // One naming rule for every per-session artifact (event log, git baseline, gate marker, @@ -15,12 +16,26 @@ export const sessionPath = (root, sid, ext = "jsonl") => const sessionFile = (root, sid) => sessionPath(root, sid); -/** Append one normalized event to a session's log (called by capture hooks). */ +/** Every string leaf of an event, secret-redacted; structure and non-strings untouched. */ +const redactEvent = (v) => + typeof v === "string" + ? redactSecrets(v) + : Array.isArray(v) + ? v.map(redactEvent) + : v && typeof v === "object" + ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, redactEvent(x)])) + : v; + +/** Append one normalized event to a session's log (called by capture hooks). Prompts and + * shell commands are the user's raw text — a pasted `GITHUB_TOKEN=…` or an + * `Authorization: Bearer …` curl used to land verbatim on disk — so every string is + * redacted BEFORE it is written (the signals read verbs like `npm test`/`git revert`, + * which redaction never touches). */ export function appendSessionEvent(root, sid, event) { if (!event) return; const path = sessionFile(root, sid); mkdirSync(join(root, ".forge", "sessions"), { recursive: true }); - appendFileSync(path, `${JSON.stringify(event)}\n`); + appendFileSync(path, `${JSON.stringify(redactEvent(event))}\n`); } export function readSession(root, sid) { diff --git a/src/gitignore.js b/src/gitignore.js index d61b1aa..0f9b87b 100644 --- a/src/gitignore.js +++ b/src/gitignore.js @@ -6,8 +6,9 @@ // ensureGitignoreBlock repeatedly is idempotent. removeGitignoreBlock reverses it, // stripping the block alone. This lets `forge tools` hide secondary-tool artifacts // (.cursor/, .gemini/, …) for a repo that only uses one agent, reversibly. -import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { BRAND } from "./brand.js"; export const BEGIN = "# forge:gitignore:begin"; export const END = "# forge:gitignore:end"; @@ -80,6 +81,35 @@ export function removeGitignoreBlock(root) { return { action: "removed", path: file }; } +// Per-session hook logs (raw prompts and shell commands) under .forge/sessions/ are local +// runtime state that must never reach git. A repo may deliberately commit OTHER .forge/ +// content (the ledger, decisions.md), so rather than rewrite the user's root .gitignore +// the tool owns a nested .forge/.gitignore listing only its private runtime dirs. +export const FORGE_PRIVATE_DIRS = ["sessions/"]; + +/** + * Ensure `/.forge/.gitignore` ignores every FORGE_PRIVATE_DIRS entry. Appends only + * the missing lines and keeps anything already there; identical content is a no-op. + * @param {string} root + * @returns {{action:"written"|"unchanged", path:string}} + */ +export function ensureForgePrivateIgnored(root) { + const dir = join(root, ".forge"); + const file = join(dir, ".gitignore"); + const existing = existsSync(file) ? readFileSync(file, "utf8") : ""; + const have = new Set(existing.split(/\r?\n/).map((l) => l.trim().replace(/^\//, ""))); + const missing = FORGE_PRIVATE_DIRS.filter((p) => !have.has(p) && !have.has(p.slice(0, -1))); + if (!missing.length) return { action: "unchanged", path: file }; + const lead = existing + ? existing.endsWith("\n") + ? "" + : "\n" + : `# ${BRAND.brand} runtime state — never committed (written by \`${BRAND.cli} init\`)\n`; + mkdirSync(dir, { recursive: true }); + writeFileSync(file, `${existing}${lead}${missing.join("\n")}\n`); + return { action: "written", path: file }; +} + /** * The paths currently listed in the managed block (comment/marker lines excluded). * Empty array when there is no block. diff --git a/src/init.js b/src/init.js index 1de2b69..5da039c 100644 --- a/src/init.js +++ b/src/init.js @@ -13,6 +13,7 @@ import { import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { BRAND } from "./brand.js"; +import { ensureForgePrivateIgnored } from "./gitignore.js"; import { GITATTRIBUTES_RULE } from "./ledger_store.js"; import { autoDetectProvider } from "./providers.js"; import { validateProfile, writeForgeConfig } from "./repo_config.js"; @@ -790,6 +791,8 @@ export function init({ if (profileResult?.error) return { profile: profileResult, aborted: true }; const r = sync({ targetRoot }); ensureLedgerGitattributes(targetRoot); + // Session hook logs hold raw prompts/commands — never let them be committed. + ensureForgePrivateIgnored(targetRoot); const settings = mergeSettings({ noSettings, settingsPath, diff --git a/test/cortex_hook.test.js b/test/cortex_hook.test.js index a67e805..e5c852d 100644 --- a/test/cortex_hook.test.js +++ b/test/cortex_hook.test.js @@ -1,16 +1,20 @@ import assert from "node:assert/strict"; -import { mkdtempSync } from "node:fs"; +import { mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; import { + appendSessionEvent, classifyEvent, detectDoomLoop, detectEpisodes, doomLoopAdvisory, processSession, + readSession, + sessionPath, } from "../src/cortex_hook.js"; import { load } from "../src/lessons_store.js"; +import { fakeGithubPat } from "./_fixtures.js"; // Default is now ledger-only; these cases exercise the legacy FILE store (the // FORGE_LEDGER_ONLY=0 escape hatch). Pin it here so they test that path directly. @@ -184,3 +188,24 @@ test("outputSignature normalizes line numbers/timings so the same error matches }); assert.equal(e1.outputSig, e2.outputSig, "line/timing noise is normalized out"); }); + +// B5: prompts and shell commands were appended to .forge/sessions/.jsonl verbatim, so a +// pasted `GITHUB_TOKEN=ghp_…` or an `Authorization: Bearer …` curl sat on disk in the repo. +test("session log never stores a raw secret from a prompt or a command (B5)", () => { + const root = fixture(); + const tok = fakeGithubPat(); + const log = (hook) => appendSessionEvent(root, "s-b5", classifyEvent(hook)); + log({ hook_event_name: "UserPromptSubmit", prompt: `deploy with GITHUB_TOKEN=${tok} please` }); + log({ + tool_name: "Bash", + tool_input: { command: `curl -H 'Authorization: Bearer ${tok}' https://api.github.com` }, + }); + log({ tool_name: "Bash", tool_input: { command: "git revert HEAD" }, exitCode: 0 }); + const raw = readFileSync(sessionPath(root, "s-b5"), "utf8"); + assert.equal(raw.includes(tok), false, "the token never reaches disk"); + assert.match(raw, /GITHUB_TOKEN=\[REDACTED\] please/, "the prompt stays readable"); + // Redaction must not blind the signal detectors (verbs are never secrets). + const events = readSession(root, "s-b5"); + assert.equal(events.length, 3); + assert.equal(events[2].command, "git revert HEAD"); +}); diff --git a/test/gitignore.test.js b/test/gitignore.test.js index 58dd533..ced15cf 100644 --- a/test/gitignore.test.js +++ b/test/gitignore.test.js @@ -1,11 +1,13 @@ import assert from "node:assert/strict"; -import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; import { BEGIN, END, + ensureForgePrivateIgnored, ensureGitignoreBlock, readGitignoreBlock, removeGitignoreBlock, @@ -102,3 +104,31 @@ test("removeGitignoreBlock on a missing / block-free file is a no-op", () => { assert.equal(removeGitignoreBlock(root).action, "unchanged"); assert.equal(read(root), "just-user\n"); }); + +// B5: session hook logs (raw prompts/commands) under .forge/sessions/ must never be +// committable, without touching the user's own root .gitignore. +test("ensureForgePrivateIgnored keeps .forge/sessions out of git (B5)", () => { + const root = tmp(); + execFileSync("git", ["init", "-q"], { cwd: root }); + mkdirSync(join(root, ".forge", "sessions"), { recursive: true }); + writeFileSync(join(root, ".forge", "sessions", "s1.jsonl"), "{}\n"); + writeFileSync(join(root, ".forge", "decisions.md"), "# decisions\n"); + assert.equal(ensureForgePrivateIgnored(root).action, "written"); + const ignored = (p) => { + try { + execFileSync("git", ["check-ignore", "-q", p], { cwd: root }); + return true; + } catch { + return false; + } + }; + assert.equal(ignored(".forge/sessions/s1.jsonl"), true, "session logs are ignored"); + assert.equal(ignored(".forge/decisions.md"), false, "committable .forge content is not"); + assert.equal(ensureForgePrivateIgnored(root).action, "unchanged", "idempotent"); + // An existing .forge/.gitignore keeps its lines; only the missing entry is appended. + const other = tmp(); + mkdirSync(join(other, ".forge"), { recursive: true }); + writeFileSync(join(other, ".forge", ".gitignore"), "cache/"); + ensureForgePrivateIgnored(other); + assert.equal(readFileSync(join(other, ".forge", ".gitignore"), "utf8"), "cache/\nsessions/\n"); +}); diff --git a/test/init.test.js b/test/init.test.js index 7327ea1..089a279 100644 --- a/test/init.test.js +++ b/test/init.test.js @@ -45,6 +45,14 @@ test("init emits the shared config for a fresh repo in one call", () => { assert.ok(existsSync(join(root, ".aider.conf.yml")), ".aider.conf.yml"); }); +test("init gitignores the per-session hook logs under .forge/sessions (B5)", () => { + const root = mkdtempSync(join(tmpdir(), "forge-init-")); + init({ targetRoot: root, settingsPath: join(root, ".claude", "settings.json") }); + const gi = join(root, ".forge", ".gitignore"); + assert.ok(existsSync(gi), ".forge/.gitignore written"); + assert.match(readFileSync(gi, "utf8"), /^sessions\/$/m); +}); + test("mergeSettings deduplicates plugin-style and settings-style hooks", () => { const tmp = mkdtempSync(join(tmpdir(), "forge-hooks-")); const settingsPath = join(tmp, ".claude", "settings.json"); From 4845f93e42a0ee9805dea631fdc87a57a212be76 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:44:10 +0200 Subject: [PATCH 21/72] =?UTF-8?q?fix(ledger-sync):=20push=20remote=20?= =?UTF-8?q?=E2=8A=94=20local=20instead=20of=20the=20local=20verified=20vie?= =?UTF-8?q?w?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A ref-mode push replaced state.json with the pushing replica's verified state, so records it had to quarantine (a file: proof only a teammate has, an unfetched commit) vanished from refs/forge/ledger for everyone. Push now writes mergeStates(remoteRaw, localVerified); verification happens only on read, so the shared ref only ever grows. Co-Authored-By: Claude Opus 5 --- src/ledger_sync.js | 51 ++++++++++++++++++++++++++++++++-------- test/ledger_sync.test.js | 36 ++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 10 deletions(-) diff --git a/src/ledger_sync.js b/src/ledger_sync.js index 55ca4cf..65a9bce 100644 --- a/src/ledger_sync.js +++ b/src/ledger_sync.js @@ -19,11 +19,12 @@ import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; import { BRAND } from "./brand.js"; -import { canonicalize, stateRoot } from "./ledger.js"; +import { canonicalize, mergeStates, stateRoot } from "./ledger.js"; import { importState, loadState, mergeDirs } from "./ledger_store.js"; import { gitAuthor } from "./util.js"; const STATE_FILE = "state.json"; +const LOG_MAPS = ["evidence", "provenance", "tombstones"]; // Contract note: syncTarget takes its environment via the injectable `env` param // (defaulting to process.env) and reads process.env.FORGE_SYNC_DIR from it — named @@ -172,15 +173,32 @@ export function syncDir(localDir, otherDir) { return { ok: true, mode: "dir", dir: otherDir, pulled, pushed }; } +/** The remote ref's state.json as parsed JSON, or null when absent/unreadable/not a state + * object. Never throws. */ +function readRemoteState(root, ref, run) { + try { + const raw = run(["cat-file", "blob", `${ref}:${STATE_FILE}`], { + cwd: root, + }); + const s = JSON.parse(raw); + const isMap = (m) => m === undefined || (m && typeof m === "object" && !Array.isArray(m)); + if (!s || typeof s !== "object" || !["claims", ...LOG_MAPS].every((k) => isMap(s[k]))) + return null; + return s; + } catch { + return null; + } +} /** Read the remote ref's state.json blob and import it into localDir. Returns import * counts; a missing/corrupt blob degrades to {claims:0,records:0} + a note (never * throws) so a garbled remote can never take the local ledger down. */ function pullRef(localDir, root, ref, run, notes) { + const remoteState = readRemoteState(root, ref, run); + if (!remoteState) { + notes.push("remote ledger state unreadable — treated as empty"); + return { claims: 0, records: 0, quarantined: 0 }; + } try { - const raw = run(["cat-file", "blob", `${ref}:${STATE_FILE}`], { - cwd: root, - }); - const remoteState = JSON.parse(raw); return importState(localDir, remoteState); } catch { notes.push("remote ledger state unreadable — treated as empty"); @@ -188,6 +206,18 @@ function pullRef(localDir, root, ref, run, notes) { } } +/** The bytes a push writes: the RAW remote state joined with our verified local state. + * Verification happens on READ (importState quarantines what this replica cannot + * resolve — a `file:` path only a teammate has, a commit not fetched yet); pushing only + * the local verified view used to ERASE those records from the shared ref for everyone + * (review C3). The join is the same semilattice merge, so this stays order-independent + * and monotone: the remote only ever grows. */ +function pushBytes(localDir, root, ref, run) { + const local = loadState(localDir); + const remote = refCommit(root, ref, run) ? readRemoteState(root, ref, run) : null; + return `${canonicalize(remote ? mergeStates(remote, local) : local)}\n`; +} + /** The local commit at `ref`, or null if the ref does not exist. `--verify --quiet` * exits non-zero (→ throw → null) when the ref is absent. */ function refCommit(root, ref, run) { @@ -246,12 +276,13 @@ export function syncRef( } if (refCommit(root, ref, run)) pulled = pullRef(localDir, root, ref, run, notes); - // PUSH: serialize local state to a blob/tree; skip entirely when the remote tree - // already equals ours (idempotence — re-running sync is a byte-level no-op). A - // non-fast-forward rejection means a teammate raced us: re-fetch, re-import - // (monotone), rebuild on the new parent, retry up to maxRetries. + // PUSH: serialize remote ⊔ local to a blob/tree (see pushBytes — never just our + // verified view); skip entirely when the remote tree already equals it (idempotence — + // re-running sync is a byte-level no-op). A non-fast-forward rejection means a teammate + // raced us: re-fetch, re-import (monotone), rebuild on the new parent, retry up to + // maxRetries. for (let retries = 0; ; ) { - const bytes = stateBytes(localDir); + const bytes = pushBytes(localDir, root, ref, run); let blob; let tree; try { diff --git a/test/ledger_sync.test.js b/test/ledger_sync.test.js index 3b036cf..a014e94 100644 --- a/test/ledger_sync.test.js +++ b/test/ledger_sync.test.js @@ -317,3 +317,39 @@ test("stateBytes: deterministic and newline-terminated", () => { assert.equal(stateBytes(a), stateBytes(a)); assert.ok(stateBytes(a).endsWith("\n")); }); + +// ── 8. no replica can erase what it could not verify (review C3) ────────────── +test("ref sync: a record one replica quarantines survives on the shared ref for everyone", async () => { + const { writeFileSync } = await import("node:fs"); + const { outcomeRecord } = await import("../src/ledger.js"); + const { appendEvidence, readEvidence } = await import("../src/ledger_store.js"); + const bare = initBare(); + const [alice, bob, carol] = [initRepo(), initRepo(), initRepo()]; + for (const r of [alice, bob, carol]) git(r, "remote", "add", "origin", bare); + const c = fact("perf", "p99 < 20ms", 20000); + putClaim(ledgerOf(alice), c); + // The proof exists only in alice's tree, so bob and carol cannot resolve it on import. + writeFileSync(join(alice, "bench.txt"), "p99 18ms"); + const o = outcomeRecord({ + oracle: "test.run", + result: "confirm", + ref: "file:bench.txt", + t: 20000, + }).outcome; + assert.equal(appendEvidence(ledgerOf(alice), c.id, o).ok, true); + const remoteEvidence = () => + JSON.parse(git(carol, "cat-file", "blob", "refs/forge/ledger:state.json")).evidence[c.id] ?? []; + + assert.equal(ledgerSync({ dir: ledgerOf(alice), root: alice }).ok, true); + const b = ledgerSync({ dir: ledgerOf(bob), root: bob }); + assert.equal(b.ok, true); + assert.equal(b.pulled.quarantined, 1, "bob cannot verify the record — quarantined locally"); + assert.equal(readEvidence(ledgerOf(bob), c.id).length, 0); + assert.equal(ledgerSync({ dir: ledgerOf(carol), root: carol }).ok, true); + assert.equal( + remoteEvidence().length, + 1, + "bob's push merged into the remote instead of replacing it with his verified view", + ); + assert.equal(readEvidence(ledgerOf(alice), c.id).length, 1, "alice still has it"); +}); From 81fbe0527afa28559d8dbea530335c27a79e952e Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:44:10 +0200 Subject: [PATCH 22/72] fix(ledger-bridge): restoring a superseded fact value leaves a live fact shadowFact v1 -> v2 -> v1 re-used v1's permanently tombstoned id, leaving no live fact. A retired value is re-asserted as the lowest non-tombstoned rev (deterministic, so teammates converge), and reconcileFacts matches store and ledger by content instead of by rev-0 id. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 14 ++++++++++++++ src/ledger_bridge.js | 34 +++++++++++++++++++++++++--------- test/ledger_bridge.test.js | 27 +++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc1e752..cfe98ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,20 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). an unwritable `.forge` — never got a reply and the client waited for its own timeout. The server now returns `-32603` with the tool name and message, and keeps serving (the repro received replies for ids `[2]` before, `[2, 1]` after). +- **`forge ledger sync` no longer erases teammates' evidence from the shared ref.** A push + wrote the pushing replica's *verified* state, so any record it had to quarantine (a `file:` + proof only a teammate's tree has, a commit it had not fetched) vanished from + `refs/forge/ledger` for everyone — the review's alice/bob/carol run ended with the remote + holding 0 of alice's 1 record. A push now writes the raw remote state joined with the local + verified state (the same semilattice merge) and verification happens only on read: the + remote keeps the record (1), bob and carol still quarantine it locally, and a re-run is + still a byte-level no-op. +- **Restoring a superseded fact leaves it live.** Fact claims are content-addressed and + tombstones are permanent, so `forge remember api-base v1` → `v2` → `v1` put the restored + value back on v1's retired id: the ledger held no live `api-base` fact at all (`list` + went from `["api-base"]` to `[]`). A retired value is now re-asserted as the next revision + (the lowest `rev` whose claim is not tombstoned — deterministic, so teammates converge), + and `reconcileFacts` matches store and ledger by content instead of by rev-0 id. - **CI is green again on Linux.** `global/guards/run.mjs` was committed without its executable bit, so `forge doctor`'s plugin-hook check (which `access(X_OK)`s every script a hook names) reported `warn` on Linux and failed `test/doctor.test.js` on Node 20 and 22 for diff --git a/src/ledger_bridge.js b/src/ledger_bridge.js index 5650745..8b2b10d 100644 --- a/src/ledger_bridge.js +++ b/src/ledger_bridge.js @@ -61,18 +61,22 @@ export function lessonClaim(lesson, t = 0) { } /** A recall fact's claim. Name/text are trimmed so the shadow-write path and the - * file-parse import path mint the SAME id for the same fact. + * file-parse import path mint the SAME id for the same fact. `rev` (≥1) re-asserts a value + * whose rev-0 claim was retracted — see shadowFact; rev 0 keeps the historical id. * @returns {{ok:boolean, reason?:string, claim?:any}} */ -export function factClaim(name, text, t = 0) { +export function factClaim(name, text, t = 0, rev = 0) { return mintClaim({ kind: "fact", - body: { name: String(name).trim(), text: String(text).trim() }, + body: { name: String(name).trim(), text: String(text).trim(), ...(rev ? { rev } : {}) }, scope: { level: "repo" }, provenance: { agent: "recall", author: gitAuthor() }, t, }); } +/** The content key of a fact — what "the same fact" means across revisions. */ +const factKey = (name, text) => JSON.stringify([String(name).trim(), String(text).trim()]); + /** * Shadow-write one lesson event into the repo ledger. * @param {string} root repo root @@ -143,7 +147,19 @@ export function supersedeLessonClaim(root, before, after, t = epochDay()) { */ export function shadowFact(ledgerDir, name, text, t = epochDay()) { return bestEffort(() => { - const minted = factClaim(name, text, t); + // Tombstones are permanent and ids are content-addressed, so restoring a value that + // was superseded (v1 → v2 → v1) would land on v1's RETIRED id and leave no live fact + // (review C4). A retired value is re-asserted as the next revision instead — the lowest + // `rev` whose claim is not tombstoned — deterministic, so teammates doing the same + // sequence still converge on one id. Unretired values keep the rev-0 id. + const retired = new Set( + loadClaims(ledgerDir) + .filter((c) => c.kind === "fact" && c.tombstone) + .map((c) => c.id), + ); + let minted = factClaim(name, text, t); + for (let rev = 1; minted.ok && retired.has(minted.claim.id); rev++) + minted = factClaim(name, text, t, rev); if (!minted.ok) return { ok: false, reason: minted.reason }; const put = putClaim(ledgerDir, minted.claim); if (!put.ok) return put; @@ -177,13 +193,12 @@ export function reconcileFacts(store, ledgerDir, t = epochDay()) { // store, so "no backing file ⇒ tombstone" is inverted and would wipe every fact. // Reconciliation only makes sense while the file store is canonical (default off). if (ledgerOnly()) return { ok: true, removed: 0 }; + // Compare CONTENT (name, text), not ids: a restored value lives under a `rev` id + // (shadowFact) that factClaim(name, text) alone would not reproduce. const current = new Set(); for (const slug of listFacts(store)) { const f = readFact(store, slug); - if (f) { - const minted = factClaim(f.name, f.text, t); - if (minted.ok) current.add(minted.claim.id); - } + if (f) current.add(factKey(f.name, f.text)); } let removed = 0; for (const c of loadClaims(ledgerDir)) { @@ -193,7 +208,8 @@ export function reconcileFacts(store, ledgerDir, t = epochDay()) { // with the P2 read flip it IS the readable fact, and tombstoning it here would // silently delete team knowledge on every consolidate. const mine = (c.provenance?.author ?? "") === gitAuthor(); - if (c.kind === "fact" && !c.tombstone && mine && !current.has(c.id)) { + const backed = current.has(factKey(c.body?.name ?? "", c.body?.text ?? "")); + if (c.kind === "fact" && !c.tombstone && mine && !backed) { tombstone(ledgerDir, c.id, { author: gitAuthor(), reason: "removed-from-store", diff --git a/test/ledger_bridge.test.js b/test/ledger_bridge.test.js index eedf876..fa1c516 100644 --- a/test/ledger_bridge.test.js +++ b/test/ledger_bridge.test.js @@ -152,6 +152,33 @@ test("shadowFact: mints, and supersedes the stale same-name claim on update", () assert.equal(refused.ok, false, "secrets refused end-to-end"); }); +test("shadowFact (C4): restoring a superseded value (v1 → v2 → v1) leaves a LIVE fact", () => { + const store = tmp(); + const dir = join(store, "ledger"); + const live = () => + loadClaims(dir) + .filter((c) => c.kind === "fact" && !c.tombstone) + .map((c) => c.body.text); + shadowFact(dir, "api-base", "https://api.v1.example", 1); + shadowFact(dir, "api-base", "https://api.v2.example", 2); + assert.deepEqual(live(), ["https://api.v2.example"]); + const back = shadowFact(dir, "api-base", "https://api.v1.example", 3); + assert.equal(back.ok, true); + assert.deepEqual(live(), ["https://api.v1.example"], "the restored value is the live fact"); + assert.equal( + loadClaims(dir).find((c) => c.id === back.id).tombstone, + undefined, + "never the permanently tombstoned v1 id", + ); + // Idempotent: re-remembering the current value changes nothing. + assert.equal(shadowFact(dir, "api-base", "https://api.v1.example", 4).id, back.id); + assert.deepEqual(live(), ["https://api.v1.example"]); + // And the store/ledger reconciliation still recognises the restored claim as backed. + recallAdd(store, "api-base", "https://api.v1.example"); + assert.equal(reconcileFacts(store, dir, 5).removed, 0); + assert.deepEqual(live(), ["https://api.v1.example"]); +}); + test("factClaim: trims name/text so the shadow path and the file-parse path mint one id", () => { const a = factClaim("deploy", "run migrations first \n", 0); const b = factClaim(" deploy ", "run migrations first", 5); From 3fd765c3583006de59d7f47da52c72f16ea0c3c2 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:46:30 +0200 Subject: [PATCH 23/72] fix(rank,context): count only incidents as hazard; cut a source at its 4th optional item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rank.history summed val() over lesson claims AND every deja session summary naming a file. A summary is minted for every session, first-try successes included, and a session whose own tests passed carries a confirm outcome: every edit became an "incident", five ordinary sessions added 2.5 to a file's history, and a tested, passing session added 0.64 against an untested one's 0.5. Only lesson claims (recorded mistakes) count now. context.assemble checked its per-source diminishing-returns cut after taking the item, against a 0.2 floor, so it took six items from one source before stopping — and stopped with a `break`, ending the fill for every other source too. The check now runs before taking an item, at the floor the comment describes (the 4th item, delta^3 ~ 0.34), and skips only that source: 10 candidate facts yield 3. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 13 +++++++++++ docs/GUIDE.md | 7 +++--- src/context.js | 16 +++++++++----- src/rank.js | 33 ++++++++++++++-------------- test/context.test.js | 20 +++++++++++++++++ test/rank.test.js | 51 ++++++++++++++++++++++++++++++++++++++++---- 6 files changed, 112 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0edbfc6..2fa6ae2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,19 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). no longer prints "the paper measured a 62% routing saving" as context: the line marks the figure as refuted next to the measured −20.2% on total spend (`research/empirical-refutation`). +- **`forge rank` hazard counts incidents, not sessions.** The history overlay summed + `val()` over lesson claims AND every deja session summary naming a file, but a summary + is minted for every session, first-try successes included, and a session whose own + tests passed carries a confirm outcome: every edit became an "incident", five ordinary + sessions added 2.5 to a file's history, and a tested, passing session added **0.64** + against an untested one's **0.5**. Only lesson claims (recorded mistakes) count now; + session summaries add 0. +- **Context assembly stops a source at its 3rd optional item, and only that source.** The + per-source diminishing-returns cut (`δ^(j−1)`, δ = 0.7, "fourth+ item from one source: + value has decayed away") was checked after taking the item with a 0.2 floor, so it took + **6** items before stopping, and it used `break`, which ended the fill for every source. + The cut is now checked before taking an item, at the floor the comment describes (the + 4th item's δ³ ≈ 0.34), and skips only that source: 10 candidate facts now yield 3. ### Documentation diff --git a/docs/GUIDE.md b/docs/GUIDE.md index a018da1..f583be4 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -320,9 +320,10 @@ rank answers "which X-es should I worry about at all". Weighted PageRank over th graph scores structural centrality (using the same edge weights the blast-radius search trusts), Tarjan SCC finds circular-import clusters, articulation points find chokepoint files whose removal would split the import graph — and the ledger join is the part -nobody else has: each file's past-incident history (val()-weighted lesson and session -claims that name it) multiplies into `hazard = centrality × (1 + history)`, so central -code that has already bitten the team outranks equally central code that hasn't. Run +nobody else has: each file's past-incident history (val()-weighted lesson claims — +recorded mistakes — that name it; ordinary session records are not incidents) multiplies +into `hazard = centrality × (1 + history)`, so central code that has already bitten the +team outranks equally central code that hasn't. Run `forge atlas build` first. Also exposed to every MCP-capable agent as `rank_code`. ```console diff --git a/src/context.js b/src/context.js index 1fc1fb9..2feac0f 100644 --- a/src/context.js +++ b/src/context.js @@ -19,8 +19,12 @@ export const tokensOf = (text) => Math.ceil(String(text).length / 3.6); /** Lessons must be THIS trusted to enter the required set (spec §3: lessons*(S)). */ export const LESSON_REQUIRED_VAL = 0.8; -/** Per-source diminishing returns for optional items (spec §2). */ +/** Per-source diminishing returns for optional items (spec §2): the j-th item taken from + * one source is worth δ^(j−1). Once that falls below the floor the source's value has + * decayed away — with δ = 0.7 that is the 4th item (δ³ ≈ 0.34), so each source adds at + * most three optional items. */ const SOURCE_DISCOUNT = 0.7; +const SOURCE_VALUE_FLOOR = 0.4; /** Default assembly budget in tokens (callers pass the real per-tool cap). */ export const DEFAULT_BUDGET = 6000; @@ -215,15 +219,17 @@ export function assemble( if (!cand) break; // everything is already a pointer — required coverage beats budget cand.v++; } - // Greedy fill by value density with per-source diminishing returns. + // Greedy fill by value density with per-source diminishing returns. The cut is checked + // BEFORE taking an item, and skips only that source — other sources keep competing + // (a `break` here used to end the whole fill, and only after taking a 6th item). const perSource = {}; for (const item of optional) { + const taken = perSource[item.source] ?? 0; + if (SOURCE_DISCOUNT ** taken < SOURCE_VALUE_FLOOR) continue; // 4th+ from this source const variant = item.variants[0]; - const discount = SOURCE_DISCOUNT ** (perSource[item.source] ?? 0); if (used() + variant.tokens > budget) continue; chosen.push({ item, v: 0 }); - perSource[item.source] = (perSource[item.source] ?? 0) + 1; - if (discount < 0.2) break; // fourth+ item from one source: value has decayed away + perSource[item.source] = taken + 1; } const covered = new Set(chosen.flatMap((c) => c.item.covers)); diff --git a/src/rank.js b/src/rank.js index c0b780c..e6babb5 100644 --- a/src/rank.js +++ b/src/rank.js @@ -3,8 +3,8 @@ // (weighted PageRank centrality), WHERE the dependency graph is knotted (Tarjan SCC → // circular-dependency clusters), WHERE the import graph would split if a file vanished // (Hopcroft–Tarjan articulation points), and — the original part — how often each file -// has ALREADY bitten the team, from the evidence ledger (val()-weighted lesson and -// session-summary claims that name it). hazard = centralityNorm × (1 + history): +// has ALREADY bitten the team, from the evidence ledger (val()-weighted lesson claims — +// recorded mistakes — that name it). hazard = centralityNorm × (1 + history): // structurally central code that has hurt before outranks equally central code that // hasn't. DATA may be a table; DECISIONS are these formulas. // @@ -234,11 +234,16 @@ export function chokepoints(graph) { } /** - * The team-history overlay — how much verified memory already points at each file. - * Per file: Σ val(claim) over lesson claims whose trigger.files glob-match it and - * summary claims (deja session records) that list it. val() is the ledger's - * time-decayed Beta posterior, so stale incidents fade on the same clock everything - * else in the substrate uses. Pure; fail-open — no claims → all zeros. + * The team-history overlay — how often each file has already bitten the team. + * Per file: Σ val(claim) over lesson claims (each one a recorded mistake) whose + * trigger.files glob-match it; val() is the ledger's time-decayed Beta posterior, so + * reviewed evidence moves the weight. Pure; fail-open — no claims → all zeros. + * + * Session `summary` claims (deja records) are NOT incidents: one is minted for every + * session, first-try successes included, and a session whose own tests passed carries a + * confirm outcome — so counting them made every edit an "incident" and made a verified, + * passing session add MORE hazard (val 0.64) than an untested one (0.5). A summary says + * work happened, not that it went wrong; it contributes nothing here. * * Path normalization is load-bearing, not cosmetic: hook-minted claims store the raw * tool-input paths (ABSOLUTE — cortex_hook stores file_path verbatim), while atlas @@ -259,15 +264,11 @@ export function history(claims, files, nowDay, root = "") { }; const out = new Map(files.map((f) => [f, { weight: 0, hits: 0 }])); for (const claim of claims ?? []) { - let touched = []; - if (claim.kind === "lesson") { - const globs = (claim.body?.trigger?.files ?? []).map(norm); - if (globs.length) - touched = files.filter((f) => globs.some((g) => globToRe(String(g)).test(f))); - } else if (claim.kind === "summary") { - const set = new Set((claim.body?.files ?? []).map(norm)); - touched = files.filter((f) => set.has(f)); - } + if (claim?.kind !== "lesson") continue; // only recorded mistakes are incidents + const globs = (claim.body?.trigger?.files ?? []).map(norm); + const touched = globs.length + ? files.filter((f) => globs.some((g) => globToRe(String(g)).test(f))) + : []; if (!touched.length) continue; const w = val(claim, nowDay); for (const f of touched) { diff --git a/test/context.test.js b/test/context.test.js index 5600f04..6c8c471 100644 --- a/test/context.test.js +++ b/test/context.test.js @@ -169,3 +169,23 @@ test("renderContext + tokensOf: sane output surface", () => { assert.match(out, /\+ deps:computeTax/); assert.equal(tokensOf("x".repeat(36)), 10); }); + +test("assemble regression (E5): a source's 4th+ optional item is dropped — not its 7th", () => { + // The per-source discount is δ^(j−1), δ = 0.7: the 4th item is worth 0.34 and "has decayed + // away" per the code's own comment, but the old loop only broke AFTER taking a 6th item. + const root = mkdtempSync(join(tmpdir(), "forge-context-")); + const facts = Array.from( + { length: 10 }, + (_, i) => + mintClaim({ + kind: "fact", + body: { name: `f${i}`, text: `fact number ${i}` }, + scope: { level: "repo" }, + t: 0, + }).claim, + ); + const r = assemble(root, "do something", { claims: facts, budget: 6000 }); + const taken = r.selection.filter((s) => s.source === "fact"); + assert.equal(taken.length, 3, "three optional facts, then the source is spent"); + assert.ok(r.tokens <= 6000); +}); diff --git a/test/rank.test.js b/test/rank.test.js index b2ed3c2..2a06570 100644 --- a/test/rank.test.js +++ b/test/rank.test.js @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; import { build } from "../src/atlas.js"; -import { mintClaim } from "../src/ledger.js"; +import { mintClaim, outcomeRecord } from "../src/ledger.js"; import { appendEvidence, putClaim, repoLedger } from "../src/ledger_store.js"; import { centrality, chokepoints, cycles, history, pagerank, rankReport } from "../src/rank.js"; import { directedImportGraph, importGraph } from "../src/scope.js"; @@ -82,7 +82,7 @@ test("chokepoints flags the bridge file between two clusters, not the leaves", ( assert.ok(points[0].splits >= 1, "it splits off at least one subtree"); }); -test("history weighs files named by lesson globs and summary file lists; empty ledger → zeros", () => { +test("history weighs files named by lesson globs; session summaries are not incidents; empty ledger → zeros", () => { const lesson = mintClaim({ kind: "lesson", body: { @@ -119,7 +119,8 @@ test("history weighs files named by lesson globs and summary file lists; empty l const h = history([lesson, summary], files, 10); assert.ok(h.get("src/gen/out.js").weight > 0, "glob-matched file carries lesson weight"); assert.equal(h.get("src/gen/out.js").hits, 1); - assert.ok(h.get("src/app.js").weight > 0, "summary-listed file carries weight"); + assert.equal(h.get("src/app.js").weight, 0, "a session summary is work done, not an incident"); + assert.equal(h.get("src/app.js").hits, 0); assert.equal(h.get("src/quiet.js").weight, 0, "unnamed file carries none"); const empty = history([], files, 10); assert.ok( @@ -203,12 +204,54 @@ test("history relativizes absolute hook-minted claim paths against root (the pro t: 10, }).claim; const h = history([lesson, summary], ["src/app.js"], 10, root); - assert.equal(h.get("src/app.js").hits, 2, "both claim kinds match after relativization"); + assert.equal(h.get("src/app.js").hits, 1, "the lesson matches after relativization"); assert.ok(h.get("src/app.js").weight > 0, "the hazard join is alive for production claims"); const without = history([lesson, summary], ["src/app.js"], 10); assert.equal(without.get("src/app.js").hits, 0, "without root the absolute paths cannot match"); }); +test("history regression (E5): sessions are not incidents, and a passing test never adds hazard", () => { + // Before: every deja session summary counted as an incident, and one whose own tests + // PASSED (a test.run confirm) weighed 0.64 against an untested session's 0.5. + const session = (i, tested) => { + const c = mintClaim({ + kind: "summary", + body: { text: `clean session ${i}`, files: ["src/a.js"] }, + scope: { level: "repo" }, + t: 0, + }).claim; + c.evidence = tested + ? [ + outcomeRecord({ oracle: "test.run", result: "confirm", ref: `session:${i}`, t: 0 }) + .outcome, + ] + : []; + return c; + }; + const at = (claims) => history(claims, ["src/a.js"], 0).get("src/a.js"); + assert.deepEqual(at([session(1, true)]), { weight: 0, hits: 0 }, "tested-passing session"); + assert.deepEqual(at([session(2, false)]), { weight: 0, hits: 0 }, "untested session"); + assert.deepEqual( + at([1, 2, 3, 4, 5].map((i) => session(i, false))), + { weight: 0, hits: 0 }, + "five ordinary sessions are not five incidents", + ); + // a recorded mistake on the file still counts + const lesson = mintClaim({ + kind: "lesson", + body: { + correctedBehavior: "re-run the pricing tests", + trigger: { action: "edit", files: ["src/a.js"], keywords: [], symbols: [] }, + whatWentWrong: "shipped a pricing regression", + }, + scope: { level: "repo" }, + t: 0, + }).claim; + const withLesson = at([lesson, session(6, true)]); + assert.equal(withLesson.hits, 1); + assert.ok(withLesson.weight > 0); +}); + test("centrality counts a duplicated atlas node id once, like pagerank does", () => { const atlas = hubAtlas(); atlas.nodes.push({ ...atlas.nodes[3] }); // exact duplicate of the hub node From bcaeac46bb0ff43bf6798e973d7f1e861e34f28e Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:55:18 +0200 Subject: [PATCH 24/72] =?UTF-8?q?fix(ledger):=20Eq.=203=20retrieval=20?= =?UTF-8?q?=E2=80=94=20scope=20inside=20=CF=83,=20unigram=20rel,=20Unicode?= =?UTF-8?q?,=20confirm-only=20rec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review C5 + C8 + the future-dated half of C11: - Scope moves into the linear term (s = 0.10) instead of multiplying σ from outside, where it was a strict priority over relevance. - rel = max(4-token-shingle Jaccard, query-term coverage), so a 2-3 word query finds its fact instead of scoring 0. - Tokenization is Unicode-aware and an empty token set no longer "agrees" with every other empty set (any two non-ASCII texts scored Jaccard 1). - rec keys on confirmations (or the mint), so a contradiction no longer raises a stale claim's score, and evidence ages by its distance from now, so a future-dated record cannot pin rec at 1. - retrieve picks one similarity backend per ranking — cosine only when every candidate is embedded — instead of mixing cosine with Jaccard. - deja gates on rel (DEJA_REL_FLOOR) instead of the total score, which fired an unrelated symbol-scoped lesson on every prompt. - EQ3_WEIGHTS no longer claims a P8 calibration that was never run; the PCM spec §4 is updated to match. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 36 +++++ docs/plans/substrate-v2/01-pcm-protocol.md | 32 ++-- src/deja.js | 24 +-- src/ledger.js | 165 ++++++++++++++++----- test/deja.test.js | 52 +++++-- test/ledger.test.js | 103 +++++++++++++ 6 files changed, 344 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfe98ef..2eaf3b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,42 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). went from `["api-base"]` to `[]`). A retired value is now re-asserted as the next revision (the lowest `rev` whose claim is not tombstoned — deterministic, so teammates converge), and `reconcileFacts` matches store and ledger by content instead of by rev-0 id. +- **Eq. 3 retrieval ranks by relevance again.** Five defects compounded into "the ledger + answers the wrong question": + - *Scope was a strict priority.* The scope weight multiplied σ from outside, and with + a+b+g = 1 the sigmoid only spans [0.5, 0.731] — so scope decided every ranking: an + unrelated, 400-day-old, contradicted **symbol** claim scored 0.5375 against a + perfect-match **repo** claim's 0.3853. Scope is now a bounded term inside σ + (`s = 0.10`, symbol−global = 0.06): the same pair now ranks 0.6815 (repo) over 0.5622. + - *Short queries found nothing.* `rel` was MinHash over 4-token shingles, so a 2–3 word + query was one shingle no claim contained: "auth token refresh" scored `rel` 0 against + the auth fact and ranked it **below** an unrelated CSS fact. `rel` is now + `max(shingle Jaccard, query-term coverage)`; the same query ranks auth first (0.713 vs + 0.589). + - *Any two non-ASCII texts were "identical".* The tokenizer split on `[^a-z0-9]`, so + Arabic, Chinese or Greek text became the empty token set and two empty sketches agreed + on all 128 lanes — Jaccard 1. Tokens are now Unicode-aware (`\p{L}\p{N}\p{M}`) and an + empty set shares nothing with anything: Arabic vs Chinese is 0, and a real Arabic + query retrieves its Arabic fact. + - *Contradictions counted as recent evidence.* `rec` keyed on the newest evidence of any + polarity, so a fresh refutation RAISED a stale claim's score (0.3280 → 0.3384). `rec` + now keys on confirmations (or the mint), and the same contradiction lowers the score. + - *Two similarity scales in one ranking.* With a partially embedded ledger, cosine + (0.4–0.6 for unrelated same-domain text) competed with Jaccard (≈0), so every embedded + claim outranked every lexical one. The backend is chosen once per ranking: cosine only + when every candidate is embedded. + `EQ3_WEIGHTS` no longer claims to be "calibrated in P8" — P8 shipped cost evaluation, not + a retrieval calibration; the spec (01-pcm-protocol.md §4) is updated to match the code. +- **Déjà vu is gated on relevance, not on the total score.** `DEJA_FLOOR` (0.39) was tuned on + repo-scoped summaries, but a symbol-scoped lesson scored ≥ 0.5 for any prompt, so an + unrelated `parseConfig` lesson surfaced on EVERY prompt — including "translate the README + into French" (0.538). The gate is now `DEJA_REL_FLOOR` on the `rel` term (0.5: at least half + the prompt's content words appear in the remembered task): the unrelated prompt is silent at + day 100 and day 400, while a genuine repeat still fires. +- **Future-dated evidence no longer counts at full weight for years.** A record dated 10 years + ahead (a skewed clock, a hand-written `t`) pinned `rec` at 1.000 and kept full val weight + until the calendar caught up. Age is now the distance from now, so that record's `rec` is + 0.000 two years later and its val weight ≈ 0, while a one-day skew stays negligible. - **CI is green again on Linux.** `global/guards/run.mjs` was committed without its executable bit, so `forge doctor`'s plugin-hook check (which `access(X_OK)`s every script a hook names) reported `warn` on Linux and failed `test/doctor.test.js` on Node 20 and 22 for diff --git a/docs/plans/substrate-v2/01-pcm-protocol.md b/docs/plans/substrate-v2/01-pcm-protocol.md index 1c7adf5..3402a6e 100644 --- a/docs/plans/substrate-v2/01-pcm-protocol.md +++ b/docs/plans/substrate-v2/01-pcm-protocol.md @@ -120,20 +120,32 @@ when tombstoned. Nothing is silently deleted — the attic is the audit trail (p ## 4. Retrieval — paper Eq. 3, implemented ``` -score(x, c) = σ( a·rel(x,c) + b·rec(c) + g·val(c) ) // paper §7.1 Eq. 3 +score(x, c) = σ( a·rel(x,c) + b·rec(c) + g·val(c) + s·scope(c) ) // paper §7.1 Eq. 3 ``` -- `rel(x,c)` — cheap path: Jaccard similarity of MinHash sketches (k = 128 hashes, - 4-token shingles over normalized text; sketches stored on the claim, so comparison is - O(k)). Optional dep path (ADR-0005): pluggable embedding backend behind the same - interface; falls back to MinHash offline. -- `rec(c) = λ^(Δt/T)` — same decay clock as confidence. +- `rel(x,c)` — cheap path: `max` of (a) Jaccard similarity of MinHash sketches (k = 128 + hashes, 4-token shingles over Unicode-aware normalized text; sketches memoized on the + claim, so comparison is O(k)) and (b) query-term coverage — the fraction of the query's + content words (stopwords removed) the claim mentions. Shingles alone made `rel` ≈ 0 for + any query shorter than four tokens, which is most of them; coverage is the unigram + backstop. Optional dep path (ADR-0005): pluggable embedding backend behind the same + interface, chosen per RANKING (never per claim — cosine and Jaccard have different noise + floors and must not be mixed in one ordering); falls back to MinHash offline. +- `rec(c) = λ^(Δt/T)` since the last CONFIRMATION (or the mint) — same decay clock as + confidence. A contradiction is not recent evidence FOR a claim, and Δt is the distance + from now, so a future-dated record cannot pin `rec` at 1. - `val(c)` — §3 above. **This term is the paper's load-bearing addition** — memories pruned by ground truth, not by the model's say-so. -- Default weights `a = 0.55, b = 0.15, g = 0.30`; stored in `source/substrate.json`, - calibrated in P8 by logistic regression on retrieval-outcome pairs (did an injected - claim get confirmed or contradicted downstream?). -- Scope multiplier: reuse `SCOPE_WEIGHT` (symbol 1.0 > dir 0.8 > repo 0.6 > global 0.4). +- Default weights `a = 0.55, b = 0.15, g = 0.30, s = 0.10`, defined in `src/ledger.js` + (`EQ3_WEIGHTS`). They are the design defaults, NOT calibrated: the planned + logistic regression on retrieval-outcome pairs (did an injected claim get confirmed or + contradicted downstream?) has not been run, and nothing may describe them as calibrated + until it is. +- Scope is a term INSIDE σ (`s·SCOPE_WEIGHT`: symbol 1.0 > dir 0.8 > repo 0.6 > global 0.4), + not a multiplier on σ. With a+b+g = 1 the sigmoid only spans [0.5, 0.731], so an outside + multiplier made scope a strict priority over relevance — an unrelated, stale, contradicted + symbol claim outranked a perfect-match repo claim. As a bounded prior it breaks ties + between comparably relevant claims and nothing more. ## 5. The three layers (ʿilm → fahm → ḥikma, paper §5 & §7.2) diff --git a/src/deja.js b/src/deja.js index 61f17cc..0975aec 100644 --- a/src/deja.js +++ b/src/deja.js @@ -26,14 +26,14 @@ import { epochDay, gitAuthor } from "./util.js"; * edges, fingerprints) are not "have I done this task before" memory. */ export const DEJA_KINDS = ["summary", "lesson", "diagnosis"]; -/** Retrieval score below which a hit is noise, not a déjà vu — calibrated against the - * REAL range of retrieve() for repo-scoped `summary` claims. score() = σ(a·rel+b·rec+g·val) - * × SCOPE_WEIGHT.repo (ledger.js): with a+b+g=1 the σ term is < 0.7311 and the 0.6 repo - * weight caps a same-day identical-task hit at ≈0.42, while an unrelated task sits ≈0.34. - * The floor must live in that band — 0.55 (the old value) exceeded the ceiling, so the - * advisory could NEVER fire. 0.39 clears the noise floor with margin and still catches a - * strong match. A test drives the full path so this stays inside the achievable range. */ -export const DEJA_FLOOR = 0.39; +/** RELEVANCE below which a hit is noise, not a déjà vu. The gate is the `rel` term, not the + * whole Eq. 3 score: score() also carries recency, validity and scope, so a symbol-scoped + * lesson scored ≥ 0.5 no matter what the prompt said and the old total-score floor (0.39) + * fired an unrelated `parseConfig` lesson on EVERY prompt, including "translate the README + * into French" (review C8). Relevance is the only term that answers "have I done THIS task + * before": 0.5 means at least half the prompt's content words appear in the remembered task + * (or a 4-token-shingle Jaccard that high). Ties are still ordered by the full score. */ +export const DEJA_REL_FLOOR = 0.5; // Same test-command grammar cortex_hook.js keys its S1 signal on — a passing run here // is exactly what "this session's work was verified" means. Kept local (one small @@ -125,7 +125,7 @@ export function recordSessionSummary(root, sid, events, nowDay = epochDay()) { * @param {any[]} claims live claims (loadClaims output) * @param {string} task the task about to be started * @param {{nowDay?:number, budget?:number}} [opts] - * @returns {{claim:any, score:number}[]} + * @returns {{claim:any, score:number, rel:number}[]} */ export function dejaLookup(claims, task, { nowDay = 0, budget = 5 } = {}) { const kinds = new Set(DEJA_KINDS); @@ -144,16 +144,16 @@ export function dejaFromLedger(root, task, { nowDay = epochDay(), budget = 5 } = } /** - * The one-line advisory for the top hit, or "" when it is below the noise floor (so an + * The one-line advisory for the top hit, or "" when it is below the relevance floor (so an * unrelated task stays silent). "verified" appears only when the claim carries a * confirming oracle outcome (val > 0.5 — a fresh, evidence-free summary sits exactly at * the 0.5 prior). - * @param {{claim:any, score:number}} [top] the highest-ranked hit + * @param {{claim:any, score:number, rel:number}} [top] the highest-ranked hit * @param {number} [nowDay] * @returns {string} */ export function dejaLine(top, nowDay = 0) { - if (!top || top.score < DEJA_FLOOR) return ""; + if (!top || (top.rel ?? 0) < DEJA_REL_FLOOR) return ""; const { claim } = top; const verified = val(claim, nowDay) > 0.5; const day = claim.provenance?.t ?? 0; diff --git a/src/ledger.js b/src/ledger.js index 79b1986..fffb30c 100644 --- a/src/ledger.js +++ b/src/ledger.js @@ -60,8 +60,12 @@ export const ORACLES = { /** One source of truth for scope weighting — lessons.js re-exports this. */ 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 }; +/** Retrieval weights for Eq. 3 (a=relevance, b=recency, g=validity, s=scope). a/b/g are the + * spec defaults (01-pcm-protocol.md §4); they are NOT calibrated — the planned + * logistic-regression calibration on retrieval outcomes has not been run. `s` puts scope + * INSIDE the linear term as a small prior (symbol vs global differ by s·0.6 = 0.06): it + * breaks ties between comparably relevant claims but can never outrank a relevance gap. */ +export const EQ3_WEIGHTS = { a: 0.55, b: 0.15, g: 0.3, s: 0.1 }; export const DEFAULT_HALF_LIFE_DAYS = 45; /** Below this val a claim is dormant: kept for audit, never retrieved. The trusted @@ -325,8 +329,12 @@ export function validOutcome(e) { // Weight comes from the ORACLES table — a stored `w` is audit metadata, never trusted // (a hand-edited or forged log line must not be able to buy extra confidence). +// The age is the DISTANCE from now: a future-dated record (a teammate's skewed clock, a +// hand-written t) decays by how far ahead it claims to be, instead of counting at full +// weight — and pinning rec at 1 — until the calendar catches up with it. +const ageOf = (t, nowDay) => Math.abs(nowDay - (t ?? 0)); const decayed = (outcome, nowDay, halfLife) => - ORACLES[outcome.oracle].w * 0.5 ** (Math.max(0, nowDay - (outcome.t ?? 0)) / halfLife); + ORACLES[outcome.oracle].w * 0.5 ** (ageOf(outcome.t, nowDay) / halfLife); /** * Validity — the paper's `val` term as a time-decayed Beta posterior mean with a @@ -397,10 +405,14 @@ export function authorTrust(claims) { return out; } -/** Recency — λ^(Δt/T) since the last evidence (or mint, if none). */ +/** Recency — λ^(Δt/T) since the last CONFIRMATION (or the mint, if none). A contradiction + * is not "recent evidence for" a claim: counting it let a fresh refutation raise a stale + * claim's Eq. 3 score (review C5). Δt is the distance from now (see ageOf). */ 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); + let nearest = ageOf(claim.provenance?.t, nowDay); + for (const e of claim.evidence ?? []) + if (e?.result === "confirm" && validOutcome(e)) nearest = Math.min(nearest, ageOf(e.t, nowDay)); + return 0.5 ** (nearest / halfLife); } /** Dormant claims are kept for audit but never retrieved. */ @@ -433,10 +445,14 @@ const SEEDS = Array.from({ length: SKETCH_K }, (_, i) => ({ b: Math.imul(i + 1, 0x85ebca6b) >>> 0, })); +// Unicode-aware tokens: letters, digits and combining marks of ANY script. The old +// `[^a-z0-9]` split turned every non-ASCII text into the EMPTY token set, and two empty sets +// "agreed" on all 128 sketch lanes — any two Arabic (or Chinese, or Greek) texts scored 1. const normalizeText = (text) => String(text) + .normalize("NFKC") .toLowerCase() - .split(/[^a-z0-9]+/) + .split(/[^\p{L}\p{N}\p{M}]+/u) .filter(Boolean); /** n-token shingle set of normalized text (short texts fall back to single tokens). */ @@ -462,15 +478,54 @@ export function sketch(text, k = SKETCH_K) { return mins; } -/** Jaccard estimate = fraction of agreeing sketch positions (1 for identical texts). */ +/** A sketch lane no hash reached — only an EMPTY shingle set leaves lanes at this value. */ +const EMPTY_LANE = 0xffffffff; + +/** Jaccard estimate = fraction of agreeing sketch positions (1 for identical non-empty + * texts). An empty set shares nothing with anything, itself included: untouched lanes + * are not agreement. */ 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++; + for (let i = 0; i < n; i++) if (a[i] === b[i] && a[i] !== EMPTY_LANE) eq++; return eq / n; } +// Function words carry no topic; dropping them keeps "the"/"to" from making every claim +// look half-relevant to a short query. +const STOPWORDS = new Set( + "a an the to of in on at by for from with and or but not no nor so as if then than that this these those it its is are was were be been being do does did can could will would should must may might shall i me my we us our you your he him his she her they them their what which who whom whose when where why how all any each into onto over under up down out off per via about also just only very".split( + " ", + ), +); + +/** Query-side precomputation for rel(): its MinHash sketch and its content terms (the + * query's tokens minus stopwords; all tokens when it is nothing but stopwords). */ +export function relQuery(text) { + const toks = normalizeText(text); + const content = toks.filter((t) => !STOPWORDS.has(t)); + return { sketch: sketch(text), terms: new Set(content.length ? content : toks) }; +} + +/** + * Lexical relevance ∈ [0,1] = max(shingle Jaccard, query-term coverage). MinHash over + * 4-token shingles is the spec's cheap `rel`, but a 2–3 word query is ONE shingle that no + * claim contains, so "csrf login" scored ~0 against the CSRF fact (review C5). Coverage — + * the fraction of the query's content terms the claim mentions — is the unigram backstop + * that makes short queries work; long near-duplicate texts still score through Jaccard. + * @param {{sketch:number[], terms?:Set}} q a relQuery() (or {sketch} alone) + * @param {any} claim + */ +export function lexicalRel(q, claim) { + const j = jaccard(q.sketch, sketchOf(claim)); + if (!q.terms?.size) return j; + const have = termsOf(claim); + let hit = 0; + for (const t of q.terms) if (have.has(t)) hit++; + return Math.max(j, hit / q.terms.size); +} + /** The retrievable text of a claim, per kind (fallback: its canonical body). */ export function claimText(claim) { const b = claim.body ?? {}; @@ -499,58 +554,94 @@ export function claimText(claim) { // are immutable, so first-use caching is safe and keeps retrieve()/clusters() from // re-hashing every claim on every call. (noAssignInExpressions is off in biome.json.) const sketchOf = (claim) => (claim._sketch ??= sketch(claimText(claim))); +const termsOf = (claim) => (claim._terms ??= new Set(normalizeText(claimText(claim)))); /** - * 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. + * Eq. 3 retrieval score (paper §7.1): σ(a·rel + b·rec + g·val + s·scope). The `g·val` term is + * the protocol's load-bearing addition — outcome-confirmed claims outrank merely-recent ones. + * Scope sits INSIDE the linear term as a small prior (EQ3_WEIGHTS.s). It used to multiply + * σ from outside; with a+b+g = 1, σ only spans [0.5, 0.731], so the multiplier made scope a + * strict priority — an unrelated, 400-day-old, contradicted symbol claim (0.5375) outranked a + * perfect-match repo claim (0.3853) (review C5). * - * `sim` (optional) replaces the lexical `rel` term with a caller-supplied similarity - * (the ADR-0005 embeddings tier — built by callers from embed.js; this pure core - * NEVER imports a provider). It returns a cosine in [-1,1] or null; null (or any - * non-finite value) falls back to MinHash Jaccard per claim, and negatives clamp to 0 - * — "anti-similar" is just irrelevant, never a penalty below unrelated. + * `query` may be a string, a relQuery() object, or (legacy) a bare sketch array — the last + * gets Jaccard-only relevance. `sim` (optional) replaces the lexical `rel` term with a + * caller-supplied similarity (the ADR-0005 embeddings tier — built by callers from embed.js; + * this pure core NEVER imports a provider). It returns a cosine in [-1,1] or null; null (or + * any non-finite value) falls back to lexical relevance, and negatives clamp to 0 — + * "anti-similar" is just irrelevant, never a penalty below unrelated. (retrieve() decides the + * backend once per ranking, so one ranking never mixes cosine with Jaccard.) * @param {*} query * @param {any} claim - * @param {{nowDay?:number, weights?:typeof EQ3_WEIGHTS, sim?:(query:any, claim:any)=>number|null}} [opts] + * @param {{nowDay?:number, weights?:{a:number,b:number,g:number,s?:number}, sim?:(query:any, claim:any)=>number|null}} [opts] */ -export function score(query, claim, { nowDay = 0, weights = EQ3_WEIGHTS, sim } = {}) { +export function score(query, claim, opts = {}) { + return scoreParts(query, claim, opts).score; +} + +/** score() plus the relevance term it used — retrieve() reports `rel` so callers (déjà vu) + * can gate on relevance rather than on the whole score. + * @param {*} query + * @param {any} claim + * @param {{nowDay?:number, weights?:{a:number,b:number,g:number,s?:number}, + * sim?:((query:any, claim:any)=>number|null)|null}} [opts] + * @returns {{score:number, rel:number}} */ +function scoreParts(query, claim, { nowDay = 0, weights = EQ3_WEIGHTS, sim } = {}) { let rel = null; if (sim) { const s = sim(query, claim); if (typeof s === "number" && Number.isFinite(s)) rel = Math.max(0, Math.min(1, s)); } if (rel === null) { - const qs = Array.isArray(query) ? query : sketch(query); - rel = jaccard(qs, sketchOf(claim)); + const q = + typeof query === "string" + ? relQuery(query) + : Array.isArray(query) + ? { sketch: query } + : query; + rel = lexicalRel(q, 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; + const x = + weights.a * rel + + weights.b * rec(claim, nowDay) + + weights.g * val(claim, nowDay) + + (weights.s ?? 0) * scopeW; + return { score: 1 / (1 + Math.exp(-x)), rel }; } -/** Rank live (non-dormant, non-tombstoned) claims for a query; caps at `budget`. - * Optional `sim` as in score() — the caller-built embedding similarity; the query - * string (not the sketch) is what a sim sees. +/** Rank live (non-dormant, non-tombstoned) claims for a query; caps at `budget`. Each row is + * {claim, score, rel}. Optional `sim` as in score() — the caller-built embedding + * similarity; the query string is what a sim sees. The backend is chosen ONCE per ranking: + * cosine only when the provider embedded every candidate, lexical for all otherwise — + * dense cosines sit at 0.4–0.6 for unrelated same-domain text while Jaccard sits near 0, + * so a partially embedded ledger used to rank every embedded claim above every lexical one. * @param {*} query * @param {any[]} claims - * @param {{nowDay?:number, budget?:number, weights?:typeof EQ3_WEIGHTS, - * sim?:((query:any, claim:any)=>number|null)|null}} [opts] */ + * @param {{nowDay?:number, budget?:number, weights?:{a:number,b:number,g:number,s?:number}, + * sim?:((query:any, claim:any)=>number|null)|null}} [opts] + * @returns {{claim:any, score:number, rel:number}[]} */ export function retrieve( query, claims, { nowDay = 0, budget = 12, weights = EQ3_WEIGHTS, sim } = {}, ) { const q = String(query); - const qs = sketch(q); - const boundSim = sim ? (_qs, c) => sim(q, c) : undefined; - return claims - .filter((c) => !c.tombstone && !isDormant(c, nowDay)) - .map((c) => ({ - claim: c, - score: score(qs, c, { nowDay, weights, sim: boundSim }), - })) + const rq = relQuery(q); + const live = claims.filter((c) => !c.tombstone && !isDormant(c, nowDay)); + let boundSim; + if (sim && live.length) { + const sims = live.map((c) => sim(q, c)); + if (sims.every((x) => typeof x === "number" && Number.isFinite(x))) { + const byClaim = new Map(live.map((c, i) => [c, sims[i]])); + boundSim = (_q, c) => byClaim.get(c); + } + } + return live + .map((c) => { + const p = scoreParts(rq, c, { nowDay, weights, sim: boundSim }); + return { claim: c, score: p.score, rel: p.rel }; + }) .sort((a, b) => b.score - a.score || (a.claim.id < b.claim.id ? -1 : 1)) .slice(0, budget); } diff --git a/test/deja.test.js b/test/deja.test.js index e956bd3..06df765 100644 --- a/test/deja.test.js +++ b/test/deja.test.js @@ -5,15 +5,15 @@ import { join } from "node:path"; import { test } from "node:test"; import { buildSummary, - DEJA_FLOOR, + DEJA_REL_FLOOR, dejaAdvisory, dejaFromLedger, dejaLine, dejaLookup, recordSessionSummary, } from "../src/deja.js"; -import { val } from "../src/ledger.js"; -import { loadClaims, repoLedger } from "../src/ledger_store.js"; +import { mintClaim, val } from "../src/ledger.js"; +import { loadClaims, putClaim, repoLedger } from "../src/ledger_store.js"; const fixture = () => mkdtempSync(join(tmpdir(), "forge-deja-")); @@ -79,8 +79,8 @@ test("dejaLookup only ranks task-shaped kinds (summary/lesson/diagnosis)", () => }); test("dejaLine: floor gate silences noise; verified marker rides evidence", () => { - const strong = { claim: claim("summary", "x"), score: DEJA_FLOOR + 0.1 }; - const weak = { claim: claim("summary", "x"), score: DEJA_FLOOR - 0.01 }; + const strong = { claim: claim("summary", "x"), score: 0.6, rel: DEJA_REL_FLOOR + 0.1 }; + const weak = { claim: claim("summary", "x"), score: 0.9, rel: DEJA_REL_FLOOR - 0.01 }; assert.equal(dejaLine(weak, 100), "", "below floor → silent"); assert.ok(dejaLine(strong, 100).includes("déjà vu")); assert.ok(!dejaLine(strong, 100).includes("verified"), "no evidence → not verified"); @@ -97,7 +97,7 @@ test("dejaLine: floor gate silences noise; verified marker rides evidence", () = ], }); assert.ok(val(confirmed, 100) > 0.5); - assert.ok(dejaLine({ claim: confirmed, score: 0.9 }, 100).includes("(verified)")); + assert.ok(dejaLine({ claim: confirmed, score: 0.9, rel: 1 }, 100).includes("(verified)")); }); test("dejaAdvisory: kill switch and empty task both yield silence", () => { @@ -131,9 +131,9 @@ test("recordSessionSummary mints a retrievable summary; passing tests make it ve assert.equal(hits[0].claim.id, r.id, "the fresh summary is retrievable next session"); }); -test("dejaAdvisory actually fires for a repeated task (DEJA_FLOOR is inside the real range)", () => { - // Regression guard: DEJA_FLOOR must sit below the achievable score() ceiling for a - // repo-scoped summary (~0.42), or the whole anti-repetition feature is a silent no-op. +test("dejaAdvisory actually fires for a repeated task (DEJA_REL_FLOOR is inside the real range)", () => { + // Regression guard: the gate must fire for a repeat and stay silent otherwise, or the + // anti-repetition feature is either a silent no-op or a permanent false positive. const root = fixture(); recordSessionSummary( root, @@ -159,3 +159,37 @@ test("recordSessionSummary is best-effort and returns cleanly on an empty sessio assert.equal(r.ok, false); assert.deepEqual(dejaFromLedger(root, "anything", { nowDay: 200 }), []); }); + +test("déjà vu is gated on RELEVANCE (C8): an unrelated prompt never surfaces a symbol lesson", () => { + const root = fixture(); + const dir = repoLedger(root); + // The exact shape that used to fire on every prompt: a symbol-scoped lesson (scope 1.0) + // whose total score cleared the old 0.39 floor regardless of the query. + const lesson = mintClaim({ + kind: "lesson", + body: { + whatWentWrong: "broke parseConfig callers", + correctedBehavior: "update callers of parseConfig", + trigger: { symbols: ["parseConfig"], keywords: [], files: ["src/config.js"], action: "edit" }, + }, + scope: { level: "symbol" }, + t: 100, + }).claim; + putClaim(dir, lesson); + recordSessionSummary( + root, + "sess-dark", + [{ type: "prompt", text: "add dark mode toggle to the settings page" }], + 100, + ); + for (const day of [100, 400]) { + assert.equal( + dejaAdvisory(root, "translate the README into French", day), + "", + `day ${day}: an unrelated task is silent`, + ); + const hit = dejaAdvisory(root, "add dark mode toggle to the settings page", day); + assert.match(hit, /déjà vu/, `day ${day}: the real repeat still fires`); + assert.match(hit, /dark mode/); + } +}); diff --git a/test/ledger.test.js b/test/ledger.test.js index 631575e..509a1fe 100644 --- a/test/ledger.test.js +++ b/test/ledger.test.js @@ -7,6 +7,7 @@ import { claimId, claimText, clusters, + EQ3_WEIGHTS, isDormant, jaccard, liveClaims, @@ -778,3 +779,105 @@ test("beliefDiff ignores claims already tombstoned before the window — dead be assert.deepEqual(d.strengthened, []); assert.deepEqual(d.weakened, [], "pure decay on a dead claim is not a belief change"); }); + +// --- Eq. 3 retrieval fixes (review C5) ------------------------------------------------- + +const factAt = (text, level = "repo", evidence = [], t = 0) => ({ + ...mintClaim({ kind: "fact", body: { name: text.slice(0, 12), text }, scope: { level }, t }) + .claim, + evidence, +}); + +test("score (C5): scope is a bounded term inside σ — never a strict priority over relevance", () => { + const q = "retry the payment webhook with exponential backoff and jitter on 503"; + const perfectRepo = factAt(q, "repo", [ev("confirm", 0, "human.accept"), ev("confirm", 0)]); + const unrelatedSymbol = factAt( + "css grid gutter width is 12px in the dashboard layout", + "symbol", + [ev("contradict", 0, "typecheck")], + ); + const ranked = retrieve(q, [unrelatedSymbol, perfectRepo], { nowDay: 400 }); + assert.equal(ranked[0].claim.id, perfectRepo.id, "a perfect match beats an unrelated symbol"); + // Scope still breaks ties among equally relevant claims. + const sym = factAt("check callers before renaming", "symbol"); + const glob = factAt("check callers before renaming", "global"); + assert.ok( + score("check callers before renaming", sym) > score("check callers before renaming", glob), + ); +}); + +test("rel (C5): a short query finds its fact — unigram coverage backs 4-token shingles", () => { + const csrf = factAt("the login handler must validate the csrf token on every POST"); + const fonts = factAt("fonts are self-hosted from the static assets folder"); + const ports = factAt("the dev server listens on port 5173 by default"); + for (const q of ["csrf login", "validate csrf in the login handler"]) { + const ranked = retrieve(q, [fonts, ports, csrf], { nowDay: 0 }); + assert.equal(ranked[0].claim.id, csrf.id, `"${q}" ranks the CSRF fact first`); + assert.ok(ranked[0].rel > 0.5, `"${q}": rel ${ranked[0].rel} reflects the overlap`); + assert.equal(ranked.find((r) => r.claim.id === fonts.id).rel, 0, "unrelated stays at 0"); + } +}); + +test("rel (C5): non-ASCII text tokenizes — unrelated scripts are NOT identical, empty ≠ everything", () => { + assert.equal(jaccard(sketch("مصادقة الرمز تفشل"), sketch("数据库连接超时")), 0); + assert.equal(jaccard(sketch(""), sketch("!!!")), 0, "two empty token sets share nothing"); + assert.equal(jaccard(sketch("مصادقة الرمز تفشل"), sketch("مصادقة الرمز تفشل")), 1); + assert.deepEqual([...shingles("café au lait")], ["café au lait"], "accented letters are kept"); + const ar = factAt("مصادقة الرمز تفشل عند انتهاء الجلسة"); + const zh = factAt("数据库连接超时"); + const [top] = retrieve("مصادقة الرمز", [zh, ar], { nowDay: 0 }); + assert.equal(top.claim.id, ar.id); + assert.equal(retrieve("مصادقة الرمز", [zh], { nowDay: 0 })[0].rel, 0); +}); + +test("rec (C5): a contradiction is not recent evidence — it never raises a stale claim's score", () => { + const base = factAt("use yarn not npm", "repo", [], 19910); + const now = 20000; + const contradicted = { ...base, evidence: [ev("contradict", now)] }; + assert.equal(rec(contradicted, now), rec(base, now), "recency keys on confirms and mint only"); + assert.ok( + score("unrelated query text here", contradicted, { nowDay: now }) < + score("unrelated query text here", base, { nowDay: now }), + "fresh negative evidence lowers the score", + ); + const confirmed = { ...base, evidence: [ev("confirm", now)] }; + assert.equal(rec(confirmed, now), 1, "a fresh confirm still refreshes recency"); +}); + +test("val/rec (C11): future-dated evidence decays by its distance from now — no pinning", () => { + const today = 20000; + const skewed = factAt("x", "repo", [ev("confirm", today + 3650)], today); + const honest = factAt("x", "repo", [ev("confirm", today)], today); + assert.ok(rec(skewed, today + 730) < 0.01, `rec ${rec(skewed, today + 730)} is not pinned at 1`); + assert.ok(val(skewed, today + 730) < 0.51, "a 10-year-future confirm carries ~no weight"); + assert.ok(val(skewed, today) < val(honest, today), "the skewed record never beats an honest one"); + assert.ok( + Math.abs( + val(factAt("x", "repo", [ev("confirm", today + 1)], today), today) - val(honest, today), + ) < 0.01, + "a one-day clock skew is negligible", + ); +}); + +test("retrieve (C5): one similarity scale per ranking — cosine and Jaccard are never mixed", () => { + const unrelatedEmbedded = factAt("render the marketing landing page hero section"); + const relevantLexical = factAt("rotate the api signing keys every ninety days"); + // The provider embedded only one claim, at a typical same-domain cosine for unrelated text. + const sim = (_q, c) => (c.id === unrelatedEmbedded.id ? 0.6 : null); + const ranked = retrieve("rotate the signing keys", [unrelatedEmbedded, relevantLexical], { + nowDay: 0, + sim, + }); + assert.equal(ranked[0].claim.id, relevantLexical.id, "a partial embedding falls back for all"); + const full = (_q, c) => (c.id === unrelatedEmbedded.id ? 0.1 : 0.9); + const both = retrieve("rotate the signing keys", [unrelatedEmbedded, relevantLexical], { + nowDay: 0, + sim: full, + }); + assert.equal(both[0].claim.id, relevantLexical.id); + assert.equal(both[0].rel, 0.9, "a complete embedding ranks by cosine"); +}); + +test("EQ3_WEIGHTS: defaults are the spec's (a, b, g) plus a small scope term — not 'calibrated'", () => { + assert.deepEqual(EQ3_WEIGHTS, { a: 0.55, b: 0.15, g: 0.3, s: 0.1 }); +}); From df1c5a32c1bd005df9a948acfda28138d8fea2fb Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:55:54 +0200 Subject: [PATCH 25/72] fix(preflight): stop reading URLs, code fences and prose as code references The entity scanner paired a code fence's third backtick with the next inline backtick, so every word inside a fence became an identifier (a broker-URL log line yielded "Setting", "up", "for", "broker") and each one reached the substring git grep behind routing fan-out. URLs, markdown links/images, "N/A" and "and/or" counted as files. The concreteness anchors fired on URLs, image links, contractions ("'t break it, it'") and versions ("since v2.3:"), while a named identifier counted for nothing, so "Rename getUser to fetchUser everywhere" was hard-flagged as having no concrete anchor. Fences are stripped before the inline-code scan and an inline span needs a closing run of equal length; stripUrls removes addresses before every scan; a bare slash token must look like a path; the quoted/filename/worked-value anchors are tightened and a named identifier is now an anchor; the success-criteria cue matches \btest, not "latest". Diagnostic on the spent 80-task held-out set: entities on the 64 well-specified tasks fall from 210 files / 1,414 symbols to 42 / 388, and the filename anchor's firing rate on gold-ask tasks from 0.69 to 0.31. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 22 +++++++++ src/preflight.js | 100 ++++++++++++++++++++++++++++++++++------- test/preflight.test.js | 91 ++++++++++++++++++++++++++++++++----- 3 files changed, 186 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eccf13..1066a44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,28 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). comparison is false for NaN, so `recommend(NaN)` — and `±Infinity`/`undefined` — fell through to fable. A non-finite score now routes to the default tier (sonnet) with an `unknown-score` reason, logged under `FORGE_DEBUG=1`. +- **The preflight scanners no longer read addresses, code fences and prose as code.** On the + 80-task held-out set (diagnostic only — those tasks are spent for tuning), the entities a task + was said to reference fell from 210 files and 1,414 symbols to 42 and 388 across the 64 + well-specified tasks. Four misfires: + - a code fence's third backtick paired with the next inline backtick, so **every word inside a + fence became an identifier** — a broker-URL log line yielded "Setting", "up", "delayed", + "for", "broker" — and each one then went to the substring `git grep` that feeds routing + fan-out. Fenced blocks are stripped before the inline-code scan, and an inline span now needs + a closing backtick run of the same length, so RST ``double`` spans stop pairing across prose; + - URLs, markdown links and images, `N/A` and `and/or` counted as **files** + (`example.com/issue/12`). Addresses are removed before every scan (`stripUrls`), and a bare + slash token must look like a path — an extension, a `./ ../ ~/ /` prefix, or a trailing `/`; + - the concreteness anchors fired on URLs, image links, contractions (`'t break it, it'`) and + versions (`since v2.3:`). The quoted-literal anchor now refuses apostrophes inside words, the + filename anchor needs a letter-initial extension, and the worked-value anchor needs a number + beside an arrow, an equality or a `key: 42` colon — the filename anchor's firing rate on + gold-ask tasks falls from 0.69 to 0.31. `e.g.` and `example:` also fire at last: their + trailing `\b` had made them unmatchable before a space; + - **a named code identifier was not an anchor**, so "Rename getUser to fetchUser everywhere" + was hard-flagged as having nothing concrete to act on. It now counts as one anchor (that task + is no longer hard-flagged; with a file path it clears the gate outright), and the + success-criteria cue matches `\btest` rather than the "test" inside "latest". - **With the LLM layer on, the assumption gate no longer asks just because a task names something the repo lacks.** In bidirectional mode `reconcileAssumption` put `hasUnresolved` in the ask condition itself, so it forced an ask even when the rubric proceeded and the model diff --git a/src/preflight.js b/src/preflight.js index fa7b620..b6153f9 100644 --- a/src/preflight.js +++ b/src/preflight.js @@ -65,8 +65,9 @@ const DIMENSIONS = [ applies: rx( "\\b(fix|optimi[sz]e|make it (faster|work|better)|improve|ensure|feature|behavior)\\b", ), + // \btest: an unanchored "test" matched "latest"/"contest" and marked criteria as present. cues: rx( - "(->|=>|test|passes|acceptance|criteria|expected|should return|should equal|should match|verify|assert|benchmark|correct when|e\\.g\\.|example|```)", + "(->|=>|\\btest|passes|acceptance|criteria|expected|should return|should equal|should match|verify|assert|benchmark|correct when|e\\.g\\.|example|```)", ), }, { @@ -89,14 +90,24 @@ const DIMENSIONS = [ const VAGUE = rx( "\\b(some|somehow|etc|and so on|things?|stuff|appropriate(ly)?|as needed|handle (it|everything)|make it (work|better|nice|good)|clean it up|cleaner|the usual|standard way|properly|correctly|the way we (discussed|talked)|like before|as before)\\b", ); +// Concrete anchors. Scanned with URLs and markdown links removed (stripUrls), because an +// address is not a specification: a URL's host read as a filename and its port as a worked +// example. A named code identifier is one more anchor (countAnchors). const ANCHORS = [ /```/, /->|=>/, /\b\w+\([^)]*\)/, - /'[^']+'|"[^"]+"/, - /\b\w+\.\w{1,5}\b/, - /\b\d+\b.*(->|=>|=|:)|\(\d/, - /\b(e\.g\.|for example|such as|example:)\b/i, + // A quoted literal. The opening quote may not follow a letter and the closing one may not + // precede one, so contractions ("don't break it, it's") are not literals. + /(?|=>|==?)\s*\S|[\w)\]]\s*(?:->|=>|==?|:)\s*-?\d+(?:\.\d+)?\b|\(\d/, + // "e.g." / "example:" end in punctuation, so a trailing \b made them unmatchable before a space. + /\be\.g\.|\bfor example\b|\bsuch as\b|\bexample:/i, ]; const SPECIFIC = /\b(python|javascript|typescript|java|rust|golang|react|django|flask|node|redis|sql|postgres|asyncio|dijkstra|lru|adjacency|owasp|regex)\b|token-?bucket|binary heap|condition[- ]variable|recursive-?descent|in-?order|standard library|o\(\s*\d|o\(n|o\(1/gi; @@ -110,8 +121,53 @@ const isCodeIdent = (p, backticked) => { ); }; +// Any scheme://… URL (http, amqp, postgres, …) and bare www. hosts. +const URL_RX = /\b[a-z][a-z0-9+.-]*:\/\/[^\s<>()[\]"'`]+|\bwww\.[^\s<>()[\]"'`]+/gi; + +/** + * Remove addresses from task text before scanning it: markdown images entirely, markdown links + * down to their text, and bare URLs. A URL is not a code reference — its path read as a file + * ("example.com/issue/12"), its host as a filename anchor, its port as a worked example. + * @param {string} text + */ +export function stripUrls(text) { + return String(text) + .replace(/!\[[^\]\n]*\]\([^)\n]*\)/g, " ") + .replace(/\[([^\]\n]*)\]\([^)\n]*\)/g, "$1") + .replace(URL_RX, " "); +} + +// Fenced code blocks (``` or ~~~, closed or running to the end). Removed before the INLINE +// code scan: a single-backtick pairing would otherwise match a fence's third backtick with the +// next backtick and turn every word of the block ("for", "in", "return") into an identifier. +const FENCE_RX = /(```|~~~)[\s\S]*?(?:\1|$)/g; +// Inline code: a run of N backticks closed by a run of the same length on the same line, so +// RST/markdown ``double`` spans don't pair their inner backticks across the prose between them. +const INLINE_CODE_RX = /(`+)([^`\n]+)\1(?!`)/g; + +// A host as the first path segment ("github.com/org/repo") means an address, not a path. +const HOST_RX = /^[\w-]+(?:\.[\w-]+)*\.[a-z]{2,}$/i; + +/** Does a slash- or extension-bearing token name a file? Backticked tokens are trusted as paths + * (the author marked them as code); a bare one must look like a path — a file extension, a + * ./ ../ ~/ or / prefix, or a trailing / — so "N/A", "and/or", "input/output" are not files. */ +function isFileRef(tok, backticked) { + if (!/[A-Za-z0-9]/.test(tok)) return false; + if (tok.includes("/")) { + const segs = tok.split("/"); + if (HOST_RX.test(segs[0])) return false; + if (backticked || CODE_EXT.test(tok)) return true; + return ( + /\.[A-Za-z0-9]{1,8}$/.test(segs[segs.length - 1]) || + /^(?:\.{1,2}|~)?\//.test(tok) || + tok.endsWith("/") + ); + } + return CODE_EXT.test(tok); +} + export function referencedEntities(text) { - const s = String(text); + const s = stripUrls(text); const symbols = new Set(); const files = new Set(); const consider = (raw, backticked) => { @@ -121,15 +177,18 @@ export function referencedEntities(text) { .replace(/[.,;:]+$/, ""); if (!tok) return; if (tok.includes("/") || CODE_EXT.test(tok)) { - files.add(tok); + if (isFileRef(tok, backticked)) files.add(tok); return; } for (const part of tok.split(".").filter(Boolean)) { if (isCodeIdent(part, backticked)) symbols.add(part); } }; - for (const m of s.matchAll(/`([^`]+)`/g)) for (const t of m[1].split(/\s+/)) consider(t, true); - for (const m of s.matchAll(/[A-Za-z_$][\w$./-]*/g)) { + for (const m of s.replace(FENCE_RX, " ").matchAll(INLINE_CODE_RX)) + for (const t of m[2].split(/\s+/)) consider(t, true); + // A bare token may carry a ./ ../ ~/ or / prefix — that prefix is what marks it as a path + // (but not the "/" of an HTML closing tag like , nor one glued to a word). + for (const m of s.matchAll(/(?:\.{1,2}\/|~\/|(? a.test(t)).length, + concreteness: countAnchors(t), specifics: new Set([...t.matchAll(SPECIFIC)].map((m) => m[0].toLowerCase())).size, vagueness: new Set( [...t.matchAll(new RegExp(VAGUE.source, "gi"))].map((m) => m[0].toLowerCase()), @@ -215,10 +275,20 @@ export function completenessScore(features, weights = COMPLETENESS_WEIGHTS) { return sigmoid(z); } +/** Concrete anchors in (URL-stripped) text: each ANCHORS kind that fires, plus one when the task + * names a code identifier ("Rename getUser to fetchUser" is concrete, not underspecified). */ +function countAnchors(text) { + return ( + ANCHORS.filter((a) => a.test(text)).length + (referencedEntities(text).symbols.length ? 1 : 0) + ); +} + export function assessTask(text, { askThreshold = 0.6 } = {}) { - const task = String(text || ""); - const words = task.trim().split(/\s+/).filter(Boolean).length; - const concreteness = ANCHORS.filter((a) => a.test(task)).length; + const raw = String(text || ""); + const words = raw.trim().split(/\s+/).filter(Boolean).length; + // Every feature below scans the task with its URLs/links removed (stripUrls). + const task = stripUrls(raw); + const concreteness = countAnchors(task); const specifics = [...new Set([...task.matchAll(SPECIFIC)].map((m) => m[0].toLowerCase()))]; const vagueHits = [ ...new Set([...task.matchAll(new RegExp(VAGUE.source, "gi"))].map((m) => m[0].toLowerCase())), diff --git a/test/preflight.test.js b/test/preflight.test.js index 95aa125..e335959 100644 --- a/test/preflight.test.js +++ b/test/preflight.test.js @@ -31,6 +31,63 @@ test("referencedEntities ignores plain English (no false identifiers)", () => { assert.deepEqual(r.files, []); }); +// --- deep review D7: the scanners read addresses, fences and prose as code --- + +test("D7: a code fence never pairs with inline backticks — its words are not identifiers", () => { + const text = + "Credentials leak from the `DelayedDelivery` consumer:\n\n```\nSetting up delayed delivery for broker `amqp://user:pw@host:5672` ...\n```\n\nUse `maybe_sanitize_url()` for this."; + const { symbols, files } = referencedEntities(text); + assert.ok(symbols.includes("DelayedDelivery") && symbols.includes("maybe_sanitize_url")); + for (const w of ["Setting", "up", "delayed", "for", "broker"]) + assert.ok(!symbols.includes(w), `fence prose "${w}" is not an identifier`); + assert.deepEqual(files, []); + // RST/markdown ``double`` spans don't pair their inner backticks across the prose between. + const rst = referencedEntities( + "Add ``closes #XYZW`` to the description and/or commits (``XYZW``)", + ); + assert.deepEqual(rst.files, [], "prose between two double-backtick spans is not code"); +}); + +test("D7: URLs, links, images and N/A are not files; real paths still are", () => { + const r = referencedEntities( + "See https://example.com/issue/12, [the docs](https://docs.example.org/a/b.html) and " + + "![shot](https://img.example.com/a.png); github.com/org/repo; N/A for docs; and/or; " + + "input/output.
    env
    " + + "Touch `src/api/`, src/api/user.js and ./scripts/run.", + ); + assert.deepEqual(r.files.sort(), ["./scripts/run", "src/api/", "src/api/user.js"]); +}); + +test("D7: concreteness anchors ignore URLs, image links, contractions and versions", () => { + const cases = [ + "Make it work somehow, don't break it, it's the login thing", + "Fix the bug. ![screenshot](https://user-images.example.com/a.png) Something is wrong with it (again) since v2.3: it's broken.", + "Improve the auth stuff. Version 2: see https://example.com/x", + ]; + for (const text of cases) + assert.equal(completenessFeatures(text).concreteness, 0, `no concrete anchor in: ${text}`); + assert.equal(assessTask(cases[0]).hardUnderspecified, true, "vague + no anchor → hard ask"); + // ...while real anchors still fire. + assert.ok(completenessFeatures("set `retries: 3` in config.yaml").concreteness >= 2); + assert.ok(completenessFeatures("rename it to 'prod', e.g. for the deploy").concreteness >= 2); +}); + +test("D7: a named code identifier is a concrete anchor", () => { + const rename = assessTask("Rename getUser to fetchUser everywhere"); + assert.equal(rename.hardUnderspecified, false, "two named identifiers are not 'no anchor'"); + assert.ok(completenessFeatures("Rename getUser to fetchUser everywhere").concreteness >= 1); + const withPath = assessTask("Rename getUser to fetchUser in src/api/user.js"); + assert.equal(withPath.shouldAsk, false, "identifier + path clears the gate"); +}); + +test("D7: the success-criteria cue needs the word test, not 'latest'", () => { + const r = assessTask("Improve startup by upgrading to the latest release"); + assert.ok( + r.missing.some((m) => m.key === "success_criteria"), + "'latest' does not say how success is verified", + ); +}); + test("ambiguityMarkers catches vague wording", () => { const m = ambiguityMarkers("handle errors somehow and add several validations, etc."); assert.ok(m.includes("somehow") || m.includes("handle errors")); @@ -310,19 +367,29 @@ test("D6: unresolved entities never force an ask the rubric and the model both c test("D6 (integration): a grounded rename with a background URL is not asked when the model agrees", () => { const root = mkdtempSync(join(tmpdir(), "forge-pre-d6-")); mkdirSync(join(root, "src")); + mkdirSync(join(root, "test")); writeFileSync(join(root, "src", "util.js"), "export function clamp01(x) { return x; }\n"); - // `clampUnit` is the rename TARGET — unresolved by definition — and the URL is background. - const task = - "Rename the helper `clamp01` in src/util.js to `clampUnit` and update every caller; " + - "the existing tests must pass unchanged. Background: https://example.com/issue/12"; - const off = preflightRepo(root, task, { llm: false }); - assert.equal(off.assumption.shouldAsk, false, "precondition: the rubric proceeds"); - const on = preflightRepo(root, task, { - llm: true, - run: () => '{"completeness":0.99,"missing":[],"questions":[]}', - }); - assert.equal(on.assumption.shouldAsk, false, "a unanimous proceed is not turned into an ask"); - assert.notEqual(on.assumption.provenance.path, "llm-tightened"); + writeFileSync(join(root, "test", "util.test.js"), "// tests\n"); + // `clampUnit` is the rename TARGET — unresolved by definition; the URL and the "N/A" + // placeholder are prose, and neither is a file the repo is missing. + const tasks = [ + "Rename the helper `clamp01` in src/util.js to `clampUnit`, update every caller; tests in " + + "test/util.test.js must pass unchanged (N/A for docs).", + "Rename the helper `clamp01` in src/util.js to `clampUnit` and update every caller; the " + + "existing tests must pass unchanged. Background: https://example.com/issue/12", + ]; + for (const task of tasks) { + const off = preflightRepo(root, task, { llm: false }); + assert.deepEqual(off.unresolved.files, [], `no phantom unresolved file in: ${task}`); + assert.deepEqual(off.unresolved.symbols, ["clampUnit"], "only the rename target is unknown"); + assert.equal(off.assumption.shouldAsk, false, "precondition: the rubric proceeds"); + const on = preflightRepo(root, task, { + llm: true, + run: () => '{"completeness":0.99,"missing":[],"questions":[]}', + }); + assert.equal(on.assumption.shouldAsk, false, "a unanimous proceed is not turned into an ask"); + assert.notEqual(on.assumption.provenance.path, "llm-tightened"); + } }); // --- deep review D8: the proposer is judged on its own scale, not clipped to det±band --- From 9e24b925ec8618825f950d573733e71d73440596 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:55:58 +0200 Subject: [PATCH 26/72] fix(util,embed): unicode-aware slug, NaN-safe clamp01, overflow-safe cosine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit slug() kept only [a-z0-9], so every non-Latin name ("مفتاح الواجهة", "数据库地址") slugged to "" and fell back to the callers' shared literal: two facts with different names overwrote each other under one "fact" slug. It is Unicode-aware now (NFKC, letters/marks/digits of any script), with a short content hash for names carrying no letter or digit at all. ASCII slugs are unchanged; knowledge_router's factName("???") now gets that hash instead of the shared "fact". clamp01(NaN) returned NaN and poisoned every score it fed; it fails to 0, as does any non-numeric input. cosine() promised "never NaN" but squared components before dividing: components above ~1e154 overflowed to Infinity/Infinity = NaN and components below ~1e-162 underflowed to a false zero vector. It now scales each vector by its largest component, rejects non-finite components, and clamps the result to [-1, 1]. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 11 +++++++++ src/embed.js | 25 +++++++++++++++---- src/util.js | 29 ++++++++++++++++++---- test/embed.test.js | 16 +++++++++++++ test/knowledge_router.test.js | 7 +++++- test/recall.test.js | 10 ++++++++ test/util.test.js | 45 +++++++++++++++++++++++++++++++++++ 7 files changed, 133 insertions(+), 10 deletions(-) create mode 100644 test/util.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fa6ae2..a2b0b4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,17 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). **6** items before stopping, and it used `break`, which ended the fill for every source. The cut is now checked before taking an item, at the floor the comment describes (the 4th item's δ³ ≈ 0.34), and skips only that source: 10 candidate facts now yield 3. +- **Non-ASCII names no longer collide, and NaN no longer propagates.** `slug()` kept only + `[a-z0-9]`, so every non-Latin name ("مفتاح الواجهة", "数据库地址") slugged to `""` and fell + back to the same literal — two facts with different names overwrote each other under one + `fact` slug. It is Unicode-aware now (NFKC, letters/marks/digits of any script), with a + short content hash for names that carry no letter or digit at all; ASCII slugs are + unchanged. `clamp01(NaN)` returned NaN (`Math.max(0, Math.min(1, NaN))`) and poisoned + every score it fed; it fails to 0, along with any non-numeric input. `cosine()` promised + "never NaN" but squared components before dividing, so vectors with components ≳ 1e154 + overflowed to Infinity/Infinity = **NaN** and components ≲ 1e-162 underflowed to a false + zero vector; it now scales each vector by its largest component, rejects non-finite + components, and clamps the result to [-1, 1]. ### Documentation diff --git a/src/embed.js b/src/embed.js index 935de71..e4019c3 100644 --- a/src/embed.js +++ b/src/embed.js @@ -66,19 +66,34 @@ export function getProvider() { return provider; } -/** Cosine similarity in [-1,1]; zero-norm or empty vectors → 0 (never NaN). */ +/** Cosine similarity in [-1,1]; zero-norm, empty or non-finite vectors → 0 (never NaN). + * Each vector is scaled by its largest |component| first — cosine is scale-invariant, and + * unscaled squares overflow to Infinity (components ≳ 1e154 gave Infinity/Infinity = NaN) + * or underflow to 0 (≲ 1e-162 read as a zero vector). */ export function cosine(a, b) { const n = Math.min(a?.length ?? 0, b?.length ?? 0); if (!n) return 0; + let ma = 0; + let mb = 0; + for (let i = 0; i < n; i++) { + const x = Math.abs(a[i]); + const y = Math.abs(b[i]); + if (!Number.isFinite(x) || !Number.isFinite(y)) return 0; // NaN / ±Infinity component + if (x > ma) ma = x; + if (y > mb) mb = y; + } + if (!ma || !mb) return 0; let dot = 0; let na = 0; let nb = 0; for (let i = 0; i < n; i++) { - dot += a[i] * b[i]; - na += a[i] * a[i]; - nb += b[i] * b[i]; + const x = a[i] / ma; + const y = b[i] / mb; + dot += x * y; + na += x * x; + nb += y * y; } - return na && nb ? dot / Math.sqrt(na * nb) : 0; + return Math.max(-1, Math.min(1, dot / Math.sqrt(na * nb))); // rounding can overshoot ±1 } const isVec = (v) => Array.isArray(v) && v.length > 0 && v.every((x) => Number.isFinite(x)); diff --git a/src/util.js b/src/util.js index 504aaf0..6dad9cc 100644 --- a/src/util.js +++ b/src/util.js @@ -6,13 +6,34 @@ import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; -export const slug = (s) => - String(s) +/** + * Filesystem- and id-safe slug. Unicode-aware: letters, marks and digits of ANY script + * survive (NFKC-normalized, lower-cased), so "مفتاح الواجهة" and "数据库地址" get distinct + * slugs — an ASCII-only class turned every non-Latin name into "" and every caller's + * fallback then collided them (two facts, one "fact" slug). A name with no letter or digit + * at all (emoji, punctuation) falls back to a short content hash, so distinct names still + * never collide; blank input stays "" for the callers' own fallbacks. ASCII input slugs + * exactly as before. + * @param {unknown} s + * @returns {string} + */ +export const slug = (s) => { + const text = String(s ?? ""); + const out = text + .normalize("NFKC") .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") + .replace(/[^\p{L}\p{M}\p{N}]+/gu, "-") .replace(/(^-|-$)/g, ""); + if (out || !text.trim()) return out; + return `h-${contentHash(text).slice(0, 10)}`; +}; -export const clamp01 = (x) => Math.max(0, Math.min(1, x)); +/** Clamp to [0,1]. NaN and non-numeric input fail to 0 — Math.max/min would pass NaN + * straight through, and one NaN poisons every score it touches. */ +export const clamp01 = (x) => { + const n = Number(x); + return n > 0 ? (n < 1 ? n : 1) : 0; +}; // Normalize a path to POSIX separators. Node's path.relative()/join() emit `\` on Windows, // but the graph/atlas/scope layers use repo-relative paths as MAP KEYS, NODE IDS, and values diff --git a/test/embed.test.js b/test/embed.test.js index f415dc8..1a01732 100644 --- a/test/embed.test.js +++ b/test/embed.test.js @@ -73,6 +73,22 @@ test("cosine: identical → 1, orthogonal → 0, opposite → -1, degenerate → assert.equal(cosine([0, 0], [1, 1]), 0); }); +test("cosine regression (E5): huge, tiny and non-finite components never yield NaN", () => { + // Unscaled, 1e200² overflowed to Infinity and Infinity/Infinity = NaN, contradicting + // "never NaN"; 1e-200² underflowed to 0 and a real vector read as the zero vector. + assert.equal(cosine([1e200, 1e200], [1e200, 1e200]), 1); + assert.equal(cosine([1e200, 0], [0, 1e200]), 0); + assert.equal(cosine([1e-200, 0], [3e-200, 0]), 1); + assert.ok(Math.abs(cosine([3e160, 4e160], [6, 8]) - 1) < 1e-12, "mixed scales"); + for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) + assert.equal(cosine([bad, 1], [1, 1]), 0, `${bad} component → 0`); + for (let i = 0; i < 50; i++) { + const v = Array.from({ length: 8 }, (_, j) => Math.sin(i * 7 + j) * 10 ** ((i % 9) - 4)); + const c = cosine(v, v); + assert.ok(c <= 1 && c >= -1, `bounded: ${c}`); + } +}); + // --- embed + the disk cache -------------------------------------------------------------- test("embed: fake provider returns aligned vectors; cache hit avoids re-spawning", () => { diff --git a/test/knowledge_router.test.js b/test/knowledge_router.test.js index 8079b75..459d1ed 100644 --- a/test/knowledge_router.test.js +++ b/test/knowledge_router.test.js @@ -159,7 +159,12 @@ test("factName: short stable slug, never empty", () => { factName("The API rate limit is 100 requests per minute"), "the-api-rate-limit-is-100", ); - assert.equal(factName("???"), "fact"); + // No letter or digit at all → util's hash fallback: still short, stable and non-empty, + // and (unlike the old shared "fact") distinct per text, so two such facts can coexist. + const q = factName("???"); + assert.ok(q.length > 0 && q.length <= 16); + assert.equal(q, factName("???"), "stable"); + assert.notEqual(q, factName("!!!")); }); test("cli: forge know --dry-run --json routes without writing", () => { diff --git a/test/recall.test.js b/test/recall.test.js index 03220fd..2ec74fb 100644 --- a/test/recall.test.js +++ b/test/recall.test.js @@ -56,3 +56,13 @@ test("consolidate removes exact-duplicate bodies", () => { assert.equal(removed, 1); assert.equal(list(s).length, 1); }); + +test("add regression (E5): two non-ASCII fact names no longer overwrite each other", () => { + // Both names slugged to "" → the "fact" fallback, so the second add overwrote the first. + const s = store(); + assert.equal(add(s, "مفتاح الواجهة", "the api base is https://a.example").ok, true); + assert.equal(add(s, "数据库地址", "db host is db.internal").ok, true); + const slugs = list(s); + assert.equal(slugs.length, 2, `both facts kept: ${slugs.join(", ")}`); + assert.ok(!slugs.includes("fact")); +}); diff --git a/test/util.test.js b/test/util.test.js new file mode 100644 index 0000000..136c78d --- /dev/null +++ b/test/util.test.js @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { clamp01, slug } from "../src/util.js"; + +test("clamp01 regression (E5): NaN and non-numeric input fail to 0, never propagate", () => { + // Math.max(0, Math.min(1, NaN)) is NaN — one NaN signal poisoned every score it touched. + assert.equal(clamp01(Number.NaN), 0); + assert.equal(clamp01(undefined), 0); + assert.equal(clamp01("not a number"), 0); + assert.equal(clamp01(null), 0); + // the ordinary contract is unchanged + assert.equal(clamp01(0.3), 0.3); + assert.equal(clamp01(-2), 0); + assert.equal(clamp01(5), 1); + assert.equal(clamp01(Number.POSITIVE_INFINITY), 1); + assert.equal(clamp01(Number.NEGATIVE_INFINITY), 0); + assert.equal(clamp01("0.5"), 0.5); +}); + +test("slug: ASCII names slug exactly as before", () => { + assert.equal(slug("DB port quirk"), "db-port-quirk"); + assert.equal(slug("Hello, World!!"), "hello-world"); + assert.equal(slug(" --lead & trail-- "), "lead-trail"); + assert.equal(slug("src/app.js"), "src-app-js"); + assert.equal(slug(""), "", "blank stays blank so callers' own fallbacks still apply"); + assert.equal(slug(" "), ""); +}); + +test("slug regression (E5): non-ASCII names get distinct slugs instead of all colliding on ''", () => { + const arabic = slug("مفتاح الواجهة"); + const chinese = slug("数据库地址"); + const hindi = slug("नमस्ते दुनिया"); + for (const s of [arabic, chinese, hindi]) assert.ok(s.length > 0, "not empty"); + assert.equal(new Set([arabic, chinese, hindi]).size, 3, "no collisions"); + assert.equal(arabic, "مفتاح-الواجهة"); + assert.equal(chinese, "数据库地址"); + assert.equal(slug("ABC"), "abc", "NFKC folds full-width forms"); + // no letter or digit at all → a content hash, still distinct and filename-safe + const a = slug("🔥🔥"); + const b = slug("🎉"); + assert.match(a, /^h-[0-9a-f]{10}$/); + assert.notEqual(a, b); + assert.equal(slug("🔥🔥"), a, "deterministic"); + for (const s of [arabic, chinese, hindi, a]) assert.ok(!/[\\/:*?"<>|\s]/.test(s), s); +}); From 0ab3d15a41af2898ab25a1691b0cf0e32b705e94 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:57:20 +0200 Subject: [PATCH 27/72] docs(pages): headline the held-out routing result, not the refuted 62.1% The landing page's evidence ledger and the generated status page both showed "62.1% cost saved" as a measured outcome. That figure comes from the 30-task Python demonstration whose thresholds were tuned on those same tasks; the pre-registered 80-task held-out run spent 20.2% MORE than always-premium. Both surfaces now carry the held-out number, and the README snapshot label build-pages.mjs lifts changed with them. Per judged-correct output ($1.06 vs $1.76) is stated in the README where it can carry its boundary. Co-Authored-By: Claude Opus 5 --- README.md | 6 ++++-- landing/index.html | 2 +- scripts/build-pages.mjs | 4 +++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index fd58632..e6205f5 100644 --- a/README.md +++ b/README.md @@ -325,8 +325,10 @@ Parser-stable snapshot labels used by the generated project pages are: - **A full pre-action gate in 118 ms median** — deterministic, warm repository graph, LLM disabled; - **Blast radius in 0.43 ms median** — warm impact query; and -- **62.1% cost saved** — the 30-task Python routing demonstration, tuned on those tasks and - **refuted** on 80 held-out tasks, where routing cost 20.2% more than always-premium (table below). +- **20.2% more cost than always-premium** — the held-out routing result. The 62.1% saving the + white paper reported came from a 30-task demonstration with thresholds tuned on those same + tasks; on 80 pre-registered held-out tasks the same router spent 20.2% *more* (table below). + Per judged-correct output the pipeline cost $1.06 against always-premium's $1.76. The boundaries in the table below are part of each result. diff --git a/landing/index.html b/landing/index.html index 5d84d21..d5864d1 100644 --- a/landing/index.html +++ b/landing/index.html @@ -1783,7 +1783,7 @@ -
    Open source cognitive substrateforgekit v0.32.1 · beta

    One operating
    memory. Every
    coding agent.

    ForgeKit gives every AI coding tool the same memory, foresight, and guardrails—without locking your work inside one vendor or one chat window.

    Runtime deps
    0
    Native targets
    9
    License
    MIT
    FK / PREFLIGHTSYSTEM READY
    01
    REQUESTRefactor authentication flow
    00:118
    1. 01Memory recalledPASS
    2. 02Blast radius mappedPASS
    3. 03Guardrails checkedPASS
    TRACE FK-031-7D4PROCEED →
    01 / The substrateState before action

    The missing layer between
    your intent and your agent.

    Models are capable. Their operating context is fragile. ForgeKit supplies the durable layer that travels with the repository and shows up before the next action.

    ACTIVE CAPABILITY / 01

    Context that survives the chat.

    Forge keeps decisions, lessons, and project state in the repository—so Claude, Codex, Cursor, and the next agent all inherit the same working memory.

    3 records recalled
    TYPERECORDSTATE
    decisionUse SQLite for local-first state94%
    lessonRun schema checks before generation88%
    preferenceKeep the CLI dependency-free82%
    02 / The protocolOne request · five checks · one trace

    Action should leave evidence.

    Forge turns agent behavior into a reviewable sequence. Each meaningful move begins with context and ends with proof.

    1. 01Recall

      Load relevant decisions and lessons.

    2. 02Classify

      Measure scope, cost, and reversibility.

    3. 03Foresee

      Map downstream surfaces before editing.

    4. 04Gate

      Pause risky or under-specified actions.

    5. 05Trace

      Record what changed and how it was verified.

    03 / One sourceNine native targets

    Change the agent. Keep the operating system.

    One source emits each tool’s native configuration. Your rules and memory stay with the project—not the provider.

    • 01Claude Code
    • 02Codex
    • 03Cursor
    • 04Gemini
    • 05Aider
    • 06Copilot
    • 07Windsurf
    • 08Zed
    • 09Continue

    Plus MCP configuration for Roo Code and VS Code-compatible clients.

    04 / Evidence ledgerMeasured, not invented

    Fast enough to stay in the loop.

    ForgeKit publishes the measurements behind its claims. The numbers below come from repository benchmarks and evaluation reports—not a marketing dashboard.

    Pre-action gate
    118ms
    End-to-end benchmark
    Blast-radius scan
    0.43ms
    Heuristic analysis
    Evaluated cost saved
    62.1%
    Repository evaluation
    Runtime dependencies
    0
    Node.js standard library
    Inspect the evidence
    05 / Honest limitsProfessional, not magical

    The guardrail is not the road.

    ForgeKit improves agent judgment; it does not replace yours. The project labels its assumptions so you can decide where to trust, test, or intervene.

    • 01

      Claude Code is the deepest-tested integration. Other targets have less real-world exercise today.

    • 02

      Blast-radius analysis is heuristic. It guides review; it is not a formal dependency proof.

    • 03

      Guardrails are not a sandbox. Keep permissions, review, and backups appropriate to the work.

    06 / Start hereAbout sixty seconds

    Give the next agent a better starting point.

    Install ForgeKit, run forge init in your repository, and keep one shared operating context across every tool.

    Open the quickstart
    forgekit / install
     /plugin marketplace add CodeWithJuber/forgekit
    +
    Open source cognitive substrateforgekit v0.32.1 · beta

    One operating
    memory. Every
    coding agent.

    ForgeKit gives every AI coding tool the same memory, foresight, and guardrails—without locking your work inside one vendor or one chat window.

    Runtime deps
    0
    Native targets
    9
    License
    MIT
    FK / PREFLIGHTSYSTEM READY
    01
    REQUESTRefactor authentication flow
    00:118
    1. 01Memory recalledPASS
    2. 02Blast radius mappedPASS
    3. 03Guardrails checkedPASS
    TRACE FK-031-7D4PROCEED →
    01 / The substrateState before action

    The missing layer between
    your intent and your agent.

    Models are capable. Their operating context is fragile. ForgeKit supplies the durable layer that travels with the repository and shows up before the next action.

    ACTIVE CAPABILITY / 01

    Context that survives the chat.

    Forge keeps decisions, lessons, and project state in the repository—so Claude, Codex, Cursor, and the next agent all inherit the same working memory.

    3 records recalled
    TYPERECORDSTATE
    decisionUse SQLite for local-first state94%
    lessonRun schema checks before generation88%
    preferenceKeep the CLI dependency-free82%
    02 / The protocolOne request · five checks · one trace

    Action should leave evidence.

    Forge turns agent behavior into a reviewable sequence. Each meaningful move begins with context and ends with proof.

    1. 01Recall

      Load relevant decisions and lessons.

    2. 02Classify

      Measure scope, cost, and reversibility.

    3. 03Foresee

      Map downstream surfaces before editing.

    4. 04Gate

      Pause risky or under-specified actions.

    5. 05Trace

      Record what changed and how it was verified.

    03 / One sourceNine native targets

    Change the agent. Keep the operating system.

    One source emits each tool’s native configuration. Your rules and memory stay with the project—not the provider.

    • 01Claude Code
    • 02Codex
    • 03Cursor
    • 04Gemini
    • 05Aider
    • 06Copilot
    • 07Windsurf
    • 08Zed
    • 09Continue

    Plus MCP configuration for Roo Code and VS Code-compatible clients.

    04 / Evidence ledgerMeasured, not invented

    Fast enough to stay in the loop.

    ForgeKit publishes the measurements behind its claims. The numbers below come from repository benchmarks and evaluation reports—not a marketing dashboard.

    Pre-action gate
    118ms
    End-to-end benchmark
    Blast-radius scan
    0.43ms
    Heuristic analysis
    Held-out routing cost
    +20.2%
    vs always-premium, 80 tasks
    Runtime dependencies
    0
    Node.js standard library
    Inspect the evidence
    05 / Honest limitsProfessional, not magical

    The guardrail is not the road.

    ForgeKit improves agent judgment; it does not replace yours. The project labels its assumptions so you can decide where to trust, test, or intervene.

    • 01

      Claude Code is the deepest-tested integration. Other targets have less real-world exercise today.

    • 02

      Blast-radius analysis is heuristic. It guides review; it is not a formal dependency proof.

    • 03

      Guardrails are not a sandbox. Keep permissions, review, and backups appropriate to the work.

    06 / Start hereAbout sixty seconds

    Give the next agent a better starting point.

    Install ForgeKit, run forge init in your repository, and keep one shared operating context across every tool.

    Open the quickstart
    forgekit / install
     /plugin marketplace add CodeWithJuber/forgekit
      /plugin install forgekit
     

    Recommended · ambient guards on every prompt

    diff --git a/scripts/build-pages.mjs b/scripts/build-pages.mjs index e42d7fe..4e774a8 100644 --- a/scripts/build-pages.mjs +++ b/scripts/build-pages.mjs @@ -166,7 +166,9 @@ export async function collect({ live = process.env.BUILD_PAGES_LIVE === "1" } = github, speed: mustMatch(readme, /\*\*A full pre-action gate in ([^*]+)\*\*/m, "speed"), impact: mustMatch(readme, /\*\*Blast radius in ([^*]+)\*\*/m, "impact"), - saved: mustMatch(readme, /\*\*([\d.]+% cost saved)/m, "saved"), + // The held-out routing result, not the refuted 30-task demonstration (the tuned 62.1% + // "cost saved" this used to lift from the README was the headline the refutation killed). + saved: mustMatch(readme, /\*\*([\d.]+% more cost than always-premium)\*\*/m, "saved"), benchUpdated: statSync(join(root, "reports/benchmarks.md")).mtime.toISOString().slice(0, 10), latest: latestChanges(), benchMentions: (benchmarks.match(/^## /gm) ?? []).length, From ee10a148abfd076fd8c84577e48129bb7a9c2ef3 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:02:14 +0200 Subject: [PATCH 28/72] fix(ledger): sticky lesson activation, latched dormancy, and real pruning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - claimToLesson derives `active` with hysteresis (on at 0.6, off below 0.55), so one confirm no longer flips to inactive after one day of decay. - isDormant latches: once val drops below the floor at an evidence event, only a later confirmation clears it — decay alone stops reviving claims a human revert refuted. - pruneLedger implements the spec's forgetting rule (tombstoned or dormant with nothing new for 2·T → attic) and is wired into the Stop-path ledger write, `ledger merge` and sync's import. putClaim/appendRecord are attic-aware: a re-import never un-prunes, new evidence does. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 16 ++++++++++ src/deja.js | 13 +++++++- src/ledger.js | 61 +++++++++++++++++++++++++++++++++-- src/ledger_read.js | 21 ++++++++++-- src/ledger_store.js | 67 +++++++++++++++++++++++++++++++++------ test/ledger.test.js | 43 +++++++++++++++++++++++++ test/ledger_read.test.js | 25 +++++++++++++++ test/ledger_store.test.js | 46 +++++++++++++++++++++++++++ 8 files changed, 276 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2eaf3b8..0c2c2f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,22 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ahead (a skewed clock, a hand-written `t`) pinned `rec` at 1.000 and kept full val weight until the calendar caught up. Age is now the distance from now, so that record's `rec` is 0.000 two years later and its val weight ≈ 0, while a one-day skew stays negligible. +- **A learned lesson stops flapping out of the injection set the next day.** One Stop-hook + confirm put a lesson's val at exactly 0.6 against an `active` bar of 0.6, so a single day of + decay (0.5988) demoted it: a lesson was injected on the day it was learned and never again + (with three confirms it dropped out around day 75). Activation is now hysteretic — on at + 0.6, off below 0.55 — so one confirm keeps a lesson active for ~52 days, three for ~124, and + a contradiction still demotes it immediately. The test that only read on the confirm day now + reads at days 101, 130, 150 and 160. +- **Dormancy latches, and pruning is wired.** A claim refuted by a human revert (val 0.333) + drifted back above the 0.35 dormancy floor 11 days later — with no new evidence — and + re-entered retrieval. Dormancy now latches at the evidence event and only a later + *confirmation* clears it; decay alone never does. `pruneToAttic` had no callers at all, so + the spec's forgetting rule (01-pcm-protocol.md §3) was unimplemented: the new `pruneLedger` + archives tombstoned or dormant claims that have had nothing new for 2·T, and runs at + session end (the déjà-vu Stop write), on `ledger merge` and on `ledger sync` import. + Nothing is deleted — the bytes move to `attic/`, every log stays, a re-import never + un-prunes, and new evidence brings a claim back with its whole history. - **CI is green again on Linux.** `global/guards/run.mjs` was committed without its executable bit, so `forge doctor`'s plugin-hook check (which `access(X_OK)`s every script a hook names) reported `warn` on Linux and failed `test/doctor.test.js` on Node 20 and 22 for diff --git a/src/deja.js b/src/deja.js index 0975aec..c11bc9d 100644 --- a/src/deja.js +++ b/src/deja.js @@ -17,7 +17,14 @@ // a failure to summarize or look up must never break a Stop hook or a CLI command. import { BRAND } from "./brand.js"; import { claimText, mintClaim, outcomeRecord, retrieve, val } from "./ledger.js"; -import { appendEvidence, loadClaims, putClaim, reindex, repoLedger } from "./ledger_store.js"; +import { + appendEvidence, + loadClaims, + pruneLedger, + putClaim, + reindex, + repoLedger, +} from "./ledger_store.js"; import { redactSecrets } from "./secrets.js"; import { epochDay, gitAuthor } from "./util.js"; @@ -109,6 +116,10 @@ export function recordSessionSummary(root, sid, events, nowDay = epochDay()) { }); if (o.ok) appendEvidence(dir, minted.claim.id, o.outcome); } + // Session-end housekeeping (the murāja'a job): archive what the protocol says is + // forgotten — tombstoned or dormant with nothing new for 2·T — so the ledger the next + // prompt reads stays bounded. Nothing is deleted; new evidence un-archives a claim. + pruneLedger(dir, nowDay); reindex(dir, nowDay); return { ok: true, id: minted.claim.id, tested: s.tested }; } catch (err) { diff --git a/src/ledger.js b/src/ledger.js index fffb30c..d19bc40 100644 --- a/src/ledger.js +++ b/src/ledger.js @@ -415,9 +415,64 @@ export function rec(claim, nowDay = 0, { halfLife = DEFAULT_HALF_LIFE_DAYS } = { return 0.5 ** (nearest / halfLife); } -/** Dormant claims are kept for audit but never retrieved. */ -export function isDormant(claim, nowDay = 0) { - return val(claim, nowDay) < DORMANT_VAL; +/** + * The claim's val AT each of its own evidence events, in (t, h) order — "what did this claim + * look like the moment that record landed". Between events val moves only by decay, and decay + * is monotone toward 0.5 (every term shares one factor), so a threshold can only be crossed + * AT an event or by that monotone drift: evaluating here plus once at `nowDay` is exact, not + * a sample. This is what lets dormancy latch and lesson activation be sticky while staying a + * pure function of the evidence SET (so replicas still agree after any merge order). + * @param {any} claim + * @param {{halfLife?:number}} [opts] + * @returns {{t:number, v:number, result:string}[]} + */ +export function valTimeline(claim, { halfLife = DEFAULT_HALF_LIFE_DAYS } = {}) { + const evs = sortRecords((claim.evidence ?? []).filter(validOutcome)); + return evs.map((e, i) => ({ + t: e.t ?? 0, + result: e.result, + v: val({ evidence: evs.slice(0, i + 1) }, e.t ?? 0, { halfLife }), + })); +} + +/** + * Dormant claims are kept for audit but never retrieved — and dormancy LATCHES. Once a + * claim's val drops below DORMANT_VAL when a record lands, only a later CONFIRMATION can + * lift it back out; decay alone must not. (Before: a claim refuted by a human revert sat at + * 0.333, then drifted back toward the 0.5 prior and re-entered retrieval 11 days later with + * no new evidence at all — review C7. Unreviewed claims decay toward uncertainty, but + * "nobody has said anything since" is not a reason to start trusting a refuted one again.) + * @param {any} claim + * @param {number} [nowDay] + * @param {{halfLife?:number}} [opts] + */ +export function isDormant(claim, nowDay = 0, { halfLife = DEFAULT_HALF_LIFE_DAYS } = {}) { + let latched = false; + for (const p of valTimeline(claim, { halfLife })) { + if (p.v < DORMANT_VAL) latched = true; + else if (latched && p.result === "confirm") latched = false; // review restores weight + } + return latched || val(claim, nowDay, { halfLife }) < DORMANT_VAL; +} + +/** + * Sticky threshold crossing with hysteresis: "on" once val reaches `high` at an evidence + * event, and off again only when it falls below `low` (by a contradiction, or by decay past + * the lower bar). A single threshold FLAPS — one confirm put a lesson at exactly 0.6 against + * an `active` bar of 0.6, so one day of decay retired it and it was never injected again + * (review C6). Pure; deterministic across replicas. + * @param {any} claim + * @param {{high:number, low:number, nowDay?:number, halfLife?:number}} opts + * @returns {boolean} + */ +export function sticky(claim, { high, low, nowDay = 0, halfLife = DEFAULT_HALF_LIFE_DAYS }) { + let on = false; + for (const p of valTimeline(claim, { halfLife })) { + if (on && p.v < low) on = false; + if (p.v >= high) on = true; + else if (p.v < low) on = false; + } + return on && val(claim, nowDay, { halfLife }) >= low; } // --------------------------------------------------------------------------- diff --git a/src/ledger_read.js b/src/ledger_read.js index e560e18..367bb6c 100644 --- a/src/ledger_read.js +++ b/src/ledger_read.js @@ -10,7 +10,7 @@ // Everything here is READ-ONLY and best-effort by design: hooks call these on every // session start / pre-edit, so a missing or corrupt ledger degrades to legacy-only — // never an error, never a write. -import { DEFAULT_HALF_LIFE_DAYS, val, validOutcome } from "./ledger.js"; +import { DEFAULT_HALF_LIFE_DAYS, sticky, val, validOutcome } from "./ledger.js"; import { loadClaims, repoLedger } from "./ledger_store.js"; import { load } from "./lessons_store.js"; import { ledgerOnly, slug } from "./util.js"; @@ -29,10 +29,14 @@ import { ledgerOnly, slug } from "./util.js"; * | claim state | derived status | rationale | * |--------------------------------------|----------------|-----------| * | tombstoned | "retired" | a retraction is the ledger's retirement | - * | val(claim, nowDay) ≥ 0.6 | "active" | one fresh confirm (bridge oracle w=0.5 → val 0.6) clears it — mirrors confirm()'s promote-on-recurrence | + * | sticky(≥ LESSON_ACTIVE_VAL, stays ≥ LESSON_KEEP_VAL) | "active" | one fresh confirm (bridge oracle w=0.5 → val 0.6) clears the bar — mirrors confirm()'s promote-on-recurrence — and it STAYS active until a contradiction or ~a half-life of silence pulls it under the lower bar | * | val < 0.45 and ≥ 1 contradiction | "quarantined" | net-negative outcome evidence — mirrors contradict()'s demotion | * | otherwise | "candidate" | a fresh claim sits at the 0.5 prior, exactly newLesson() | * + * The two activation bars are what stops the lifecycle FLAPPING: one confirm put val at + * exactly 0.6 against a single `active` bar of 0.6, so ONE day of decay (0.5988) retired the + * lesson and it was never injected again (review C6). + * * Count/date fields are rebuilt from the evidence log: evidenceCount = valid confirm * outcomes, contradictionCount = valid contradict outcomes, lastConfirmedDay = latest * confirm t (else the mint day), createdDay = the mint day (provenance.t). @@ -40,6 +44,12 @@ import { ledgerOnly, slug } from "./util.js"; * @param {number} [nowDay] epoch day used for the val() decay clock * @returns {object} a legacy-shaped lesson */ +/** Activation bars for a ledger-derived lesson (hysteresis, review C6): a confirm lifts a + * lesson to "active" at 0.6, and it stays active while val ≥ 0.55 — about one half-life of + * silence after a single bridge-oracle confirm, or until a contradiction pulls it under. */ +export const LESSON_ACTIVE_VAL = 0.6; +export const LESSON_KEEP_VAL = 0.55; + export function claimToLesson(claim, nowDay = 0) { const body = claim.body ?? {}; const evidence = (claim.evidence ?? []).filter(validOutcome); @@ -47,9 +57,14 @@ export function claimToLesson(claim, nowDay = 0) { const contradictions = evidence.length - confirms.length; const createdDay = claim.provenance?.t ?? 0; const v = val(claim, nowDay); + const active = sticky(claim, { + high: LESSON_ACTIVE_VAL, + low: LESSON_KEEP_VAL, + nowDay, + }); const status = claim.tombstone ? "retired" - : v >= 0.6 + : active ? "active" : v < 0.45 && contradictions >= 1 ? "quarantined" diff --git a/src/ledger_store.js b/src/ledger_store.js index ec77991..b649aef 100644 --- a/src/ledger_store.js +++ b/src/ledger_store.js @@ -23,9 +23,11 @@ import { authorTrust, canonicalize, claimId, + DEFAULT_HALF_LIFE_DAYS, DORMANT_VAL, emptyState, hasSecret, + isDormant, liveClaims, mergeStates, mintClaim, @@ -37,7 +39,7 @@ import { validOutcome, } from "./ledger.js"; import { redactSecrets } from "./secrets.js"; -import { contentHash, readJsonSafe } from "./util.js"; +import { contentHash, epochDay, readJsonSafe } from "./util.js"; /** The canonical repo ledger. (recall's global store keeps its own sibling ledger.) */ export const repoLedger = (root = process.cwd()) => join(root, ".forge", "ledger"); @@ -107,7 +109,10 @@ function appendLine(path, line) { appendFileSync(path, `${torn ? "\n" : ""}${line}\n`); } const claimPath = (dir, id) => join(dir, "claims", id.slice(0, 2), `${id}.json`); +const atticPath = (dir, id) => join(dir, "attic", `${id}.json`); const logPath = (dir, log, id) => join(dir, log, `${id}.log`); +/** A pruned claim is ARCHIVED, not missing: its file sits in attic/ and its logs never move. */ +const inAttic = (dir, id) => existsSync(atticPath(dir, id)); /** Claim file bytes: pure content only. Identical for the same id on every replica. */ const claimBytes = (claim) => @@ -163,9 +168,17 @@ function appendRecord(dir, log, id, record) { ok: false, reason: "refused: record metadata looks like a secret/credential", }; - if (!existsSync(claimPath(dir, id))) - return { ok: false, reason: `no such claim in ledger: ${id}` }; + const live = existsSync(claimPath(dir, id)); + const archived = !live && inAttic(dir, id); + if (!live && !archived) return { ok: false, reason: `no such claim in ledger: ${id}` }; if (readLog(dir, log, id).some((e) => e.h === record.h)) return { ok: true, deduped: true }; + // NEW evidence on a pruned claim brings it back out of the attic — review restores weight + // (01-pcm-protocol.md §3). Any other record (a tombstone, another author's mint) is + // appended without un-archiving it. + if (archived && log === "evidence") { + mkdirSync(join(dir, "claims", id.slice(0, 2)), { recursive: true }); + renameSync(atticPath(dir, id), claimPath(dir, id)); + } mkdirSync(join(dir, log), { recursive: true }); appendLine(logPath(dir, log, id), canonicalize(record)); return { ok: true, deduped: false }; @@ -200,7 +213,7 @@ function* walkClaimFiles(dir) { * the claim's provenance record (if any) is appended to the provenance log. A * corrupt/truncated file at the claim's path is REPAIRED by rewriting the canonical * bytes — a killed process must never leave a claim permanently unloadable. - * @returns {{ok:boolean, reason?:string, id?:string, existed?:boolean}} + * @returns {{ok:boolean, reason?:string, id?:string, existed?:boolean, pruned?:boolean}} */ export function putClaim(dir, claim) { if (!claim?.id || claim.id !== claimId(claim.kind, claim.body, claim.scope)) @@ -215,6 +228,13 @@ export function putClaim(dir, claim) { reason: "refused: claim looks like it contains a secret/credential", }; const path = claimPath(dir, claim.id); + // Re-importing a claim this replica has PRUNED must not resurrect it into the live set: + // the attic copy is the same content-addressed bytes, and new evidence is what brings a + // claim back (see appendRecord). Reported as existing, so merge counts stay honest. + if (!existsSync(path) && inAttic(dir, claim.id)) { + if (claim.provenance?.h) appendRecord(dir, "provenance", claim.id, claim.provenance); + return { ok: true, id: claim.id, existed: true, pruned: true }; + } const already = existsSync(path); const healthy = already && readJsonSafe(path) !== null && readFileSync(path, "utf8") === text; if (!healthy) { @@ -411,7 +431,7 @@ function tryImportLine(dir, log, id, rec) { * identity. Reading raw — instead of through loadState's hash-dedup — is what lets two * forged records sharing one fake `h`, and malformed no-`h` lines, all reach quarantine * instead of being silently collapsed or dropped. */ -export function mergeDirs(dstDir, srcDir) { +export function mergeDirs(dstDir, srcDir, { nowDay = epochDay() } = {}) { let claims = 0; let records = 0; let quarantined = 0; @@ -444,7 +464,8 @@ export function mergeDirs(dstDir, srcDir) { } } } - reindex(dstDir); + pruneLedger(dstDir, nowDay); + reindex(dstDir, nowDay); return { claims, records, quarantined }; } @@ -510,7 +531,7 @@ function quarantineRecord(dir, id, rec, reason) { * evidence goes through the full appendEvidence gate (validOutcome + ref resolution * against THIS repo) and every record must prove its content hash in appendRecord — * rejects land in quarantine/ for audit and are counted in `quarantined`. */ -export function importState(dir, other) { +export function importState(dir, other, { nowDay = epochDay() } = {}) { const merged = mergeStates(loadState(dir), other); let claims = 0; let records = 0; @@ -527,7 +548,8 @@ export function importState(dir, other) { } } } - reindex(dir); + pruneLedger(dir, nowDay); + reindex(dir, nowDay); return { claims, records, quarantined }; } @@ -605,7 +627,34 @@ export function verify(dir) { return { ok: issues.length === 0, claims, outcomes, issues }; } -/** Move dormant/tombstoned claim files to the attic (audit trail, never retrieved). */ +/** + * Prune to the attic — the spec's forgetting rule (01-pcm-protocol.md §3) made real: a claim + * is archived once it is tombstoned, or dormant, AND nothing new has landed on it for more + * than 2·T. (The spec prunes a tombstone immediately; waiting the same 2·T keeps + * `forge ledger show/blame` able to answer for a recent retraction — the attic is the audit + * trail, not a deletion.) Nothing is lost: the claim bytes move to attic/, every log stays, + * and new evidence un-archives the claim. Idempotent. + * @param {string} dir + * @param {number} [nowDay] + * @param {{halfLife?:number}} [opts] + * @returns {{pruned:string[]}} ids archived by this pass + */ +export function pruneLedger(dir, nowDay = epochDay(), { halfLife = DEFAULT_HALF_LIFE_DAYS } = {}) { + const pruned = []; + for (const c of loadClaims(dir)) { + const last = Math.max( + c.provenance?.t ?? 0, + c.tombstone?.t ?? 0, + ...(c.evidence ?? []).map((e) => e.t ?? 0), + ); + if (nowDay - last <= 2 * halfLife) continue; // still within the review window + if (!c.tombstone && !isDormant(c, nowDay, { halfLife })) continue; + if (pruneToAttic(dir, c.id).ok) pruned.push(c.id); + } + return { pruned }; +} + +/** Move one dormant/tombstoned claim file 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" }; diff --git a/test/ledger.test.js b/test/ledger.test.js index 509a1fe..403e504 100644 --- a/test/ledger.test.js +++ b/test/ledger.test.js @@ -24,6 +24,7 @@ import { sortRecords, stateAt, stateRoot, + sticky, UNRESOLVED_VAL_CAP, val, } from "../src/ledger.js"; @@ -881,3 +882,45 @@ test("retrieve (C5): one similarity scale per ranking — cosine and Jaccard are test("EQ3_WEIGHTS: defaults are the spec's (a, b, g) plus a small scope term — not 'calibrated'", () => { assert.deepEqual(EQ3_WEIGHTS, { a: 0.55, b: 0.15, g: 0.3, s: 0.1 }); }); + +test("isDormant (C7): dormancy latches — decay alone never revives a refuted claim", () => { + const refuted = mkClaim([ev("contradict", 0, "human.revert")]); // val 1/3 ≈ 0.333 + assert.equal(isDormant(refuted, 0), true); + assert.equal(isDormant(refuted, 11), true, "11 days of decay used to bring it back"); + assert.equal(isDormant(refuted, 400), true); + assert.equal(retrieve("x", [refuted], { nowDay: 400 }).length, 0, "and it stays out of reach"); + // Review restores weight: a later CONFIRMATION is the only way back. + const reviewed = mkClaim([ + ev("contradict", 0, "human.revert"), + ev("confirm", 30, "human.accept"), + ]); + assert.equal(isDormant(reviewed, 30), false); + assert.equal(retrieve("f body", [reviewed], { nowDay: 30 }).length, 1); +}); + +test("sticky: hysteresis — on at `high`, off only below `low` (never one-day flapping)", () => { + const c = mkClaim([ev("confirm", 0, "cortex.episode")]); // val exactly 0.6 + assert.equal(sticky(c, { high: 0.6, low: 0.55, nowDay: 0 }), true); + assert.equal( + sticky(c, { high: 0.6, low: 0.55, nowDay: 1 }), + true, + "one day of decay is not a demotion", + ); + assert.equal(sticky(c, { high: 0.6, low: 0.55, nowDay: 52 }), true); + assert.equal( + sticky(c, { high: 0.6, low: 0.55, nowDay: 60 }), + false, + "it still expires unreviewed", + ); + const never = mkClaim([ev("confirm", 0, "behavioral")]); // val 0.535 — never reaches high + assert.equal(sticky(never, { high: 0.6, low: 0.55, nowDay: 0 }), false); + const refuted = mkClaim([ + ev("confirm", 0, "cortex.episode"), + ev("contradict", 1, "human.revert"), + ]); + assert.equal( + sticky(refuted, { high: 0.6, low: 0.55, nowDay: 1 }), + false, + "a contradiction demotes", + ); +}); diff --git a/test/ledger_read.test.js b/test/ledger_read.test.js index 54389fd..9f555b0 100644 --- a/test/ledger_read.test.js +++ b/test/ledger_read.test.js @@ -279,3 +279,28 @@ test("brain.buildIndex inlines repo-ledger facts (merged team memory reaches AGE assert.match(block, /- \*\*deploy-order\*\* — run migrations before the app roll/); assert.match(block, /- \*\*flaky-suite\*\* — retry integration tests once before failing/); }); + +test("claimToLesson (C6): activation is sticky — a lesson is not retired by one day of decay", () => { + const claim = mkClaim({ t: 100 }); + // The real shape: a Stop-hook episode confirm (bridge oracle w=0.5 → val exactly 0.6). + claim.evidence = [ev("confirm", "episode:ep_m0_cfg#n1", 100, "cortex.episode")]; + assert.equal(claimToLesson(claim, 100).status, "active", "the confirm promotes it"); + for (const day of [101, 130, 150]) { + assert.equal(claimToLesson(claim, day).status, "active", `day ${day}: still injected`); + } + assert.equal(claimToLesson(claim, 160).status, "candidate", "unreviewed, it still expires"); + // Three confirms hold it much longer than the old single-threshold 75 days. + const thrice = mkClaim({ t: 100 }); + thrice.evidence = [100, 101, 102].map((t) => + ev("confirm", `episode:ep_m0_cfg#n${t}`, t, "cortex.episode"), + ); + assert.equal(claimToLesson(thrice, 175).status, "active", "still active at day 175"); + assert.equal(claimToLesson(thrice, 260).status, "candidate"); + // A contradiction demotes it even while it is fresh. + const refuted = mkClaim({ t: 100 }); + refuted.evidence = [ + ev("confirm", "episode:ep_m0_cfg#n1", 100, "cortex.episode"), + ev("contradict", "git:c0ffee2", 101, "human.revert"), + ]; + assert.notEqual(claimToLesson(refuted, 101).status, "active"); +}); diff --git a/test/ledger_store.test.js b/test/ledger_store.test.js index 4a73133..cdfe697 100644 --- a/test/ledger_store.test.js +++ b/test/ledger_store.test.js @@ -20,6 +20,7 @@ import { loadClaims, loadState, mergeDirs, + pruneLedger, pruneToAttic, putClaim, ratify, @@ -620,3 +621,48 @@ test("mergeDirs: imported forged/unresolvable evidence is quarantined and cannot assert.equal(readFileSync(qPath, "utf8"), qLog, "no duplicate quarantine lines"); assert.equal(val(loadClaims(dst)[0], 5), before, "val still untouched after re-merge"); }); + +test("pruneLedger (C7): tombstoned and long-dormant claims go to the attic, new evidence brings them back", () => { + // A real repo: a human.revert must cite a git object that resolves here (review C2). + const root = mkdtempSync(join(tmpdir(), "forge-prune-")); + const g = (...args) => execFileSync("git", args, { cwd: root, stdio: "ignore" }); + g("init"); + g("config", "user.email", "t@t.t"); + g("config", "user.name", "t"); + writeFileSync(join(root, "f.txt"), "x"); + g("add", "-A"); + g("commit", "-m", "init"); + const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim(); + const dir = repoLedger(root); + const now = 400; + const live = fact("live", "still believed", 0); + const refuted = fact("refuted", "the legacy auth endpoint is fine", 0); + const retracted = fact("retracted", "superseded value", 0); + const recent = fact("recent", "refuted yesterday", 0); + for (const c of [live, refuted, retracted, recent]) putClaim(dir, c); + const revert = (t) => + outcomeRecord({ oracle: "human.revert", result: "contradict", ref: `git:${head}`, t }).outcome; + assert.equal(appendEvidence(dir, live.id, ev("confirm", `git:${head.slice(0, 9)}`, 0)).ok, true); + assert.equal(appendEvidence(dir, refuted.id, revert(0)).ok, true); + assert.equal(appendEvidence(dir, recent.id, revert(now - 1)).ok, true); + tombstone(dir, retracted.id, { author: "alice", reason: "superseded", t: 0 }); + + const { pruned } = pruneLedger(dir, now); + assert.deepEqual(pruned.sort(), [refuted.id, retracted.id].sort()); + const ids = loadClaims(dir).map((c) => c.id); + assert.ok(!ids.includes(refuted.id) && !ids.includes(retracted.id), "archived, not retrieved"); + assert.ok(ids.includes(live.id) && ids.includes(recent.id), "live and recently-refuted stay"); + assert.ok(existsSync(join(dir, "attic", `${refuted.id}.json`)), "the bytes are kept for audit"); + assert.deepEqual(pruneLedger(dir, now).pruned, [], "idempotent"); + // Re-importing the same state must not resurrect a pruned claim… + importState(dir, loadState(dir), { nowDay: now }); + assert.ok(!loadClaims(dir).some((c) => c.id === refuted.id), "a re-import never un-prunes"); + // …but new evidence does: review restores weight. + assert.equal( + appendEvidence(dir, refuted.id, ev("confirm", `git:${head.slice(0, 10)}`, now)).ok, + true, + ); + const back = loadClaims(dir).find((c) => c.id === refuted.id); + assert.ok(back, "new evidence brought the claim back out of the attic"); + assert.equal(back.evidence.length, 2, "its whole history is intact"); +}); From 99d86ff5711411c1ec25343f55bdf64ea11b0613 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:03:00 +0200 Subject: [PATCH 29/72] =?UTF-8?q?fix(scope):=20one=20import=20resolver=20?= =?UTF-8?q?=E2=80=94=20masked=20lexing,=20every=20JS/TS=20form,=20Python?= =?UTF-8?q?=20package=20roots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scope.js resolved only six regex shapes against a candidate list that could not express TypeScript NodeNext (`./x.js` → `x.ts`) and gave Python zero edges: its `from .y import` pattern fed a dotted module name to a path resolver. Imports written inside comments or strings counted as imports. It now owns the ONE import parser the rest of the graph uses: - `maskCode()` blanks comments and string/regex CONTENTS (offsets, lengths and line structure preserved, delimiters kept) for JS/TS, Python, Ruby, PHP, Go and the C-family, including template-literal interpolation and regex-vs-division. - `jsImports()` reads static imports with multi-line clauses, `export * from`, `export { x as y } from`, dynamic `import()` and `require()`, with their named bindings; `pyImports()` reads relative, parenthesised, backslash-continued and aliased dotted imports, one statement at a time. - `resolveSpec()` (now exported and shared with atlas.js) resolves exact, NodeNext twin, extensionless and `/index.*` specifiers; `pyModuleIndex()` / `resolvePyImport()` resolve Python modules by PACKAGE ROOT, so `src/mypkg/core.py` is `mypkg.core` in a src layout exactly as in a flat one, with a prefix-stripped fallback only for dotted names and only when unique (so `import json` cannot bind to a local `tools/json.py`). Measured on the fixtures: the file graph goes from 0 to 7 edges on the Python fixture and from 0 to 2 on the TypeScript NodeNext one. Co-Authored-By: Claude Opus 5 --- src/scope.js | 613 +++++++++++++++++++++++++++++++-- test/fixtures/impact_repos.mjs | 157 +++++++++ test/scope.test.js | 84 ++++- 3 files changed, 824 insertions(+), 30 deletions(-) create mode 100644 test/fixtures/impact_repos.mjs diff --git a/src/scope.js b/src/scope.js index 50e1ee5..683e2d2 100644 --- a/src/scope.js +++ b/src/scope.js @@ -3,19 +3,593 @@ // separate sessions, so the context window isn't polluted) vs. COUPLED — and which coupled // files they didn't mention (the "forgot the related module" guard). Regex imports are // approximate (dynamic/DI edges missed) — a real call-graph MCP is the upgrade seam. +// +// This module is also the ONE import resolver: atlas.js imports maskCode / jsImports / +// pyImports / resolveSpec / pyModuleIndex from here, so the file graph (scope, rank, the +// repo map) and the symbol graph (atlas, impact) can never disagree on what a specifier +// points at. import { readdirSync, readFileSync } from "node:fs"; -import { dirname, join, relative, resolve } from "node:path"; +import { extname, join, posix, relative, resolve } from "node:path"; import { IGNORE_DIRS, SRC_EXT, toPosix } from "./util.js"; -const IMPORT_RES = [ - /import\s+[^'"]*from\s+['"]([^'"]+)['"]/g, // import x from "y" - /import\s+['"]([^'"]+)['"]/g, // import "y" - /require\(\s*['"]([^'"]+)['"]\s*\)/g, // require("y") - /export\s+[^'"]*from\s+['"]([^'"]+)['"]/g, // export … from "y" - /import\(\s*['"]([^'"]+)['"]\s*\)/g, // dynamic import("y") - /^\s*from\s+(\.[.\w/]*)\s+import/gm, // python: from .y import +// --------------------------------------------------------------------------------------- +// Lexical masking — comments and string/regex CONTENTS become spaces (same length, newlines +// kept), so every structural regex downstream sees code only: a comment that says +// `class Parser` defines nothing, and `"import x from './y'"` inside a string imports +// nothing. Quote delimiters are kept so an import specifier's position survives; its text +// is read back from the original source at the same offsets. +// --------------------------------------------------------------------------------------- + +const JS_EXTS = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts"]); +const LANG_BY_EXT = { ".py": "py", ".rb": "rb", ".php": "php", ".go": "go" }; +/** Lexer family for a file extension: js | py | rb | php | go | c. */ +export const lexOf = (ext) => (JS_EXTS.has(ext) ? "js" : (LANG_BY_EXT[ext] ?? "c")); + +// A `/` starts a regex literal (not a division) after these characters or keywords. +const REGEX_AFTER = new Set([..."(,=:[!&|?{};+-*%<>~^"]); +const REGEX_KEYWORDS = new Set([ + "return", + "typeof", + "instanceof", + "in", + "of", + "new", + "delete", + "void", + "throw", + "case", + "do", + "else", + "yield", + "await", +]); +const IDENT_CHAR = /[\w$]/; +// A char literal in C-family languages: 'a', '\n', '\x41', '\u{1F600}'. Anything else +// (Rust lifetimes `'a`, Kotlin/Swift apostrophes in odd places) stays code. +const CHAR_LIT = /'(?:\\(?:u\{[0-9a-fA-F]{1,6}\}|x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|.)|[^\\'\n])'/y; + +const blankOf = (s) => s.replace(/[^\n\r]/g, " "); + +/** + * Mask comments and string/regex contents. Output has the same length and line structure + * as `text`; string delimiters are kept. + * @param {string} text + * @param {string} ext file extension (".js", ".py", …) + * @returns {string} + */ +export function maskCode(text, ext) { + const lang = lexOf(ext); + const n = text.length; + /** @type {string[]} */ + const out = []; + let seg = 0; // start of the pending verbatim-code run + let i = 0; + // JS state: brace depth, template-interpolation stack, last significant token. + let depth = 0; + /** @type {number[]} */ + const tpl = []; + let prev = ""; + let word = ""; + let inWord = false; + + const flush = (to) => { + if (to > seg) out.push(text.slice(seg, to)); + }; + /** Emit text[a,b) with its inner part blanked, keeping `open`/`close` delimiter chars. */ + const masked = (a, b, open, close) => { + flush(a); + const o = Math.min(open, b - a); + const c = Math.min(close, b - a - o); + out.push(text.slice(a, a + o) + blankOf(text.slice(a + o, b - c)) + text.slice(b - c, b)); + seg = b; + }; + const lineEnd = (from) => { + const e = text.indexOf("\n", from); + return e < 0 ? n : e; + }; + // A single-line quoted string: ends at the closing quote or (unterminated) the newline. + const quoteEnd = (from, q) => { + let j = from + 1; + while (j < n) { + const ch = text[j]; + if (ch === "\\") j += 2; + else if (ch === q) return j + 1; + else if (ch === "\n" && lang !== "rb" && lang !== "php") return j; + else j += 1; + } + return n; + }; + const blockEnd = (from, closer) => { + const e = text.indexOf(closer, from); + return e < 0 ? n : e + closer.length; + }; + // JS template literal chunk starting at `from` (the char after ` or after the } closing + // a ${…}). Returns where the chunk ends and whether it ended at a ${ interpolation. + const templateChunk = (from) => { + let j = from; + while (j < n) { + const ch = text[j]; + if (ch === "\\") j += 2; + else if (ch === "`") return { end: j + 1, interp: false, closed: true }; + else if (ch === "$" && text[j + 1] === "{") + return { end: j + 2, interp: true, closed: false }; + else j += 1; + } + return { end: n, interp: false, closed: false }; + }; + const template = (at, keepOpen) => { + const r = templateChunk(at + 1); + flush(at); + const tail = r.interp ? 2 : r.closed ? 1 : 0; + out.push( + (keepOpen ? "`" : " ") + + blankOf(text.slice(at + 1, r.end - tail)) + + (r.interp ? " " : r.closed ? "`" : ""), + ); + seg = r.end; + if (r.interp) { + tpl.push(depth); + depth += 1; + } + prev = "`"; + word = ""; + inWord = false; + return r.end; + }; + const regexEnd = (from) => { + let j = from + 1; + let inClass = false; + while (j < n) { + const ch = text[j]; + if (ch === "\n") return -1; + if (ch === "\\") j += 2; + else { + if (inClass) { + if (ch === "]") inClass = false; + } else if (ch === "[") inClass = true; + else if (ch === "/") return j + 1; + j += 1; + } + } + return -1; + }; + const regexAllowed = () => { + if (!prev) return true; + if (word) return REGEX_KEYWORDS.has(word); + if (prev === ")" || prev === "]") return false; + return prev === "}" || REGEX_AFTER.has(prev); + }; + + while (i < n) { + const ch = text[i]; + const nx = text[i + 1]; + // ---- comments ----------------------------------------------------------------- + if ((lang === "py" || lang === "rb" || lang === "php") && ch === "#") { + const e = lineEnd(i); + masked(i, e, 0, 0); + i = e; + continue; + } + if (lang !== "py" && lang !== "rb" && ch === "/" && nx === "/") { + const e = lineEnd(i); + masked(i, e, 0, 0); + i = e; + continue; + } + if (lang !== "py" && lang !== "rb" && ch === "/" && nx === "*") { + const e = blockEnd(i + 2, "*/"); + masked(i, e, 0, 0); + i = e; + continue; + } + // ---- strings ------------------------------------------------------------------ + if (ch === '"' || ch === "'") { + if (lang === "py" && text.startsWith(ch.repeat(3), i)) { + let j = i + 3; + while (j < n && !text.startsWith(ch.repeat(3), j)) j += text[j] === "\\" ? 2 : 1; + const e = Math.min(n, j + 3); + masked(i, e, 3, j < n ? 3 : 0); + i = e; + continue; + } + if (lang === "c" && ch === '"' && text.startsWith('"""', i)) { + const e = blockEnd(i + 3, '"""'); // Kotlin/Swift/Java text blocks + masked(i, e, 3, 3); + i = e; + continue; + } + if ((lang === "c" || lang === "go") && ch === "'") { + CHAR_LIT.lastIndex = i; + const m = CHAR_LIT.exec(text); + if (!m) { + i += 1; // a lone apostrophe (Rust lifetime, etc.) is code + continue; + } + masked(i, i + m[0].length, 1, 1); + i += m[0].length; + continue; + } + const e = quoteEnd(i, ch); + masked(i, e, 1, text[e - 1] === ch && e - 1 > i ? 1 : 0); + i = e; + prev = ch; + word = ""; + inWord = false; + continue; + } + if (lang === "go" && ch === "`") { + const e = blockEnd(i + 1, "`"); + masked(i, e, 1, 1); + i = e; + continue; + } + if (lang !== "js") { + i += 1; + continue; + } + // ---- JS-only: templates, regex literals, token tracking -------------------------- + if (ch === "`") { + i = template(i, true); + continue; + } + if (ch === "/" && regexAllowed()) { + const e = regexEnd(i); + if (e > 0) { + masked(i, e, 1, 1); + i = e; + prev = "/"; + word = ""; + inWord = false; + continue; + } + } + if (ch === "{") depth += 1; + else if (ch === "}") { + if (tpl.length && tpl[tpl.length - 1] === depth - 1) { + tpl.pop(); + depth -= 1; + i = template(i, false); + continue; + } + depth -= 1; + } + if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") { + inWord = false; + } else if (IDENT_CHAR.test(ch)) { + word = inWord ? word + ch : ch; + inWord = true; + prev = ch; + } else { + word = ""; + inWord = false; + prev = ch; + } + i += 1; + } + flush(n); + return out.join(""); +} + +// --------------------------------------------------------------------------------------- +// Import extraction (run on MASKED code; specifier text read from the original). +// --------------------------------------------------------------------------------------- + +/** + * @typedef {{imported:string, local:string}} ImportName + * @typedef {{spec:string, index:number, names:ImportName[], form:"static"|"reexport"|"dynamic"|"require"}} JsImport + * @typedef {{module:string, level:number, names:ImportName[], index:number}} PyImport + */ + +// Each pattern ends at the OPENING quote of the specifier (group 1 = the quote char). +const JS_IMPORT_RES = [ + // import x from "y" · import {a, b as c} from "y" · import * as ns from "y" · import "y" + // · import type {T} from "y" (the clause spans lines; it never contains quotes/parens/;) + /(? { + re.lastIndex = 0; + for (const m of code.matchAll(re)) { + const open = (m.index ?? 0) + m[0].length - 1; + if (seen.has(open)) continue; // `import x = require("y")` hits two patterns + const close = code.indexOf(m[2], open + 1); + const eol = code.indexOf("\n", open + 1); + if (close < 0 || (eol >= 0 && eol < close)) continue; + const spec = text.slice(open + 1, close); + if (!spec) continue; + seen.add(open); + found.push({ + spec, + index: m.index ?? 0, + names: k <= 1 ? clauseNames(m[1] || "", k === 1) : [], + form: /** @type {JsImport["form"]} */ (JS_IMPORT_FORMS[k]), + }); + } + }); + return found.sort((a, b) => a.index - b.index); +} + +const PY_FROM_RE = + /^[ \t]*from[ \t]+(\.*)[ \t]*([\w.]*)[ \t]+import[ \t]*(\([^)]*\)|(?:[^\n;]|\\\n)*)/gm; +const PY_IMPORT_RE = /^[ \t]*import[ \t]+((?:[^\n;]|\\\n)*)/gm; + +const pyNames = (list) => + list + .replace(/[()\\]/g, " ") + .split(",") + .map((part) => part.trim().match(/^([\w.]+|\*)(?:\s+as\s+(\w+))?$/)) + .filter((m) => m !== null) + .map((m) => ({ imported: m[1], local: m[2] || m[1] })); + +/** + * Python `import a.b as c` and `from ..a import (x, y as z)` statements. Parenthesised and + * backslash-continued lists are read whole; a statement never runs past its own line + * otherwise (the old regex's `\s` swallowed the next three imports into one "module"). + * @param {string} code masked code (maskCode) — comments/strings already blank + * @returns {PyImport[]} + */ +export function pyImports(code) { + /** @type {PyImport[]} */ + const found = []; + PY_FROM_RE.lastIndex = 0; + for (const m of code.matchAll(PY_FROM_RE)) { + const level = m[1].length; + if (!level && !m[2]) continue; + found.push({ module: m[2], level, names: pyNames(m[3]), index: m.index ?? 0 }); + } + PY_IMPORT_RE.lastIndex = 0; + for (const m of code.matchAll(PY_IMPORT_RE)) { + // `import a.b as c, d` — each dotted module is its own import + for (const n of pyNames(m[1])) + if (n.imported !== "*") + found.push({ module: n.imported, level: 0, names: [], index: m.index ?? 0 }); + } + return found.sort((a, b) => a.index - b.index); +} + +// --------------------------------------------------------------------------------------- +// Resolution — specifier → repo-relative file (POSIX), against the set of files on disk. +// --------------------------------------------------------------------------------------- + +const JS_RESOLVE_EXTS = [".js", ".ts", ".tsx", ".jsx", ".mjs", ".cjs", ".mts", ".cts"]; +// TypeScript NodeNext/Node16: source says `./x.js`, disk has `x.ts` (and friends). +const TS_TWIN = { + ".js": [".ts", ".tsx"], + ".jsx": [".tsx"], + ".mjs": [".mts"], + ".cjs": [".cts"], +}; + +/** + * Resolve a relative JS/TS specifier the way Node + TypeScript do: exact file, the + * NodeNext `.js`→`.ts` twin, extensionless, then `/index.*`. Bare/package specifiers + * return null (external — not a local edge). + * @param {string} fromRel importing file, repo-relative POSIX + * @param {string} spec + * @param {Set} fileSet repo-relative POSIX paths + * @returns {string|null} + */ +export function resolveSpec(fromRel, spec, fileSet) { + if (!spec.startsWith("./") && !spec.startsWith("../") && spec !== "." && spec !== "..") + return null; + const raw = posix.normalize(posix.join(posix.dirname(fromRel), spec.split(/[?#]/)[0])); + if (raw.startsWith("../") || raw === "..") return null; // escapes the repo + const base = raw === "." ? "" : raw.replace(/\/$/, ""); + const ext = posix.extname(base); + const cands = [base]; + for (const twin of TS_TWIN[ext] ?? []) cands.push(base.slice(0, -ext.length) + twin); + for (const e of JS_RESOLVE_EXTS) cands.push(base + e); + for (const e of JS_RESOLVE_EXTS) cands.push(base ? `${base}/index${e}` : `index${e}`); + for (const c of cands) if (c && fileSet.has(c)) return c; + return null; +} + +/** + * Python module index. A module's CANONICAL name is its dotted path from its package root + * (the directory above its top-most `__init__.py` package — `src/` in a src layout, the + * repo root in a flat one), so `src/mypkg/core.py` is `mypkg.core`, exactly what + * `from mypkg.core import …` spells. Files directly inside a package root are top-level + * modules. Namespace/script directories have no canonical name and are reachable only + * through the unique prefix-stripped fallback in resolvePyModule. + * @param {Iterable} files repo-relative POSIX paths (any extension; .py used) + */ +export function pyModuleIndex(files) { + const all = [...files]; + const fileSet = new Set(all); + const py = all.filter((f) => f.endsWith(".py")); + const isPkg = (dir) => dir !== "." && fileSet.has(`${dir}/__init__.py`); + const topPkgParent = (dir) => { + let d = dir; + while (isPkg(d)) d = posix.dirname(d); + return d; + }; + const roots = new Set(["."]); + for (const f of py) { + const dir = posix.dirname(f); + if (isPkg(dir)) roots.add(topPkgParent(dir)); + } + const dotted = (rel) => + rel + .replace(/\.py$/, "") + .replace(/(^|\/)__init__$/, "") + .split("/") + .filter(Boolean) + .join("."); + /** @type {Map} */ + const canonical = new Map(); + /** @type {Map} */ + const suffix = new Map(); + /** @type {Map} */ + const nameOf = new Map(); + const add = (map, key, f) => { + if (!key) return; + const arr = map.get(key); + if (arr) arr.push(f); + else map.set(key, [f]); + }; + for (const f of py) { + const dir = posix.dirname(f); + let name = ""; + if (isPkg(dir)) { + const root = topPkgParent(dir); + name = dotted(root === "." ? f : f.slice(root.length + 1)); + } else if (roots.has(dir) && !f.endsWith("/__init__.py")) { + name = dotted(posix.basename(f)); + } + if (name) { + add(canonical, name, f); + nameOf.set(f, name); + } + const parts = dotted(f).split("."); + for (let k = 0; k < parts.length - 1; k++) add(suffix, parts.slice(k).join("."), f); + } + return { canonical, suffix, nameOf, fileSet }; +} + +/** + * Resolve a dotted Python module name to a file: a unique canonical (package-root) match, + * else — for dotted names only — a unique prefix-stripped match. Single-segment names + * never take the fallback: `import json` must not bind to some `tools/json.py`. + * @param {ReturnType} index + * @param {string} name + * @returns {string|null} + */ +export function resolvePyModule(index, name) { + const hit = index.canonical.get(name); + if (hit) return hit.length === 1 ? hit[0] : null; + if (!name.includes(".")) return null; + const strip = index.suffix.get(name); + return strip && strip.length === 1 ? strip[0] : null; +} + +/** + * Resolve one Python import statement to target files. Relative imports walk up from the + * importing file's package; `from X import Y` prefers submodule X.Y, else module X. + * @param {string} fromRel + * @param {PyImport} imp + * @param {ReturnType} index + * @returns {{file:string, names:ImportName[]}[]} one entry per resolved module; `names` + * are the imported names to look up INSIDE that module (empty for submodule hits) + */ +export function resolvePyImport(fromRel, imp, index) { + const { fileSet } = index; + const fileFor = (path) => + fileSet.has(`${path}.py`) + ? `${path}.py` + : fileSet.has(`${path}/__init__.py`) + ? `${path}/__init__.py` + : null; + /** @type {{file:string, names:ImportName[]}[]} */ + const out = []; + if (imp.level > 0) { + const dir = posix.dirname(fromRel); + const segs = dir === "." ? [] : dir.split("/"); + if (imp.level - 1 > segs.length) return out; // climbs above the repo root + const modPath = [ + ...segs.slice(0, segs.length - (imp.level - 1)), + ...imp.module.split(".").filter(Boolean), + ].join("/"); + const rest = []; + for (const n of imp.names) { + const sub = + n.imported !== "*" ? fileFor(modPath ? `${modPath}/${n.imported}` : n.imported) : null; + if (sub) out.push({ file: sub, names: [] }); + else rest.push(n); + } + const mod = modPath ? fileFor(modPath) : fileSet.has("__init__.py") ? "__init__.py" : null; + if (mod && (rest.length || !imp.names.length)) out.push({ file: mod, names: rest }); + return out; + } + if (!imp.names.length) { + const f = resolvePyModule(index, imp.module); + if (f) out.push({ file: f, names: [] }); + return out; + } + const rest = []; + for (const n of imp.names) { + const sub = n.imported !== "*" ? resolvePyModule(index, `${imp.module}.${n.imported}`) : null; + if (sub) out.push({ file: sub, names: [] }); + else rest.push(n); + } + if (rest.length) { + const f = resolvePyModule(index, imp.module); + if (f) out.push({ file: f, names: rest }); + } + return out; +} + +/** + * Every local file one source file imports (JS/TS specifiers + Python modules). + * @param {string} rel repo-relative POSIX path of the importing file + * @param {string} text its source + * @param {Set} fileSet + * @param {ReturnType} [pyIndex] + * @returns {Set} + */ +export function localImports(rel, text, fileSet, pyIndex) { + const ext = extname(rel); + const code = maskCode(text, ext); + const targets = new Set(); + if (ext === ".py") { + const index = pyIndex ?? pyModuleIndex(fileSet); + for (const imp of pyImports(code)) + for (const r of resolvePyImport(rel, imp, index)) targets.add(r.file); + } else { + for (const imp of jsImports(code, text)) { + const t = resolveSpec(rel, imp.spec, fileSet); + if (t) targets.add(t); + } + } + targets.delete(rel); + return targets; +} + +// --------------------------------------------------------------------------------------- +// The file graph. +// --------------------------------------------------------------------------------------- + function walk(dir, root, out) { for (const entry of readdirSync(dir, { withFileTypes: true })) { if (IGNORE_DIRS.has(entry.name)) continue; @@ -25,21 +599,6 @@ function walk(dir, root, out) { } } -function resolveSpec(fromRel, spec, root, fileSet) { - if (!spec.startsWith(".")) return null; // external / stdlib — not a local edge - const raw = resolve(root, dirname(fromRel), spec); - const cands = [ - raw, - ...[".js", ".ts", ".tsx", ".jsx", ".mjs", ".cjs", ".py"].map((ext) => raw + ext), - ...["index.js", "index.ts"].map((idx) => join(raw, idx)), - ]; - for (const c of cands) { - const rel = toPosix(relative(root, c)); - if (fileSet.has(rel)) return rel; - } - return null; -} - /** Build an UNDIRECTED file→file import graph (coupling is symmetric for decomposition). */ export function importGraph(root) { const { nodes, edges } = directedImportGraph(root); @@ -59,6 +618,7 @@ export function directedImportGraph(root) { const files = []; walk(root, root, files); const fileSet = new Set(files); + const pyIndex = pyModuleIndex(files); const edges = new Map(files.map((f) => [f, new Set()])); for (const f of files) { let text = ""; @@ -67,12 +627,7 @@ export function directedImportGraph(root) { } catch { continue; } - for (const re of IMPORT_RES) { - for (const m of text.matchAll(re)) { - const target = resolveSpec(f, m[1], root, fileSet); - if (target && target !== f) edges.get(f).add(target); - } - } + edges.set(f, localImports(f, text, fileSet, pyIndex)); } return { nodes: files, edges }; } diff --git a/test/fixtures/impact_repos.mjs b/test/fixtures/impact_repos.mjs new file mode 100644 index 0000000..f7c810c --- /dev/null +++ b/test/fixtures/impact_repos.mjs @@ -0,0 +1,157 @@ +// Fixture repos for the impact-graph regression tests (atlas import resolution, Python +// package roots, sibling/forward relations, comment/string masking, call attribution). +// Each builder writes a tiny repo into a fresh temp dir and returns its root. The ground +// truth for every fixture is spelled out next to it, so a failing assertion can be checked +// by reading the fixture, not by trusting the graph. +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +/** Write `{relPath: text}` under a new temp dir; returns the root. */ +export function writeRepo(files, prefix = "forge-impact-") { + const root = mkdtempSync(join(tmpdir(), prefix)); + for (const [rel, text] of Object.entries(files)) { + mkdirSync(dirname(join(root, rel)), { recursive: true }); + writeFileSync(join(root, rel), text); + } + return root; +} + +// js1 — src/util.js has exactly TEN direct importers, one per import form. Traps that must +// NOT be linked: a string and a comment naming "./util.js", and a DIFFERENT util.js in +// another directory (its importer resolves to that file, not this one). +export const JS1_IMPORTERS = [ + "src/d01_named.js", + "src/d02_namespace.js", + "src/d03_default_extensionless.js", + "src/d04_export_star.js", + "src/d05_export_rename.js", + "src/d06_dynamic.js", + "src/d07_require.cjs", + "src/d08_side_effect.js", + "src/nested/d09_multiline.js", + "src/widgets/index.js", +]; +export const js1Files = { + "src/util.js": + "export function helper(x) {\n return x + 1;\n}\nexport function other() {\n return 2;\n}\nexport default { helper };\n", + "src/d01_named.js": 'import { helper } from "./util.js";\nexport const d01 = () => helper(1);\n', + "src/d02_namespace.js": + 'import * as util from "./util.js";\nexport function d02() {\n return util.helper(2);\n}\n', + "src/d03_default_extensionless.js": + 'import util from "./util";\nexport function d03() {\n return util.helper(3);\n}\n', + "src/d04_export_star.js": 'export * from "./util.js";\n', + "src/d05_export_rename.js": 'export { helper as increment } from "./util.js";\n', + "src/d06_dynamic.js": + 'export async function d06() {\n const m = await import("./util.js");\n return m.helper(6);\n}\n', + "src/d07_require.cjs": + 'const { helper } = require("./util.js");\nmodule.exports = () => helper(7);\n', + "src/d08_side_effect.js": 'import "./util.js";\nexport const d08 = 8;\n', + "src/nested/d09_multiline.js": + 'import {\n helper,\n other,\n} from "../util.js";\nexport function d09() {\n return helper(other());\n}\n', + // A directory package: imported below as "./widgets" (index.js directory import). + "src/widgets/index.js": + 'import { other } from "../util.js";\nexport function widget() {\n return other();\n}\n', + "src/page.js": 'import { widget } from "./widgets";\nexport const page = () => widget();\n', + // Traps. + "src/noise.js": + '// util helpers live in "./util.js" — this comment is not an import\nexport const doc = "see ./util.js";\n', + "src/other/util.js": "export function helper() {\n return 0;\n}\n", + "src/other/consumer.js": + 'import { helper } from "./util.js";\nexport const consume = () => helper();\n', +}; + +// sib — serializer.js and deserializer.js share wire_format.js; app.js uses both. +// impact(serializer.js): reverse = app.js, sibling = deserializer.js, forward = wire_format.js. +export const sibFiles = { + "src/wire_format.js": + "export function encodeFrame(x) {\n return JSON.stringify(x);\n}\nexport function decodeFrame(s) {\n return JSON.parse(s);\n}\n", + "src/serializer.js": + 'import { encodeFrame } from "./wire_format.js";\nexport function serialize(obj) {\n return encodeFrame(obj);\n}\n', + "src/deserializer.js": + 'import { decodeFrame } from "./wire_format.js";\nexport function deserialize(s) {\n return decodeFrame(s);\n}\n', + "src/app.js": + 'import { serialize } from "./serializer.js";\nimport { deserialize } from "./deserializer.js";\nexport function roundTrip(o) {\n return deserialize(serialize(o));\n}\n', +}; + +// py_rel — pkg/core.py has exactly SEVEN importers, one per Python import form, including +// relative, parenthesised multi-line, aliased dotted, and an import after other imports. +export const PY_REL_IMPORTERS = [ + "pkg/a.py", + "pkg/b.py", + "pkg/c.py", + "pkg/d.py", + "pkg/sub/e.py", + "pkg/f.py", + "app.py", +]; +export const pyRelFiles = { + "pkg/__init__.py": "", + "pkg/sub/__init__.py": "", + "pkg/core.py": + "def run():\n return 1\n\n\ndef start():\n return 2\n\n\nclass Engine:\n pass\n", + "pkg/a.py": "from .core import run\n\n\ndef a():\n return run()\n", + "pkg/b.py": "from . import core\n\n\ndef b():\n return core.run()\n", + "pkg/c.py": + "from pkg.core import (\n run,\n start,\n)\n\n\ndef c():\n return run() + start()\n", + "pkg/d.py": "import pkg.core as c\n\n\ndef d():\n return c.start()\n", + "pkg/sub/e.py": "from ..core import start\n\n\ndef e():\n return start()\n", + "pkg/f.py": + "import os\nimport sys\nfrom pkg.core import start\n\n\ndef f():\n return start() + len(sys.argv) + len(os.sep)\n", + "app.py": "from pkg import core\n\nprint(core.run())\n", + // Trap: mentions pkg.core only in a comment and a string. + "pkg/noise.py": '# from pkg.core import run (commented out)\nDOC = "import pkg.core"\n', +}; + +// py_flat / py_src — the SAME package in a flat and in a src layout. cli.py imports +// mypkg.core by its absolute package name in both; both layouts must give [mypkg/cli.py]. +// worker.py defines a second `run`, so a bare-name guess cannot paper over a wrong qname. +const MYPKG = { + "mypkg/__init__.py": "", + "mypkg/core.py": "def run():\n return 1\n", + "mypkg/worker.py": "def run():\n return 2\n", + "mypkg/cli.py": "from mypkg.core import run\n\n\ndef main():\n return run()\n", +}; +export const pyFlatFiles = { ...MYPKG, "pyproject.toml": '[project]\nname = "mypkg"\n' }; +export const pySrcFiles = { + ...Object.fromEntries(Object.entries(MYPKG).map(([k, v]) => [`src/${k}`, v])), + "pyproject.toml": '[project]\nname = "mypkg"\n', +}; + +// coll — two files define `render`; each importer must resolve to ITS render.js. +export const collFiles = { + "src/ui/render.js": 'export function render(x) {\n return "

    " + x + "

    ";\n}\n', + "src/pdf/render.js": "export function render(x) {\n return Buffer.from(String(x));\n}\n", + "src/ui/view.js": + 'import { render } from "./render.js";\nexport function view(x) {\n return render(x);\n}\n', + "src/pdf/export.js": + 'import { render } from "./render.js";\nexport function exportPdf(x) {\n return render(x);\n}\n', +}; + +// phantom — a comment that says `class Parser` must not create a second Parser symbol +// (which would make the name ambiguous and erase main.js's real edge). +export const phantomFiles = { + "src/parser.js": + "export class Parser {\n parse(s) {\n return s.split(',');\n }\n}\nexport function makeParser() {\n return new Parser();\n}\n", + "src/main.js": + 'import { makeParser } from "./parser.js";\nexport function main() {\n return makeParser().parse("a,b");\n}\n', + "src/notes.js": + '// class Parser is documented in parser.js; function makeParser() too\nexport const NOTE = "class Parser";\n', +}; + +// trans — mid() assigns leaf()'s result to a LOCAL const; top() calls mid(). The call to +// leaf() belongs to mid(), so impact(leaf) must reach top.js through mid. +export const transFiles = { + "src/leaf.js": "export function leaf() {\n return 1;\n}\n", + "src/mid.js": + 'import { leaf } from "./leaf.js";\nexport function mid() {\n const value = leaf();\n return value * 2;\n}\n', + "src/top.js": + 'import { mid } from "./mid.js";\nexport function top() {\n return mid() + 1;\n}\n', +}; + +// ts1 — TypeScript NodeNext: `./x.js` in source refers to x.ts on disk. +export const ts1Files = { + "src/x.ts": "export function fx(n: number): number {\n return n * 2;\n}\n", + "src/y.ts": 'import { fx } from "./x.js";\nexport const fy = (n: number) => fx(n) + 1;\n', + "src/z.ts": 'export * from "./x.js";\n', +}; diff --git a/test/scope.test.js b/test/scope.test.js index ac1620d..b47b732 100644 --- a/test/scope.test.js +++ b/test/scope.test.js @@ -3,7 +3,17 @@ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; -import { components, decompose, importGraph } from "../src/scope.js"; +import { + components, + decompose, + directedImportGraph, + importGraph, + jsImports, + maskCode, + pyImports, + resolveSpec, +} from "../src/scope.js"; +import { pyRelFiles, ts1Files, writeRepo } from "./fixtures/impact_repos.mjs"; function repo() { const root = mkdtempSync(join(tmpdir(), "forge-scope-")); @@ -47,3 +57,75 @@ test("decompose normalizes ./ and absolute paths so coupling still resolves", () const d = decompose(root, [join(root, "src/a.js"), "./src/b.js"]); assert.equal(d.independentGroups, 1, "two coupled files are NOT reported as independent"); }); + +// --- the shared import machinery (also what atlas.js resolves with) ------------------- + +test("maskCode blanks comments and string/regex contents but keeps offsets and delimiters", () => { + const src = [ + '// import fake from "./nope.js"', + "const s = \"import also from './nope.js'\";", + "const re = /[\"']/g;", + 'import { real } from "./real.js";', + ].join("\n"); + const code = maskCode(src, ".js"); + assert.equal(code.length, src.length, "offsets are preserved"); + assert.equal(code.split("\n").length, src.split("\n").length, "lines are preserved"); + assert.deepEqual( + jsImports(code, src).map((i) => i.spec), + ["./real.js"], + "a comment and a string are not imports", + ); + const py = maskCode('# from a import b\nX = "from c import d"\nfrom real import thing\n', ".py"); + assert.deepEqual( + pyImports(py).map((i) => `${i.module}:${i.names.map((n) => n.imported)}`), + ["real:thing"], + ); +}); + +test("jsImports reads every form; pyImports keeps statements apart", () => { + const src = [ + 'import a, { b as c } from "./m.js";', + 'export * from "./star.js";', + 'export { d as e } from "./re.js";', + 'const f = await import("./dyn.js");', + 'const g = require("./req.cjs");', + 'import "./side.js";', + ].join("\n"); + const found = jsImports(maskCode(src, ".js"), src); + assert.deepEqual( + found.map((i) => i.spec), + ["./m.js", "./star.js", "./re.js", "./dyn.js", "./req.cjs", "./side.js"], + ); + assert.deepEqual(found[0].names, [ + { imported: "b", local: "c" }, + { imported: "default", local: "a" }, + ]); + const py = pyImports( + maskCode("import os\nimport sys\nfrom pkg.core import start\nimport pkg.core as c\n", ".py"), + ); + assert.deepEqual( + py.map((i) => i.module), + ["os", "sys", "pkg.core", "pkg.core"], + "`\s` in the old regex fused three statements into one module name", + ); +}); + +test("resolveSpec handles NodeNext .js→.ts, extensionless and index directories", () => { + const files = new Set(["src/a.ts", "src/b.js", "src/dir/index.js", "src/c.py"]); + assert.equal(resolveSpec("src/x.ts", "./a.js", files), "src/a.ts"); + assert.equal(resolveSpec("src/x.js", "./b", files), "src/b.js"); + assert.equal(resolveSpec("src/x.js", "./dir", files), "src/dir/index.js"); + assert.equal(resolveSpec("src/x.js", "lodash", files), null, "packages are not local edges"); + assert.equal(resolveSpec("src/x.js", "../../outside.js", files), null); +}); + +test("the file graph covers Python imports and TypeScript NodeNext specifiers", () => { + const py = directedImportGraph(writeRepo(pyRelFiles)); + assert.ok(py.edges.get("pkg/a.py").has("pkg/core.py"), "relative from-import"); + assert.ok(py.edges.get("pkg/sub/e.py").has("pkg/core.py"), "two-level relative import"); + assert.ok(py.edges.get("pkg/d.py").has("pkg/core.py"), "aliased dotted import"); + assert.equal(py.edges.get("pkg/noise.py").size, 0, "a commented import is not an edge"); + const ts = directedImportGraph(writeRepo(ts1Files)); + assert.ok(ts.edges.get("src/y.ts").has("src/x.ts"), "./x.js resolves to x.ts"); + assert.ok(ts.edges.get("src/z.ts").has("src/x.ts"), "export * from './x.js'"); +}); From 54ff25fea3f7996b2731b489ef5856ceaac595ea Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:03:14 +0200 Subject: [PATCH 30/72] fix(atlas): resolve imports, read only code, attribute calls to scopes, walk siblings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deep review's findings A1–A6 on the impact graph, all reproduced first on the unmodified code and each pinned by a regression test in test/atlas_resolve.test.js and test/atlas_relations.test.js (fixtures: test/fixtures/impact_repos.mjs). A1/A3/A4/A5 — imports are resolved STRUCTURALLY through scope.js instead of being stored as raw strings and matched by their last dotted segment. On this repo 3 of 502 relative import statements resolved and all three were spurious ("../scripts/ build-pages.mjs" → `mjs` → `const mjs` in doctor.js); now 679 of 679 relative statements (of 1,392 seen, up from 1,196 — multi-line, `export … from`, dynamic and `require` forms were invisible) resolve to the exact file the specifier names, none wrong. Python modules resolve by package root, so a src layout answers like a flat one, and an unresolvable import stays unresolved instead of being pinned to a same-named symbol — in particular never across languages (a Python `from impact_oracle.oracle import …` used to land on the JS `const oracle`). A6 — every structural regex now runs on masked source, so a comment saying `class Parser` no longer defines a phantom that makes the name ambiguous and erases a real edge; a call is attributed to its innermost enclosing function instead of the nearest preceding local `const`, restoring transitive callers; ambiguous references are counted (3,136 → 877 here) and surfaced with the cap and unresolved-import counts in `impact()` and `forge impact`; the file cap counts source files only; and extraction is linear — 16k lines of JS plus 16k of Python build in 0.3 s, down from 4.9 s. ATLAS-Q: `query()` ranks exact definitions above path-only qname matches. A2 — the reverse-only traversal is extended with the empirical refutation's SIBLING and FORWARD relations at the replication package's FROZEN parameters (sibling: one forward hop to the shared dependency's file, one reverse hop back, weight 0.7, bridge in-degree cap 100; forward: ≤2 hops, weight 0.5), both terminal. `impact(serializer)` now reports the `deserializer` that shares `wire_format` with it; every item carries its `relation`, and `relations: ["reverse"]` reproduces the old answer exactly. The atlas artifact is version 3: an older `.forge/atlas.json` or per-file cache is rebuilt rather than trusted, since it holds unresolved specifiers. Co-Authored-By: Claude Opus 5 --- src/atlas.js | 919 +++++++++++++++++++++++++++++------ src/cli.js | 33 +- test/atlas_relations.test.js | 92 ++++ test/atlas_resolve.test.js | 210 ++++++++ 4 files changed, 1115 insertions(+), 139 deletions(-) create mode 100644 test/atlas_relations.test.js create mode 100644 test/atlas_resolve.test.js diff --git a/src/atlas.js b/src/atlas.js index ecb5c80..6667fb5 100644 --- a/src/atlas.js +++ b/src/atlas.js @@ -5,8 +5,21 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSy import { extname, join, relative } from "node:path"; import { adjudicate, asText, buildRunner, llmEnabled } from "./adjudicate.js"; import { CALL_RE } from "./extract.js"; +import { + jsImports, + lexOf, + maskCode, + pyImports, + pyModuleIndex, + resolvePyImport, + resolveSpec, +} from "./scope.js"; import { contentHash, IGNORE_DIRS, toPosix } from "./util.js"; +// Bumped whenever extraction or resolution changes shape: an atlas.json or per-file cache +// from an older version is rebuilt, never trusted (v2 stored unresolved import specifiers). +export const ATLAS_VERSION = 3; + const JS_RULES = [ { re: /(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/g, @@ -134,8 +147,6 @@ export function isConfigFile(name) { return CONFIG_EXTS.has(extname(name)) || CONFIG_FILE_RE.test(name); } -const IMPORT_RE = - /(?:import\s+(?:[^"'\n]+\s+from\s+)?["']([^"']+)["']|require\(["']([^"']+)["']\)|^\s*(?:from\s+([\w.]+)\s+)?import\s+([\w*,\s]+))/gm; const BUILTINS = new Set([ "if", "for", @@ -175,7 +186,54 @@ const BUILTINS = new Set([ const hash = contentHash; -function walk(dir, files, cap) { +// Language families. A bare name never resolves across families — a Python +// `from impact_oracle.oracle import …` is not the JS `const oracle` in eval.js. Kotlin↔Java +// interop is real, so the JVM languages share a family; the C/C++ extensions share one. +const FAMILY_BY_EXT = { + ".js": "js", + ".jsx": "js", + ".ts": "js", + ".tsx": "js", + ".mjs": "js", + ".cjs": "js", + ".py": "py", + ".go": "go", + ".rs": "rs", + ".java": "jvm", + ".kt": "jvm", + ".kts": "jvm", + ".rb": "rb", + ".cs": "cs", + ".php": "php", + ".swift": "swift", +}; +const familyOf = (file) => { + const ext = extname(String(file || "")); + return FAMILY_BY_EXT[ext] ?? (RULES[ext] ? "c" : ""); +}; + +// Extensions an import specifier can name and still be CODE we expect to resolve; an +// unresolved `./styles.css` is an asset, not a missing edge. +const CODE_SPEC_EXTS = new Set(["", ".mts", ".cts", ...Object.keys(RULES)]); + +// Docs and configs have their own fixed bound (they are cheap to extract, but a repo of +// 100k generated JSON files must still not make a build unbounded). +const OTHER_FILE_CAP = 20000; + +/** + * The files the graph is built from. `cap` bounds SOURCE files (the RULES extensions) only, + * so a repo full of JSON/Markdown can no longer crowd code out of the graph; docs/configs + * have OTHER_FILE_CAP. Every file a cap drops is counted — a capped graph says so. + * @param {string} root + * @param {number} cap + */ +function inventory(root, cap) { + const inv = { files: /** @type {string[]} */ ([]), source: 0, other: 0, skipped: 0 }; + walk(root, inv, cap); + return inv; +} + +function walk(dir, inv, cap) { let entries; try { entries = readdirSync(dir); @@ -195,14 +253,20 @@ function walk(dir, files, cap) { } catch { continue; } - if (st.isDirectory()) walk(path, files, cap); - else if ( - (RULES[extname(name)] || - (DOC_EXTS.has(extname(name)) && !DOC_SKIP.test(name)) || - isConfigFile(name)) && - files.length < cap - ) - files.push(path); + if (st.isDirectory()) { + walk(path, inv, cap); + continue; + } + const ext = extname(name); + const isSource = Boolean(RULES[ext]); + if (!isSource && !((DOC_EXTS.has(ext) && !DOC_SKIP.test(name)) || isConfigFile(name))) continue; + if (isSource ? inv.source >= cap : inv.other >= OTHER_FILE_CAP) { + inv.skipped += 1; + continue; + } + if (isSource) inv.source += 1; + else inv.other += 1; + inv.files.push(path); } } @@ -210,16 +274,26 @@ function moduleId(rel) { return rel.replace(/\.[^.]+$/, "").replace(/[/\\]/g, "."); } -function lineOf(text, index) { - return text.slice(0, index).split("\n").length; -} - -function nearestSource(nodes, line, fallback) { - let best = fallback; - for (const n of nodes) { - if (n.line <= line && (!best.line || n.line >= best.line)) best = n; - } - return best; +/** + * Offset → 1-based line by binary search over precomputed line starts. The old + * `text.slice(0, i).split("\n")` per match made extraction O(n²) (16k lines: ~20 s). + * @param {string} text + */ +function lineIndex(text) { + const starts = [0]; + for (let i = text.indexOf("\n"); i >= 0; i = text.indexOf("\n", i + 1)) starts.push(i + 1); + /** @param {number} pos */ + const at = (pos) => { + let lo = 0; + let hi = starts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (starts[mid] <= pos) lo = mid; + else hi = mid - 1; + } + return lo + 1; + }; + return { at, starts }; } // A markdown file becomes ONE doc node whose outgoing `references` edges point at the @@ -231,6 +305,7 @@ function extractDoc(rel, text) { const doc = { id: `doc:${rel}`, name: rel, kind: "doc", file: rel, line: 1 }; const edges = []; const seen = new Set(); + const lines = lineIndex(text); const refEdge = (target, confidence, line) => { if (seen.has(target)) return; seen.add(target); @@ -243,7 +318,7 @@ function extractDoc(rel, text) { }); }; for (const m of text.matchAll(/`([^`\n]+)`/g)) { - const line = lineOf(text, m.index); + const line = lines.at(m.index ?? 0); for (const raw of m[1].trim().split(/\s+/)) { const tok = raw.replace(/[(),;:]+$/, "").replace(/^\.\//, ""); if (!tok) continue; @@ -258,7 +333,7 @@ function extractDoc(rel, text) { for (const m of text.matchAll(/\]\(([^)#\s]+)\)/g)) { const tok = m[1].replace(/^\.\//, ""); if (/^[a-z]+:/i.test(tok)) continue; // external URL, not a repo path - if (RULES[extname(tok)]) refEdge(`module:${moduleId(tok)}`, 0.8, lineOf(text, m.index)); + if (RULES[extname(tok)]) refEdge(`module:${moduleId(tok)}`, 0.8, lines.at(m.index ?? 0)); } return { symbols: [], nodes: [doc], edges, hash: hash(text) }; } @@ -277,6 +352,7 @@ function extractConfig(rel, text) { }; const edges = []; const seen = new Set(); + const lines = lineIndex(text); for (const m of text.matchAll(/[A-Za-z0-9_.@-]+(?:[/\\][A-Za-z0-9_.@-]+)*/g)) { const tok = m[0].replace(/^\.\//, ""); if (!RULES[extname(tok)]) continue; // only path-like tokens ending in a code extension @@ -288,12 +364,219 @@ function extractConfig(rel, text) { target, kind: "references", confidence: 0.8, - line: lineOf(text, m.index), + line: lines.at(m.index ?? 0), }); } return { symbols: [], nodes: [cfg], edges, hash: hash(text) }; } +// --------------------------------------------------------------------------------------- +// Scopes — which definition OWNS a call or import. A call belongs to the innermost +// function/class (or top-level const) whose body contains it; a local `const value = +// leaf()` owns nothing, so its call stays with the enclosing function and transitive +// callers of that function stay reachable. +// --------------------------------------------------------------------------------------- + +const CONTAINER_KINDS = new Set(["function", "class", "type"]); + +/** The `d`-flag twin of a rule regex, so a match reports its NAME's offset (group 1). */ +const INDICES = new Map(); +function withIndices(re) { + let d = INDICES.get(re); + if (!d) { + d = new RegExp(re.source, re.flags.includes("d") ? re.flags : `${re.flags}d`); + INDICES.set(re, d); + } + d.lastIndex = 0; + return d; +} + +/** Index of the matching `}` for every `{` in masked code (-1 when unbalanced). */ +function closingBraces(code) { + const close = new Int32Array(code.length).fill(-1); + const stack = []; + for (let i = 0; i < code.length; i++) { + const c = code.charCodeAt(i); + if (c === 123) stack.push(i); + else if (c === 125 && stack.length) close[stack.pop()] = i; + } + return close; +} + +/** Brace depth at each of `positions` (ascending), in one linear sweep. */ +function depthsAt(code, positions) { + const out = new Map(); + let depth = 0; + let k = 0; + for (let i = 0; i <= code.length && k < positions.length; i++) { + while (k < positions.length && positions[k] === i) out.set(positions[k++], depth); + const c = code.charCodeAt(i); + if (c === 123) depth += 1; + else if (c === 125) depth -= 1; + } + return out; +} + +const MAX_HEADER = 4096; // chars between a definition's name and its body's `{` + +// The body of a definition in a brace language: the first `{` after its name at bracket +// depth 0, before `limit` (the next definition's line). A `;`, `}`, `=` (Kotlin/C# +// expression body) or an unbalanced `)` first means a declaration with no body here. +function braceBody(code, from, limit, close) { + let depth = 0; + for (let j = from; j < limit; j++) { + const c = code[j]; + if (c === "(" || c === "[") depth += 1; + else if (c === ")" || c === "]") { + depth -= 1; + if (depth < 0) return -1; + } else if (depth === 0) { + if (c === "{") return close[j]; + if (c === ";" || c === "}" || c === "=") return -1; + } + } + return -1; +} + +// A line ending in one of these (or the next line starting with one of CONT_START) +// continues the statement — JS automatic semicolon insertion, approximated. +const CONT_END = new Set([..."=+-*/%&|^!~?:,([{<>"]); +const CONT_START = new Set([...".?:,=&|*%^+->"]); + +/** End offset of the top-level JS statement starting at `from` (a const initializer). */ +function statementEnd(code, from) { + let depth = 0; + for (let j = from; j < code.length; j++) { + const c = code[j]; + if (c === "(" || c === "[" || c === "{") depth += 1; + else if (c === ")" || c === "]" || c === "}") { + depth -= 1; + if (depth < 0) return j; + } else if (depth === 0 && c === ";") return j; + else if (depth === 0 && c === "\n") { + let k = j - 1; + while (k >= from && (code[k] === " " || code[k] === "\t" || code[k] === "\r")) k -= 1; + if (k < from || code[k] === "\n" || CONT_END.has(code[k])) continue; + let m = j + 1; + while (m < code.length && /\s/.test(code[m])) m += 1; + if (m < code.length && CONT_START.has(code[m])) continue; + return j; + } + } + return code.length; +} + +/** Python def/class extents by indentation (continuation lines inside brackets skipped). */ +function pyScopes(code, defs, lines) { + const byLine = new Map(); + for (const d of defs) { + const line = lines.at(d.pos); + const arr = byLine.get(line); + if (arr) arr.push(d); + else byLine.set(line, [d]); + } + const out = []; + const open = []; + let paren = 0; + const { starts } = lines; + for (let li = 0; li < starts.length; li++) { + const s = starts[li]; + const e = li + 1 < starts.length ? starts[li + 1] : code.length; + const text = code.slice(s, e); + if (paren === 0 && text.trim()) { + const indent = text.length - text.trimStart().length; + while (open.length && indent <= open[open.length - 1].indent) { + const o = open.pop(); + out.push({ start: o.start, end: s - 1, node: o.node }); + } + for (const d of byLine.get(li + 1) || []) open.push({ indent, start: s, node: d.node }); + } + for (const ch of text) { + if (ch === "(" || ch === "[" || ch === "{") paren += 1; + else if (ch === ")" || ch === "]" || ch === "}") paren = Math.max(0, paren - 1); + } + } + for (const o of open) out.push({ start: o.start, end: code.length, node: o.node }); + return out; +} + +/** + * Container extents for one file's definitions (sorted by name offset). + * @param {string} code masked code + * @param {{node:any, pos:number, kind:string}[]} defs + * @param {string} lex + * @param {ReturnType} lines + */ +function containerScopes(code, defs, lex, lines) { + const containers = defs.filter((d) => CONTAINER_KINDS.has(d.kind)); + if (lex === "py") return pyScopes(code, containers, lines); + if (lex === "rb") { + // `def … end` — no braces to match: a definition owns code up to the next one. + return containers.map((d, k) => ({ + start: d.pos, + end: k + 1 < containers.length ? containers[k + 1].pos - 1 : code.length, + node: d.node, + })); + } + const close = closingBraces(code); + const constDepth = + lex === "js" + ? depthsAt( + code, + defs.filter((d) => d.kind === "const").map((d) => d.pos), + ) + : new Map(); + // A header never runs into a definition on a LATER line (a Kotlin `fun f(): Int` without + // a body must not claim the next function's braces) nor past MAX_HEADER chars. Limits + // are computed in one backward pass, so minified one-line files stay linear. + const defLine = defs.map((d) => lines.at(d.pos)); + const limitOf = new Array(defs.length).fill(code.length); + for (let k = defs.length - 2; k >= 0; k--) + limitOf[k] = defLine[k + 1] > defLine[k] ? lines.starts[defLine[k + 1] - 1] : limitOf[k + 1]; + const out = []; + for (let k = 0; k < defs.length; k++) { + const d = defs[k]; + const from = d.pos + d.node.name.length; + if (CONTAINER_KINDS.has(d.kind)) { + const limit = Math.max(from, Math.min(limitOf[k], from + MAX_HEADER)); + const end = braceBody(code, from, limit, close); + if (end > 0) out.push({ start: d.pos, end, node: d.node }); + } else if (d.kind === "const" && constDepth.get(d.pos) === 0) { + out.push({ start: d.pos, end: statementEnd(code, from), node: d.node }); + } + } + return out; +} + +/** Innermost container scope at an offset: binary search + parent chain over nested extents. */ +function scopeFinder(scopes) { + const sorted = scopes + .filter((s) => s.end >= s.start) + .sort((a, b) => a.start - b.start || b.end - a.end); + const parent = new Int32Array(sorted.length).fill(-1); + const stack = []; + for (let i = 0; i < sorted.length; i++) { + while (stack.length && sorted[stack[stack.length - 1]].end < sorted[i].start) stack.pop(); + parent[i] = stack.length ? stack[stack.length - 1] : -1; + stack.push(i); + } + /** @param {number} pos */ + return (pos) => { + let lo = 0; + let hi = sorted.length - 1; + let idx = -1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (sorted[mid].start <= pos) { + idx = mid; + lo = mid + 1; + } else hi = mid - 1; + } + while (idx >= 0 && sorted[idx].end < pos) idx = parent[idx]; + return idx >= 0 ? sorted[idx] : null; + }; +} + function extractFile(path, root, preRead) { const ext = extname(path); const rules = RULES[ext]; @@ -311,6 +594,12 @@ function extractFile(path, root, preRead) { if (DOC_EXTS.has(ext)) return extractDoc(rel, text); if (isConfigFile(rel.split(/[/\\]/).pop() || "")) return extractConfig(rel, text); + // Every structural regex below runs on MASKED code: comments and string contents are + // blank, so a comment saying `class Parser` defines nothing and a string holding + // `foo(` calls nothing. Offsets and line numbers are unchanged by masking. + const lex = lexOf(ext); + const code = maskCode(text, ext); + const lines = lineIndex(text); const mod = { id: `module:${moduleId(rel)}`, name: moduleId(rel), @@ -321,13 +610,19 @@ function extractFile(path, root, preRead) { const symbols = []; const nodes = [mod]; const edges = []; + const defs = []; for (const { re, kind } of rules) { - re.lastIndex = 0; + const dre = withIndices(re); let m; - while ((m = re.exec(text))) { + while ((m = dre.exec(code))) { + if (!m[0]) { + dre.lastIndex += 1; // never loop on an empty match + continue; + } const name = m[1]; - const line = lineOf(text, m.index); + const pos = m.indices?.[1]?.[0] ?? m.index; + const line = lines.at(pos); const node = { id: `${rel}:${name}:${line}`, qname: `${moduleId(rel)}.${name}`, @@ -345,6 +640,7 @@ function extractFile(path, root, preRead) { qname: node.qname, }); nodes.push(node); + defs.push({ node, pos, kind }); edges.push({ source: mod.id, target: node.id, @@ -354,6 +650,18 @@ function extractFile(path, root, preRead) { }); } } + defs.sort((a, b) => a.pos - b.pos); + const scopes = containerScopes(code, defs, lex, lines); + const scopeAt = scopeFinder(scopes); + const ownerAt = (pos) => scopeAt(pos)?.node ?? mod; + // A definition nested inside another (a local const, a nested def, a method) is `local`: + // same-file calls may bind to it, but it is never a cross-file bare-name candidate. + const ownStart = new Map(scopes.map((sc) => [sc.node, sc.start])); + for (const d of defs) { + const at = (ownStart.get(d.node) ?? d.pos) - 1; + const parent = at >= 0 ? scopeAt(at) : null; + if (parent && parent.node !== d.node && parent.end >= d.pos) d.node.local = true; + } // Inheritance edges — `class X extends Y` (JS/TS) and `class X(Base, …)` (Python). Without // these the `inherits` edge weight was dead and a base-class change never appeared in blast @@ -366,10 +674,10 @@ function extractFile(path, root, preRead) { for (const re of INHERIT_RES) { re.lastIndex = 0; let cm; - while ((cm = re.exec(text))) { + while ((cm = re.exec(code))) { const child = classNodes.get(cm[1]); if (!child) continue; - const line = lineOf(text, cm.index); + const line = lines.at(cm.index + cm[0].indexOf(cm[1])); const bases = cm[2] .split(",") .map((b) => b.trim()) @@ -387,55 +695,52 @@ function extractFile(path, root, preRead) { } } - IMPORT_RE.lastIndex = 0; - let im; - while ((im = IMPORT_RE.exec(text))) { - const target = im[1] || im[2] || im[3] || ""; - const names = im[4] - ? im[4] - .split(",") - .map((s) => s.trim()) - .filter(Boolean) - : []; - const line = lineOf(text, im.index); - const source = nearestSource(nodes.slice(1), line, mod); - if (target) + // Imports — parsed structurally (every JS/TS form incl. `export * from`, re-exports, + // dynamic import, require; Python relative/parenthesised/aliased) and resolved to FILES + // in resolveEdges, which needs the whole file set. Owned by the enclosing scope, so a + // dynamic import inside a function is that function's dependency. + if (lex === "js" || lex === "py") { + const found = lex === "py" ? pyImports(code) : jsImports(code, text); + for (const imp of found) { + const owner = ownerAt(imp.index); + const py = /** @type {import("./scope.js").PyImport} */ (imp); + const js = /** @type {import("./scope.js").JsImport} */ (imp); edges.push({ - source: source.id, - target, + source: owner.id, + target: lex === "py" ? `${".".repeat(py.level)}${py.module}` : js.spec, kind: "imports", confidence: 0.85, - line, + line: lines.at(imp.index), + lang: lex, + names: imp.names, + ...(lex === "py" ? { module: py.module, level: py.level } : { form: js.form }), }); - for (const name of names) { - if (name !== "*") - edges.push({ - source: source.id, - target: target ? `${target}.${name}` : name, - kind: "imports", - confidence: 0.85, - line, - }); } } - const lines = text.split("\n"); - for (let index = 0; index < lines.length; index += 1) { - const lineText = lines[index]; - CALL_RE.lastIndex = 0; - const line = index + 1; + // Calls — attributed to the innermost enclosing container (see containerScopes). A match + // that IS a definition's name (`function foo(`, `def foo(`) is not a call. + const defAt = new Set(defs.map((d) => d.pos)); + const callRe = withIndices(CALL_RE); + const { starts } = lines; + for (let li = 0; li < starts.length; li++) { + const s = starts[li]; + const lineText = code.slice(s, li + 1 < starts.length ? starts[li + 1] : code.length); + callRe.lastIndex = 0; let cm; - while ((cm = CALL_RE.exec(lineText))) { + while ((cm = callRe.exec(lineText))) { const callee = cm[1]; if (BUILTINS.has(callee)) continue; - const source = nearestSource(nodes.slice(1), line, mod); + const pos = s + (cm.indices?.[1]?.[0] ?? cm.index); + if (defAt.has(pos)) continue; + const source = ownerAt(pos); if (source.name === callee) continue; edges.push({ source: source.id, target: callee, kind: "calls", confidence: 0.75, - line, + line: li + 1, }); } } @@ -443,50 +748,194 @@ function extractFile(path, root, preRead) { return { symbols, nodes, edges, hash: hash(text) }; } -function resolveEdges(nodes, edges) { - const byName = new Map(); - const byQname = new Map(); - const idSet = new Set(); +/** + * Resolve raw edges against the whole graph. + * - imports: STRUCTURALLY — a JS/TS specifier through scope.resolveSpec (exact, NodeNext + * `.js`→`.ts`, extensionless, `index.*`), a Python module through package-root qnames + * (scope.pyModuleIndex). Never a bare-name guess: an import that does not resolve to a + * file stays unresolved (counted), it is not pinned to whatever shares its last segment. + * - calls/inherits: a definition in the same file, else a name this file imported, else a + * unique definition in the same LANGUAGE FAMILY. More than one candidate is ambiguous: + * the edge is dropped from traversal but marked and counted, never silently lost. + * - doc references: exact module ids, or a unique symbol name in any language. + * @param {any[]} nodes + * @param {any[]} rawEdges + * @param {string[]} files repo-relative POSIX paths of every walked file + */ +function resolveEdges(nodes, rawEdges, files) { + const fileSet = new Set(files); + const pyIndex = pyModuleIndex(files); + const localPyTops = new Set([...pyIndex.canonical.keys()].map((n) => n.split(".")[0])); + const nodeById = new Map(); + const byName = new Map(); // bare name → code definitions + const byFile = new Map(); // file → (name → definitions) + const fileNode = new Map(); // file → its module/config/doc node for (const n of nodes) { - idSet.add(n.id); - if (n.name) { - const arr = byName.get(n.name) || []; - arr.push(n); - byName.set(n.name, arr); - } - if (n.qname) byQname.set(n.qname, n); - } - return edges.map((edge) => { - // O(1) membership — this was a full nodes.some() scan per edge (O(E·N) on real repos). - if (idSet.has(edge.target)) return edge; - const direct = byQname.get(edge.target); - if (direct) return { ...edge, target: direct.id, resolved: true }; - const short = String(edge.target).split(".").pop(); - const matches = byName.get(short) || []; - if (matches.length === 1) - return { - ...edge, - target: matches[0].id, - resolved: true, - confidence: edge.confidence * 0.9, - }; - return { ...edge, unresolved: true }; - }); + nodeById.set(n.id, n); + if (n.kind === "module" || n.kind === "config" || n.kind === "doc") { + if (!fileNode.has(n.file) || n.kind === "module") fileNode.set(n.file, n); + continue; + } + if (!n.name) continue; + if (!n.local) { + const arr = byName.get(n.name); + if (arr) arr.push(n); + else byName.set(n.name, [n]); + } + let names = byFile.get(n.file); + if (!names) byFile.set(n.file, (names = new Map())); + const own = names.get(n.name); + if (own) own.push(n); + else names.set(n.name, [n]); + } + const defIn = (file, name) => { + const cands = byFile.get(file)?.get(name); + return cands?.length ? (cands.find((n) => n.kind !== "const") ?? cands[0]) : null; + }; + const stats = { + imports: { total: 0, resolved: 0, external: 0, unresolved: 0, assets: 0 }, + names: { resolved: 0, ambiguous: 0, unresolved: 0 }, + }; + // file → (local name → node id | null), from named imports; null = bound to something + // that is not a local definition (an external package), so never guessed by bare name. + const bindings = new Map(); + const bind = (file, local, id) => { + let m = bindings.get(file); + if (!m) bindings.set(file, (m = new Map())); + if (!m.has(local)) m.set(local, id); + }; + const out = []; + + // Pass 1 — imports. + for (const e of rawEdges) { + if (e.kind !== "imports" || !e.lang) continue; + stats.imports.total += 1; + const from = nodeById.get(e.source)?.file ?? ""; + /** @type {{file:string, names:{imported:string, local:string}[]}[]} */ + let hits = []; + let local = false; + if (e.lang === "py") { + hits = resolvePyImport( + from, + { module: e.module, level: e.level, names: e.names || [], index: 0 }, + pyIndex, + ); + local = e.level > 0 || localPyTops.has(String(e.module).split(".")[0]); + } else { + const file = resolveSpec(from, e.target, fileSet); + if (file) hits = [{ file, names: e.names || [] }]; + local = /^\.\.?(\/|$)/.test(e.target); + } + const base = { + source: e.source, + kind: "imports", + confidence: e.confidence, + line: e.line, + spec: e.target, + }; + if (!hits.length) { + for (const n of e.names || []) bind(from, n.local, null); + const asset = local && e.lang === "js" && !CODE_SPEC_EXTS.has(extname(e.target)); + if (asset) stats.imports.assets += 1; + else if (local) stats.imports.unresolved += 1; + else stats.imports.external += 1; + out.push({ + ...base, + target: e.target, + unresolved: true, + ...(local && !asset ? { reason: "not-found" } : { external: true }), + }); + continue; + } + stats.imports.resolved += 1; + for (const hit of hits) { + if (hit.file === from) continue; + // A named import that pins a definition is an edge to THAT symbol (the Python + // oracle's `from X import a` → `X.a`). The file-level edge is kept only when the + // import cannot be narrowed — namespace, default, side-effect, `export *`, dynamic, + // require, or a name not defined in the target file — otherwise every importer of + // an importer would inherit the dependency (module-level over-approximation). + let wholeModule = hit.names.length === 0; + for (const n of hit.names) { + const sym = + n.imported === "*" || n.imported === "default" ? null : defIn(hit.file, n.imported); + if (!sym) { + wholeModule = true; + continue; + } + out.push({ ...base, target: sym.id, resolved: true }); + if (e.form !== "reexport") bind(from, n.local, sym.id); + } + const target = fileNode.get(hit.file); + if (wholeModule && target) out.push({ ...base, target: target.id, resolved: true }); + } + } + + // Pass 2 — everything else. + for (const e of rawEdges) { + if (e.kind === "imports" && e.lang) continue; + if (nodeById.has(e.target)) { + out.push(e); // contains, module-id references + continue; + } + const target = String(e.target); + if (target.startsWith("module:")) { + out.push({ ...e, unresolved: true }); // a doc/config path that is not in the graph + continue; + } + const src = nodeById.get(e.source); + const file = src?.file ?? ""; + let hit = null; + let confidence = e.confidence; + let bound = false; + if (e.kind !== "references") { + const own = defIn(file, target); + if (own && own.id !== e.source) hit = own.id; + else if (bindings.get(file)?.has(target)) { + hit = bindings.get(file).get(target); + bound = true; // imported by name: never re-guessed globally, even when external + } + } + let ambiguous = false; + if (!hit && !bound) { + const fam = e.kind === "references" ? "" : familyOf(file); + const cands = (byName.get(target) || []).filter((n) => !fam || familyOf(n.file) === fam); + if (cands.length === 1) { + hit = cands[0].id; + confidence = e.confidence * 0.9; + } else ambiguous = cands.length > 1; + } + if (hit) { + stats.names.resolved += 1; + out.push({ ...e, target: hit, resolved: true, confidence }); + } else { + if (ambiguous) stats.names.ambiguous += 1; + else stats.names.unresolved += 1; + out.push({ + ...e, + unresolved: true, + ...(ambiguous ? { ambiguous: true } : bound ? { external: true } : {}), + }); + } + } + return { edges: out, stats }; } const cachePath = (root) => join(root, ".forge", "atlas.cache.json"); function readCache(root) { try { - return existsSync(cachePath(root)) ? JSON.parse(readFileSync(cachePath(root), "utf8")) : {}; + if (!existsSync(cachePath(root))) return {}; + const parsed = JSON.parse(readFileSync(cachePath(root), "utf8")); + // An older extractor's per-file output is not reusable (different edge shapes). + return parsed?.version === ATLAS_VERSION ? parsed.entries || {} : {}; } catch { return {}; } } export function build({ root = process.cwd(), cap = 20000 } = {}) { - const files = []; - walk(root, files, cap); + const inv = inventory(root, cap); // Incremental: reuse the prior per-file extraction when the content hash is unchanged, so a // rebuild only re-parses edited files instead of re-running every regex over the whole repo. const prev = readCache(root); @@ -495,7 +944,8 @@ export function build({ root = process.cwd(), cap = 20000 } = {}) { const nodes = []; const rawEdges = []; const fileHashes = {}; - for (const f of files) { + const rels = []; + for (const f of inv.files) { const rel = toPosix(relative(root, f)); let text; try { @@ -513,33 +963,40 @@ export function build({ root = process.cwd(), cap = 20000 } = {}) { nodes.push(...data.nodes); rawEdges.push(...data.edges); fileHashes[rel] = h; + rels.push(rel); } - const edges = resolveEdges(nodes, rawEdges); + const { edges, stats } = resolveEdges(nodes, rawEdges, rels); const atlas = { - version: 2, - files: files.length, + version: ATLAS_VERSION, + files: inv.files.length, + sourceFiles: inv.source, symbols, nodes, edges, fileHashes, - capped: files.length >= cap, + cap, + // True only when a file was actually dropped (the old `files >= cap` also fired at + // exactly `cap` files, and counted docs/configs against a source-file cap). + capped: inv.skipped > 0, + skippedFiles: inv.skipped, + stats, }; mkdirSync(join(root, ".forge"), { recursive: true }); writeFileSync(join(root, ".forge", "atlas.json"), JSON.stringify(atlas)); - writeFileSync(cachePath(root), JSON.stringify(cache)); + writeFileSync(cachePath(root), JSON.stringify({ version: ATLAS_VERSION, entries: cache })); return atlas; } /** - * True if the atlas no longer reflects the repo: a tracked file changed or vanished, - * OR the current eligible-file inventory differs from the indexed one (a brand-new or - * removed eligible file). Inventory drift is invisible to a fileHashes-only scan — the - * new file isn't in the map — so it's re-walked here with build()'s OWN walk/eligibility - * (never a second extension list). Skipped when the graph was capped (files were dropped, - * so a size diff is expected, not staleness). + * True if the atlas no longer reflects the repo: it was built by an older extractor, a + * tracked file changed or vanished, OR the current eligible-file inventory differs from + * the indexed one (a brand-new or removed eligible file). Inventory drift is invisible to a + * fileHashes-only scan — the new file isn't in the map — so it's re-walked here with + * build()'s OWN walk/eligibility (never a second extension list). Skipped when the graph + * was capped (files were dropped, so a size diff is expected, not staleness). */ export function isStale(root, atlas) { - if (!atlas?.fileHashes) return true; + if (!atlas?.fileHashes || atlas.version !== ATLAS_VERSION) return true; const indexed = new Set(Object.keys(atlas.fileHashes)); for (const rel of indexed) { let text; @@ -551,8 +1008,7 @@ export function isStale(root, atlas) { if (hash(text) !== atlas.fileHashes[rel]) return true; // a tracked file changed } if (!atlas.capped) { - const current = []; - walk(root, current, 20000); + const current = inventory(root, atlas.cap ?? 20000).files; if (current.length !== indexed.size) return true; // a file was added or removed for (const p of current) if (!indexed.has(toPosix(relative(root, p)))) return true; } @@ -564,15 +1020,36 @@ export function load(root = process.cwd()) { return existsSync(p) ? JSON.parse(readFileSync(p, "utf8")) : null; } +/** + * Symbols matching `term`, RANKED: exact name, then case-insensitive exact, then name + * prefix, then name substring, and qname-only (i.e. file-path) matches last — stable within + * a tier. Unranked, `build` returned 30 symbols from files whose PATH contains "build" + * before `function build` itself. + * @param {object} atlas + * @param {string} term + * @returns {object[]} + */ export function query(atlas, term) { - const t = String(term).toLowerCase(); - return (atlas.symbols || []).filter( - (s) => - s.name.toLowerCase().includes(t) || - String(s.qname || "") - .toLowerCase() - .includes(t), - ); + const raw = String(term); + const t = raw.toLowerCase(); + const tier = (s) => { + const name = String(s.name ?? ""); + if (name === raw) return 0; + const lower = name.toLowerCase(); + if (lower === t) return 1; + if (lower.startsWith(t)) return 2; + if (lower.includes(t)) return 3; + return String(s.qname || "") + .toLowerCase() + .includes(t) + ? 4 + : -1; + }; + return (atlas.symbols || []) + .map((s, i) => ({ s, i, k: tier(s) })) + .filter((x) => x.k >= 0) + .sort((a, b) => a.k - b.k || a.i - b.i) + .map((x) => x.s); } export function has(atlas, name) { @@ -599,7 +1076,7 @@ export const EDGE_WEIGHT = { contains: 0.45, }; -// Reverse-adjacency (node id → incoming edges) + node lookup, built once per atlas and memoized. +// Reverse- and forward-adjacency + node lookup, built once per atlas and memoized. // substrateCheck calls impact() up to 8× on the same atlas; without this each call rebuilt both. const ADJ_CACHE = new WeakMap(); function adjacency(atlas) { @@ -607,17 +1084,51 @@ function adjacency(atlas) { if (cached) return cached; const nodeById = new Map((atlas.nodes || []).map((n) => [n.id, n])); const incoming = new Map(); + const outgoing = new Map(); + const ambiguousByName = new Map(); // bare name → dropped ambiguous references to it for (const e of atlas.edges || []) { - if (e.unresolved) continue; - const arr = incoming.get(e.target) || []; - arr.push(e); - incoming.set(e.target, arr); + if (e.unresolved) { + if (e.ambiguous) ambiguousByName.set(e.target, (ambiguousByName.get(e.target) ?? 0) + 1); + continue; + } + const inc = incoming.get(e.target); + if (inc) inc.push(e); + else incoming.set(e.target, [e]); + const out = outgoing.get(e.source); + if (out) out.push(e); + else outgoing.set(e.source, [e]); + } + const nodesByFile = new Map(); + for (const n of atlas.nodes || []) { + if (!n.file || !isCode(n)) continue; + const arr = nodesByFile.get(n.file); + if (arr) arr.push(n.id); + else nodesByFile.set(n.file, [n.id]); } - const built = { nodeById, incoming }; + const fanIn = new Map(); + /** Distinct OTHER source files whose code depends on `file` (docs/configs excluded): + * the in-degree the sibling hub cap is applied to. */ + const fileIndegree = (file) => { + let d = fanIn.get(file); + if (d === undefined) { + const users = new Set(); + for (const id of nodesByFile.get(file) || []) + for (const e of incoming.get(id) || []) { + const src = nodeById.get(e.source); + if (isCode(src) && src.file !== file) users.add(src.file); + } + d = users.size; + fanIn.set(file, d); + } + return d; + }; + const built = { nodeById, incoming, outgoing, ambiguousByName, nodesByFile, fileIndegree }; ADJ_CACHE.set(atlas, built); return built; } +const isCode = (node) => Boolean(node) && node.kind !== "doc" && node.kind !== "config"; + // Imagination (§8) — LLM proposer for the edges the regex graph structurally misses: dynamic // dispatch, DI, reflection, string-keyed lookups. PROPOSER ONLY. Every candidate is then // verified twice — it must resolve to a REAL node in the graph AND (via the caller's `verify` @@ -662,13 +1173,41 @@ export function buildSccIndex(sccs) { return index; } +// The two relations the empirical refutation found missing (research/empirical-refutation, +// Defect 2: reverse-only traversal; 94.7% of real misses were siblings). Ported from the +// repaired Python oracle (replication package, impact_oracle v2, oracle.py) with the +// FROZEN_PARAMETERS.json values chosen on the tuning repos before the held-out run — +// deliberately not re-tuned here: +// sibling_forward_hops 1 · sibling_reverse_hops 1 · sibling_weight 0.7 · +// sibling_bridge_max_indegree 100 · forward_max_hops 2 · forward_weight 0.5 +// - SIBLING: one forward hop to a shared dependency (the bridge), then one reverse hop +// from it: A and B both use module C, so a change to how A uses C's contract co-changes +// B. The bridge is C's FILE (a named import points at one symbol of C; bridging on that +// exact node would miss a B that uses another part of C). Bridges used by more than +// `bridgeMaxIndegree` other files are hubs — weak sibling evidence — and are skipped. +// - FORWARD: the changed code's own dependencies, up to `maxHops` (editing a call site +// may mean updating the callee). +// Both are TERMINAL: a node they reach is reported but never expanded further. Hop counts +// are fixed at the frozen values: the walks below implement exactly 1+1 and ≤2 hops. +export const SIBLING = Object.freeze({ + forwardHops: 1, + reverseHops: 1, + weight: 0.7, + bridgeMaxIndegree: 100, +}); +export const FORWARD = Object.freeze({ maxHops: 2, weight: 0.5 }); +export const IMPACT_RELATIONS = Object.freeze(["reverse", "sibling", "forward"]); + +const round4 = (x) => Number(x.toFixed(4)); + /** * @param {object} atlas * @param {string} target * @param {object} [opts] * @param {number} [opts.threshold] - * @param {number} [opts.maxHops] + * @param {number} [opts.maxHops] reverse-dependency hop cap * @param {number} [opts.decay] + * @param {readonly string[]} [opts.relations] subset of IMPACT_RELATIONS (default: all) * @param {boolean} [opts.llm] * @param {(p:string)=>string} [opts.run] * @param {(file:string, target:string)=>boolean} [opts.verify] @@ -678,19 +1217,35 @@ export function buildSccIndex(sccs) { export function impact( atlas, target, - { threshold = 0.1, maxHops = 6, decay = 0.85, llm, run, verify, sccIndex, hazards } = {}, + { + threshold = 0.1, + maxHops = 6, + decay = 0.85, + relations = IMPACT_RELATIONS, + llm, + run, + verify, + sccIndex, + hazards, + } = {}, ) { const starts = targetIds(atlas, target); const startSet = new Set(starts); - const { nodeById, incoming } = adjacency(atlas); + const { nodeById, incoming, outgoing, ambiguousByName, nodesByFile, fileIndegree } = + adjacency(atlas); + const wanted = new Set(relations); + const step = (conf, edge) => + conf * (EDGE_WEIGHT[edge.kind] || 0.5) * (edge.confidence ?? 1) * decay; const visited = new Map(); - const queue = starts.map((id) => ({ - id, - confidence: 1, - hop: 0, - path: [id], - edgeKinds: [], - })); + const queue = wanted.has("reverse") + ? starts.map((id) => ({ + id, + confidence: 1, + hop: 0, + path: [id], + edgeKinds: [], + })) + : []; // Label-correcting search: a node re-enters the queue whenever a better path is // found, so the loop converges to the max-product confidence. The queue is drained // with an index pointer (queue.shift() is O(n) on V8 arrays — quadratic on large @@ -703,8 +1258,7 @@ export function impact( if (!current || current.hop >= maxHops) continue; for (const edge of incoming.get(current.id) || []) { if (startSet.has(edge.source)) continue; - const nextConfidence = - current.confidence * (EDGE_WEIGHT[edge.kind] || 0.5) * (edge.confidence ?? 1) * decay; + const nextConfidence = step(current.confidence, edge); const srcNode = nodeById.get(edge.source); const srcFile = srcNode?.file; const effectiveThreshold = @@ -721,8 +1275,9 @@ export function impact( name: edge.source, kind: "unknown", }, - confidence: Number(nextConfidence.toFixed(4)), + confidence: round4(nextConfidence), hopDistance: current.hop + 1, + relation: "reverse", path: [...current.path, edge.source], edgeKinds: [...current.edgeKinds, edge.kind], }; @@ -745,8 +1300,9 @@ export function impact( const sccItem = { id: node.id, node, - confidence: Number(nextConfidence.toFixed(4)), + confidence: round4(nextConfidence), hopDistance: current.hop + 1, + relation: "reverse", path: [...current.path, edge.source, node.id], edgeKinds: [...current.edgeKinds, edge.kind, "scc"], }; @@ -762,6 +1318,81 @@ export function impact( } } } + + // Sibling/forward items carry `relation` + `relationHops`; `hopDistance` stays the + // REVERSE-dependency distance (null here), so "direct dependents" filters keep meaning. + const offer = (id, confidence, relation, path, edgeKinds) => { + const node = nodeById.get(id); + if (!node || confidence < threshold) return; + const prev = visited.get(id); + if (prev && prev.confidence >= round4(confidence)) return; + visited.set(id, { + id, + node, + confidence: round4(confidence), + hopDistance: null, + relationHops: path.length - 1, + relation, + path, + edgeKinds, + }); + }; + + const startFiles = new Set(starts.map((id) => nodeById.get(id)?.file).filter(Boolean)); + const codeStarts = starts.filter((id) => isCode(nodeById.get(id))); + + if (wanted.has("sibling")) { + // Step 1 — one forward hop from the changed code; the FILE it lands in is the bridge + // (the paper's "common module C"). A named import points at a symbol, so bridging on + // the exact node would miss B when A and B use different parts of C's contract. + const bridges = new Map(); // file → best forward step into it + for (const s of codeStarts) { + for (const e of outgoing.get(s) || []) { + const t = nodeById.get(e.target); + if (!isCode(t) || startSet.has(e.target) || startFiles.has(t.file)) continue; + const conf = step(1, e); + const had = bridges.get(t.file); + if (!had || had.conf < conf) + bridges.set(t.file, { conf, from: s, entry: e.target, kind: e.kind }); + } + } + // Step 2 — one reverse hop from each non-hub bridge to the other code that uses it. + for (const [file, b] of bridges) { + if (fileIndegree(file) > SIBLING.bridgeMaxIndegree) continue; // hub: weak evidence + for (const id of nodesByFile.get(file) || []) { + for (const e of incoming.get(id) || []) { + const src = nodeById.get(e.source); + if (!isCode(src) || startSet.has(e.source) || src.file === file) continue; + const path = id === b.entry ? [b.from, id, e.source] : [b.from, b.entry, id, e.source]; + const kinds = id === b.entry ? [b.kind, e.kind] : [b.kind, "same-file", e.kind]; + offer(e.source, step(b.conf, e) * SIBLING.weight, "sibling", path, kinds); + } + } + } + } + + if (wanted.has("forward")) { + for (const s of codeStarts) { + const seen = new Set(startSet); + let frontier = [{ id: s, conf: 1, path: [s], kinds: [] }]; + for (let hop = 0; hop < FORWARD.maxHops && frontier.length; hop++) { + const next = []; + for (const cur of frontier) { + for (const e of outgoing.get(cur.id) || []) { + if (seen.has(e.target)) continue; + seen.add(e.target); + const conf = step(cur.conf, e); + const path = [...cur.path, e.target]; + const kinds = [...cur.kinds, e.kind]; + offer(e.target, conf * FORWARD.weight, "forward", path, kinds); + next.push({ id: e.target, conf, path, kinds }); + } + } + frontier = next; + } + } + } + const impacted = [...visited.values()].sort((a, b) => b.confidence - a.confidence); const deterministicFiles = new Set(impacted.map((x) => x.node.file).filter(Boolean)); @@ -778,22 +1409,36 @@ export function impact( llmImpacted.push({ id: `llm:${file}`, node: { id: `llm:${file}`, name: file, kind: "module", file }, - confidence: Number((threshold * 0.9).toFixed(4)), + confidence: round4(threshold * 0.9), hopDistance: null, + relation: "llm-verified", source: "llm-verified", }); } } const all = [...impacted, ...llmImpacted]; + const relationCounts = {}; + for (const x of all) relationCounts[x.relation] = (relationCounts[x.relation] ?? 0) + 1; + // Completeness signals — a blast radius is only as good as the graph under it. Say when + // files were dropped by the cap, how many local imports did not resolve anywhere, and how + // many references to THIS target's names were dropped as ambiguous. + const startNames = new Set(starts.map((id) => nodeById.get(id)?.name).filter(Boolean)); + let ambiguousRefs = 0; + for (const name of startNames) ambiguousRefs += ambiguousByName.get(name) ?? 0; return { target, found: starts.length > 0, threshold, impacted: all, impactedFiles: [...new Set(all.map((x) => x.node.file).filter(Boolean))].sort(), + relations: relationCounts, llmVerified: llmImpacted.map((x) => x.node.file), totalGraphNodes: (atlas.nodes || []).length, totalGraphEdges: (atlas.edges || []).length, + capped: Boolean(atlas.capped), + skippedFiles: atlas.skippedFiles ?? 0, + unresolvedImports: atlas.stats?.imports?.unresolved ?? 0, + ambiguousRefs, }; } diff --git a/src/cli.js b/src/cli.js index 368b8f3..b40f124 100755 --- a/src/cli.js +++ b/src/cli.js @@ -1204,13 +1204,14 @@ HANDLERS.atlas = async (argv) => { } else if (sub === "query") { const at = need(); if (!at) return; + // Ranked: exact definitions first, path-only (qname) matches last. const hits = a.query(at, argv.slice(2).join(" ")); console.log( hits.length ? hits .slice(0, 30) .map((s) => ` ${s.file}:${s.line} ${s.kind} ${s.name}`) - .join("\n") + .join("\n") + (hits.length > 30 ? `\n … ${hits.length - 30} more` : "") : " no match", ); } else if (sub === "has") { @@ -1703,9 +1704,37 @@ HANDLERS.impact = async (argv) => { } heading(`${BRAND.brand} impact — blast radius${basic ? "" : " (hazard-aware)"}\n`); console.log(` target: ${target} ${r.found ? "✓ found" : "not found"}`); - console.log(` impacted files: ${r.impactedFiles.length}`); + const rel = r.relations || {}; + const parts = ["reverse", "sibling", "forward", "llm-verified"] + .filter((k) => rel[k]) + .map((k) => `${k} ${rel[k]}`); + console.log( + ` impacted files: ${r.impactedFiles.length}${parts.length ? ` (nodes: ${parts.join(" · ")})` : ""}`, + ); for (const file of r.impactedFiles.slice(0, 20)) console.log(` - ${file}`); if (r.impactedFiles.length > 20) console.log(` … ${r.impactedFiles.length - 20} more`); + // Completeness: a blast radius is only as good as the graph under it — say when it isn't. + if (r.capped) + console.log( + paint( + ` ! graph capped: ${r.skippedFiles} file(s) not indexed — this list may be incomplete`, + "warn", + ), + ); + if (r.ambiguousRefs) + console.log( + paint( + ` ! ${r.ambiguousRefs} reference(s) to this target's name(s) matched more than one definition and were not linked`, + "warn", + ), + ); + if (r.unresolvedImports) + console.log( + paint( + ` · ${r.unresolvedImports} local import(s) in the repo did not resolve to a file`, + "dim", + ), + ); return; }; HANDLERS.substrate = async (argv) => { diff --git a/test/atlas_relations.test.js b/test/atlas_relations.test.js new file mode 100644 index 0000000..b2cc724 --- /dev/null +++ b/test/atlas_relations.test.js @@ -0,0 +1,92 @@ +// Review finding A2: the impact traversal was reverse-only (the empirical refutation's +// Defect 2). These pin the ported SIBLING and FORWARD relations and their FROZEN +// parameters (sibling: 1 forward + 1 reverse hop, weight 0.7, bridge in-degree cap 100; +// forward: ≤2 hops, weight 0.5 — research/empirical-refutation replication package, +// FROZEN_PARAMETERS.json). +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { build, impact } from "../src/atlas.js"; +import { sibFiles, writeRepo } from "./fixtures/impact_repos.mjs"; + +const mod = (name) => ({ id: `module:${name}`, name, kind: "module", file: `${name}.js` }); +const imp = (a, b) => ({ + source: `module:${a}`, + target: `module:${b}`, + kind: "imports", + confidence: 1, +}); +const graph = (names, edges) => ({ nodes: names.map(mod), edges, symbols: [] }); +const item = (r, file) => r.impacted.find((x) => x.node.file === file); +const near = (a, b) => Math.abs(a - b) < 1e-3; +// one hop at edge confidence 1: imports weight 0.85 × decay 0.85 +const HOP = 0.85 * 0.85; + +test("A2: serializer.js finds its sibling deserializer.js through the shared wire_format.js", () => { + const atlas = build({ root: writeRepo(sibFiles) }); + const r = impact(atlas, "src/serializer.js"); + assert.equal(item(r, "src/app.js")?.relation, "reverse"); + assert.equal(item(r, "src/deserializer.js")?.relation, "sibling", JSON.stringify(r.relations)); + assert.equal(item(r, "src/wire_format.js")?.relation, "forward"); + const reverseOnly = impact(atlas, "src/serializer.js", { relations: ["reverse"] }); + assert.deepEqual( + reverseOnly.impactedFiles.filter((f) => f !== "src/serializer.js"), + ["src/app.js"], + "relations:['reverse'] reproduces the old reverse-only answer", + ); +}); + +test("A2: sibling and forward confidences use the frozen weights", () => { + // a → c ← b (b is a's sibling via c); c → d → f (forward chain from a) + const atlas = graph( + ["a", "b", "c", "d", "f"], + [imp("a", "c"), imp("b", "c"), imp("c", "d"), imp("d", "f")], + ); + const r = impact(atlas, "a.js", { threshold: 0.01 }); + const b = item(r, "b.js"); + assert.equal(b?.relation, "sibling"); + assert.ok(near(b.confidence, HOP * HOP * 0.7), `sibling ${b.confidence}`); + assert.ok(near(item(r, "c.js").confidence, HOP * 0.5), "forward hop 1 × 0.5"); + assert.ok(near(item(r, "d.js").confidence, HOP * HOP * 0.5), "forward hop 2 × 0.5"); + assert.equal(item(r, "f.js"), undefined, "forward stops at 2 hops"); + assert.equal(b.hopDistance, null, "hopDistance stays the REVERSE distance"); + assert.equal(b.relationHops, 2); +}); + +test("A2: sibling and forward nodes are terminal — never expanded further", () => { + // e depends on sibling b; g depends on forward-reached d. Neither is in a's radius. + const atlas = graph( + ["a", "b", "c", "d", "e", "g"], + [imp("a", "c"), imp("b", "c"), imp("a", "d"), imp("e", "b"), imp("g", "d")], + ); + const r = impact(atlas, "a.js", { threshold: 0.01 }); + assert.ok(item(r, "b.js"), "sibling found"); + assert.ok(item(r, "d.js"), "forward found"); + assert.equal(item(r, "e.js"), undefined, "a sibling's dependents are not expanded"); + assert.equal(item(r, "g.js")?.relation, "sibling", "g shares d with a: a sibling, not more"); +}); + +test("A2: a bridge used by more than 100 other files is a hub and yields no siblings", () => { + const withUsers = (n) => { + const names = ["a", "hub", ...Array.from({ length: n }, (_, i) => `u${i}`)]; + const edges = [imp("a", "hub"), ...names.slice(2).map((u) => imp(u, "hub"))]; + return impact(graph(names, edges), "a.js", { threshold: 0.01 }); + }; + const under = withUsers(99); // hub in-degree 100 (a + 99): at the cap → still a bridge + assert.equal(under.relations.sibling, 99); + const over = withUsers(100); // in-degree 101 → hub, skipped + assert.equal(over.relations.sibling ?? 0, 0); + assert.ok(item(over, "hub.js"), "the hub itself is still a forward dependency"); +}); + +test("A2: docs and configs are never siblings", () => { + const atlas = graph(["a", "c"], [imp("a", "c")]); + atlas.nodes.push({ id: "doc:README.md", name: "README.md", kind: "doc", file: "README.md" }); + atlas.edges.push({ + source: "doc:README.md", + target: "module:c", + kind: "references", + confidence: 1, + }); + const r = impact(atlas, "a.js", { threshold: 0.01 }); + assert.equal(item(r, "README.md"), undefined); +}); diff --git a/test/atlas_resolve.test.js b/test/atlas_resolve.test.js new file mode 100644 index 0000000..145a421 --- /dev/null +++ b/test/atlas_resolve.test.js @@ -0,0 +1,210 @@ +// Regression tests for the impact-graph review findings A1, A3–A6 and ATLAS-Q +// (docs/…/forgekit-deep-review: "Research vs implementation: impact graph"). Every +// fixture and its ground truth lives in test/fixtures/impact_repos.mjs. +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { build, impact, isStale, load, query } from "../src/atlas.js"; +import { + collFiles, + JS1_IMPORTERS, + js1Files, + PY_REL_IMPORTERS, + phantomFiles, + pyFlatFiles, + pyRelFiles, + pySrcFiles, + transFiles, + ts1Files, + writeRepo, +} from "./fixtures/impact_repos.mjs"; + +const CLI = fileURLToPath(new URL("../src/cli.js", import.meta.url)); +const others = (r, target) => r.impactedFiles.filter((f) => f !== target); + +test("A1: every JS import form resolves — src/util.js finds all 10 importers, no false positives", () => { + const atlas = build({ root: writeRepo(js1Files) }); + const r = impact(atlas, "src/util.js"); + for (const f of JS1_IMPORTERS) assert.ok(r.impactedFiles.includes(f), `missed ${f}`); + // page.js imports the widgets/ directory (index.js) → a real transitive dependent. + assert.ok(r.impactedFiles.includes("src/page.js"), "index.js directory import"); + for (const trap of ["src/noise.js", "src/other/consumer.js", "src/other/util.js"]) + assert.ok(!r.impactedFiles.includes(trap), `false positive ${trap}`); + assert.deepEqual(others(r, "src/util.js").sort(), [...JS1_IMPORTERS, "src/page.js"].sort()); +}); + +test("A1: a directory import resolves to /index.js", () => { + const atlas = build({ root: writeRepo(js1Files) }); + const r = impact(atlas, "src/widgets/index.js", { relations: ["reverse"] }); + assert.deepEqual(others(r, "src/widgets/index.js"), ["src/page.js"]); + const edge = atlas.edges.find((e) => e.kind === "imports" && e.source === "module:src.page"); + assert.ok(edge && !edge.unresolved, "`./widgets` resolved to widgets/index.js"); +}); + +test("A1: TypeScript NodeNext — `./x.js` in source resolves to x.ts on disk", () => { + const atlas = build({ root: writeRepo(ts1Files) }); + assert.deepEqual(others(impact(atlas, "src/x.ts"), "src/x.ts"), ["src/y.ts", "src/z.ts"]); +}); + +test("A1: an import never resolves by its last path segment (no spurious `mjs`-style edges)", () => { + const root = writeRepo({ + "src/doctor.js": "export function check() {\n const mjs = 1;\n return mjs;\n}\n", + "src/pages.js": + 'import { render } from "../scripts/build-pages.mjs";\nexport const p = () => render();\n', + }); + const atlas = build({ root }); + assert.deepEqual(others(impact(atlas, "src/doctor.js"), "src/doctor.js"), []); + const edge = atlas.edges.find((e) => e.kind === "imports" && e.source === "module:src.pages"); + assert.ok(edge?.unresolved, "a missing local file stays unresolved"); +}); + +test("A3: the same package gives the same answer in a flat and a src layout", () => { + const flat = impact(build({ root: writeRepo(pyFlatFiles) }), "mypkg/core.py"); + const src = impact(build({ root: writeRepo(pySrcFiles) }), "src/mypkg/core.py"); + assert.deepEqual(others(flat, "mypkg/core.py"), ["mypkg/cli.py"]); + assert.deepEqual(others(src, "src/mypkg/core.py"), ["src/mypkg/cli.py"]); +}); + +test("A4: Python relative, parenthesised, aliased and stacked imports — 7 of 7 importers", () => { + const atlas = build({ root: writeRepo(pyRelFiles) }); + const r = impact(atlas, "pkg/core.py"); + assert.deepEqual(others(r, "pkg/core.py").sort(), [...PY_REL_IMPORTERS].sort()); + assert.ok(!r.impactedFiles.includes("pkg/noise.py"), "commented/string imports are not imports"); + // `import os\nimport sys\nfrom pkg.core import start` must not fuse into one bogus module. + assert.ok( + !atlas.edges.some((e) => e.kind === "imports" && /\n/.test(String(e.target))), + "no import target spans lines", + ); +}); + +test("A5: no bare-name fallback for imports and no cross-language links", () => { + const root = writeRepo({ + "src/eval.js": "export const oracle = { run() {} };\nexport function helper() {}\n", + "research/proto/cli.py": + "from impact_oracle.oracle import ImpactOracle\n\n\ndef main():\n return helper()\n", + }); + const atlas = build({ root }); + const byId = new Map(atlas.nodes.map((n) => [n.id, n])); + for (const e of atlas.edges) { + if (e.unresolved || e.kind === "contains" || e.kind === "references") continue; + const a = byId.get(e.source)?.file ?? ""; + const b = byId.get(e.target)?.file ?? ""; + assert.equal(a.endsWith(".py"), b.endsWith(".py"), `cross-language edge ${a} → ${b}`); + } + assert.deepEqual(others(impact(atlas, "src/eval.js"), "src/eval.js"), []); +}); + +test("A6: a comment or string naming a class defines nothing and cannot erase a real edge", () => { + const atlas = build({ root: writeRepo(phantomFiles) }); + assert.ok( + !atlas.symbols.some((s) => s.file === "src/notes.js" && s.name === "Parser"), + "no phantom Parser symbol from a comment/string", + ); + assert.ok(impact(atlas, "src/parser.js").impactedFiles.includes("src/main.js")); + assert.ok(impact(atlas, "makeParser").impactedFiles.includes("src/main.js")); +}); + +test("A6: a call belongs to its enclosing function, not the nearest local const (transitivity)", () => { + const atlas = build({ root: writeRepo(transFiles) }); + const r = impact(atlas, "leaf"); + assert.ok(r.impactedFiles.includes("src/mid.js")); + assert.ok(r.impactedFiles.includes("src/top.js"), JSON.stringify(r.impactedFiles)); + const call = atlas.edges.find((e) => e.kind === "calls" && e.line === 3); + assert.match(String(call?.source), /:mid:/, "leaf() at line 3 is owned by mid()"); +}); + +test("A6: two definitions of one name — importers resolve to their own file; ambiguity is counted", () => { + const root = writeRepo({ + ...collFiles, + // calls render() without importing it: genuinely ambiguous → counted, not silently lost + "src/loose.js": "export function loose(x) {\n return render(x);\n}\n", + }); + const atlas = build({ root }); + const r = impact(atlas, "src/ui/render.js"); + assert.deepEqual(others(r, "src/ui/render.js"), ["src/ui/view.js"]); + assert.ok(r.ambiguousRefs >= 1, `ambiguous references reported (${r.ambiguousRefs})`); + assert.ok(atlas.stats.names.ambiguous >= 1); +}); + +test("A6: the file cap counts only source files and a capped graph says so", () => { + const files = { "a.js": "export const a = 1;\n", "b.js": "export const b = 2;\n" }; + for (let i = 0; i < 6; i++) files[`doc${i}.md`] = `# doc ${i}\n`; + for (let i = 0; i < 6; i++) files[`c${i}.json`] = "{}\n"; + const root = writeRepo(files); + const roomy = build({ root, cap: 2 }); + assert.equal(roomy.capped, false, "docs/configs do not count against the source cap"); + assert.equal(roomy.sourceFiles, 2); + const tight = build({ root, cap: 1 }); + assert.equal(tight.capped, true); + assert.ok(tight.skippedFiles >= 1); + assert.equal(impact(tight, "a.js").capped, true, "impact() surfaces the cap"); +}); + +test("A6: extraction is linear — a 16k-line JS file plus a 16k-line Python file build fast", () => { + const root = mkdtempSync(join(tmpdir(), "forge-atlas-big-")); + let js = ""; + let py = ""; + for (let i = 0; i < 4000; i++) { + js += `export function f${i}(x) {\n const v = f${i ? i - 1 : 1}(x);\n return v + 1;\n}\n`; + py += `def g${i}(x):\n v = g${i ? i - 1 : 1}(x)\n return v\n\n`; + } + writeFileSync(join(root, "big.js"), js); + writeFileSync(join(root, "big.py"), py); + const t = Date.now(); + const atlas = build({ root }); + const ms = Date.now() - t; + // The O(n²) extractor took ~5 s here (19.6 s for the review's 16k-line file). + assert.ok(ms < 2500, `build took ${ms}ms`); + assert.ok( + atlas.edges.some((e) => e.kind === "calls" && e.resolved), + "calls still resolve at scale", + ); +}); + +test("an atlas from an older extractor is stale (rebuilt, never trusted)", () => { + const root = writeRepo(js1Files); + const atlas = build({ root }); + assert.equal(isStale(root, atlas), false); + const old = { ...load(root), version: 2 }; + writeFileSync(join(root, ".forge", "atlas.json"), JSON.stringify(old)); + assert.equal(isStale(root, load(root)), true); +}); + +test("ATLAS-Q: query ranks the exact definition above path-only matches", () => { + const root = writeRepo({ + "scripts/build-pages.mjs": + "export function renderPage() {}\nexport function writeIndex() {}\nexport function copyAssets() {}\n", + "src/zz.js": "export function rebuildCache() {}\nexport function build() {}\n", + }); + const hits = query(build({ root }), "build"); + assert.equal(hits[0].name, "build", JSON.stringify(hits.map((h) => h.name))); + assert.equal(hits[0].file, "src/zz.js"); + const names = hits.map((h) => h.name); + assert.ok(names.indexOf("rebuildCache") < names.indexOf("renderPage"), "name match before path"); + assert.ok(names.includes("copyAssets"), "path-only matches are still returned, last"); +}); + +test("CLI: forge impact lists the real importers (no more 'found · impacted files: 0')", () => { + const root = writeRepo(js1Files); + const out = spawnSync(process.execPath, [CLI, "impact", "src/util.js", "--basic"], { + cwd: root, + encoding: "utf8", + }); + assert.equal(out.status, 0, out.stderr); + assert.match(out.stdout, /✓ found/); + assert.match(out.stdout, /impacted files: 11\b/); + assert.match(out.stdout, /src\/d05_export_rename\.js/); + const json = JSON.parse( + spawnSync(process.execPath, [CLI, "impact", "src/util.js", "--basic", "--json"], { + cwd: root, + encoding: "utf8", + }).stdout, + ); + assert.equal(json.capped, false); + assert.equal(typeof json.unresolvedImports, "number"); + assert.ok(readFileSync(join(root, ".forge", "atlas.json"), "utf8").length > 0); +}); From d1c60e70999fea0ad3414780df0a88cb959a6906 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:03:16 +0200 Subject: [PATCH 31/72] fix(route): weigh task length once and require more than one shared word to match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both defects pushed every real task to the middle tier (54 of 64 well-specified held-out tasks went to mid, premium was reached once). Length was weighted by the repo facet's `size` AND by the rubric's struct.length, and both saturate on issue prose; the later of the two is gone and lengthTokens stays informational. A k-NN neighbour now has to share RUBRIC.minShared (2) grams — one shared token carried 146 of 167 top-3 matches, and against a one-gram exemplar ("fix a typo" -> {typo}) the overlap coefficient read 1.00, which is how a deadlock issue matched "add a comment" and routed mid. A task shorter than minShared still matches on its whole footprint. rubric.band now uses recommend()'s cutoffs instead of a second, disagreeing pair, and the architectural exemplars drop from y 0.85 (fable band) to 0.78, since model_tiers routes architecture to Opus. Diagnostic only on the spent 80-task set (empty repo): exact 0.344 -> 0.453, distribution 9/54/1 -> 38/25/1, premium-vs-rest AUROC 0.652 -> 0.753. Premium recall stays 0/17 — the toy exemplar bank is the remaining defect. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 25 ++++++++++++++++ src/route.js | 75 +++++++++++++++++++++++++++------------------- test/route.test.js | 44 +++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1066a44..4d94e0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,6 +103,31 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Changed +- **The routing rubric stops counting a task's length twice and stops matching on one shared + word.** Both defects pushed every real task into the middle: on the reviewer's 80-task + held-out set the router sent 54 of 64 well-specified tasks to mid and reached premium once. + - **Length was weighted twice** — by the repo facet's `size` signal and again by the rubric's + `struct.length` — and both saturate on real issue prose, so every long task was floored near + the cheap/mid line whatever it was about. The later of the two (`struct.length`, added with + the k-NN rubric) is gone; `rubricSignals().lengthTokens` stays as an informational field. + - **One shared word counted as a match.** 146 of 167 top-3 matches rested on a single token, + and against an exemplar whose whole footprint is that token (`fix a typo` → `{typo}`) the + overlap coefficient reads 1.00 — full confidence in a coincidence, which is how "resolve the + deadlock between the comment writer and the comment indexer threads" matched "add a comment" + at 1.00 and routed mid. A neighbor now has to share `RUBRIC.minShared` (2) grams, or the + task's whole footprint when the task is shorter than that, so "fix the deadlock" still + matches its exemplar. + - `rubric.band` now uses recommend()'s own cutoffs (0.25 / 0.55) instead of a second, different + pair (0.3 / 0.6) that disagreed with the tier actually routed. + - **No exemplar labels into the fable band any more.** The architectural rows carried y = 0.85, + at or above the 0.8 fable cutoff, while `model_tiers` puts "architecture, cross-module + refactor, novel algorithms" on Opus and keeps Fable for research-grade reasoning; they are + 0.78 now (the held-out calibration fixture too). + - Diagnostic on the spent 80-task set (**not** an evaluation — those tasks are burnt for + tuning, and routing was measured in an empty repo): exact tier accuracy 0.344 → 0.453, the + predicted distribution 9/54/1 → 38/25/1 (cheap/mid/premium), and premium-vs-rest AUROC + 0.652 → 0.753. Premium recall is still 0 of 17: the toy exemplar bank has no vocabulary for + real premium issue prose, which needs a real-issue bank and a **new** held-out set. - **Model routing reconciles the proposer's band with the deterministic band, not a point score.** `routeTask` compared the proposer's band floor (cheap 0.15 / mid 0.40 / premium 0.65) against the deterministic point score, so even a vote that _agreed_ moved the score: a diff --git a/src/route.js b/src/route.js index 47e20c5..41dce04 100644 --- a/src/route.js +++ b/src/route.js @@ -30,7 +30,10 @@ import { clamp01, contentHash, epochDay } from "./util.js"; /** * Labeled exemplars. `y` = target complexity in [0,1], calibrated to the tier * cutoffs in recommend(): ~0.08 trivial, ~0.42 data-structure/library level, - * ~0.78 algorithmic/systems, ~0.85 architectural. Add rows freely — coverage + * ~0.78 algorithmic/systems AND architectural/cross-module. No label may reach + * the fable cutoff (0.8): model_tiers puts "architecture, cross-module refactor, + * novel algorithms" on Opus and keeps Fable for research-grade reasoning, so a + * label at 0.85 contradicted the table it routes into. Add rows freely — coverage * improves routing without touching any weight. */ export const EXEMPLARS = [ @@ -87,14 +90,14 @@ export const EXEMPLARS = [ { text: "compiler pass over an abstract syntax tree", y: 0.78 }, { text: "idempotent retry with exactly-once delivery semantics", y: 0.78 }, // architectural / cross-module - { text: "design the architecture of a new service", y: 0.85 }, - { text: "refactor module boundaries across the codebase", y: 0.85 }, - { text: "design a schema migration for the database", y: 0.85 }, - { text: "api design with consistency guarantees and trade-offs", y: 0.85 }, - { text: "migrate a multi-module system end to end", y: 0.85 }, - { text: "design a locking strategy across services", y: 0.85 }, - { text: "plan scalability for a growing distributed system", y: 0.85 }, - { text: "cross-module refactor of shared interfaces", y: 0.85 }, + { text: "design the architecture of a new service", y: 0.78 }, + { text: "refactor module boundaries across the codebase", y: 0.78 }, + { text: "design a schema migration for the database", y: 0.78 }, + { text: "api design with consistency guarantees and trade-offs", y: 0.78 }, + { text: "migrate a multi-module system end to end", y: 0.78 }, + { text: "design a locking strategy across services", y: 0.78 }, + { text: "plan scalability for a growing distributed system", y: 0.78 }, + { text: "cross-module refactor of shared interfaces", y: 0.78 }, ]; // Excluded from the lexical footprint: function words AND generic task verbs @@ -127,6 +130,14 @@ export function contentGrams(text) { return grams; } +/** How many grams two footprints have in common. */ +const sharedGrams = (a, b) => { + let n = 0; + const [small, large] = a.size <= b.size ? [a, b] : [b, a]; + for (const x of small) if (large.has(x)) n++; + return n; +}; + /** Every rubric constant in one inspectable table (same transparency rule as WEIGHTS). */ export const RUBRIC = { k: 3, // neighbors in the k-NN estimate @@ -137,8 +148,10 @@ export const RUBRIC = { // overlaps its exemplar at ~0.43 (extra scope words dilute the coefficient), and // the floor MUST hold there — 0.5 let a bad LLM vote talk concurrency work down. strongConf: 0.35, - bands: { cheap: 0.3, mid: 0.6 }, // score < cheap → cheap; ≤ mid → mid; else premium - struct: { codeContext: 0.05, length: 0.1, constraints: 0.05, steps: 0.05 }, + minShared: 2, // grams a neighbor must share before it counts as a match at all + // Bands are recommend()'s own cutoffs (bandOf) — a second, different pair of band edges here + // meant `rubric.band` and the routed tier disagreed on what "mid" was. + struct: { codeContext: 0.05, constraints: 0.05, steps: 0.05 }, }; // Exemplar footprints are static — compute once, not per routeTask call (the ambient @@ -149,6 +162,9 @@ const EXEMPLAR_GRAMS = EXEMPLARS.map((e) => ({ ...e, grams: contentGrams(e.text) export function rubricSignals(task = "") { const text = String(task); return { + // Informational only: task size is weighted ONCE, by the repo facet's `size` signal. It used + // to be weighted here as well, and both terms saturate on real issue prose (a 600-char body + // maxes this one out), which floored every long task near the cheap/mid boundary. lengthTokens: Math.max(1, Math.floor(text.length / 4)), hasCodeContext: /```/.test(text), // Explicit requirement markers: bullet/numbered lines and modal verbs. A count @@ -171,10 +187,18 @@ export function rubricSignals(task = "") { export function rubricComplexity(task = "") { const sig = rubricSignals(task); const grams = contentGrams(task); + // One shared word is a coincidence, not a match: on a real issue corpus 146 of 167 top-3 + // matches rested on a single token, and against an exemplar whose whole footprint is that + // token ("fix a typo" → {typo}) the overlap coefficient reads 1.0 — full confidence in the + // coincidence. A neighbor must share `minShared` grams, or the task's whole footprint when + // the task itself is shorter than that ("fix the deadlock" still matches its exemplar). + const need = Math.min(RUBRIC.minShared, grams.size); const neighbors = EXEMPLAR_GRAMS.map(({ grams: eg, ...e }) => ({ ...e, + shared: sharedGrams(grams, eg), sim: setOverlap(grams, eg), })) + .filter((n) => n.shared >= need) .sort((a, b) => b.sim - a.sim) .slice(0, RUBRIC.k) .filter((n) => n.sim > 0); @@ -186,13 +210,12 @@ export function rubricComplexity(task = "") { const s = RUBRIC.struct; const struct = s.codeContext * (sig.hasCodeContext ? 1 : 0) + - s.length * clamp01(sig.lengthTokens / 150) + s.constraints * clamp01(sig.nConstraints / 5) + s.steps * clamp01(sig.nSteps / 3); // Structure adds complexity on top of topic, saturating — it can never flip a - // trivial topic into premium on its own (struct is bounded by Σ weights = 0.25). + // trivial topic into premium on its own (struct is bounded by Σ weights = 0.15). const score = clamp01(topic + struct * (1 - topic)); - const band = score < RUBRIC.bands.cheap ? "cheap" : score <= RUBRIC.bands.mid ? "mid" : "premium"; + const band = bandOf(score); const strongTopicSignal = knn >= RUBRIC.strongScore && confidence >= RUBRIC.strongConf; const reasons = [ ...neighbors @@ -202,14 +225,6 @@ export function rubricComplexity(task = "") { reason: `similar to "${n.text}" (sim ${n.sim.toFixed(2)}, complexity ${n.y})`, })), ...(sig.hasCodeContext ? [{ weight: s.codeContext, reason: "carries code context" }] : []), - ...(sig.lengthTokens > 55 - ? [ - { - weight: s.length * clamp01(sig.lengthTokens / 150), - reason: `long spec (~${sig.lengthTokens} tok)`, - }, - ] - : []), ...(sig.nConstraints >= 5 ? [{ weight: s.constraints, reason: `${sig.nConstraints} explicit constraints` }] : []), @@ -230,34 +245,34 @@ export function rubricComplexity(task = "") { /** * Held-out labeled complexities, DISTINCT from EXEMPLARS (the k-NN bank), so the gate * measures generalization, not memorization. Interleaved by tier so any strided split is - * balanced. y matches the recommend() cutoffs: ~0.08 trivial · ~0.42 library-level · - * ~0.78 algorithmic/systems · ~0.85 architectural. + * balanced. y matches the recommend() cutoffs: ~0.08 trivial · ~0.42 library-level · ~0.78 + * algorithmic/systems and architectural (Opus; no label reaches the fable band). */ export const CALIBRATION_SAMPLES = [ { text: "print numbers from 1 to 100", y: 0.08 }, { text: "implement a fixed-size ring buffer", y: 0.42 }, { text: "detect a cycle in a directed graph", y: 0.78 }, - { text: "design a multi-tenant billing subsystem", y: 0.85 }, + { text: "design a multi-tenant billing subsystem", y: 0.78 }, { text: "trim whitespace from a string", y: 0.08 }, { text: "group a list of records by a key", y: 0.42 }, { text: "implement quicksort in place", y: 0.78 }, - { text: "plan a migration from a monolith to services", y: 0.85 }, + { text: "plan a migration from a monolith to services", y: 0.78 }, { text: "swap two variables", y: 0.08 }, { text: "flatten a deeply nested array", y: 0.42 }, { text: "build a thread-safe bounded blocking queue", y: 0.78 }, - { text: "architect an event-sourced order pipeline", y: 0.85 }, + { text: "architect an event-sourced order pipeline", y: 0.78 }, { text: "return the length of an array", y: 0.08 }, { text: "add pagination to a list query", y: 0.42 }, { text: "write an lru eviction policy with o(1) operations", y: 0.78 }, - { text: "design cross-region data replication", y: 0.85 }, + { text: "design cross-region data replication", y: 0.78 }, { text: "convert a string to uppercase", y: 0.08 }, { text: "build a simple event emitter class", y: 0.42 }, { text: "parse arithmetic expressions with operator precedence", y: 0.78 }, - { text: "define the module boundaries for a new platform", y: 0.85 }, + { text: "define the module boundaries for a new platform", y: 0.78 }, { text: "add two integers", y: 0.08 }, { text: "validate an email address format", y: 0.42 }, { text: "coordinate leader election across nodes", y: 0.78 }, - { text: "design an auth system with roles and sessions", y: 0.85 }, + { text: "design an auth system with roles and sessions", y: 0.78 }, ]; /** Least-squares affine calibration a·x + b mapping a rubric score x to the label y. Pure. */ diff --git a/test/route.test.js b/test/route.test.js index c96bf5b..c00b20d 100644 --- a/test/route.test.js +++ b/test/route.test.js @@ -58,6 +58,50 @@ test("rubric: confidence shrinks weak matches toward the prior", () => { assert.ok(strong.confidence > weak.confidence); }); +test("rubric: one shared word is not a match (deep review D1)", () => { + // "add a comment" reduces to the single gram {comment}, so the overlap coefficient read 1.00 + // against any prose containing "comment" — and a concurrency bug was routed as a trivial one. + const r = rubricComplexity( + "resolve the deadlock between the comment writer and the comment indexer threads", + ); + assert.ok( + !r.neighbors.some((n) => n.text === "add a comment"), + `a one-word coincidence is not a neighbor: ${JSON.stringify(r.neighbors)}`, + ); + assert.equal(r.band, "premium", `deadlock work stays premium (score ${r.score})`); + for (const n of r.neighbors) assert.ok(n.shared >= 2, "every neighbor shares at least 2 grams"); + // A task whose whole footprint IS one gram still matches on it — that is all the evidence there is. + assert.equal(rubricComplexity("fix the deadlock").band, "premium"); + // ...and an incidental single-word overlap falls back to the no-signal prior. + const weak = rubricComplexity("the dijkstra approach discussion notes"); + assert.equal(weak.neighbors.length, 0); + assert.ok(Math.abs(weak.score - RUBRIC.prior) < 1e-9); +}); + +test("rubric: task size is weighted once, by the repo facet — not again here (D1)", () => { + // Both terms saturate on real issue prose, which floored every long task near the cheap/mid + // line. Padding a task with function words must not move its rubric score at all now. + const task = "add validation to user input"; + const padded = `${task} ${"the ".repeat(200)}`; + assert.equal(rubricComplexity(padded).score, rubricComplexity(task).score); + assert.equal(RUBRIC.struct.length, undefined, "no length weight in the struct table"); + // The repo facet still counts size, so the signal is not lost. + assert.ok(complexity({ sizeWords: 90 }).score > complexity({ sizeWords: 4 }).score); +}); + +test("EXEMPLARS: no label reaches the fable band — model_tiers routes architecture to Opus", () => { + for (const e of EXEMPLARS) + assert.notEqual( + recommend(e.y).key, + "fable", + `${e.text} (y ${e.y}) must not label into the fable band`, + ); + assert.ok( + EXEMPLARS.some((e) => /architecture|module boundaries|cross-module/.test(e.text)), + "architectural rows are still in the bank", + ); +}); + test("EXEMPLARS: labels are valid and every band is represented", () => { for (const e of EXEMPLARS) { assert.ok(typeof e.text === "string" && e.text.length > 0); From 9790d9a968ef13e9ca03ad8c7eefd92a3b683030 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:03:25 +0200 Subject: [PATCH 32/72] fix(merge-impact): terminal sibling/forward relations, and one clamp01 that means it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `atlasEvidence` inverted atlas edges into consequence edges, so MergeField inherited the graph's reverse-only blind spot (review A2): a file that shares a dependency with the change, or the change's own dependency, never received any consequence. `analyzeDiffImpact` now adds the impact oracle's sibling and forward relations for each changed file as TERMINAL relations, reusing the transfer matrix of the atlas edge they rest on and the oracle's frozen-parameter confidence. `analyzeMergeImpact` grew the terminal concept: such a relation's target is reported but never propagates further, so a sibling's own dependents are not dragged in (`best` for reporting, `flow` for propagation). E5 — the two local `clamp01` helpers disagreed: merge_impact.js mapped every non-number to 0, while merge_impact_adapter.js used `Number(value) || 0`, which turned `Infinity` into a maximal 1.0 criticality — an unknown weight reading as certainty. One exported helper now maps every non-finite value to 0 and still accepts numeric strings. Co-Authored-By: Claude Opus 5 --- src/merge_impact.js | 31 +++++++++++---- src/merge_impact_adapter.js | 63 +++++++++++++++++++++++++++++-- test/merge_impact.test.js | 60 +++++++++++++++++++++++++++++ test/merge_impact_adapter.test.js | 34 +++++++++++++++++ 4 files changed, 177 insertions(+), 11 deletions(-) diff --git a/src/merge_impact.js b/src/merge_impact.js index d6f00a0..65fa84c 100644 --- a/src/merge_impact.js +++ b/src/merge_impact.js @@ -9,7 +9,16 @@ export const DIMENSIONS = Object.freeze([ ]); const ZERO = Object.freeze(Object.fromEntries(DIMENSIONS.map((dimension) => [dimension, 0]))); -const clamp01 = (value) => Math.max(0, Math.min(1, Number.isFinite(value) ? Number(value) : 0)); +/** + * Clamp to [0, 1]. Anything that is not a finite number after `Number()` — NaN, ±Infinity, + * a non-numeric string, undefined — is 0, never 1: an unknown weight must not read as + * certainty. Numeric strings ("0.4") are honored. Shared with merge_impact_adapter.js. + * @param {unknown} value + */ +export const clamp01 = (value) => { + const n = typeof value === "number" ? value : Number(value); + return Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : 0; +}; const vector = (value = {}) => Object.fromEntries(DIMENSIONS.map((dimension) => [dimension, clamp01(value[dimension] ?? 0)])); const maxDimension = (value) => Math.max(...DIMENSIONS.map((dimension) => value[dimension] ?? 0)); @@ -281,7 +290,12 @@ export function analyzeMergeImpact({ let truncated = false; for (const change of changes) { const signal = signalForChange(change); + // `best` is what each artifact REPORTS; `flow` is what it PROPAGATES. They differ only + // for `terminal` relations (the ported sibling/forward relations of the impact oracle): + // their target is reported but never expanded, so a sibling's own dependents are not + // dragged in through it — even if the same artifact is also reached another way. const best = new Map([[change.artifact, signal]]); + const flow = new Map([[change.artifact, signal]]); const queue = [change.artifact]; let head = 0; const maxRelaxations = Math.max( @@ -292,7 +306,7 @@ export function analyzeMergeImpact({ while (head < queue.length && relaxations < maxRelaxations) { const from = queue[head++]; - const current = best.get(from) || ZERO; + const current = flow.get(from) || ZERO; for (const relation of outgoing.get(from) || []) { const matrix = relation.matrix || RELATION_MATRICES[relation.kind]; if (!matrix) continue; @@ -302,11 +316,14 @@ export function analyzeMergeImpact({ clamp01(relation.confidence ?? 1), clamp01(relation.decay ?? decay), ); - const previous = best.get(relation.to) || ZERO; - const { next, changed } = improve(previous, candidate, epsilon); - if (changed) { - best.set(relation.to, next); - queue.push(relation.to); + const reported = improve(best.get(relation.to) || ZERO, candidate, epsilon); + if (reported.changed) best.set(relation.to, reported.next); + if (!relation.terminal) { + const flowing = improve(flow.get(relation.to) || ZERO, candidate, epsilon); + if (flowing.changed) { + flow.set(relation.to, flowing.next); + queue.push(relation.to); + } } relaxations++; if (relaxations >= maxRelaxations) break; diff --git a/src/merge_impact_adapter.js b/src/merge_impact_adapter.js index e840f6a..97c5945 100644 --- a/src/merge_impact_adapter.js +++ b/src/merge_impact_adapter.js @@ -1,5 +1,6 @@ import { extname } from "node:path"; -import { analyzeMergeImpact } from "./merge_impact.js"; +import { impact } from "./atlas.js"; +import { analyzeMergeImpact, clamp01 } from "./merge_impact.js"; const DOC_EXTS = new Set([".md", ".mdx", ".rst", ".adoc"]); const CONFIG_EXTS = new Set([".json", ".yaml", ".yml", ".toml", ".ini", ".cfg"]); @@ -18,8 +19,6 @@ const SECURITY_RE = const DEPENDENCY_TEXT_RE = /["']?(?:dependencies|devDependencies|peerDependencies|optionalDependencies)["']?\s*:|\b(?:version|image)\s*:/i; -const clamp01 = (value) => Math.max(0, Math.min(1, Number(value) || 0)); - export function artifactKind(path = "") { const normalized = String(path).replaceAll("\\", "/"); const ext = extname(normalized).toLowerCase(); @@ -222,6 +221,47 @@ export function atlasEvidence(atlas, { criticality = {}, generatedTargets = {} } return { artifacts: [...artifactMap.values()], relations: [...relations.values()] }; } +const PROPAGATING_KINDS = new Set(["calls", "imports", "inherits"]); + +/** + * The impact oracle's SIBLING and FORWARD relations for the changed files, as TERMINAL + * MergeField relations (reported, never expanded). atlasEvidence alone is reverse-only — + * the refutation's Defect 2 — so a file sharing a dependency with the change, or the + * change's own dependency, never received a consequence. Each relation reuses the transfer + * matrix of the atlas edge it rests on (the sibling's own edge into the shared file; the + * change's first forward edge) and carries the oracle's frozen-parameter confidence. + * @param {object|null} atlas + * @param {string[]} changedFiles + * @returns {{from:string, to:string, kind:string, confidence:number, terminal:true, relation:string}[]} + */ +export function siblingForwardRelations(atlas, changedFiles = []) { + if (!atlas) return []; + const changed = new Set(changedFiles); + const relations = new Map(); + for (const file of changed) { + const report = impact(atlas, file, { relations: ["sibling", "forward"] }); + for (const item of report.impacted) { + const to = item.node?.file; + if (!to || changed.has(to)) continue; + const kinds = item.edgeKinds || []; + const kind = item.relation === "sibling" ? kinds[kinds.length - 1] : kinds[0]; + if (!PROPAGATING_KINDS.has(kind)) continue; + const relation = { + from: file, + to, + kind, + confidence: clamp01(item.confidence), + terminal: /** @type {const} */ (true), + relation: item.relation, + }; + const key = relationKey(relation); + const prior = relations.get(key); + if (!prior || relation.confidence > prior.confidence) relations.set(key, relation); + } + } + return [...relations.values()]; +} + export function analyzeDiffImpact({ files = [], atlas = null, @@ -242,10 +282,23 @@ export function analyzeDiffImpact({ } } + const oracleRelations = siblingForwardRelations( + atlas, + changes.map((change) => change.artifact), + ); + for (const relation of oracleRelations) { + if (!artifactMap.has(relation.to)) + artifactMap.set(relation.to, { + id: relation.to, + kind: artifactKind(relation.to), + criticality: clamp01(criticality[relation.to] || 0), + }); + } + const result = analyzeMergeImpact({ artifacts: [...artifactMap.values()], changes, - relations: [...evidence.relations, ...extraRelations], + relations: [...evidence.relations, ...oracleRelations, ...extraRelations], }); return { @@ -253,6 +306,8 @@ export function analyzeDiffImpact({ changes, evidence: { atlasRelations: evidence.relations.length, + siblingRelations: oracleRelations.filter((r) => r.relation === "sibling").length, + forwardRelations: oracleRelations.filter((r) => r.relation === "forward").length, extraRelations: extraRelations.length, generatedRelations: Object.values(generatedTargets || {}).reduce( (sum, targets) => sum + (targets?.length || 0), diff --git a/test/merge_impact.test.js b/test/merge_impact.test.js index c3970d8..190c7ae 100644 --- a/test/merge_impact.test.js +++ b/test/merge_impact.test.js @@ -154,3 +154,63 @@ test("documentation relation creates docs risk without pretending runtime execut assert.ok(doc.dimensions.docs > 0.7); assert.equal(doc.dimensions.runtime, 0); }); + +test("a terminal relation reports its target but never propagates through it", () => { + const changes = [{ artifact: "a.js", kind: "public_api", linesChanged: 8 }]; + const artifacts = ["a.js", "b.js", "c.js"].map((id) => artifact(id)); + const chain = [{ ...relation("b.js", "c.js", "imports") }]; + const open = analyzeMergeImpact({ + artifacts, + changes, + relations: [relation("a.js", "b.js", "imports"), ...chain], + }); + assert.ok( + open.impacted.some((i) => i.id === "c.js"), + "an ordinary relation propagates on", + ); + const terminal = analyzeMergeImpact({ + artifacts, + changes, + relations: [{ ...relation("a.js", "b.js", "imports"), terminal: true }, ...chain], + }); + assert.ok( + terminal.impacted.some((i) => i.id === "b.js"), + "the terminal target is reported", + ); + assert.ok( + !terminal.impacted.some((i) => i.id === "c.js"), + "a sibling's own dependents are not dragged in", + ); +}); + +test("a non-finite weight is zero, never certainty (clamp01)", () => { + const changes = [{ artifact: "a.js", kind: "public_api", linesChanged: 4 }]; + const infinite = analyzeMergeImpact({ + artifacts: [artifact("a.js"), artifact("b.js", "source", Number.POSITIVE_INFINITY)], + changes, + relations: [relation("a.js", "b.js", "imports", Number.POSITIVE_INFINITY)], + }); + assert.ok( + !infinite.impacted.some((i) => i.id === "b.js"), + "an Infinity confidence carries 0 consequence, not full consequence", + ); + const critical = analyzeMergeImpact({ + artifacts: [artifact("a.js"), artifact("b.js", "source", Number.POSITIVE_INFINITY)], + changes, + relations: [relation("a.js", "b.js", "imports", 0.9)], + }); + assert.equal( + critical.impacted.find((i) => i.id === "b.js")?.criticality, + 0, + "Infinity criticality is not 1", + ); + const stringy = analyzeMergeImpact({ + artifacts: [artifact("a.js"), artifact("b.js")], + changes, + relations: [relation("a.js", "b.js", "imports", "0.9")], + }); + assert.ok( + stringy.impacted.find((i) => i.id === "b.js")?.dimensions.runtime > 0, + "a numeric string is still a number", + ); +}); diff --git a/test/merge_impact_adapter.test.js b/test/merge_impact_adapter.test.js index d27b823..1f41326 100644 --- a/test/merge_impact_adapter.test.js +++ b/test/merge_impact_adapter.test.js @@ -1,11 +1,14 @@ import assert from "node:assert/strict"; import { test } from "node:test"; +import { build } from "../src/atlas.js"; import { analyzeDiffImpact, artifactKind, atlasEvidence, classifyChangedFile, + siblingForwardRelations, } from "../src/merge_impact_adapter.js"; +import { sibFiles, writeRepo } from "./fixtures/impact_repos.mjs"; const node = (id, file, kind = "module") => ({ id, file, kind, name: id }); @@ -188,3 +191,34 @@ test("workflow and manifest paths get distinct change semantics", () => { "dependency", ); }); + +test("A2: the diff analysis carries sibling and forward relations from the atlas", () => { + const atlas = build({ root: writeRepo(sibFiles) }); + const result = analyzeDiffImpact({ + atlas, + files: [ + { + filename: "src/serializer.js", + additions: 1, + deletions: 1, + patch: + "@@ -1 +1 @@\n-export function serialize(obj) {\n+export function serialize(obj, opts) {", + }, + ], + }); + assert.ok(result.evidence.siblingRelations >= 1, JSON.stringify(result.evidence)); + assert.ok( + result.impacted.some((item) => item.id === "src/deserializer.js"), + "the file that shares wire_format.js is in the field", + ); + assert.ok( + result.impacted.some((item) => item.id === "src/app.js"), + "reverse still works", + ); + const relations = siblingForwardRelations(atlas, ["src/serializer.js"]); + assert.ok( + relations.every((r) => r.terminal === true), + "ported relations never re-expand", + ); + assert.deepEqual(siblingForwardRelations(null, ["src/serializer.js"]), [], "no atlas → none"); +}); From e6e06ada28414a242ddcdc7bd2610cb6cf5c79f5 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:03:25 +0200 Subject: [PATCH 33/72] fix(cortex-features): whole-word fan-out and a churn window `grepFanout` ran a substring `git grep`, so "get" counted every file containing "target"; it now matches whole words with a fixed string (`-w -F -e`). `gitChurn` counted the last 50 commits of ALL history, so a file untouched since 2015 still scored a maximal 1.0; it now counts commits inside a 90-day window (`CHURN_DAYS`, overridable per call). Both signatures stay backward compatible for their callers. Co-Authored-By: Claude Opus 5 --- src/cortex_features.js | 19 ++++++++++---- test/cortex_features.test.js | 50 ++++++++++++++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/src/cortex_features.js b/src/cortex_features.js index 70f7f0b..bcbb193 100644 --- a/src/cortex_features.js +++ b/src/cortex_features.js @@ -52,17 +52,26 @@ const tryExec = (bin, args, root) => { } }; -/** How many commits recently touched this file (git churn) — 0 if not a git repo. */ -export function gitChurn(root, file) { +// Churn is RECENT activity: commits in the last CHURN_DAYS days. Without a window, `-n 50` +// counted a file's whole history, so a file untouched since 2015 scored as maximally hot. +export const CHURN_DAYS = 90; + +/** How many commits touched this file in the last `days` days (git churn) — 0 if not a git repo. */ +export function gitChurn(root, file, { days = CHURN_DAYS } = {}) { if (!file) return 0; - const out = tryExec("git", ["log", "--oneline", "-n", "50", "--", file], root); + const out = tryExec( + "git", + ["log", `--since=${days} days ago`, "--oneline", "-n", "50", "--", file], + root, + ); return out ? out.trim().split("\n").filter(Boolean).length : 0; } -/** Rough fan-out: how many files mention the symbol (grep). SEAM for a real call graph. */ +/** Rough fan-out: how many files mention the symbol as a WHOLE WORD (git grep -w, fixed + * string — "get" no longer counts every "target"). SEAM for a real call graph. */ export function grepFanout(root, symbol) { if (!symbol) return 0; - const out = tryExec("git", ["grep", "-l", "--", symbol], root); + const out = tryExec("git", ["grep", "-l", "-w", "-F", "-e", symbol], root); return out ? out.trim().split("\n").filter(Boolean).length : 0; } diff --git a/test/cortex_features.test.js b/test/cortex_features.test.js index 2a97471..c4f16c8 100644 --- a/test/cortex_features.test.js +++ b/test/cortex_features.test.js @@ -1,9 +1,10 @@ import assert from "node:assert/strict"; -import { mkdtempSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; -import { computeFeatures, featuresForEdit } from "../src/cortex_features.js"; +import { computeFeatures, featuresForEdit, gitChurn, grepFanout } from "../src/cortex_features.js"; import { newLesson } from "../src/lessons.js"; test("computeFeatures normalizes and derives every feature", () => { @@ -60,3 +61,48 @@ test("featuresForEdit degrades gracefully on a non-git repo (no throw, valid vec assert.equal(f.caller_fanout, 0, "no git grep → no fan-out"); assert.equal(f.lesson_match, 0, "no lessons yet"); }); + +function gitRepo() { + const root = mkdtempSync(join(tmpdir(), "forge-feat-git-")); + const git = (args, env) => + execFileSync("git", args, { + cwd: root, + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, ...env }, + }); + git(["init", "-q"]); + git(["config", "user.email", "forge@test.invalid"]); + git(["config", "user.name", "forge-test"]); + const commit = (message, date) => { + git(["add", "-A"]); + git(["-c", "commit.gpgsign=false", "commit", "-qm", message], { + GIT_AUTHOR_DATE: date, + GIT_COMMITTER_DATE: date, + }); + }; + return { root, commit }; +} + +test("grepFanout counts whole-word matches only ('get' no longer matches 'target')", () => { + const { root, commit } = gitRepo(); + writeFileSync(join(root, "a.js"), "export const target = 1;\nexport const widget = 2;\n"); + writeFileSync(join(root, "b.js"), "import { get } from './x.js';\nexport const b = get();\n"); + writeFileSync(join(root, "c.js"), "export function get() {\n return 1;\n}\n"); + commit("fixture"); + assert.equal(grepFanout(root, "get"), 2, "only the two files that use `get` as a word"); + assert.equal(grepFanout(root, "target"), 1); + assert.equal(grepFanout(root, ""), 0, "no symbol → no fan-out"); +}); + +test("gitChurn counts only commits inside the time window", () => { + const { root, commit } = gitRepo(); + writeFileSync(join(root, "ancient.js"), "export const a = 1;\n"); + commit("ancient", "2015-01-01T00:00:00Z"); + writeFileSync(join(root, "ancient.js"), "export const a = 2;\n"); + commit("ancient again", "2015-02-01T00:00:00Z"); + writeFileSync(join(root, "fresh.js"), "export const f = 1;\n"); + commit("fresh"); + assert.equal(gitChurn(root, "ancient.js"), 0, "untouched for a decade → no churn"); + assert.equal(gitChurn(root, "fresh.js"), 1); + assert.ok(gitChurn(root, "ancient.js", { days: 6000 }) >= 2, "a wider window sees the history"); +}); From c02d9a344dbe8190d795cd506f74f4a762ed3f69 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:03:35 +0200 Subject: [PATCH 34/72] research(impact-oracle): ship the repaired v2 prototype, not the refuted v1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-repo `oracle.py` was byte-identical to `impact_oracle_v1_as_shipped.zip` — the version whose claims research/empirical-refutation demolished — so the repository carried the paper's repair only inside the replication tarball. Both repairs are now here, applied on top of this copy's own hardening (JSON-only persistence, cache_dir confined to the root): - Defect 1 (`world_model.py::_merge_phantom_nodes`): the symmetric matching direction for src-layout imports, stripping only a top-level path segment the parser actually observed and only when exactly one real node results. Pooled recall 0.0220 → 0.2424. - Defect 2 (`oracle.py::predict_impact`): the terminal `sibling` and `forward` relations. Held-out repos: precision 0.320, recall 0.647, F1 0.428 against grep's 0.371 — a reversal of the as-shipped 0.042 vs 0.437. The module defaults are the FROZEN_PARAMETERS.json values (sibling weight 0.7, bridge in-degree cap 100, forward weight 0.5, ≤2 forward hops), not v2's provisional ones, and `ImpactOracle(wm, sibling_enabled=False, forward_enabled=False)` still reproduces the as-shipped traversal exactly. tests/test_repair_fixes.py adds the 13 regression tests; its end-to-end case now puts its throwaway cache inside the package root, which this WorldModel requires. Co-Authored-By: Claude Opus 5 --- .../python-prototypes/impact_oracle/README.md | 29 ++ .../impact_oracle/impact_oracle/oracle.py | 217 ++++++++++++ .../impact_oracle/world_model.py | 102 +++++- .../impact_oracle/tests/test_repair_fixes.py | 323 ++++++++++++++++++ 4 files changed, 663 insertions(+), 8 deletions(-) create mode 100644 research/python-prototypes/impact_oracle/tests/test_repair_fixes.py diff --git a/research/python-prototypes/impact_oracle/README.md b/research/python-prototypes/impact_oracle/README.md index 5269fad..017933d 100644 --- a/research/python-prototypes/impact_oracle/README.md +++ b/research/python-prototypes/impact_oracle/README.md @@ -30,12 +30,40 @@ traverses reverse dependencies to predict what will break. ┌───────────────────────────────────────────────────────┐ │ Impact Oracle (oracle.py) │ │ - Reverse-dependency BFS with confidence decay │ +│ - + SIBLING and FORWARD relations (repair v2) │ │ - Per-edge-kind weights (calls > imports > refs) │ │ - Ranked impact set with explanation paths │ │ - Baselines: grep + edited-file-only │ └───────────────────────────────────────────────────────┘ ``` +### Repaired (v2) — what changed and why + +The empirical refutation in [`research/empirical-refutation/`](../../empirical-refutation/) +measured this prototype against real co-change data on nine repositories and found two +defects. Both repairs now live here, and the frozen parameters they were tuned with are +in `oracle.py` as the module defaults: + +1. **src-layout phantom nodes** (`world_model.py`, `_merge_phantom_nodes`). A module's + qualified name came from its path relative to the codebase root, so in a `src/` layout a + real node carried a `src.` prefix that absolute imports elsewhere in the same repo + legitimately omit — and the unprefixed target was auto-created as an empty phantom. The + merge now also matches that direction, stripping only a top-level path segment the + parser actually observed, and only when exactly one real node results. + Pooled recall 0.0220 → 0.2424 at threshold 0.02. +2. **Reverse-only traversal** (`oracle.py`, `predict_impact`). 94.7% of the remaining + misses were *siblings* — A and B both depend on module C — and 2.1% were pure forward + dependencies. Two terminal relations were added: `sibling` (one bounded forward hop to a + bridge, then one bounded reverse hop from it, skipping bridges whose in-degree exceeds + the cap) and `forward` (the changed symbol's own dependencies, ≤2 hops). + Held-out (never-tuned) repos at threshold 0.10: precision 0.320, recall 0.647, + **F1 0.428 vs the grep baseline's 0.371** — a reversal of the as-shipped 0.042 vs 0.437. + +`ImpactOracle(wm, sibling_enabled=False, forward_enabled=False)` reproduces the +as-shipped reverse-only traversal exactly; the untouched as-shipped package is archived as +`prototypes/impact_oracle_v1_as_shipped.zip` inside the replication tarball. The same two +repairs are ported to the shipped Node implementation (`src/atlas.js`, `forge impact`). + ### Graph structure - **Nodes** = symbols: modules, classes, functions/methods, module-level names @@ -114,6 +142,7 @@ demo_package/ # Example multi-module codebase (8 files) tests/ test_demo_package.py # 36 tests exercising the demo package + test_repair_fixes.py # 13 regression tests for the two v2 repairs demo.py # End-to-end demonstration script evaluate.py # Mutation-based evaluation diff --git a/research/python-prototypes/impact_oracle/impact_oracle/oracle.py b/research/python-prototypes/impact_oracle/impact_oracle/oracle.py index 69700df..cb1761e 100644 --- a/research/python-prototypes/impact_oracle/impact_oracle/oracle.py +++ b/research/python-prototypes/impact_oracle/impact_oracle/oracle.py @@ -5,6 +5,37 @@ to predict the set of symbols and files that would be affected. Each impacted node carries a decaying confidence score and the dependency path that connects it to X. + +CHANGES (defect-2 fix): the reverse-only traversal above is EXTENDED (not +replaced) with two additional, clearly-labeled relation types, each with its +own confidence treatment, hop cap, and (for siblings) a bridge in-degree cap. +See ImpactOracle's docstring and the module-level `SIBLING_*`/`FORWARD_*` +defaults below for the full rationale; the short version: + +- SIBLING relation (fixes the dominant false-negative category, "sibling + common dependency" = 94.7% of misses in the pre-repair diagnostic): A and + B both depend on a shared module C (e.g. both `import json`), so they + co-change when C's usage contract shifts, even though neither is + reverse-reachable from the other. Reached by one bounded forward hop (to + find C) then one bounded reverse hop from C (to find B) -- NOT chained + recursively, and only through bridges C whose in-degree is below a cap + (a shared dependency used by 3 files is strong sibling evidence; one used + by 200 files is not -- almost everything is "similar" via a hub, and + treating hub fan-in as an impact signal would tank precision). +- FORWARD relation (fixes "forward_only_blindspot" = 2.1% of misses): the + changed symbol's OWN dependencies (successors) -- covers the case where + editing a call site plausibly means updating the callee too. Reached by a + small bounded forward walk from the changed symbol only (not from every + node in the reverse tree, since this relation has no bridge-based + precision guard to fall back on). + +Both new relation types are TERMINAL: a node discovered via a sibling or +forward hop is scored and included in the impact report, but is never +itself re-expanded through the primary reverse BFS or through another +sibling/forward hop. This bounds the algorithm's fan-out to a small, +fixed multiple of the reverse-BFS frontier size regardless of graph +structure, which is what makes the in-degree cap sufficient to reason about +independent of graph size. """ from __future__ import annotations @@ -29,6 +60,25 @@ # Default per-hop decay factor DEFAULT_DECAY = 0.85 +# --- Defect-2 fix: new relation-type parameters ----------------------- +# These are the FROZEN parameters: chosen by a 120-configuration grid search over the +# six TUNING repos only, written to the replication package's +# `protocol/FROZEN_PARAMETERS.json`, and never adjusted after the held-out repos were +# run (held-out: precision 0.320, recall 0.647, F1 0.428 vs grep's 0.371). Do not +# re-tune them here: the numbers in the paper are only meaningful at these values. + +# SIBLING relation: A and B are "siblings" if both depend on a common node C. +DEFAULT_SIBLING_ENABLED = True +DEFAULT_SIBLING_WEIGHT = 0.7 # extra multiplicative dampening (sibling evidence is weaker than direct dependency) +DEFAULT_SIBLING_FORWARD_HOPS = 1 # how far to walk forward from the changed symbol to find bridge candidates +DEFAULT_SIBLING_REVERSE_HOPS = 1 # how far to walk reverse from a bridge to find siblings +DEFAULT_SIBLING_BRIDGE_MAX_INDEGREE = 100 # skip bridges more "popular" than this (hub modules give weak sibling evidence) + +# FORWARD relation: the changed symbol's own transitive dependencies. +DEFAULT_FORWARD_ENABLED = True +DEFAULT_FORWARD_WEIGHT = 0.5 # extra multiplicative dampening relative to the reverse-direction formula +DEFAULT_FORWARD_MAX_HOPS = 2 # small cap: forward-only is a minority failure mode (2.1% of misses); deep forward chains are increasingly indirect + @dataclass class ImpactedNode: @@ -40,6 +90,13 @@ class ImpactedNode: hop_distance: int # number of edges from the changed symbol path: list[str] # sequence of qualified_names from changed → this edge_kinds: list[str] # kind of each edge on the path + # CHANGES (defect-2 fix): provenance of HOW this node was reached. + # "reverse" = shipped behavior (who depends on the changed symbol). + # "sibling" = shares a common dependency with the changed symbol. + # "forward" = something the changed symbol itself depends on. + # Purely diagnostic -- above_threshold() and impacted_files still work + # exactly as before regardless of relation. + relation: str = "reverse" def to_dict(self) -> dict[str, Any]: return self.__dict__.copy() @@ -77,6 +134,13 @@ class ImpactOracle: Works by traversing *reverse* dependency edges in the world-model graph: starting from the changed symbol, it walks predecessors (nodes that depend on the changed symbol) with decaying confidence. + + CHANGES (defect-2 fix): also optionally traverses two more relation + types -- SIBLING (shared-dependency co-change) and FORWARD (the changed + symbol's own dependencies) -- each independently toggleable and with + its own confidence treatment, so a caller who wants byte-identical + as-shipped behavior can construct with `sibling_enabled=False, + forward_enabled=False`. See module docstring for the full rationale. """ def __init__( @@ -85,11 +149,27 @@ def __init__( decay: float = DEFAULT_DECAY, edge_weights: dict[str, float] | None = None, max_hops: int = 10, + sibling_enabled: bool = DEFAULT_SIBLING_ENABLED, + sibling_weight: float = DEFAULT_SIBLING_WEIGHT, + sibling_forward_hops: int = DEFAULT_SIBLING_FORWARD_HOPS, + sibling_reverse_hops: int = DEFAULT_SIBLING_REVERSE_HOPS, + sibling_bridge_max_indegree: int = DEFAULT_SIBLING_BRIDGE_MAX_INDEGREE, + forward_enabled: bool = DEFAULT_FORWARD_ENABLED, + forward_weight: float = DEFAULT_FORWARD_WEIGHT, + forward_max_hops: int = DEFAULT_FORWARD_MAX_HOPS, ): self.wm = world_model self.decay = decay self.edge_weights = edge_weights or EDGE_WEIGHTS self.max_hops = max_hops + self.sibling_enabled = sibling_enabled + self.sibling_weight = sibling_weight + self.sibling_forward_hops = sibling_forward_hops + self.sibling_reverse_hops = sibling_reverse_hops + self.sibling_bridge_max_indegree = sibling_bridge_max_indegree + self.forward_enabled = forward_enabled + self.forward_weight = forward_weight + self.forward_max_hops = forward_max_hops def predict_impact( self, @@ -162,10 +242,25 @@ def predict_impact( hop_distance=hop + 1, path=new_path, edge_kinds=new_ekinds, + relation="reverse", ) visited[pred] = impact queue.append((pred, new_conf, hop + 1, new_path, new_ekinds)) + # --- Defect-2 fix: SIBLING relation ----------------------------- + # A and B are siblings if both depend on a common bridge node C. + # Reached by a bounded forward walk from `symbol` to find bridge + # candidates, then a bounded reverse walk from each low-in-degree + # bridge to find siblings. See module docstring for full rationale. + if self.sibling_enabled: + self._add_sibling_impacts(graph, symbol, threshold, visited) + + # --- Defect-2 fix: FORWARD relation ------------------------------ + # The changed symbol's own transitive dependencies (successors), + # up to a small bounded hop count. + if self.forward_enabled: + self._add_forward_impacts(graph, symbol, threshold, visited) + impacted = sorted(visited.values(), key=lambda n: -n.confidence) files = sorted({n.file for n in impacted if n.file}) @@ -178,6 +273,128 @@ def predict_impact( total_graph_edges=graph.number_of_edges(), ) + def _edge_step_conf(self, graph: nx.DiGraph, conf: float, edge_source: str, edge_target: str) -> tuple[float, str]: + """One hop's confidence update, shared by reverse/sibling/forward walks. + + `edge_source`/`edge_target` are the edge exactly as networkx stores + it (edges always go depender -> dependee, i.e. `graph.edges[source, + target]` regardless of which logical direction a BFS is walking) -- + callers pass whichever concrete (source, target) pair the hop just + traversed, so the SAME edge-weight/decay formula applies everywhere + in the oracle, not a bespoke one per relation type. + """ + edge_data = graph.edges[edge_source, edge_target] + ek = edge_data.get("kind", "references") + ew = self.edge_weights.get(ek, 0.5) + edge_conf = edge_data.get("confidence", 1.0) + return conf * ew * edge_conf * self.decay, ek + + def _add_sibling_impacts(self, graph, symbol, threshold, visited: dict[str, ImpactedNode]) -> None: + """Extend `visited` in place with SIBLING-relation impacts. + + Step 1: bounded forward BFS from `symbol` (up to + `sibling_forward_hops`) to collect bridge candidates -- nodes + `symbol` transitively depends on. + Step 2: for each bridge whose GLOBAL in-degree is <= the cap + (skip hub modules -- weak sibling evidence, see module docstring), + a bounded reverse BFS (up to `sibling_reverse_hops`) to collect + sibling candidates -- other nodes that also depend on the bridge. + Score = (forward path conf to bridge) * (reverse path conf from + bridge) * sibling_weight. Terminal: sibling nodes are not + themselves re-expanded through any relation. + """ + # Step 1: forward walk to find bridges (dedup by keeping best conf/path per bridge) + bridges: dict[str, tuple[float, list[str], list[str]]] = {} # node -> (conf, path, ekinds) + fqueue: list[tuple[str, float, int, list[str], list[str]]] = [(symbol, 1.0, 0, [symbol], [])] + fseen = {symbol} + while fqueue: + current, conf, hop, path, ekinds = fqueue.pop(0) + if hop >= self.sibling_forward_hops: + continue + for succ in graph.successors(current): + if succ == symbol: + continue + new_conf, ek = self._edge_step_conf(graph, conf, current, succ) + new_path = path + [succ] + new_ekinds = ekinds + [ek] + if succ not in bridges or bridges[succ][0] < new_conf: + bridges[succ] = (new_conf, new_path, new_ekinds) + if succ not in fseen: + fseen.add(succ) + fqueue.append((succ, new_conf, hop + 1, new_path, new_ekinds)) + + # Step 2: for each low-in-degree bridge, reverse walk to find siblings + for bridge, (bridge_conf, bridge_path, bridge_ekinds) in bridges.items(): + if graph.in_degree(bridge) > self.sibling_bridge_max_indegree: + continue # hub module: weak sibling evidence, skip + + rqueue: list[tuple[str, float, int, list[str], list[str]]] = [(bridge, bridge_conf, 0, bridge_path, bridge_ekinds)] + rseen = {bridge, symbol} + while rqueue: + current, conf, hop, path, ekinds = rqueue.pop(0) + if hop >= self.sibling_reverse_hops: + continue + for pred in graph.predecessors(current): + if pred in rseen or pred == symbol: + continue + step_conf, ek = self._edge_step_conf(graph, conf, pred, current) + final_conf = step_conf * self.sibling_weight + new_path = path + [pred] + new_ekinds = ekinds + [ek] + rseen.add(pred) + if final_conf < threshold: + continue + if pred in visited and visited[pred].confidence >= round(final_conf, 4): + continue + node_data = graph.nodes.get(pred, {}) + visited[pred] = ImpactedNode( + qualified_name=pred, + kind=node_data.get("kind", "unknown"), + file=node_data.get("file", ""), + confidence=round(final_conf, 4), + hop_distance=len(new_path) - 1, + path=new_path, + edge_kinds=new_ekinds, + relation="sibling", + ) + # terminal: do not enqueue `pred` for further sibling expansion + if hop + 1 < self.sibling_reverse_hops: + rqueue.append((pred, step_conf, hop + 1, new_path, new_ekinds)) + + def _add_forward_impacts(self, graph, symbol, threshold, visited: dict[str, ImpactedNode]) -> None: + """Extend `visited` in place with FORWARD-relation impacts: the + changed symbol's own transitive dependencies, up to + `forward_max_hops`. Terminal: forward nodes are not re-expanded + through the reverse or sibling relations. + """ + queue: list[tuple[str, float, int, list[str], list[str]]] = [(symbol, 1.0, 0, [symbol], [])] + seen = {symbol} + while queue: + current, conf, hop, path, ekinds = queue.pop(0) + if hop >= self.forward_max_hops: + continue + for succ in graph.successors(current): + if succ in seen: + continue + step_conf, ek = self._edge_step_conf(graph, conf, current, succ) + final_conf = step_conf * self.forward_weight + new_path = path + [succ] + new_ekinds = ekinds + [ek] + seen.add(succ) + if final_conf >= threshold and (succ not in visited or visited[succ].confidence < round(final_conf, 4)): + node_data = graph.nodes.get(succ, {}) + visited[succ] = ImpactedNode( + qualified_name=succ, + kind=node_data.get("kind", "unknown"), + file=node_data.get("file", ""), + confidence=round(final_conf, 4), + hop_distance=hop + 1, + path=new_path, + edge_kinds=new_ekinds, + relation="forward", + ) + queue.append((succ, step_conf, hop + 1, new_path, new_ekinds)) + @staticmethod def grep_baseline(symbol_name: str, root: str) -> set[str]: """Baseline 1: grep for the bare symbol name across all .py files. diff --git a/research/python-prototypes/impact_oracle/impact_oracle/world_model.py b/research/python-prototypes/impact_oracle/impact_oracle/world_model.py index cfd7bdc..68d43a9 100644 --- a/research/python-prototypes/impact_oracle/impact_oracle/world_model.py +++ b/research/python-prototypes/impact_oracle/impact_oracle/world_model.py @@ -249,6 +249,24 @@ def _resolve_cross_module_edges(self): node in the graph, the edge target is already correct. When the target is a module but the actual use is 'mod.func', we try to find the function inside the module. + + NOTE (investigated during defect-1 repair, kept AS-SHIPPED): this + method's own resolution loop is permanently dead code as written -- + `existing = set(self.graph.nodes)` includes every edge target, + because `graph.add_edge(u, v, ...)` auto-vivifies `v` as a bare + placeholder node the instant the edge is added, so `v in existing` + is true unconditionally and the candidate search below never runs. + Fixing that guard was considered and rejected as the defect-1 fix: + doing so does resolve the src-layout phantom-node defect (see + `_merge_phantom_nodes` below, where the fix actually lives), but it + ALSO silently activates this method's second, much weaker fallback + arm (`n.endswith(f".{v.split('.')[-1]}")` -- match on the last + identifier alone, e.g. any node ending in `.Pytester`), which is a + materially different, untested, and unrequested heuristic change + with its own false-positive risk. `_merge_phantom_nodes` gives a + clean, symmetric, purpose-built fix without waking that second + heuristic, so this method is left byte-identical to the shipped + version and its dead branch is untouched. """ existing = set(self.graph.nodes) edges_to_add: list[tuple[str, str, dict]] = [] @@ -296,29 +314,97 @@ def _resolve_cross_module_edges(self): self._merge_phantom_nodes() def _merge_phantom_nodes(self): - """Merge nodes whose qualified_name is a suffix of another existing node. - - Import targets like 'demo_package.utils.validation.validate_positive' - are phantom duplicates of parsed nodes like 'utils.validation.validate_positive' - when the codebase root IS 'demo_package/'. We redirect all edges - from the phantom to the real node. + """Merge nodes whose qualified_name is a dotted-prefix mismatch of a + real, already-parsed node, in EITHER direction. + + Two distinct import-prefix mismatches create phantom duplicates of a + real node -- both stem from the same root cause (parser.py derives a + module's qualified_name from its file path relative to WorldModel's + `root`, but an import elsewhere in the SAME codebase may spell the + same target with a different, also-legitimate root offset): + + 1. PHANTOM LONGER (shipped, unchanged): import target + 'demo_package.utils.validation.validate_positive' is a phantom of + the real parsed node 'utils.validation.validate_positive' when + the codebase root IS 'demo_package/' -- the import spells out a + leading component the parser's root already absorbed. + + 2. PHANTOM SHORTER (defect-1 fix): in a **src-layout** package + (`src/pkg/...`), the parser's root is the REPO root, so the real + parsed node is 'src.pkg.mod.func'. Code elsewhere in the repo + legitimately writes an absolute import WITHOUT the 'src.' prefix + (`from pkg.mod import func` -- correct at runtime, since `src/` + is put on `sys.path` by the package's build config), which parses + to edge target 'pkg.mod.func': a phantom missing the leading + 'src.' the real node carries. This directionality was previously + never checked at all, which is why recall was ~0.000 in every + src-layout repo of the evaluation corpus and non-negligible only + in flat-layout ones (where package IS the repo root, so no + prefix mismatch is structurally possible). + + SAFETY (why this isn't a blind suffix search): naively checking "is + `n` a dotted suffix of ANY real node" is unsound -- e.g. a plain + `import json` (stdlib) parses to phantom target 'json', which IS a + dotted suffix of a real, uniquely-named LOCAL submodule in more than + one corpus repo (e.g. flask's own 'src.flask.json' re-export + module -- the exact sibling-pattern example in this evaluation's own + failure-mode analysis). Blindly merging that pair would fabricate a + false dependency edge between every `import json` call site in the + codebase and flask's unrelated local json submodule, inflating false + positives. Instead, direction 2 only strips a prefix that is a + **top-level path segment this parser actually used** for some real + node's file in THIS build (e.g. 'src', because some real node's + `file` metadata starts with 'src/') -- i.e. only reconstructs a + root-offset the parser is independently known to use, rather than + pattern-matching arbitrary name collisions. A match is only made + when exactly one such prefix yields a real node (ambiguous cases + are left unmerged rather than guessed, matching direction 1's own + conservative precedent of exact-string membership tests only). """ # "Real" nodes are those explicitly added by the parser with metadata real_nodes = {n for n, d in self.graph.nodes(data=True) if d.get("kind")} all_nodes = set(self.graph.nodes) phantoms_to_merge: dict[str, str] = {} # phantom -> real + # Layout prefixes actually observed: the first path component of + # every real node's file, when that file lives more than one + # directory level deep (a bare top-level file like 'setup.py' + # contributes no prefix -- there's nothing to strip). + layout_prefixes: set[str] = set() + for _, d in self.graph.nodes(data=True): + f = d.get("file", "") + if not f: + continue + parts = Path(f).parts + if len(parts) > 1: + layout_prefixes.add(parts[0]) + for n in all_nodes: if n in real_nodes: continue - # Check if this is a prefixed version of a real node + # Direction 1 (shipped): n is a prefixed version of a real node # e.g. 'demo_package.utils.validation.validate_positive' -> 'utils.validation.validate_positive' parts = n.split(".") + matched = None for i in range(1, len(parts)): suffix = ".".join(parts[i:]) if suffix in real_nodes: - phantoms_to_merge[n] = suffix + matched = suffix break + if matched: + phantoms_to_merge[n] = matched + continue + + # Direction 2 (defect-1 fix): n is an UNprefixed version of a + # real node -- try prepending each observed layout prefix and + # require a unique hit. + candidates = { + cand for prefix in layout_prefixes + if (cand := f"{prefix}.{n}") in real_nodes + } + if len(candidates) == 1: + phantoms_to_merge[n] = next(iter(candidates)) + # len(candidates) > 1 (or 0): ambiguous or no match -- left unmerged. for phantom, real in phantoms_to_merge.items(): # Transfer all incoming edges to the real node diff --git a/research/python-prototypes/impact_oracle/tests/test_repair_fixes.py b/research/python-prototypes/impact_oracle/tests/test_repair_fixes.py new file mode 100644 index 0000000..84a3fcb --- /dev/null +++ b/research/python-prototypes/impact_oracle/tests/test_repair_fixes.py @@ -0,0 +1,323 @@ +"""Regression tests for the two repair-track fixes. + +Defect 1: src-layout absolute imports created disconnected phantom nodes +because WorldModel derives module qualified-names from the file path +relative to `root`, and code elsewhere in the same repo may reference the +same module without the layout prefix. Fixed in +`WorldModel._merge_phantom_nodes` (a second, symmetric direction added +alongside the shipped demo-package direction), grounded in prefixes the +parser actually observed (not a blind name-suffix search, which is unsound +-- see the stdlib-collision test below). + +Defect 2: predict_impact only traversed reverse (who-depends-on-me) edges, +missing the dominant real-world co-change pattern where two files share a +common dependency ("siblings") without either being reverse-reachable from +the other, and the minority pattern where the changed symbol's own +dependencies plausibly need a matching update ("forward"). Fixed by two new, +independently-toggleable, terminal (non-recursively-expanded) traversal +extensions in ImpactOracle.predict_impact. +""" +from __future__ import annotations + +import os +import shutil +import tempfile + +import networkx as nx +import pytest + +from impact_oracle.world_model import WorldModel +from impact_oracle.oracle import ImpactOracle + + +# --------------------------------------------------------------------- +# Defect 1: src-layout phantom-node merge +# --------------------------------------------------------------------- + +def _wm_from_graph(nodes, edges): + """Build a WorldModel-like object around a hand-constructed graph, + bypassing file parsing (isolates the merge logic under test).""" + wm = WorldModel.__new__(WorldModel) + wm.graph = nx.DiGraph() + for n, meta in nodes: + wm.graph.add_node(n, **meta) + for u, v, meta in edges: + wm.graph.add_edge(u, v, **meta) + return wm + + +class TestDefect1PhantomNodeMerge: + def test_src_layout_unprefixed_import_resolves(self): + """The exact pytest/_pytest.pytester scenario from the diagnostic: + a real node carries the 'src.' prefix (parser root = repo root), + but an absolute import elsewhere in the repo legitimately omits + it. Before the fix, this phantom was never merged and the file + was structurally unreachable from predict_impact.""" + wm = _wm_from_graph( + nodes=[ + ("src._pytest.pytester.Pytester", {"kind": "class", "file": "src/_pytest/pytester.py"}), + ("testing.test_setuponly", {"kind": "module", "file": "testing/test_setuponly.py"}), + ], + edges=[ + ("testing.test_setuponly", "_pytest.pytester.Pytester", + {"kind": "imports", "confidence": 1.0, "lineno": 7}), + ], + ) + wm._resolve_cross_module_edges() + assert wm.graph.has_edge("testing.test_setuponly", "src._pytest.pytester.Pytester") + assert "_pytest.pytester.Pytester" not in wm.graph # phantom removed, not left dangling + + oracle = ImpactOracle(wm, sibling_enabled=False, forward_enabled=False) + report = oracle.predict_impact("src._pytest.pytester.Pytester", threshold=0.02) + assert "testing/test_setuponly.py" in report.impacted_files + + def test_shipped_demo_package_direction_still_works(self): + """The direction the method shipped with (phantom carries an EXTRA + prefix relative to a shorter real node) must be unaffected by the + fix -- this is a non-regression check.""" + wm = _wm_from_graph( + nodes=[ + ("utils.validation.validate_positive", {"kind": "function", "file": "utils/validation.py"}), + ("orders", {"kind": "module", "file": "orders.py"}), + ], + edges=[ + ("orders", "demo_package.utils.validation.validate_positive", + {"kind": "calls", "confidence": 0.95, "lineno": 10}), + ], + ) + wm._resolve_cross_module_edges() + assert wm.graph.has_edge("orders", "utils.validation.validate_positive") + + def test_stdlib_name_collision_is_not_merged(self): + """SAFETY test: a bare `import json` (stdlib) must NOT be merged + into an unrelated local submodule that happens to share the name + (e.g. a local 'pkg.json' re-export module) just because it is a + dotted suffix match. This is the exact failure mode a naive + "any suffix of any real node" search would introduce -- and is + also literally the sibling-pattern example from the evaluation's + own failure-mode analysis (flask's src.flask.json), so it must be + handled by the SIBLING relation (defect 2), not by incorrectly + collapsing the two nodes into one (which would fabricate a false + direct dependency, not just a weaker sibling one).""" + wm = _wm_from_graph( + nodes=[ + ("src.flask.json", {"kind": "module", "file": "src/flask/json/__init__.py"}), + ("src.flask", {"kind": "module", "file": "src/flask/__init__.py"}), + ("some_other_module", {"kind": "module", "file": "src/flask/somewhere.py"}), + ], + edges=[ + ("some_other_module", "json", {"kind": "imports", "confidence": 1.0, "lineno": 1}), + ], + ) + wm._resolve_cross_module_edges() + assert not wm.graph.has_edge("some_other_module", "src.flask.json") + + def test_no_layout_prefix_no_spurious_merge(self): + """If no real node's file has a multi-component path (i.e. no + layout prefix was ever observed), an unresolved phantom must be + left alone rather than guessed at.""" + wm = _wm_from_graph( + nodes=[ + ("mymodule", {"kind": "module", "file": "mymodule.py"}), + ], + edges=[ + ("mymodule", "totally_unrelated_name", {"kind": "references", "confidence": 0.7, "lineno": 3}), + ], + ) + wm._resolve_cross_module_edges() + # phantom has no incoming/outgoing beyond the one edge -> not merged, but also not + # spuriously deleted if it still carries the edge (degree > 0 keeps it) + assert not any(wm.graph.has_edge("mymodule", n) for n in wm.graph.nodes + if n not in ("mymodule", "totally_unrelated_name")) + + +# --------------------------------------------------------------------- +# Defect 2: sibling + forward traversal +# --------------------------------------------------------------------- + +class TestDefect2SiblingTraversal: + def test_sibling_relation_recovers_shared_dependency_pair(self): + """The exact flask __init__.py / json/__init__.py diagnostic + example: both modules import/reference the stdlib `json` module; + neither is reverse-reachable from the other, so the shipped + reverse-only oracle could never find this pair.""" + wm = _wm_from_graph( + nodes=[ + ("src.flask", {"kind": "module", "file": "src/flask/__init__.py"}), + ("src.flask.json", {"kind": "module", "file": "src/flask/json/__init__.py"}), + ("src.flask.json.load", {"kind": "function", "file": "src/flask/json/__init__.py", "parent": "src.flask.json"}), + ], + edges=[ + ("src.flask", "json", {"kind": "imports", "confidence": 1.0, "lineno": 1}), + ("src.flask.json.load", "json", {"kind": "references", "confidence": 0.8, "lineno": 5}), + ], + ) + oracle = ImpactOracle(wm, sibling_forward_hops=1, sibling_reverse_hops=1, sibling_bridge_max_indegree=25) + report = oracle.predict_impact("src.flask", threshold=0.02) + assert "src/flask/json/__init__.py" in report.impacted_files + sib_nodes = [n for n in report.impacted if n.relation == "sibling"] + assert len(sib_nodes) >= 1 + + def test_sibling_relation_respects_indegree_cap(self): + """A hub bridge (high in-degree) must NOT generate sibling + impacts -- otherwise every file sharing a common stdlib/utility + import would be flagged, destroying precision.""" + wm_graph_nodes = [("changed_module", {"kind": "module", "file": "changed.py"})] + wm_graph_edges = [("changed_module", "os", {"kind": "imports", "confidence": 1.0, "lineno": 1})] + for i in range(200): + wm_graph_nodes.append((f"unrelated_{i}", {"kind": "module", "file": f"unrelated_{i}.py"})) + wm_graph_edges.append((f"unrelated_{i}", "os", {"kind": "imports", "confidence": 1.0, "lineno": 1})) + wm = _wm_from_graph(wm_graph_nodes, wm_graph_edges) + + oracle = ImpactOracle(wm, sibling_bridge_max_indegree=25) + report = oracle.predict_impact("changed_module", threshold=0.02) + sibling_files = [n.file for n in report.impacted if n.relation == "sibling"] + assert sibling_files == [] + + def test_sibling_relation_allows_low_indegree_bridge(self): + """The complement of the cap test: a bridge with in-degree well + under the cap SHOULD produce sibling impacts.""" + wm = _wm_from_graph( + nodes=[ + ("module_a", {"kind": "module", "file": "a.py"}), + ("module_b", {"kind": "module", "file": "b.py"}), + ("shared_util", {"kind": "module", "file": "shared_util.py"}), + ], + edges=[ + ("module_a", "shared_util", {"kind": "imports", "confidence": 1.0, "lineno": 1}), + ("module_b", "shared_util", {"kind": "imports", "confidence": 1.0, "lineno": 1}), + ], + ) + oracle = ImpactOracle(wm, sibling_bridge_max_indegree=25) + report = oracle.predict_impact("module_a", threshold=0.02) + assert "b.py" in report.impacted_files + + def test_sibling_disabled_falls_back_to_reverse_only(self): + """sibling_enabled=False must reproduce byte-identical (empty, in + this case) as-shipped reverse-only behavior.""" + wm = _wm_from_graph( + nodes=[ + ("module_a", {"kind": "module", "file": "a.py"}), + ("module_b", {"kind": "module", "file": "b.py"}), + ("shared_util", {"kind": "module", "file": "shared_util.py"}), + ], + edges=[ + ("module_a", "shared_util", {"kind": "imports", "confidence": 1.0, "lineno": 1}), + ("module_b", "shared_util", {"kind": "imports", "confidence": 1.0, "lineno": 1}), + ], + ) + oracle = ImpactOracle(wm, sibling_enabled=False, forward_enabled=False) + report = oracle.predict_impact("module_a", threshold=0.02) + assert report.impacted_files == [] + + +class TestDefect2ForwardTraversal: + def test_forward_relation_finds_own_dependency(self): + """Editing a call site plausibly means its callee needs a matching + update too -- the shipped oracle never looked forward at all.""" + wm = _wm_from_graph( + nodes=[ + ("caller_module", {"kind": "module", "file": "caller.py"}), + ("callee_module.helper", {"kind": "function", "file": "callee.py"}), + ], + edges=[ + ("caller_module", "callee_module.helper", {"kind": "calls", "confidence": 0.95, "lineno": 1}), + ], + ) + oracle = ImpactOracle(wm, forward_max_hops=2) + report = oracle.predict_impact("caller_module", threshold=0.02) + assert "callee.py" in report.impacted_files + fwd_nodes = [n for n in report.impacted if n.relation == "forward"] + assert len(fwd_nodes) >= 1 + + def test_forward_relation_respects_hop_cap(self): + """A dependency chain longer than forward_max_hops must not be + reported via the forward relation.""" + wm = _wm_from_graph( + nodes=[ + ("a", {"kind": "module", "file": "a.py"}), + ("b", {"kind": "module", "file": "b.py"}), + ("c", {"kind": "module", "file": "c.py"}), + ("d", {"kind": "module", "file": "d.py"}), + ], + edges=[ + ("a", "b", {"kind": "imports", "confidence": 1.0, "lineno": 1}), + ("b", "c", {"kind": "imports", "confidence": 1.0, "lineno": 1}), + ("c", "d", {"kind": "imports", "confidence": 1.0, "lineno": 1}), + ], + ) + oracle = ImpactOracle(wm, forward_max_hops=1, forward_weight=1.0, decay=1.0, + sibling_enabled=False) + report = oracle.predict_impact("a", threshold=0.01) + files = set(report.impacted_files) + assert "b.py" in files + assert "c.py" not in files # 2 hops away, cap is 1 + assert "d.py" not in files + + def test_forward_disabled_falls_back_to_reverse_only(self): + wm = _wm_from_graph( + nodes=[ + ("caller_module", {"kind": "module", "file": "caller.py"}), + ("callee_module.helper", {"kind": "function", "file": "callee.py"}), + ], + edges=[ + ("caller_module", "callee_module.helper", {"kind": "calls", "confidence": 0.95, "lineno": 1}), + ], + ) + oracle = ImpactOracle(wm, forward_enabled=False, sibling_enabled=False) + report = oracle.predict_impact("caller_module", threshold=0.02) + assert report.impacted_files == [] + + +class TestDefect2Termination: + def test_sibling_and_forward_nodes_are_terminal(self): + """A node reached via sibling or forward must not itself become a + new expansion root for another sibling/forward search -- this is + what keeps the algorithm's fan-out bounded independent of graph + size (see oracle.py module docstring).""" + # b and c are siblings via bridge; d is a further sibling of c that + # should NOT be reached (would require re-expanding a sibling node). + wm = _wm_from_graph( + nodes=[(n, {"kind": "module", "file": f"{n}.py"}) for n in ["a", "b", "bridge1", "c", "bridge2", "d"]], + edges=[ + ("a", "bridge1", {"kind": "imports", "confidence": 1.0, "lineno": 1}), + ("b", "bridge1", {"kind": "imports", "confidence": 1.0, "lineno": 1}), + ("c", "bridge2", {"kind": "imports", "confidence": 1.0, "lineno": 1}), + ("b", "bridge2", {"kind": "imports", "confidence": 1.0, "lineno": 1}), + ("d", "bridge2", {"kind": "imports", "confidence": 1.0, "lineno": 1}), + ], + ) + oracle = ImpactOracle(wm, sibling_weight=1.0, decay=1.0, sibling_bridge_max_indegree=100) + report = oracle.predict_impact("a", threshold=0.01) + files = set(report.impacted_files) + assert "b.py" in files # direct sibling via bridge1 + assert "c.py" not in files # would require expanding FROM sibling b via bridge2 + assert "d.py" not in files + + +# --------------------------------------------------------------------- +# End-to-end: fixes compose without breaking the original mutation demo +# --------------------------------------------------------------------- + +class TestEndToEndDemoStillWorks: + def test_demo_package_recall_still_perfect_with_both_fixes(self): + """The demo package's own headline mutation-testing claim (recall + 1.0 across all 5 mutations) must not regress now that both fixes + are active by default.""" + import subprocess + import sys as _sys + + workspace = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + root = os.path.join(workspace, "demo_package") + # This WorldModel refuses a cache_dir outside its root (hardening against a + # hostile MCP argument), so the throwaway cache lives inside the demo package. + cache_dir = tempfile.mkdtemp(dir=root) + try: + wm = WorldModel(root, cache_dir=cache_dir) + wm.build(incremental=False) + oracle = ImpactOracle(wm) # defaults: both fixes active + # Spot check one of evaluate.py's mutation targets directly + report = oracle.predict_impact("models.PremiumProduct.discounted_price", threshold=0.1) + assert "models.py" in report.impacted_files + finally: + shutil.rmtree(cache_dir, ignore_errors=True) From f80323affdab567e87ebe8d6ca1958fb22676392 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:03:36 +0200 Subject: [PATCH 35/72] docs(changelog): record the impact-graph repairs and their measured numbers Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83d748f..89be735 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,85 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- **`forge impact` actually resolves imports.** JS/TS import specifiers were stored as raw + strings and matched against symbol names, so `"./util.js"` could only ever resolve by its + last dotted segment: on this repo, 3 of 502 relative import statements resolved and all + three were spurious (`"../scripts/build-pages.mjs"` → `mjs` → `const mjs` in `doctor.js`). + `export * from`, `export { x as y } from`, multi-line clauses, dynamic `import()` and + `require()` were not parsed at all, and the Python pattern crossed newlines (three stacked + `import` lines fused into a single edge to a module named `"osimport + sysfrom pkg"`), dropped parenthesised lists, mapped `import pkg.core as c` to + `pkg`, and never resolved a relative import. Specifiers now resolve through one shared + resolver in `src/scope.js` — exact file, TypeScript NodeNext `./x.js`→`x.ts`, + extensionless, `/index.*`, and Python modules indexed by PACKAGE ROOT + (`src/mypkg/core.py` is `mypkg.core`, so a src layout answers exactly like a flat one) — and an import that resolves to no file stays unresolved instead + of being pinned to whatever shares its name. **Measured on this repo: 1,196 → 1,392 import + statements seen, 679 of 679 relative ones resolved to the exact file the specifier names, 0 + wrong (was 3, all wrong).** On a ten-importer fixture the graph now finds 10 of 10 with no + false positives (grep finds 10 with 2), and on a seven-importer Python fixture 7 of 7 (was + 4, plus a file whose only mention is a comment). +- **The impact graph no longer reads comments and strings as code, and a call belongs to its + function.** A comment saying `class Parser` defined a second `Parser`, which made the name + ambiguous and silently erased the real edge from `main.js`; a string containing an import + was an import. Every structural regex now runs on a masked copy of the source (comments and + string/regex contents blanked, offsets and line numbers preserved), and a call is attributed + to the innermost enclosing function/class instead of the nearest preceding `const` — so + `const value = leaf()` inside `mid()` no longer hides `mid`'s own callers from + `impact(leaf)`. Bare names are never resolved across languages any more (a Python + `from impact_oracle.oracle import …` used to land on the JS `const oracle` in `eval.js`), + local definitions are not cross-file candidates, and names imported from a package are never + re-guessed locally: ambiguous references dropped on this repo fell from 3,136 to 877, and + they are now COUNTED and reported instead of vanishing (`impact()` returns `ambiguousRefs`, + `unresolvedImports`, `capped` and `skippedFiles`; `forge impact` prints them). +- **Building the graph is linear again, and the file cap counts source files.** Line numbers + came from a `slice(0, i).split()` over the whole file per match, and every call scanned + every node, so a 16k-line file took seconds; extraction now uses a line index and scope + intervals: a 16k-line JavaScript file plus a 16k-line Python file build in **0.3 s, down + from 4.9 s** on the same machine (`test/atlas_resolve.test.js` keeps it under 2.5 s). The + 20,000-file cap counted JSON and Markdown against code and was never reported; it now bounds source files + only, docs/configs have their own bound, and a capped graph says so in `forge atlas build`, + in `impact()` and in `forge impact`. +- **Blast radius is no longer reverse-only — the refutation's sibling and forward relations + are ported.** `research/empirical-refutation/` diagnosed that 94.7% of real misses were + *siblings* (A and B both depend on module C, so C's contract shift co-changes both) and + 2.1% were forward-only, but only the Python prototype was repaired; the shipped JS graph + still walked reverse edges exclusively, so `impact(serializer.js)` reported `app.js` and not + the `deserializer.js` that shares `wire_format.js` with it. `impact()` now runs the two + ported relations at the replication package's FROZEN parameters (sibling: 1 forward + 1 + reverse hop, weight 0.7, bridge in-degree cap 100; forward: ≤2 hops, weight 0.5), both + terminal — a node they reach is reported, never expanded. Every result carries its + `relation`, `relations: ["reverse"]` reproduces the old answer exactly, and + `analyzeDiffImpact` (MergeField) receives the same two relations as terminal edges. +- **The impact-quality numbers are re-measured, and they are not the README's.** The + README's precision 0.90 / F1 0.92 did not reproduce at HEAD (`evalImpact` over the + committed `bench/impact_cases.mjs` gave precision 0.341, recall 0.972, F1 0.500 there). + With the repaired graph it gives **precision 0.094, recall 1.000, F1 0.170** with all three + relations and **0.146 / 1.000 / 0.248** reverse-only. The labels name only DIRECT + referencers, so every transitive dependent, every doc that mentions the symbol and every + sibling now counts against precision — restricted to code files the reverse-only precision + is 0.346, and restricted to one hop it is 0.830 at recall 1.000. Four of the six label sets + are also stale (`contentHash` has nine importers in `src/` today, six are labelled), so + these numbers under-report precision; the fixture needs relabelling before any claim rests + on it. +- **The in-repo Python prototype is the repaired v2, not the refuted v1.** + `research/python-prototypes/impact_oracle/oracle.py` was byte-identical to the as-shipped + version whose claims the refutation demolished. It now carries both repairs — the src-layout + phantom-node merge (pooled recall 0.0220 → 0.2424) and the sibling/forward traversal + (held-out precision 0.320, recall 0.647, **F1 0.428 vs grep's 0.371**, reversing 0.042 vs + 0.437) — with the frozen parameters as module defaults, 13 new regression tests, and + `ImpactOracle(wm, sibling_enabled=False, forward_enabled=False)` for the old behaviour. +- **`forge atlas query` shows the definition you asked for.** Results were unranked, and a + qualified name carries the file path, so `query build` returned 30 symbols from + `scripts/build-pages.mjs` before `function build` itself. Matches are now ranked: exact + name, case-insensitive exact, name prefix, name substring, then path-only matches. +- **Fan-out and churn stop lying.** `grepFanout` was a substring `git grep`, so "get" counted + every file containing "target"; it now matches whole words (`-w -F`). `gitChurn` counted the + last 50 commits of ALL history, so a file untouched since 2015 still scored 1.0; it now + counts commits inside a 90-day window. +- **Non-finite weights are zero, not certainty.** The MergeField `clamp01` helpers disagreed: + `merge_impact.js` mapped any non-number to 0 while `merge_impact_adapter.js` used + `Number(value) || 0`, which turned `Infinity` into a maximal 1.0 criticality. One shared + helper now maps every non-finite value to 0 and still accepts numeric strings. - **CI is green again on Linux.** `global/guards/run.mjs` was committed without its executable bit, so `forge doctor`'s plugin-hook check (which `access(X_OK)`s every script a hook names) reported `warn` on Linux and failed `test/doctor.test.js` on Node 20 and 22 for From b3cab9b1b84b3a6db0fcfd4e23498dfcb88c3a2a Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:06:25 +0200 Subject: [PATCH 36/72] style(test): drop a useless escape in a scope assertion message Co-Authored-By: Claude Opus 5 --- test/scope.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/scope.test.js b/test/scope.test.js index b47b732..240195a 100644 --- a/test/scope.test.js +++ b/test/scope.test.js @@ -106,7 +106,7 @@ test("jsImports reads every form; pyImports keeps statements apart", () => { assert.deepEqual( py.map((i) => i.module), ["os", "sys", "pkg.core", "pkg.core"], - "`\s` in the old regex fused three statements into one module name", + "`s` in the old regex fused three statements into one module name", ); }); From fee13d1050bc887a9304ae4da10be82c298255b3 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:08:38 +0200 Subject: [PATCH 37/72] fix(reuse): exact/near compare task IDENTITY; Unicode specs; 32x4 LSH banding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Artifacts carry an identity key (Unicode-aware tokens, case/punctuation normalized, identifiers kept). exact and near compare it; the shape form (identifiers → ⟨ident⟩) keys only the adapt tier. "add pagination to listOrders" no longer gets the listUsers artifact at exact/similarity 1, and two unrelated Arabic specs are no longer the same spec. - LSH banding 16x8 → 32x4: recall at the adapt bar J=0.6 goes from 0.24 to 0.99 (67/67 vs 39/67 in the review's harness). The old comment's figures were wrong and are corrected. - The optional embedding sim now embeds the identity text. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 15 ++++ docs/plans/substrate-v2/03-reuse-cache.md | 45 ++++++----- src/reuse.js | 93 +++++++++++++++++------ test/reuse.test.js | 49 +++++++++++- 4 files changed, 160 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c2c2f8..eefeb12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,21 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). session end (the déjà-vu Stop write), on `ledger merge` and on `ledger sync` import. Nothing is deleted — the bytes move to `attic/`, every log stays, a re-import never un-prunes, and new evidence brings a claim back with its whole history. +- **The reuse cache's "exact" tier means the same task again.** The exact and near tiers + compared the SHAPE-normalized spec, in which every identifier is `⟨ident⟩` — so + "add pagination to listOrders" was served the **listUsers** artifact at tier exact, + similarity 1, and (because the tokenizer's `\w` is ASCII-only, which erased every Arabic + word) two unrelated Arabic specs were exact matches of each other. Artifacts now carry an + identity key — Unicode-aware tokens, case and punctuation normalized, identifiers kept — + which the exact and near tiers compare; the shape form still keys the adapt tier, so the + listUsers artifact can still be offered as a starting point for listOrders, never as the + answer. The three collision cases from the review are now misses. +- **The LSH prefilter stopped dropping three of every four adapt candidates.** The comment + claimed "≈0.96 at J=0.8 and ≈0.17 at J=0.5" for 16 bands × 8 rows; the real figures are + 0.95 and 0.06, and at the adapt threshold J=0.6 recall was 0.24 — so once a ledger passed + 32 artifacts, most adapt-tier hits silently became misses. Banding is now 32 × 4 (0.99 at + J=0.6, ≈1.00 at J=0.8): in the review's own harness, 67 of 67 adapt-band pairs are found + with the prefilter active, against 39 of 67 before. - **CI is green again on Linux.** `global/guards/run.mjs` was committed without its executable bit, so `forge doctor`'s plugin-hook check (which `access(X_OK)`s every script a hook names) reported `warn` on Linux and failed `test/doctor.test.js` on Node 20 and 22 for diff --git a/docs/plans/substrate-v2/03-reuse-cache.md b/docs/plans/substrate-v2/03-reuse-cache.md index f7c9a93..8a58a3b 100644 --- a/docs/plans/substrate-v2/03-reuse-cache.md +++ b/docs/plans/substrate-v2/03-reuse-cache.md @@ -25,7 +25,8 @@ served — the cache prunes itself by ground truth. ``` artifact.body := { - spec: normalized task specification text, + key: IDENTITY-normalized task text (case/whitespace/punctuation only), + spec: SHAPE-normalized task specification text, sketch: MinHash sketch of spec (for near-match), slice: sha256 of the atlas graph slice the artifact touches, // context key interface: [ exported symbols + signatures the artifact declares ], @@ -37,29 +38,39 @@ artifact.body := { ## 2. Fingerprinting -**Normalization** strips volatility so the same task fingerprints identically across -sessions and teammates: lowercase, collapse whitespace, drop punctuation-only tokens, -replace literal identifiers/paths/numbers with typed placeholders (`⟨ident⟩`, `⟨path⟩`, -`⟨num⟩`), sort simple constraint clauses. (Deterministic, pure — a P3 unit-test surface; -same spirit as `src/preflight.js`'s lexical feature extraction.) +**Normalization comes in two forms**, because "the same task" and "the same +neighbourhood" are different questions: -**Two keys per artifact:** +- **identity** (`key`): Unicode-aware tokens, lowercased, edge punctuation dropped, + whitespace collapsed — and NOTHING else. Identifiers, paths and numbers stay verbatim. +- **shape** (`spec`): identity plus typed placeholders for identifiers, paths, numbers and + string literals (`⟨ident⟩`, `⟨path⟩`, `⟨num⟩`, `⟨str⟩`). -- `exact = sha256(normalized spec ‖ slice)` — O(1) lookup for the literal repeat. -- `sketch = MinHash_k(shingles₄(normalized spec))`, k = 128 — near-match. `E[|sketch - match|/k] = Jaccard(A,B)`, so sketch agreement is an unbiased Jaccard estimator with - standard error `≈ √(J(1−J)/k)` ≤ 0.045 — accurate enough to threshold at τ = 0.8. - LSH banding (16 bands × 8 rows) finds candidates without scanning: collision - probability `1−(1−J⁸)¹⁶` — ≈ 0.96 at J = 0.8, ≈ 0.17 at J = 0.5 — a sharp cliff - exactly where we want it. +The shape form alone once keyed the exact tier, which made "add pagination to listOrders" +an EXACT hit on the listUsers artifact (similarity 1), and — because the tokenizer was +ASCII-only — made any two non-ASCII specs identical. Identity keys the tiers that serve +code as-is; shape only keys the adapt tier, which asks for the delta. + +**Keys per artifact:** + +- `exact = sha256(identity key ‖ slice)` — O(1) lookup for the literal repeat. +- `keySketch = MinHash_k(shingles₄(identity key))`, k = 128 — the NEAR bar (τ = 0.8). + `E[|sketch match|/k] = Jaccard(A,B)`, an unbiased Jaccard estimator with standard error + `≈ √(J(1−J)/k)` ≤ 0.045. +- `sketch = MinHash_k(shingles₄(shape))` — the ADAPT bar (τ = 0.6) and the LSH prefilter. + Banding is 32 bands × 4 rows: collision probability `1−(1−J⁴)³²` ≈ 1.00 at J = 0.8, + 0.99 at J = 0.6 (the adapt bar), 0.56 at J = 0.4, 0.23 at J = 0.3. (The previous 16×8 + banding was documented as ≈0.96 at J = 0.8 / ≈0.17 at J = 0.5; the true figures were + 0.95 and 0.06, and recall at the adapt bar was 0.24 — the prefilter dropped three of + every four adapt candidates.) ## 3. The lookup ladder ``` reuse(x): - 1. exact hit (same spec, same slice) → serve, cost ≈ 0 - 2. near hit (Jaccard ≥ 0.8, compatible slice) → REVALIDATE, then serve-with-diff - 3. adapt hit (Jaccard ≥ 0.6) → inject artifact as context ("start + 1. exact hit (same identity key, same slice) → serve, cost ≈ 0 + 2. near hit (identity Jaccard ≥ 0.8) → REVALIDATE, then serve-with-diff + 3. adapt hit (identity or shape Jaccard ≥ 0.6) → inject artifact as context ("start from this verified code"), generate the delta only — cheaper prompt, strong anchor against re-invention diff --git a/src/reuse.js b/src/reuse.js index 03ae97d..03982f7 100644 --- a/src/reuse.js +++ b/src/reuse.js @@ -31,10 +31,17 @@ export const NEAR_COS = 0.85; export const ADAPT_COS = 0.7; // --------------------------------------------------------------------------- -// Normalization — the same task worded across sessions/teammates must fingerprint -// identically. Volatile literals become typed placeholders; identifiers keep only -// their SHAPE (an ident is an ident) so `add pagination to listUsers` and -// `add pagination to listOrders` land in the same near-neighborhood. +// Normalization — TWO forms, because "the same neighbourhood" and "the same task" are +// different questions (review C9): +// • specKey (IDENTITY) — case, whitespace and edge punctuation normalized, everything +// else kept verbatim. This is what the exact and near tiers compare: `listUsers` and +// `listOrders` are different tasks, and serving one's artifact for the other at tier +// "exact, similarity 1" was this cache's worst failure mode. +// • normalizeSpec (SHAPE) — volatile literals and identifiers become typed placeholders, +// so those two specs still land in one near-NEIGHBOURHOOD and the adapt tier can offer +// the artifact as a verified starting point ("generate only the delta"). +// Both tokenizers are Unicode-aware: the ASCII `\w` trim erased every Arabic (or Chinese, +// or Greek) word, so any two non-ASCII specs normalized to "" and collided as exact. // --------------------------------------------------------------------------- const NUM_RE = /^-?\d[\d.,_]*$/; @@ -45,12 +52,26 @@ const STR_RE = /^["'`].*["'`]$/; // prose emphasis and lowercased: shouting is not an identifier. const IDENT_RE = /^(?:[a-z][a-z0-9]*[A-Z]|[A-Z][a-z0-9]+[A-Z]|\w+_\w+|\w+\.\w+)\w*$/; -/** Deterministic, pure spec normalization (unit-tested surface). */ +// Trim leading/trailing punctuation while keeping what MAKES a token: letters, digits and +// marks of any script, plus the code punctuation the classifiers below key on. +const TRIM_RE = /^[^\p{L}\p{N}\p{M}_"'`./\\-]+|[^\p{L}\p{N}\p{M}_"'`./\\-]+$/gu; + +/** Identity normalization: case, whitespace and edge punctuation only. The exact/near key — + * two specs match here only when they name the SAME things. */ +export function specKey(text) { + return String(text) + .split(/\s+/) + .map((raw) => raw.replace(TRIM_RE, "").toLowerCase()) + .filter(Boolean) + .join(" "); +} + +/** Deterministic, pure spec SHAPE normalization (unit-tested surface). */ export function normalizeSpec(text) { return String(text) .split(/\s+/) .map((raw) => { - const tok = raw.replace(/^[^\w"'`./\\-]+|[^\w"'`./\\-]+$/g, ""); + const tok = raw.replace(TRIM_RE, ""); if (!tok) return ""; if (STR_RE.test(tok)) return "⟨str⟩"; if (NUM_RE.test(tok)) return "⟨num⟩"; @@ -62,19 +83,31 @@ export function normalizeSpec(text) { .join(" "); } -/** The two cache keys: exact (spec + graph-slice context) and the MinHash sketch. */ +/** The cache keys: `exact` (identity key + graph-slice context), `keySketch` (what the near + * tier measures) and `sketch` (the shape form the adapt tier and the LSH prefilter use). */ export function fingerprint(spec, slice = "") { const norm = normalizeSpec(spec); - return { norm, exact: contentHash(`${norm}\0${slice}`), sketch: sketch(norm) }; + const key = specKey(spec); + return { + norm, + key, + exact: contentHash(`${key}\u0000${slice}`), + sketch: sketch(norm), + keySketch: sketch(key), + }; } // --------------------------------------------------------------------------- -// LSH banding — 16 bands × 8 rows over the 128-lane sketch. Collision probability -// 1−(1−J⁸)¹⁶ ≈ 0.96 at J=0.8 and ≈ 0.17 at J=0.5: a sharp cliff exactly at the -// near-hit threshold, so big ledgers don't need an all-pairs scan. +// LSH banding — 32 bands × 4 rows over the 128-lane sketch. Collision probability +// 1−(1−J⁴)³²: ≈1.00 at J=0.8, 0.99 at J=0.6 (the ADAPT bar), 0.56 at J=0.4, 0.23 at J=0.3. +// A prefilter that keeps essentially every real candidate while still pruning the unrelated +// mass (unrelated specs sit at J≈0). The old 16×8 banding was documented as "≈0.96 at J=0.8 +// and ≈0.17 at J=0.5"; the true figures were 0.95 and 0.06, and at the adapt threshold +// J=0.6 recall was 0.24 — three of every four adapt-tier hits were silently dropped once a +// ledger passed 32 artifacts (review C9). // --------------------------------------------------------------------------- -const BANDS = 16; +const BANDS = 32; const ROWS = SKETCH_K / BANDS; export function bandKeys(sk) { @@ -106,6 +139,9 @@ export function artifactClaim( deps: [...deps].sort(), form, iface: [...iface].sort(), + // `key` is the identity form (exact + near); `spec` stays the SHAPE form (adapt and + // the LSH prefilter). A pre-C9 artifact has no key and can only reach adapt. + key: specKey(spec), lang, slice, spec: normalizeSpec(spec), @@ -171,7 +207,7 @@ export function revalidate(artifact, atlas) { * (the backend label the CLI prints); lookup itself never sets it. */ export function lookup(claims, spec, { slice = "", atlas = null, nowDay = 0, sim = null } = {}) { - const { norm, sketch: qs } = fingerprint(spec, slice); + const { key, sketch: qs, keySketch: qk } = fingerprint(spec, slice); const reasons = []; const artifacts = claims.filter( (c) => c.kind === "artifact" && !c.tombstone && !isDormant(c, nowDay), @@ -184,9 +220,11 @@ export function lookup(claims, spec, { slice = "", atlas = null, nowDay = 0, sim return false; }; - // 1. exact: same normalized spec, same graph-slice context. + // 1. exact: the same task (identity key — NOT the shape form, which erases the very + // identifiers that distinguish two tasks), same graph-slice context. An empty key is + // not an identity, so a spec that normalizes to nothing never matches anything. for (const c of artifacts) { - if (c.body.spec === norm && (c.body.slice ?? "") === slice && proved(c, "exact")) { + if (key && c.body.key === key && (c.body.slice ?? "") === slice && proved(c, "exact")) { const rv = revalidate(c, atlas); if (rv.ok) return { tier: "exact", artifact: c, jaccard: 1, similarity: 1, revalidation: rv, reasons }; @@ -200,21 +238,28 @@ export function lookup(claims, spec, { slice = "", atlas = null, nowDay = 0, sim // sim the LSH prefilter is skipped — banding indexes MinHash sketches, not vectors, // and would drop exactly the paraphrase candidates only the embedding can see // (cosine over precomputed vectors is cheap, so all-pairs is fine). + // (`_specSketch`/`_keySketch`, not `_sketch`: ledger.js memoizes the CLAIM-text sketch + // under that name, and the two would overwrite each other.) + const shapeOf = (c) => (c._specSketch ??= sketch(c.body.spec ?? "")); + const keyOf = (c) => (c._keySketch ??= sketch(c.body.key ?? "")); let pool = artifacts; if (!sim && artifacts.length > 32) { const qBands = new Set(bandKeys(qs)); - pool = artifacts.filter((c) => - bandKeys(c._sketch ?? (c._sketch = sketch(c.body.spec))).some((k) => qBands.has(k)), - ); + pool = artifacts.filter((c) => bandKeys(shapeOf(c)).some((k) => qBands.has(k))); } + // near compares IDENTITY (same names, reworded prose); adapt compares SHAPE too, so + // `add pagination to listOrders` can still be offered the listUsers artifact as a + // starting point — the tier that says "generate only the delta" — but never as-is. const measure = (c) => { if (sim) { - const s = sim(norm, c); + const s = sim(key, c); if (typeof s === "number" && Number.isFinite(s)) return { c, v: s, backend: "embed", near: s >= NEAR_COS, adapt: s >= ADAPT_COS }; } - const j = jaccard(qs, c._sketch ?? (c._sketch = sketch(c.body.spec))); - return { c, v: j, backend: "minhash", near: j >= NEAR_J, adapt: j >= ADAPT_J }; + const jKey = c.body.key ? jaccard(qk, keyOf(c)) : 0; + const jShape = jaccard(qs, shapeOf(c)); + const v = Math.max(jKey, jShape); + return { c, v, backend: "minhash", near: jKey >= NEAR_J, adapt: v >= ADAPT_J }; }; const ranked = pool .map(measure) @@ -269,9 +314,11 @@ const savedEstimate = (tier, artifact) => { const specSim = (root, spec, claims) => claimSim( root, - normalizeSpec(spec), + specKey(spec), claims.filter((c) => c.kind === "artifact" && !c.tombstone), - (c) => c.body?.spec ?? "", + // The IDENTITY text, so the vector sees the identifiers the tier decision cares about + // (a pre-C9 artifact has only the shape form). + (c) => c.body?.key ?? c.body?.spec ?? "", ); export function reuseQuery(root, spec, { slice = "", atlas = null, nowDay = 0 } = {}) { diff --git a/test/reuse.test.js b/test/reuse.test.js index 08a6721..3325ce1 100644 --- a/test/reuse.test.js +++ b/test/reuse.test.js @@ -61,13 +61,13 @@ test("fingerprint: exact key is context-sensitive (slice), sketch is stable", () assert.deepEqual(f1.sketch, f2.sketch); }); -test("bandKeys: 16 deterministic bands; near-duplicates share at least one", () => { +test("bandKeys: 32 deterministic bands; near-duplicates share at least one", () => { const long = "implement a token bucket rate limiter for the public api gateway with configurable " + "burst size and a redis backing store for distributed counters across instances"; const k1 = bandKeys(fingerprint(long).sketch); const k2 = bandKeys(fingerprint(`${long} please`).sketch); - assert.equal(k1.length, 16); + assert.equal(k1.length, 32); assert.deepEqual(k1, bandKeys(fingerprint(long).sketch), "deterministic"); assert.ok( k1.some((k) => k2.includes(k)), @@ -248,3 +248,48 @@ test("metrics: record/read/summarize roundtrip; corrupt lines skipped", () => { assert.equal(s.cache.byOutcome.hit_exact, 1); assert.equal(s.cache.savedEstimate, 100); }); + +// --- C9: the exact tier must mean "the same task" -------------------------------------- + +test("lookup (C9): a different identifier is never served as exact or near", () => { + const cache = [verified("add pagination to listUsers")]; + const same = lookup(cache, "Add pagination to listUsers", { nowDay: 0 }); + assert.equal(same.tier, "exact", "the same task, reworded in case, is still exact"); + const other = lookup(cache, "add pagination to listOrders", { nowDay: 0 }); + assert.ok( + other.tier === "adapt" || other.tier === "miss", + `listOrders got tier ${other.tier} (similarity ${other.similarity}) from the listUsers artifact`, + ); + const renamed = lookup(cache, "rename snake_case_var to parseConfig", { nowDay: 0 }); + assert.equal(renamed.tier, "miss"); +}); + +test("lookup (C9): two unrelated non-ASCII specs are not 'exact'", () => { + const cache = [verified("أضف ترقيم الصفحات إلى قائمة المستخدمين")]; + assert.equal(lookup(cache, "احذف حساب المستخدم", { nowDay: 0 }).tier, "miss"); + assert.equal( + lookup(cache, "أضف ترقيم الصفحات إلى قائمة المستخدمين", { nowDay: 0 }).tier, + "exact", + "the identical Arabic spec still hits", + ); + assert.notEqual(normalizeSpec("احذف حساب المستخدم"), "", "non-ASCII words survive normalization"); +}); + +test("lookup (C9): the LSH prefilter keeps adapt-tier candidates in a big ledger", () => { + const BASE = + "implement a token bucket rate limiter for the public api gateway with configurable " + + "burst size and sliding window fallback plus prometheus metrics and a redis backing store"; + const query = BASE.replace( + "and sliding window fallback plus prometheus metrics", + "plus prometheus metrics", + ); + const target = verified(BASE); + const fillers = Array.from({ length: 60 }, (_, i) => + verified(`refactor the ${i} unrelated widget renderer module for the storefront theme ${i}`), + ); + const small = lookup([target, ...fillers.slice(0, 10)], query, { nowDay: 0 }); + assert.equal(small.tier, "adapt", "all-pairs (small pool) finds it"); + const big = lookup([target, ...fillers], query, { nowDay: 0 }); + assert.equal(big.tier, "adapt", "and the banded prefilter must not drop it"); + assert.equal(big.artifact.id, target.id); +}); From fab8e694a506675db32a4b4f3df1e4b059770e33 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:08:55 +0200 Subject: [PATCH 38/72] fix: balanced extractJson, parsed gateway versions, honest intent/router/jev signals Five small proposer-path defects from the deep review (D9): - extractJson matched first-brace-to-last, so a reply with two objects or a stray brace in the prose ("the {config} object") parsed as nothing and the proposal was dropped. It now returns the first BALANCED object, string-aware. - gateway_model_map scored tiers on loose digit tokens, so the "5" in "claude-3-5-sonnet" matched Sonnet 5 and beat claude-sonnet-4-5 on the lexicographic tie. Versions are parsed into one token and ties prefer the newest model of the family. - classifyIntent reported the top neighbour's similarity even when a different intent won the vote; it now reports the winner's own best similarity. - knowledge_router tokenized facts with intent's stop-set, which drops the first-person pronouns that distinguish a personal preference from a project convention; it keeps them now. - jev.js read a null noul as 0 (Number(null)) instead of "no answer", and discarded a correct choice whose case differed from the option we offered. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 27 ++++++++++++++ src/adjudicate.js | 40 ++++++++++++++++---- src/gateway_model_map.js | 67 +++++++++++++++++++++++++++++----- src/intent.js | 19 +++++++--- src/jev.js | 34 +++++++++++++---- src/knowledge_router.js | 17 +++++++-- test/adjudicate.test.js | 24 ++++++++++++ test/gateway_model_map.test.js | 19 +++++++++- test/intent.test.js | 11 ++++++ test/jev.test.js | 55 ++++++++++++++++++++++++++++ test/knowledge_router.test.js | 13 +++++++ 11 files changed, 288 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d94e0a..f6e845d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,33 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). comparison is false for NaN, so `recommend(NaN)` — and `±Infinity`/`undefined` — fell through to fable. A non-finite score now routes to the default tier (sonnet) with an `unknown-score` reason, logged under `FORGE_DEBUG=1`. +- **`extractJson` reads the first balanced JSON object, not everything between the first brace + and the last.** The greedy `/\{[\s\S]*\}/` meant any reply carrying two objects, or a stray + brace in the prose around one ("Considering the {config} object: {…}"), parsed as nothing and + the proposal was silently dropped — for every faculty that adjudicates (routing band, + assumption gate, impact, distill). Brace counting is now string-aware, and a candidate that + does not parse is skipped rather than grown. +- **The gateway model map parses versions instead of matching loose digits.** A tier's reference + tokens were `{haiku, 4, 5}`, so "claude-3-5-sonnet-20241022" scored exactly as well as + "claude-sonnet-4-5-20250929" for the Sonnet tier — the "5" of "3-5" matched the "5" of Sonnet + 5 — and won the lexicographic tie, pointing a self-hosted gateway at a two-generation-old + model. Consecutive version numbers collapse into one token ("3.5"), a date stamp is not a + version, and equal scores break toward the newest model of the family. +- **`classifyIntent` reports the winning intent's confidence, not a losing neighbor's.** When two + runner-up rows outvoted one closer row, the reported confidence was the closer row's + similarity — evidence for the intent that lost ("what does the release script do" → `release` + at 0.571, the `question` neighbor's score; now 0.333). +- **`knowledge_router` keeps the first-person signal it routes on.** It tokenized facts with + intent.js's stop-set, which drops `i/my/we/our/your/their` as function words — the one thing + separating a personal preference (recall) from a project convention. "i prefer short commit + messages" and "the team prefers short commit messages in this repo" both scored 1.00 against + the same recall row; now 1.00 and 0.78. +- **A null `noul` from Jev is "no answer", not a confident zero, and a choice matches the offered + option case-insensitively.** `Number(null)` is 0, so a dimension the API returned as null read + as "definitely unspecified" and dragged the assumption gate's completeness down; it now fails + safe like any other garble. A `"Mid"` answer to a `{cheap, mid, premium}` choice was thrown + away entirely; it now resolves back to the `mid` we offered (an option we never offered still + fails safe). - **The preflight scanners no longer read addresses, code fences and prose as code.** On the 80-task held-out set (diagnostic only — those tasks are spent for tuning), the entities a task was said to reference fell from 210 files and 1,414 symbols to 42 and 388 across the 64 diff --git a/src/adjudicate.js b/src/adjudicate.js index c308bcc..fb543de 100644 --- a/src/adjudicate.js +++ b/src/adjudicate.js @@ -65,16 +65,40 @@ export function buildRunner({ model = "haiku", timeoutMs = 20000 } = {}) { }); } -/** Extract the first balanced-ish JSON object from model output, or null. */ +/** + * Extract the FIRST balanced JSON object from model output, or null. The old greedy + * `/\{[\s\S]*\}/` spanned from the first brace to the LAST one, so a reply with two objects, or + * with a stray brace in the prose around it ("the {config} object"), parsed as nothing at all + * and the proposal was silently dropped. Brace counting is string-aware — a brace inside a JSON + * string never opens or closes an object — and a candidate that does not parse is skipped, not + * grown. + */ export function extractJson(text) { - if (!text) return null; - const match = String(text).match(/\{[\s\S]*\}/); - if (!match) return null; - try { - return JSON.parse(match[0]); - } catch { - return null; + const s = String(text ?? ""); + for (let i = s.indexOf("{"); i !== -1; i = s.indexOf("{", i + 1)) { + let depth = 0; + let inString = false; + let escaped = false; + for (let j = i; j < s.length; j++) { + const c = s[j]; + if (inString) { + if (escaped) escaped = false; + else if (c === "\\") escaped = true; + else if (c === '"') inString = false; + continue; + } + if (c === '"') inString = true; + else if (c === "{") depth++; + else if (c === "}" && --depth === 0) { + try { + return JSON.parse(s.slice(i, j + 1)); + } catch { + break; // not JSON after all — try the next opening brace + } + } + } } + return null; } /** diff --git a/src/gateway_model_map.js b/src/gateway_model_map.js index 59b432d..2369263 100644 --- a/src/gateway_model_map.js +++ b/src/gateway_model_map.js @@ -97,15 +97,59 @@ export function fetchModelIds(base, { timeoutMs = 5000, fetchImpl } = {}) { return result; } -const tokenize = (s) => - new Set( - String(s) - .toLowerCase() - .split(/[^a-z0-9]+/) - .filter(Boolean), - ); +// A version part is a short number; a date stamp (20250929) is not. A run of them is ONE token +// ("claude-3-5-sonnet" → "3.5"), because as separate "3" and "5" tokens the 5 of Sonnet 3.5 +// matched the 5 of Sonnet 5 and the gateway map picked a two-generation-old model. +const isVersionPart = (t) => /^\d{1,3}$/.test(t); + +/** Tokens of a model id or name, with consecutive version numbers collapsed into one token. */ +function tokenize(s) { + const parts = String(s) + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean); + const out = new Set(); + for (let i = 0; i < parts.length; ) { + if (!isVersionPart(parts[i])) { + out.add(parts[i++]); + continue; + } + const run = []; + while (i < parts.length && isVersionPart(parts[i])) run.push(parts[i++]); + out.add(run.join(".").replace(/(?:\.0)+$/, "")); + } + return out; +} + +/** The first version in an id ("claude-sonnet-4-5-20250929" → [4,5]), or null. */ +export function versionOf(modelId) { + const parts = String(modelId) + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean); + for (let i = 0; i < parts.length; i++) { + if (!isVersionPart(parts[i])) continue; + const run = []; + while (i < parts.length && isVersionPart(parts[i])) run.push(Number(parts[i++])); + while (run.length > 1 && run[run.length - 1] === 0) run.pop(); + return run; + } + return null; +} + +/** Newer first; an id with no version ranks last. */ +function compareVersions(a, b) { + if (!a && !b) return 0; + if (!a) return 1; + if (!b) return -1; + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const d = (b[i] ?? 0) - (a[i] ?? 0); + if (d) return d; + } + return 0; +} -/** Reference token set for a tier: the family key plus its marketing-name tokens (e.g. haiku → {haiku,4,5}). */ +/** Reference token set for a tier: the family key plus its marketing-name tokens (e.g. haiku → {haiku,"4.5"}). */ export function familyTokens(tier) { return tokenize(`${tier} ${MODELS[tier]?.name ?? ""}`); } @@ -126,9 +170,12 @@ export function familyScore(modelId, tier) { return setOverlap(familyTokens(tier), toks); } -// Deterministic tie-break among equal-scoring candidates: prefer the id closest to the canonical -// name (fewest tokens — less vendor/deployment noise), then lexicographic for stability. +// Deterministic tie-break among equal-scoring candidates: the newest version of the family first +// (Sonnet 4.5 over Sonnet 3.5 when neither is the pinned Sonnet 5), then the id closest to the +// canonical name (fewest tokens — less vendor/deployment noise), then lexicographic for stability. function tieBreak(a, b) { + const byVersion = compareVersions(versionOf(a), versionOf(b)); + if (byVersion) return byVersion; const na = tokenize(a).size; const nb = tokenize(b).size; if (na !== nb) return na - nb; diff --git a/src/intent.js b/src/intent.js index 470077a..f67afad 100644 --- a/src/intent.js +++ b/src/intent.js @@ -73,8 +73,10 @@ export const INTENT_EXEMPLARS = [ ]; // Function words ONLY (English + Hinglish auxiliaries/pronouns). Task verbs stay — -// they are the intent signal (see module header). -const STOP = new Set( +// they are the intent signal (see module header). Exported because knowledge_router routes on +// a signal this set removes (first person: "i prefer …" is a personal fact), so it subtracts +// the pronouns before tokenizing. +export const INTENT_STOP = new Set( ( "a an the in on of to for with and or is are be it its this that as at by from into up out " + "my your our their please can you i we " + @@ -84,12 +86,13 @@ const STOP = new Set( const stem = (t) => (t.length > 3 && t.endsWith("s") ? t.slice(0, -1) : t); -/** Same shape as route.js contentGrams — different stop-set (function words only). */ -export function intentGrams(text) { +/** Same shape as route.js contentGrams — different stop-set (function words only, overridable + * for callers whose signal lives in a word this set drops). */ +export function intentGrams(text, stop = INTENT_STOP) { const toks = String(text) .toLowerCase() .split(/[^a-z0-9]+/) - .filter((t) => t && !STOP.has(t)) + .filter((t) => t && !stop.has(t)) .map(stem); const grams = new Set(toks); for (let i = 0; i + 1 < toks.length; i++) grams.add(`${toks[i]} ${toks[i + 1]}`); @@ -119,7 +122,11 @@ export function classifyIntent(text, { k = 3, minConf = 0.25 } = {}) { const votes = new Map(); for (const s of sims) votes.set(s.intent, (votes.get(s.intent) ?? 0) + s.sim); const [intent] = [...votes.entries()].sort((a, b) => b[1] - a[1])[0]; - return { intent, confidence: Number(top.sim.toFixed(3)), neighbors: sims }; + // The WINNER's own best similarity, not the top neighbor's: when two runner-up rows outvote a + // single closer one, reporting that closer row's similarity credited the verdict to evidence + // for a different intent ("what does the release script do" → release at the question row's 0.57). + const confidence = Math.max(...sims.filter((s) => s.intent === intent).map((s) => s.sim)); + return { intent, confidence: Number(confidence.toFixed(3)), neighbors: sims }; } /** Protocol cards — data. `question`/`none` deliberately have no card (no ceremony). */ diff --git a/src/jev.js b/src/jev.js index 61f0c42..298886d 100644 --- a/src/jev.js +++ b/src/jev.js @@ -51,6 +51,14 @@ export const score = (instructions, criteria) => ({ type: "score", instructions, const isUnit = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= 1; +// Number(null) is 0 and Number("") is 0, so a null/absent noul used to validate as a confident +// "definitely not" instead of failing safe as no answer at all. +const toNumber = (v) => { + if (typeof v === "number") return v; + if (typeof v === "string" && v.trim()) return Number(v); + return Number.NaN; +}; + /** * Validate one raw answer against its question; returns a clean answer or null. * Typed output guarantees the interface, not the values — a Choice naming an option we @@ -59,28 +67,38 @@ const isUnit = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0 && v function validateAnswer(question, answer) { if (!answer || typeof answer !== "object" || answer.type !== question.type) return null; if (question.type === "noul") { - const n = Number(answer.noul); + const n = toNumber(answer.noul); if (!Number.isFinite(n)) return null; return { type: "noul", noul: clamp01(n) }; } if (question.type === "choice") { - const pick = String(answer.choice ?? ""); - if (!Object.hasOwn(question.criteria, pick)) return null; + // Case-insensitive, but resolved back to the option WE offered: an answer of "Mid" is the + // "mid" we asked about, not garble, while an option we never offered still fails safe. + const option = (v) => + Object.keys(question.criteria).find( + (k) => + k.toLowerCase() === + String(v ?? "") + .trim() + .toLowerCase(), + ); + const pick = option(answer.choice); + if (!pick) return null; const out = { type: "choice", choice: pick }; if (isUnit(answer.confidence)) out.confidence = answer.confidence; if (answer.probabilities && typeof answer.probabilities === "object") { const probabilities = {}; - for (const [option, p] of Object.entries(answer.probabilities)) { - const n = Number(p); - if (Object.hasOwn(question.criteria, option) && Number.isFinite(n)) - probabilities[option] = clamp01(n); + for (const [key, p] of Object.entries(answer.probabilities)) { + const n = toNumber(p); + const named = option(key); + if (named && Number.isFinite(n)) probabilities[named] = clamp01(n); } out.probabilities = probabilities; } return out; } if (question.type === "score") { - const n = Number(answer.score); + const n = toNumber(answer.score); if (!Number.isFinite(n)) return null; const out = { type: "score", score: n }; if (isUnit(answer.confidence)) out.confidence = answer.confidence; diff --git a/src/knowledge_router.js b/src/knowledge_router.js index f8c458d..f2497ec 100644 --- a/src/knowledge_router.js +++ b/src/knowledge_router.js @@ -11,7 +11,7 @@ import { join } from "node:path"; import { BRAND } from "./brand.js"; import { appendDecision } from "./decide.js"; -import { intentGrams } from "./intent.js"; +import { INTENT_STOP, intentGrams } from "./intent.js"; import { shadowFact } from "./ledger_bridge.js"; import { repoLedger } from "./ledger_store.js"; import { setOverlap } from "./math.js"; @@ -233,10 +233,19 @@ export const HOME_EXEMPLARS = [ ]; // intentGrams, not a new tokenizer: task verbs and function-word stripping behave the -// same for "what kind of knowledge is this" as for "what kind of work is this". +// same for "what kind of knowledge is this" as for "what kind of work is this" — except for +// person. "i prefer …" / "my editor is …" is exactly what separates a personal preference +// (recall) from a project convention (claude-md), and intent's stop-set drops those pronouns, +// so a team convention scored 1.00 against a first-person recall row. Here they are content. +const PERSON = new Set(["i", "me", "my", "we", "us", "our", "you", "your", "their"]); +const FACT_STOP = new Set([...INTENT_STOP].filter((w) => !PERSON.has(w))); + +/** intentGrams with the person pronouns kept — the signal this router routes on. */ +export const factGrams = (text) => intentGrams(text, FACT_STOP); + const EXEMPLAR_GRAMS = HOME_EXEMPLARS.map((e) => ({ ...e, - grams: intentGrams(e.text), + grams: factGrams(e.text), })); /** @@ -249,7 +258,7 @@ const EXEMPLAR_GRAMS = HOME_EXEMPLARS.map((e) => ({ * @returns {{home:string, confidence:number, provenance:"knn"|"fallback", write:"auto"|"advise", neighbors:{text:string, home:string, sim:number}[]}} */ export function routeFact(text, { k = 3, minConf = 0.25 } = {}) { - const grams = intentGrams(text); + const grams = factGrams(text); const sims = !grams.size ? [] : EXEMPLAR_GRAMS.map((e) => ({ diff --git a/test/adjudicate.test.js b/test/adjudicate.test.js index 81e03c5..e3c6b4b 100644 --- a/test/adjudicate.test.js +++ b/test/adjudicate.test.js @@ -40,6 +40,30 @@ test("extractJson: pulls a JSON object out of chatty output, tolerates garbage", assert.equal(extractJson(""), null); }); +test("extractJson: the FIRST balanced object, not first-brace-to-last (deep review D9)", () => { + // The greedy /\{[\s\S]*\}/ spanned to the LAST brace in the reply, so each of these parsed as + // nothing and the proposal was dropped. + assert.deepEqual(extractJson('{"band":"premium"}\n{"note":"alt"}'), { band: "premium" }); + assert.deepEqual(extractJson('Considering the {config} object: {"band":"premium"}'), { + band: "premium", + }); + assert.deepEqual(extractJson('{"band":"premium"} (I ignored the {placeholder})'), { + band: "premium", + }); + assert.deepEqual( + extractJson('```json\n{"band":"cheap"}\n```\nFormat was {"band":"cheap|mid|premium"}'), + { band: "cheap" }, + ); + // String-aware: a brace inside a string neither opens nor closes an object. + assert.deepEqual(extractJson('{"reason":"a } brace","ok":true}'), { + reason: "a } brace", + ok: true, + }); + assert.deepEqual(extractJson('{"reason":"escaped \\" quote }"}'), { + reason: 'escaped " quote }', + }); +}); + test("adjudicate: happy path returns the validated proposal", () => { const run = () => '{"score": 0.42, "reason": "some detail"}'; assert.deepEqual(adjudicate({ prompt: "x", parse: parseScore, run }), { diff --git a/test/gateway_model_map.test.js b/test/gateway_model_map.test.js index 9715f6a..82dcb73 100644 --- a/test/gateway_model_map.test.js +++ b/test/gateway_model_map.test.js @@ -9,6 +9,7 @@ import { gatewayBase, gatewayModelId, gatewayModelMap, + versionOf, } from "../src/gateway_model_map.js"; // --------------------------------------------------------------------------- @@ -26,9 +27,23 @@ test("familyScore requires the family word and scores higher on a version match" assert.ok(bare > 0 && bare < exact, "a bare family match still scores, but below an exact one"); }); -test("familyTokens carries the tier key plus its marketing-name tokens", () => { +test("familyTokens carries the tier key plus its marketing-name version", () => { assert.deepEqual([...familyTokens("sonnet")].sort(), ["5", "sonnet"]); - assert.deepEqual([...familyTokens("haiku")].sort(), ["4", "5", "haiku"]); + // One version token, not loose digits: "4" and "5" separately matched any id containing a 5. + assert.deepEqual([...familyTokens("haiku")].sort(), ["4.5", "haiku"]); +}); + +test("versions are parsed, so Sonnet 3.5 no longer wins the Sonnet tier (deep review D9)", () => { + assert.deepEqual(versionOf("claude-3-5-sonnet-20241022"), [3, 5]); + assert.deepEqual(versionOf("claude-sonnet-4-5-20250929"), [4, 5]); + assert.deepEqual(versionOf("claude-sonnet-5"), [5]); + assert.equal(versionOf("prod-sonnet"), null, "a date stamp alone is not a version"); + const map = buildGatewayMap(["claude-3-5-sonnet-20241022", "claude-sonnet-4-5-20250929"]); + assert.equal( + map.sonnet.id, + "claude-sonnet-4-5-20250929", + "neither is the pinned Sonnet 5 — the newest of the family wins", + ); }); // --------------------------------------------------------------------------- diff --git a/test/intent.test.js b/test/intent.test.js index 4b47042..365a5dd 100644 --- a/test/intent.test.js +++ b/test/intent.test.js @@ -9,6 +9,17 @@ import { classifyIntent, intentCard, PROTOCOL_CARDS } from "../src/intent.js"; const ENTRY = fileURLToPath(new URL("../src/cortex_hook_main.js", import.meta.url)); +test("classifyIntent: confidence belongs to the winning intent (deep review D9)", () => { + // Two release rows outvote one closer question row; the reported confidence used to be the + // question row's similarity — evidence for an intent that lost. + const r = classifyIntent("what does the release script do"); + const best = Math.max(...r.neighbors.filter((n) => n.intent === r.intent).map((n) => n.sim)); + assert.ok(Math.abs(r.confidence - best) < 1e-3, `confidence ${r.confidence} vs best ${best}`); + const top = r.neighbors[0]; + if (top.intent !== r.intent) + assert.ok(r.confidence < top.sim, "and it is not the losing neighbor's similarity"); +}); + test("classifyIntent: each intent recognized from unseen phrasings (English)", () => { assert.equal(classifyIntent("fix the crash on the settings page").intent, "bugfix"); assert.equal(classifyIntent("add a page to export invoices as csv").intent, "feature"); diff --git a/test/jev.test.js b/test/jev.test.js index cf5e4b0..c1d7f68 100644 --- a/test/jev.test.js +++ b/test/jev.test.js @@ -108,6 +108,61 @@ test("systemOne: garble fails safe per question — and wholly null when nothing ); }); +test("systemOne: a missing noul is no answer, not a confident zero (deep review D9)", () => { + const questions = { io: noul("Specified?"), rate: score("Rate", ["low", "high"]) }; + const res = systemOne({ + state: "x", + questions, + call: () => ({ + answers: { io: { type: "noul", noul: null }, rate: { type: "score", score: 2 } }, + }), + }); + assert.equal(res.answers.io, undefined, "Number(null) is 0 — a null noul must fail safe"); + assert.equal(res.answers.rate.score, 2, "a real answer beside it still validates"); + assert.equal( + systemOne({ + state: "x", + questions: { io: noul("Specified?") }, + call: () => ({ answers: { io: { type: "noul", noul: null } } }), + }), + null, + "nothing left to read → the caller keeps its deterministic path", + ); + assert.equal( + systemOne({ + state: "x", + questions: { io: noul("Specified?") }, + call: () => ({ answers: { io: { type: "noul", noul: "0.7" } } }), + }).answers.io.noul, + 0.7, + "a numeric string is still a number", + ); +}); + +test("systemOne: a choice matches the offered option case-insensitively (deep review D9)", () => { + const questions = { band: choice("Band?", { cheap: "c", mid: "m", premium: "p" }) }; + const res = systemOne({ + state: "x", + questions, + call: () => ({ + answers: { + band: { type: "choice", choice: "Mid", probabilities: { Mid: 0.8, Cheap: 0.2 } }, + }, + }), + }); + assert.equal(res.answers.band.choice, "mid", "resolved back to the option we offered"); + assert.deepEqual(res.answers.band.probabilities, { mid: 0.8, cheap: 0.2 }); + assert.equal( + systemOne({ + state: "x", + questions, + call: () => ({ answers: { band: { type: "choice", choice: "Deluxe" } } }), + }), + null, + "an option we never offered is still garble", + ); +}); + test("systemOne: refuses to send secret-shaped state, key or no key", () => { let called = false; const res = systemOne({ diff --git a/test/knowledge_router.test.js b/test/knowledge_router.test.js index 8079b75..154c139 100644 --- a/test/knowledge_router.test.js +++ b/test/knowledge_router.test.js @@ -21,6 +21,19 @@ process.env.FORGE_LEDGER_ONLY = "0"; const CLI = fileURLToPath(new URL("../src/cli.js", import.meta.url)); +test("routeFact: the first-person signal survives tokenization (deep review D9)", () => { + // intent.js drops pronouns as function words, but "i prefer …" vs "the team prefers …" is + // exactly what separates a personal preference from a project convention — both scored + // identically against the recall rows before. + const mine = routeFact("i prefer short commit messages"); + const team = routeFact("the team prefers short commit messages in this repo"); + assert.equal(mine.home, "recall"); + assert.ok( + mine.confidence > team.confidence, + `first person is stronger evidence for recall (${mine.confidence} vs ${team.confidence})`, + ); +}); + test("routeFact: every home recognized from unseen phrasings (per-family routing)", () => { assert.equal(routeFact("always run the linter before committing changes").home, "claude-md"); assert.equal(routeFact("never push directly to the release branch").home, "rule"); From 8a5d551f828c1f00dafbe9f8a22653a95adfdf72 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:14:25 +0200 Subject: [PATCH 39/72] =?UTF-8?q?docs(route):=20describe=20`route=20calibr?= =?UTF-8?q?ate`=20as=20what=20it=20is=20=E2=80=94=20a=20hand-labelled=20ru?= =?UTF-8?q?bric=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Outcome-calibrated routing" uses no outcomes: the fixture is 24 hand-written task phrases with hand-assigned complexities, and forge records nothing that could replace them (a route metrics event carries the tier and a task hash, a verify event carries pass/fail with no task reference, so no (task, tier, outcome) triple exists). calibratedComplexity also has no caller in src/ — routing keeps the raw rubric. The command output, module comment, GUIDE and ROADMAP now say so, and joining a routed task's tier to its verification result is named as open work. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 8 ++++++++ ROADMAP.md | 6 ++++-- docs/GUIDE.md | 22 ++++++++++++++-------- src/cli.js | 12 +++++++----- src/route.js | 28 ++++++++++++++++++---------- 5 files changed, 51 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6e845d..d5ab1b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -130,6 +130,14 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Changed +- **`forge route calibrate` stops calling itself "outcome-calibrated routing".** Nothing in it + comes from an outcome: the fixture is 24 hand-written task phrases with hand-assigned + complexities, and forge records nothing that could replace them — a `route` metrics event + carries the chosen tier and a task hash, a `verify` event carries pass/fail with no task + reference, so no (task, tier, outcome) triple exists to calibrate on. `calibratedComplexity` + has no caller in `src/` either: routing keeps the raw rubric. The command heading and closing + note, the module comment, GUIDE and ROADMAP now say so plainly, and joining a routed task's + tier to its verification result is named as open work rather than implied to be done. - **The routing rubric stops counting a task's length twice and stops matching on one shared word.** Both defects pushed every real task into the middle: on the reviewer's 80-task held-out set the router sent 54 of 64 well-specified tasks to mid and reached premium once. diff --git a/ROADMAP.md b/ROADMAP.md index cbdbd4e..4fa2e3d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -77,8 +77,10 @@ confidence only from independent oracles, and merges across teammates conflict-f - **Advisory → gated promotions** — the measured-promotion gate has shipped (`src/promote.js`, generalizing the risk predictor's kill-criteria): a candidate only replaces a baseline when it beats it on held-out data, never by assertion. First - application: outcome-calibrated routing (`forge route calibrate`). Remaining - applications of the same gate: consolidation promotion (ʿilm→fahm) and M6 hazard + application: the routing rubric's calibration check (`forge route calibrate`) — against a + hand-labelled fixture, since no routing outcome is recorded to calibrate on; wiring real + outcome data (a routed task's tier joined to its verification result) into it is still open. + Remaining applications of the same gate: consolidation promotion (ʿilm→fahm) and M6 hazard estimates. ## Later / exploring diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 9223e90..fa36fd4 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -206,23 +206,29 @@ weights). `ANTHROPIC_MODEL` / `FORGE_MODEL` override the tier choice entirely. Run `forge route gateway` to emit a LiteLLM config so the routing happens automatically. **`forge route calibrate`** is the _advisory → gated promotion_ (overview §4): it fits an -affine correction of the rubric's score toward a held-out labeled fixture and reports -whether that calibration **measurably** beats the raw rubric (lower held-out MAE past a +affine correction of the rubric's score toward a held-out split of a labelled fixture and +reports whether that calibration **measurably** beats the raw rubric (lower held-out MAE past a margin) — the same kill-criteria discipline as the risk predictor (`src/predictor.js`), generalized in `src/promote.js` so any advisory signal (routing weights here; consolidation and hazard next) can only become active by measurement, never by assertion. -It is advisory: routing keeps the rubric until a promoted calibration is explicitly -adopted. + +Read the name literally: it calibrates **the rubric against hand-written labels**, not against +outcomes. The fixture is 24 hand-written task phrases with hand-assigned complexities, and forge +records nothing that could replace them — a `route` metrics event carries the tier and a task +hash, a `verify` event carries pass/fail with no task reference — so there is no +(task, tier, outcome) triple to calibrate on. It is advisory twice over: routing keeps the raw +rubric, and nothing in `src/` adopts a promoted calibration. ```console $ forge route calibrate -Forge route calibrate — outcome-calibrated routing (measured gate) +Forge route calibrate — rubric calibration check (measured gate) - samples: 24 labeled task(s) - held-out MAE: rubric 0.152 · calibrated 0.226 + samples: 24 hand-labelled task phrase(s) — no routing outcomes exist + held-out MAE: rubric 0.191 · calibrated 0.266 → keep the rubric — baseline retained — candidate did not beat it by the margin - advisory — routing stays on the rubric until a promoted calibration is adopted + advisory — routing stays on the rubric; nothing adopts a promoted calibration yet, + and calibrating on real routing outcomes needs data forge does not record ``` Here the gate does exactly its job: the rubric already generalizes well, the affine diff --git a/src/cli.js b/src/cli.js index 368b8f3..80ca17f 100755 --- a/src/cli.js +++ b/src/cli.js @@ -1896,12 +1896,13 @@ HANDLERS.route = async (argv) => { } if (argv[1] === "calibrate") { // Advisory → gated promotion (ROADMAP): measure whether an affine calibration of the - // routing rubric beats the raw rubric on the held-out fixture. Advisory — routing - // keeps the rubric unless the gate promotes AND a caller adopts the calibration. + // routing rubric beats the raw rubric on a held-out split of the HAND-LABELLED fixture + // (there is no outcome data). Advisory — routing keeps the rubric unless the gate + // promotes AND a caller adopts the calibration, which nothing in src/ does. const res = r.calibrateRouting(); if (argv.includes("--json")) return console.log(JSON.stringify(res, null, 2)); - heading(`${BRAND.brand} route calibrate — outcome-calibrated routing (measured gate)\n`); - console.log(` samples: ${res.n} labeled task(s)`); + heading(`${BRAND.brand} route calibrate — rubric calibration check (measured gate)\n`); + console.log(` samples: ${res.n} hand-labelled task phrase(s) — no routing outcomes exist`); if (res.baselineMetric !== undefined) console.log( ` held-out MAE: rubric ${res.baselineMetric} · calibrated ${res.candidateMetric}`, @@ -1912,8 +1913,9 @@ HANDLERS.route = async (argv) => { : ` → keep the rubric — ${res.reason}`, ); console.log( - "\n advisory — routing stays on the rubric until a promoted calibration is adopted", + "\n advisory — routing stays on the rubric; nothing adopts a promoted calibration yet,", ); + console.log(" and calibrating on real routing outcomes needs data forge does not record"); return; } const json = argv.includes("--json"); diff --git a/src/route.js b/src/route.js index 41dce04..46f718c 100644 --- a/src/route.js +++ b/src/route.js @@ -234,12 +234,17 @@ export function rubricComplexity(task = "") { } // --------------------------------------------------------------------------- -// Outcome-calibrated routing (ROADMAP: advisory → gated promotion). The rubric above -// is the advisory baseline. Below: fit an affine correction of its score toward labeled -// complexities and PROMOTE it over the raw rubric ONLY if it beats the rubric on a -// held-out fixture (promote.js measured gate) — never on assertion. recommend() keeps -// the raw rubric unless a caller opts into the returned calibration, so this stays -// advisory until the measurement earns the promotion (overview §4 honesty register). +// Rubric calibration check (ROADMAP: advisory → gated promotion). Fit an affine correction of +// the rubric's score toward labeled complexities and PROMOTE it over the raw rubric ONLY if it +// beats the rubric on a held-out split (promote.js measured gate) — never on assertion. +// +// HONESTY, since this was once described as "outcome-calibrated routing": there are NO outcomes +// in it. The labels below are 24 hand-written phrases with hand-assigned complexities, and forge +// records nothing that could replace them — `stage:"route"` metrics carry the chosen tier and a +// task hash, `stage:"verify"` metrics carry a pass/fail with no task reference, so no +// (task, tier, outcome) triple exists to calibrate on. Nothing here is wired into routeTask +// either: `calibratedComplexity` has no caller in src/, by design (the gate has never promoted), +// and outcome-labelled routing data remains open work. // --------------------------------------------------------------------------- /** @@ -308,8 +313,10 @@ const stridedSplit = (samples) => { /** * Run the measured-promotion gate on the routing rubric: fit an affine correction on the - * training split and promote it only if it lowers held-out MAE past the margin. - * @param {{text:string,y:number}[]} [samples] labeled tasks (default: the held-out fixture) + * training split and promote it only if it lowers held-out MAE past the margin. The default + * fixture is hand-written phrases with hand-assigned labels — a generalization check for the + * rubric, not evidence from routed work. + * @param {{text:string,y:number}[]} [samples] labeled tasks (default: the hand-labelled fixture) * @param {{margin?:number, minSamples?:number}} [opts] */ export function calibrateRouting(samples = CALIBRATION_SAMPLES, opts = {}) { @@ -327,8 +334,9 @@ export function calibrateRouting(samples = CALIBRATION_SAMPLES, opts = {}) { } /** - * The live complexity estimate: the calibrated mapping ONLY if the gate blessed it - * (mirrors predictor.riskFor). Falls back to the raw rubric otherwise. + * The complexity estimate under a promotion: the calibrated mapping ONLY if the gate blessed it + * (mirrors predictor.riskFor), the raw rubric otherwise. NOT called by routeTask — routing keeps + * the raw rubric, and adopting a calibration is an explicit caller's choice. * @param {string} task * @param {{mode:string, model?:{a:number,b:number}}} [promotion] result of calibrateRouting */ From 74001c239a4eeea21f7bfd02ce89306c93da8b47 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:15:10 +0200 Subject: [PATCH 40/72] fix(atlas): a method definition is not a call to a same-named function elsewhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `emit(ctx) {` inside an object or class is a DEFINITION, but object/class methods are not indexed as symbols, so CALL_RE read it as a call and the unique-name fallback bound it to whatever `emit` existed elsewhere — `forge impact src/emit/claude.js` reported 19 files including a minified landing-page chunk that also contains `emit(`, reached as its "sibling" through that invented edge. An identifier followed by `(…) {` in a brace language is now skipped as a definition: the same query reports 9 files, all of them real (src/sync.js imports it; cli.js and cortex_hook_main.js reach it transitively; the rest are docs and config that name the path). Ambiguous dropped references on this repo fall 877 → 862. Co-Authored-By: Claude Opus 5 --- src/atlas.js | 28 ++++++++++++++++++++++++++++ test/atlas_resolve.test.js | 16 ++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/atlas.js b/src/atlas.js index 6667fb5..896889c 100644 --- a/src/atlas.js +++ b/src/atlas.js @@ -577,6 +577,33 @@ function scopeFinder(scopes) { }; } +// `name(args) {` in a brace language is a method/function DEFINITION, not a call — and an +// object or class method is not indexed as a symbol, so without this every `emit(ctx) {` +// became a call edge to whatever unique `emit` existed elsewhere in the repo. +const METHOD_DEF_SCAN = 2000; +function isMethodDef(code, from) { + let i = from; + while (i < code.length && /[ \t]/.test(code[i])) i += 1; + if (code[i] !== "(") return false; + let depth = 0; + const limit = Math.min(code.length, i + METHOD_DEF_SCAN); + for (; i < limit; i++) { + const c = code[i]; + if (c === "(") depth += 1; + else if (c === ")") { + depth -= 1; + if (depth === 0) break; + } + } + if (depth !== 0) return false; + for (i += 1; i < code.length; i++) { + const c = code[i]; + if (/\s/.test(c)) continue; + return c === "{"; + } + return false; +} + function extractFile(path, root, preRead) { const ext = extname(path); const rules = RULES[ext]; @@ -733,6 +760,7 @@ function extractFile(path, root, preRead) { if (BUILTINS.has(callee)) continue; const pos = s + (cm.indices?.[1]?.[0] ?? cm.index); if (defAt.has(pos)) continue; + if (lex !== "py" && isMethodDef(code, pos + callee.length)) continue; const source = ownerAt(pos); if (source.name === callee) continue; edges.push({ diff --git a/test/atlas_resolve.test.js b/test/atlas_resolve.test.js index 145a421..9640013 100644 --- a/test/atlas_resolve.test.js +++ b/test/atlas_resolve.test.js @@ -130,6 +130,22 @@ test("A6: two definitions of one name — importers resolve to their own file; a assert.ok(atlas.stats.names.ambiguous >= 1); }); +test("A6: a method definition is not a call to a same-named function elsewhere", () => { + const root = writeRepo({ + "src/hook.js": 'export function emit(event, text) {\n return event + ":" + text;\n}\n', + "src/emit/claude.js": + 'export const claude = {\n name: "claude",\n emit(ctx) {\n return ctx;\n },\n};\n', + "src/caller.js": 'import { emit } from "./hook.js";\nexport const go = () => emit("a", "b");\n', + }); + const atlas = build({ root }); + const r = impact(atlas, "emit"); + assert.ok(r.impactedFiles.includes("src/caller.js"), "the real caller is found"); + assert.ok( + !r.impactedFiles.includes("src/emit/claude.js"), + `an \`emit(ctx) {\` method is a definition, not a call: ${JSON.stringify(r.impactedFiles)}`, + ); +}); + test("A6: the file cap counts only source files and a capped graph says so", () => { const files = { "a.js": "export const a = 1;\n", "b.js": "export const b = 2;\n" }; for (let i = 0; i < 6; i++) files[`doc${i}.md`] = `# doc ${i}\n`; From feccf45c3dffc15917559910f79cea4afd381f7e Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:16:21 +0200 Subject: [PATCH 41/72] docs(changelog): refresh the impact numbers after the method-definition fix Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89be735..74318e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,9 +36,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `pkg`, and never resolved a relative import. Specifiers now resolve through one shared resolver in `src/scope.js` — exact file, TypeScript NodeNext `./x.js`→`x.ts`, extensionless, `/index.*`, and Python modules indexed by PACKAGE ROOT - (`src/mypkg/core.py` is `mypkg.core`, so a src layout answers exactly like a flat one) — and an import that resolves to no file stays unresolved instead - of being pinned to whatever shares its name. **Measured on this repo: 1,196 → 1,392 import - statements seen, 679 of 679 relative ones resolved to the exact file the specifier names, 0 + (`src/mypkg/core.py` is `mypkg.core`, so a src layout answers exactly like a flat one) — + and an import that resolves to no file stays unresolved instead of being pinned to whatever + shares its name. **Measured on this repo: 1,196 → 1,392 import statements seen, 679 of 679 + relative ones resolved to the exact file the specifier names, 0 wrong (was 3, all wrong).** On a ten-importer fixture the graph now finds 10 of 10 with no false positives (grep finds 10 with 2), and on a seven-importer Python fixture 7 of 7 (was 4, plus a file whose only mention is a comment). @@ -52,17 +53,19 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `impact(leaf)`. Bare names are never resolved across languages any more (a Python `from impact_oracle.oracle import …` used to land on the JS `const oracle` in `eval.js`), local definitions are not cross-file candidates, and names imported from a package are never - re-guessed locally: ambiguous references dropped on this repo fell from 3,136 to 877, and - they are now COUNTED and reported instead of vanishing (`impact()` returns `ambiguousRefs`, - `unresolvedImports`, `capped` and `skippedFiles`; `forge impact` prints them). + re-guessed locally, and `emit(ctx) {` inside an object is a method DEFINITION rather than + a call to whatever unique `emit` exists elsewhere: ambiguous references dropped on this + repo fell from 3,136 to 862, and they are now COUNTED and reported instead of vanishing + (`impact()` returns `ambiguousRefs`, `unresolvedImports`, `capped` and `skippedFiles`, and + `forge impact` prints them). - **Building the graph is linear again, and the file cap counts source files.** Line numbers came from a `slice(0, i).split()` over the whole file per match, and every call scanned every node, so a 16k-line file took seconds; extraction now uses a line index and scope intervals: a 16k-line JavaScript file plus a 16k-line Python file build in **0.3 s, down from 4.9 s** on the same machine (`test/atlas_resolve.test.js` keeps it under 2.5 s). The - 20,000-file cap counted JSON and Markdown against code and was never reported; it now bounds source files - only, docs/configs have their own bound, and a capped graph says so in `forge atlas build`, - in `impact()` and in `forge impact`. + 20,000-file cap counted JSON and Markdown against code and was never reported; it now + bounds source files only, docs/configs have their own bound, and a capped graph says so + in `forge atlas build`, in `impact()` and in `forge impact`. - **Blast radius is no longer reverse-only — the refutation's sibling and forward relations are ported.** `research/empirical-refutation/` diagnosed that 94.7% of real misses were *siblings* (A and B both depend on module C, so C's contract shift co-changes both) and @@ -84,7 +87,12 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). is 0.346, and restricted to one hop it is 0.830 at recall 1.000. Four of the six label sets are also stale (`contentHash` has nine importers in `src/` today, six are labelled), so these numbers under-report precision; the fixture needs relabelling before any claim rests - on it. + on it. What the repair fixes outright: nine `src/` files (every `src/emit/*.js`, plus + `src/taste.js`) reported **"✓ found · impacted files: 0"** while being imported — + **now none do**. The median blast radius of a `src/` file goes from 8 files to 16 + reverse-only and 70 with the sibling relation on, which is the frozen parameters working + as measured, not a bug: `impact(…, { relations: ["reverse"] })` is the dependents-only + view. - **The in-repo Python prototype is the repaired v2, not the refuted v1.** `research/python-prototypes/impact_oracle/oracle.py` was byte-identical to the as-shipped version whose claims the refutation demolished. It now carries both repairs — the src-layout From c1f201f6475e123669cfaec556818ddcf8e94486 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:17:17 +0200 Subject: [PATCH 42/72] fix(anchor,lean,cortex): per-checkpoint drift, persisted goal, untracked files, doom sig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review C10: - The preflight hook measures goal drift against the PERSISTED goal instead of the current prompt, and makes no drift claim when no goal is set. - The CUSUM series gets per-checkpoint increments (what moved since the last prompt) instead of the cumulative off-goal ratio, so one static off-goal file no longer alarms by itself after three idle prompts. - M5 lean counts untracked files — where over-engineering actually lives. - The doom-loop signature hashes stdout AND stderr over the whole normalized output (head+tail above 64 KB) instead of the first 800 chars of stdout, and the advisory no longer claims edits were made when none were. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 20 +++++++++++++++ src/anchor.js | 42 +++++++++++++++++++++++++++++++ src/cortex_hook.js | 40 +++++++++++++++++++++++------ src/cortex_hook_main.js | 23 +++++++++++++++-- src/lean.js | 44 ++++++++++++++++++++++++-------- test/assumption_trail.test.js | 47 +++++++++++++++++++++++++++++++++++ test/cortex_hook.test.js | 41 ++++++++++++++++++++++++++++++ test/lean.test.js | 38 ++++++++++++++++++++++++++++ 8 files changed, 275 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eefeb12..8b810b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -123,6 +123,26 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 32 artifacts, most adapt-tier hits silently became misses. Banding is now 32 × 4 (0.99 at J=0.6, ≈1.00 at J=0.8): in the review's own harness, 67 of 67 adapt-band pairs are found with the prefilter active, against 39 of 67 before. +- **Goal anchoring measures the right thing, per checkpoint.** Three separate defects: + - The per-prompt advisory compared the working diff against the **current prompt**, so + "ok, now run the tests please" reported every changed file as goal drift. The hook now + re-runs the check against the persisted goal (`.forge/goal.md`), and with no goal set it + makes no drift claim at all — a prompt is not a goal. + - The CUSUM chart was fed the **cumulative** off-goal ratio every prompt, so one static + off-goal file alarmed by itself after three idle prompts (C = 0.32 → 0.63 → 0.95 → 1.27 + > h = 1.0). It now gets the per-checkpoint increment — the off-goal fraction of what + actually moved since the last prompt — so idle prompts score 0 and drain the chart, + while a file edited again scores 1 again. + - M5 minimality ignored untracked files, which is where over-engineering lives: a 6-class, + 212-line "framework" dropped next to a one-line fix measured as 1 file, +1 line, 0 + warnings. Untracked files are now part of the measured footprint (2 files, +213 lines, + 13 new abstractions, 2 warnings) whether or not they have been `git add`ed. +- **The doom-loop signature sees the whole failure.** It hashed `tool_response.stdout` only + and just its first 800 characters, so a stderr-only failure (jest, mocha, tsc) was never + seen at all, and three different failures behind one long passing header shared a signature + and were reported as a loop. It now covers stdout and stderr and the whole normalized + output (head + tail above 64 KB). The advisory also stops claiming "different edits aren't + fixing it" when nothing was edited between the runs. - **CI is green again on Linux.** `global/guards/run.mjs` was committed without its executable bit, so `forge doctor`'s plugin-hook check (which `access(X_OK)`s every script a hook names) reported `warn` on Linux and failed `test/doctor.test.js` on Node 20 and 22 for diff --git a/src/anchor.js b/src/anchor.js index 39b91b9..093a707 100644 --- a/src/anchor.js +++ b/src/anchor.js @@ -3,6 +3,8 @@ // changed (git) against the area the goal named. Flags work that has wandered off it. // Advisory — a stated goal is re-read against real diffs, not trusted to stay in view. import { execFileSync } from "node:child_process"; +import { statSync } from "node:fs"; +import { join } from "node:path"; import { adjudicate, asText, buildRunner, llmEnabled } from "./adjudicate.js"; import { load as loadAtlas, query as queryAtlas } from "./atlas.js"; import { referencedEntities } from "./preflight.js"; @@ -259,6 +261,46 @@ export function goalDrift(root, goal, opts = {}) { }; } +/** + * A cheap content stamp per changed file (size + mtime), so the next checkpoint can tell + * which files actually MOVED since this one. Missing/unreadable files stamp as "gone". + * @param {string} root + * @param {string[]} files repo-relative paths + * @returns {Record} + */ +export function fileStamps(root, files = []) { + /** @type {Record} */ + const out = {}; + for (const f of files) { + try { + const s = statSync(join(root, f)); + out[f] = `${s.size}:${Math.round(s.mtimeMs)}`; + } catch { + out[f] = "gone"; + } + } + return out; +} + +/** + * The per-checkpoint drift INCREMENT Dₜ that cusum() expects — the off-goal fraction of the + * work done SINCE the previous checkpoint, not of the whole working diff. Feeding the + * cumulative ratio made one static off-goal file alarm by itself after three idle prompts + * (C = 0.32 → 0.63 → 0.95 → 1.27 > h), and made a D ≤ k series unable to alarm at all: a + * control chart needs increments. A checkpoint where nothing moved scores 0 and drains the + * chart, which is exactly what an idle prompt should do. + * @param {{changed?:string[], offGoal?:string[]}} drift a goalDrift() result + * @param {Record} stamps this checkpoint's fileStamps() + * @param {Record} [prev] the previous checkpoint's stamps + * @returns {{score:number, moved:string[]}} + */ +export function driftIncrement(drift, stamps, prev = {}) { + const off = new Set(drift.offGoal ?? []); + const moved = (drift.changed ?? []).filter((f) => stamps[f] !== prev[f]); + const offMoved = moved.filter((f) => off.has(f)).length; + return { score: moved.length ? offMoved / moved.length : 0, moved }; +} + /** * M4 — one-sided CUSUM control chart over a drift-signal series (spec §5: * docs/plans/substrate-v2/06-faculties-and-mechanisms.md). A raw threshold on a diff --git a/src/cortex_hook.js b/src/cortex_hook.js index 68db267..fd9ee94 100644 --- a/src/cortex_hook.js +++ b/src/cortex_hook.js @@ -46,11 +46,26 @@ const TEST_RE = /\b(npm\s+(run\s+)?test|node\s+--test|jest|vitest|pytest|go\s+te // Negation must be corrective, not incidental ("no problem"); require a corrective verb. const NEG_RE = /\b(undo|revert|that'?s\s+wrong|not\s+what|you\s+broke|regression|wrong\s+again)\b/i; -// A cheap signature of a failing test's output. Line numbers, hex addresses, -// timings, and temp paths are normalized out so "the same failure" hashes the same across runs -// even as surrounding noise shifts — this is what lets us catch a same-error doom loop. +// A cheap signature of a failing run's output. Line numbers, hex addresses, timings, and +// temp paths are normalized out so "the same failure" hashes the same across runs even as +// surrounding noise shifts — this is what lets us catch a same-error doom loop. +// +// It hashes the WHOLE normalized output (head + tail for very long runs), not the first 800 +// characters: a test runner prints a long, identical passing header before the failure, so a +// head-only signature made three DIFFERENT failures one loop (review C10). Numbers are still +// collapsed, which is what keeps a re-run of the same failure stable across timings and +// counts — the documented cost is that two failures differing only in digits share a +// signature. +const SIG_HEAD = 8000; +const SIG_TAIL = 56000; + function outputSignature(text) { - const norm = String(text) + const raw = String(text); + const bounded = + raw.length > SIG_HEAD + SIG_TAIL + ? `${raw.slice(0, SIG_HEAD)}\n…\n${raw.slice(-SIG_TAIL)}` + : raw; + const norm = bounded .toLowerCase() .replace(/0x[0-9a-f]+/g, "0xADDR") .replace(/\b\d+(\.\d+)?(ms|s)\b/g, "T") @@ -58,8 +73,7 @@ function outputSignature(text) { .replace(/\b\d+\b/g, "N") .replace(/\/tmp\/\S+/g, "/tmp/X") .replace(/\s+/g, " ") - .trim() - .slice(0, 800); + .trim(); return norm ? contentHash(norm).slice(0, 12) : ""; } @@ -73,7 +87,12 @@ export function classifyEvent(hook) { if (tool === "Bash") { const exitCode = hook.exitCode; const failed = typeof exitCode === "number" && exitCode !== 0; - const out = hook.tool_response?.stdout ?? hook.tool_response ?? hook.output ?? ""; + // BOTH streams: jest, mocha and tsc print their failures on stderr, so a stdout-only + // signature never saw them and those loops were invisible (review C10). + const res = hook.tool_response; + const out = [typeof res === "string" ? res : "", res?.stdout, res?.stderr, hook.output] + .filter((x) => typeof x === "string" && x) + .join("\n"); return { type: "bash", command: inp.command ?? "", @@ -189,7 +208,12 @@ export function doomLoopAdvisory(events, opts = {}) { const r = detectDoomLoop(events, opts); if (!r.loop) return ""; const where = r.files.length ? ` around ${r.files.slice(0, 5).join(", ")}` : ""; - return `Forge Cortex — doom loop: the SAME test failure has recurred ${r.count}× this session${where}. Different edits aren't fixing it. Stop, find the root cause (re-read the failing assertion and the code it exercises), or ask a human — don't keep patching.`; + // Only claim edits were made when some were: with nothing edited between the runs, the + // honest advice is "re-running is not a fix", not "different edits aren't fixing it". + const what = r.files.length + ? "Different edits aren't fixing it." + : "Nothing was edited between the runs — re-running without changing anything cannot fix it."; + return `Forge Cortex — doom loop: the SAME test failure has recurred ${r.count}× this session${where}. ${what} Stop, find the root cause (re-read the failing assertion and the code it exercises), or ask a human — don't keep patching.`; } /** Drive the orchestrator from a session's events (called by the Stop hook). */ diff --git a/src/cortex_hook_main.js b/src/cortex_hook_main.js index 3acbb76..3261737 100644 --- a/src/cortex_hook_main.js +++ b/src/cortex_hook_main.js @@ -181,11 +181,30 @@ async function main() { const { getGoal } = await import("./goal.js"); const goal = getGoal(root); if (goal) { - const { goalDrift } = await import("./anchor.js"); + const { driftIncrement, fileStamps, goalDrift } = await import("./anchor.js"); const d = goalDrift(root, goal, { changed: result.goalAnchor?.changed, }); - appendSessionEvent(root, sid, { type: "drift", score: d.driftScore }); + // The advisory must be measured against the PERSISTED goal, not against this + // prompt: substrateCheck() compares the working diff to the prompt text, so + // "ok, now run the tests please" flagged every changed file as drift. + result.goalAnchor = d; + // …and the CUSUM series gets the per-checkpoint INCREMENT (what moved since the + // last prompt), not the cumulative off-goal ratio, which alarmed on one static + // off-goal file after three idle prompts. + const stamps = fileStamps(root, d.changed); + const prev = + readSession(root, sid) + .filter((e) => e.type === "drift") + .at(-1)?.stamps ?? {}; + appendSessionEvent(root, sid, { + type: "drift", + score: driftIncrement(d, stamps, prev).score, + stamps, + }); + } else if (result.goalAnchor?.drift) { + // No goal is set, so there is nothing to have drifted FROM. + result.goalAnchor = { ...result.goalAnchor, drift: false, offGoal: [] }; } const a = result.assumption; if (!a.shouldAsk && ((a.missing?.length ?? 0) > 0 || (a.questions?.length ?? 0) > 0)) diff --git a/src/lean.js b/src/lean.js index 7721d2a..b173544 100644 --- a/src/lean.js +++ b/src/lean.js @@ -4,6 +4,8 @@ // touched, lines added, and NEW abstractions introduced — against what the task NAMED, and flags // the excess. Deterministic, git/diff-based, zero-dep. Advisory (never blocks); tests always win. import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { referencedEntities } from "./preflight.js"; // A new top-level definition introduced on an added (+) diff line — the over-abstraction signal. @@ -94,21 +96,43 @@ export function assessFootprint(task, actual, { maxLinesForShortTask = 120 } = { }; } +// A brand-new file is INVISIBLE to `git diff HEAD` until it is staged — and a new file is +// exactly where over-engineering lives (review C10: a 6-class, 212-line "framework" next to +// a one-line fix measured as 1 file, +1 line, 0 warnings). Untracked files are rendered as +// what they are: an all-added diff. Binary and very large files are counted as touched files +// without inventing added lines. +const UNTRACKED_LINE_CAP = 20000; + +function untrackedDiff(root, run) { + const listed = run(["ls-files", "--others", "--exclude-standard"]) + .split(/\r?\n/) + .map((s) => s.trim()) + .filter(Boolean); + const parts = []; + for (const rel of listed) { + let text = ""; + try { + text = readFileSync(join(root, rel), "utf8"); + } catch { + text = ""; + } + const lines = text.includes("\0") ? [] : text.split(/\r?\n/).slice(0, UNTRACKED_LINE_CAP); + if (lines.length && lines.at(-1) === "") lines.pop(); + parts.push(`--- /dev/null\n+++ b/${rel}\n${lines.map((l) => `+${l}`).join("\n")}`); + } + return parts.length ? `${parts.join("\n")}\n` : ""; +} + function gitDiff(root, base) { - try { - const staged = execFileSync("git", ["diff", "--unified=0", base], { + const run = (args) => + execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }); - return ( - staged || - execFileSync("git", ["diff", "--unified=0", "--cached"], { - cwd: root, - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }) - ); + try { + const tracked = run(["diff", "--unified=0", base]) || run(["diff", "--unified=0", "--cached"]); + return tracked + untrackedDiff(root, run); } catch (err) { if (process.env.FORGE_DEBUG === "1") process.stderr.write(`forge lean gitDiff: ${err?.message ?? err}\n`); diff --git a/test/assumption_trail.test.js b/test/assumption_trail.test.js index 0fc2aaa..f5ff1d0 100644 --- a/test/assumption_trail.test.js +++ b/test/assumption_trail.test.js @@ -7,6 +7,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; +import { cusum } from "../src/anchor.js"; import { setGoal } from "../src/goal.js"; const ENTRY = fileURLToPath(new URL("../src/cortex_hook_main.js", import.meta.url)); @@ -89,3 +90,49 @@ test("a session with no goal and no assumptions records nothing extra", () => { "fully specified → no record", ); }); + +test("preflight (C10): the drift series is per-checkpoint, and the advisory names the PERSISTED goal", () => { + const { root } = gitFixture(); + setGoal(root, "improve tax calculation accuracy"); + writeFileSync(join(root, "src", "unrelated.js"), "export const x = 1;\n"); + const first = feed("preflight", { + session_id: "c10", + cwd: root, + prompt: "tweak the logging colors", + }); + const ctx = JSON.parse(first.stdout).hookSpecificOutput.additionalContext; + assert.match(ctx, /Goal drift/, "drift is reported"); + assert.match(ctx, /unrelated\.js/, "…for the file that is off the STATED goal"); + assert.doesNotMatch(ctx, /tax\.js/, "…and not for files that match the goal"); + // Four more prompts that change NOTHING: the chart must drain, not alarm. + for (let i = 0; i < 4; i += 1) + feed("preflight", { session_id: "c10", cwd: root, prompt: `status check ${i}` }); + const scores = events(root, "c10") + .filter((e) => e.type === "drift") + .map((e) => e.score); + assert.equal(scores.length, 5); + assert.equal(scores[0], 1, "the checkpoint that introduced the off-goal file scores 1"); + assert.deepEqual(scores.slice(1), [0, 0, 0, 0], "idle prompts add nothing to the chart"); + assert.equal(cusum(scores).alarm, false, "one static off-goal file never alarms by itself"); + // Editing the off-goal file again IS new drift. + writeFileSync(join(root, "src", "unrelated.js"), "export const x = 2;\nexport const y = 3;\n"); + feed("preflight", { session_id: "c10", cwd: root, prompt: "one more tweak" }); + assert.equal( + events(root, "c10") + .filter((e) => e.type === "drift") + .at(-1).score, + 1, + ); +}); + +test("preflight (C10): with no goal set, the prompt is not treated as one", () => { + const { root } = gitFixture(); + writeFileSync(join(root, "src", "unrelated.js"), "export const x = 1;\n"); + const r = feed("preflight", { + session_id: "c10b", + cwd: root, + prompt: "ok now run the tests please", + }); + const ctx = JSON.parse(r.stdout || "{}").hookSpecificOutput?.additionalContext ?? ""; + assert.doesNotMatch(ctx, /Goal drift/, "no stated goal → no drift claim"); +}); diff --git a/test/cortex_hook.test.js b/test/cortex_hook.test.js index a67e805..88c73f5 100644 --- a/test/cortex_hook.test.js +++ b/test/cortex_hook.test.js @@ -184,3 +184,44 @@ test("outputSignature normalizes line numbers/timings so the same error matches }); assert.equal(e1.outputSig, e2.outputSig, "line/timing noise is normalized out"); }); + +test("classifyEvent (C10): the signature covers stderr and the WHOLE output, not the first 800 chars", () => { + const bash = (response) => + classifyEvent({ + tool_name: "Bash", + tool_input: { command: "npx jest" }, + exitCode: 1, + tool_response: response, + }); + // (a) a stderr-only failure (jest, mocha, tsc) used to carry no signature at all. + const e = bash({ stdout: "", stderr: "FAIL src/a.test.js\n expected 3 received 4" }); + assert.ok(e.outputSig, "a stderr-only failure is still a failure"); + assert.equal(detectDoomLoop([e, e, e]).loop, true); + // (b) three DIFFERENT failures behind the same long passing header are not one loop. + const header = `> app@1.0.0 test\n> node --test\n${"▶ suite\n ✔ passes (1.2ms)\n".repeat(40)}`; + const f1 = bash({ stdout: `${header}✖ auth: expected 3 got 4` }); + const f2 = bash({ stdout: `${header}✖ billing: TypeError x is undefined` }); + const f3 = bash({ stdout: `${header}✖ cache: timeout waiting for the queue` }); + assert.equal(new Set([f1.outputSig, f2.outputSig, f3.outputSig]).size, 3, "distinct failures"); + assert.equal( + detectDoomLoop([f1, { type: "edit", file: "a" }, f2, { type: "edit", file: "b" }, f3]).loop, + false, + ); + // …while the same failure, header and all, still is one. + assert.equal(detectDoomLoop([f1, { type: "edit", file: "a" }, f1, f1]).loop, true); +}); + +test("doomLoopAdvisory (C10): with no edits between runs it does not claim edits were made", () => { + const same = { + type: "bash", + command: "npm test", + exitCode: 1, + outputSig: "aaaaaaaaaaaa", + }; + const noEdits = doomLoopAdvisory([same, same, same]); + assert.match(noEdits, /doom loop/i); + assert.doesNotMatch(noEdits, /Different edits aren't fixing it/); + assert.match(noEdits, /without chang/i, noEdits); + const withEdits = doomLoopAdvisory([same, { type: "edit", file: "a.js" }, same, same]); + assert.match(withEdits, /Different edits aren't fixing it/); +}); diff --git a/test/lean.test.js b/test/lean.test.js index a4d612a..f8475b8 100644 --- a/test/lean.test.js +++ b/test/lean.test.js @@ -63,3 +63,41 @@ test("leanRepo: no diff → quiet; injected diff → measured", () => { assert.equal(measured.hasDiff, true); assert.ok(measured.warnings.length >= 1); }); + +test("leanRepo (C10): a brand-new UNTRACKED file counts — that is where over-building lives", async () => { + const { execFileSync } = await import("node:child_process"); + const { mkdtempSync, writeFileSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const root = mkdtempSync(join(tmpdir(), "forge-lean-")); + const g = (...args) => execFileSync("git", args, { cwd: root, stdio: "ignore" }); + g("init"); + g("config", "user.email", "t@t.t"); + g("config", "user.name", "t"); + writeFileSync(join(root, "login.js"), "export function login(){}\n"); + g("add", "-A"); + g("commit", "-m", "init"); + writeFileSync(join(root, "login.js"), "export function login(){ return 1 }\n"); + const framework = `${Array.from( + { length: 6 }, + (_, i) => `export class Factory${i} {}\nexport function make${i}(){}\n`, + ).join("")}${"x\n".repeat(200)}`; + writeFileSync(join(root, "framework.js"), framework); // never `git add`ed + + const r = leanRepo(root, "fix login return value"); + assert.equal(r.footprint.files, 2, "the untracked file is part of the footprint"); + assert.ok(r.footprint.linesAdded > 200, `linesAdded ${r.footprint.linesAdded}`); + assert.ok( + r.footprint.unrequestedAbstractions.includes("Factory0"), + "its new abstractions are counted", + ); + assert.ok(r.warnings.length >= 2, "and the over-engineering warnings actually fire"); + g("add", "framework.js"); + const staged = leanRepo(root, "fix login return value"); + const shape = (x) => ({ + files: x.files, + linesAdded: x.linesAdded, + abstractions: [...x.newAbstractions].sort(), + }); + assert.deepEqual(shape(staged.footprint), shape(r.footprint), "staging it changes nothing"); +}); From eb4dcf3dbd536ae95ae6fece6108803dd119c1a2 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:19:05 +0200 Subject: [PATCH 43/72] fix(guards): parse hook JSON with node, fail closed, cover Read (B6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without jq every guard fell back to a regex that cut the value at the first escaped quote, so `echo "x"; cat .env` and `git diff -- ".env"` walked past the deny (the two protect-paths tests that fail on a jq-less machine), and the status line lost every segment but the dir. `printf | grep -q` under pipefail also lost matches to SIGPIPE on large commands, and internal errors exited 1 — a NON-blocking hook error. - protect-paths.sh is now a thin launcher over protect-paths.mjs (the secret-redact split): one parser, pure rule set, deny on any internal error or unparsable payload. - _guardlib.sh + statusline.sh read fields through guards/hookfield.mjs (jq when installed, node otherwise); no regex path remains. - rules: git reset --hard, git clean -f, find -delete/-exec rm, chmod -R, dd of=, lowercase SQL; --force-with-lease allowed again; .aws/credentials, .netrc, .npmrc, .git-credentials protected. - both manifests run protect-paths on Read (plugin installs ship no permissions.deny), and the template denies the credential stores. - resolveBash skips …\Microsoft\WindowsApps\bash.exe (the WSL alias that made every guard exit 127). - settings allowlist: drop Bash(fd:*) (fd -x runs anything), narrow git branch to its read-only forms, deny git branch -d/-D and git diff --output (B8). Co-Authored-By: Claude Opus 5 --- .claude/settings.json | 13 +++ biome.json | 10 +- global/guards/_guardlib.sh | 23 ++-- global/guards/format-on-edit.sh | 13 ++- global/guards/hookfield.mjs | 55 +++++++++ global/guards/protect-paths.mjs | 193 +++++++++++++++++++++++++++++++ global/guards/protect-paths.sh | 101 +++------------- global/guards/run.mjs | 5 +- global/guards/session-learner.sh | 9 +- global/settings.template.json | 32 ++++- global/statusline.sh | 51 ++++++-- hooks/hooks.json | 13 +++ test/guards.test.js | 101 ++++++++++++++++ test/hook_launcher.test.js | 25 ++++ test/settings_template.test.js | 26 +++++ tsconfig.json | 8 +- 16 files changed, 559 insertions(+), 119 deletions(-) create mode 100644 global/guards/hookfield.mjs create mode 100644 global/guards/protect-paths.mjs diff --git a/.claude/settings.json b/.claude/settings.json index 705b93f..750341f 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -108,6 +108,19 @@ ] } ] + }, + { + "matcher": "Read", + "hooks": [ + { + "type": "command", + "command": "node", + "args": [ + "${CLAUDE_PROJECT_DIR}/global/guards/run.mjs", + "${CLAUDE_PROJECT_DIR}/global/guards/protect-paths.sh" + ] + } + ] } ], "PostToolUse": [ diff --git a/biome.json b/biome.json index 90c726b..10ae15a 100644 --- a/biome.json +++ b/biome.json @@ -3,7 +3,15 @@ "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, "files": { "ignoreUnknown": true, - "includes": ["src/**", "test/**", "scripts/**", "bench/**", "global/guards/run.mjs"] + "includes": [ + "src/**", + "test/**", + "scripts/**", + "bench/**", + "global/guards/run.mjs", + "global/guards/hookfield.mjs", + "global/guards/protect-paths.mjs" + ] }, "formatter": { "enabled": true, diff --git a/global/guards/_guardlib.sh b/global/guards/_guardlib.sh index 4459f74..1bc63de 100755 --- a/global/guards/_guardlib.sh +++ b/global/guards/_guardlib.sh @@ -1,19 +1,26 @@ # shellcheck shell=bash # Sourced helpers for Forge guards. Not executable on its own. -# Provides field extraction (jq or grep) and an atomic re-entrancy lock so a +# Provides field extraction (a real JSON parser) and an atomic re-entrancy lock so a # guard can never recurse — the class of bug behind the runaway-loop cost # incident (claude-code #4095: 1.67B tokens / 5h, est. $16k–50k). +GUARDLIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + # forge_field — read a field from $INPUT (the raw hook JSON on stdin). +# `command`/`file_path` are the usual `tool_input.*` shortcuts; anything else is read from +# the top level. Parsed by jq when it is installed, else by node through hookfield.mjs — +# never by a regex: the old grep fallback cut the value at the first escaped quote, so +# `echo "x"; cat .env` arrived as `echo \` and every rule after it silently missed. forge_field() { - if command -v jq >/dev/null 2>&1; then - case "$1" in - command) printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' ;; - file_path) printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty' ;; - *) printf '%s' "$INPUT" | jq -r ".$1 // empty" ;; - esac + local path + case "$1" in + command | file_path) path="tool_input.$1" ;; + *) path="$1" ;; + esac + if command -v jq > /dev/null 2>&1; then + printf '%s' "$INPUT" | jq -r ".${path} // empty" else - printf '%s' "$INPUT" | grep -o "\"$1\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -1 | sed 's/.*"\([^"]*\)"$/\1/' + printf '%s' "$INPUT" | node "$GUARDLIB_DIR/hookfield.mjs" "$path" fi } diff --git a/global/guards/format-on-edit.sh b/global/guards/format-on-edit.sh index f097dce..8523eb0 100755 --- a/global/guards/format-on-edit.sh +++ b/global/guards/format-on-edit.sh @@ -3,13 +3,14 @@ # Non-blocking: never fails the turn. Keeps diffs clean without Claude spending tokens on it. set -uo pipefail -input="$(cat)" +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=/dev/null +. "$DIR/_guardlib.sh" -if command -v jq >/dev/null 2>&1; then - fpath="$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')" -else - fpath="$(printf '%s' "$input" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')" -fi +INPUT="$(cat)" +# A real JSON parser (jq, else node): the old grep fallback cut the value at the first +# escaped quote, and mangled Windows paths (backslashes arrive doubled inside JSON). +fpath="$(forge_field file_path)" [ -n "${fpath:-}" ] && [ -f "$fpath" ] || exit 0 diff --git a/global/guards/hookfield.mjs b/global/guards/hookfield.mjs new file mode 100644 index 0000000..998d0ca --- /dev/null +++ b/global/guards/hookfield.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +// Hook-JSON field reader for the bash guards — a real JSON parser, so no guard depends on +// jq (absent from stock Git for Windows and many minimal images) and none falls back to a +// regex. The old grep fallback stopped at the first escaped quote: `echo "x"; cat .env` +// was read as `echo \`, so the secret-read deny never fired, and the status line lost +// every segment. node is always present where the guards run — every hook is launched as +// `node run.mjs .sh`. +// +// node hookfield.mjs → that field, raw (no trailing newline) +// node hookfield.mjs -0 ... → "1", then each field, every one NUL-terminated +// +// A path is dot-separated (`tool_input.command`); `a|b` tries alternatives and the first +// non-empty wins. Missing/null → empty; a number/boolean/object prints as JSON text (what +// `jq -r` prints). Empty stdin reads as `{}`. Invalid JSON → exit 3 and NO output, so a +// `-0` caller that finds no leading "1" knows the parse failed and can fail closed. +// Node built-ins only. + +/** @param {unknown} root @param {string} path */ +function field(root, path) { + for (const alt of path.split("|")) { + /** @type {unknown} */ + let v = root; + for (const key of alt.split(".")) { + v = + v !== null && typeof v === "object" + ? /** @type {Record} */ (v)[key] + : undefined; + } + if (v === undefined || v === null || v === "") continue; + return typeof v === "string" ? v : JSON.stringify(v); + } + return ""; +} + +let raw = ""; +process.stdin.setEncoding("utf8"); +for await (const chunk of process.stdin) raw += chunk; +/** @type {unknown} */ +let data; +try { + data = raw.trim() ? JSON.parse(raw) : {}; +} catch { + process.exit(3); +} +const args = process.argv.slice(2); +if (args[0] === "-0") { + process.stdout.write( + `1\0${args + .slice(1) + .map((p) => `${field(data, p)}\0`) + .join("")}`, + ); +} else if (args[0]) { + process.stdout.write(field(data, args[0])); +} diff --git a/global/guards/protect-paths.mjs b/global/guards/protect-paths.mjs new file mode 100644 index 0000000..a376386 --- /dev/null +++ b/global/guards/protect-paths.mjs @@ -0,0 +1,193 @@ +#!/usr/bin/env node +// PreToolUse guard — block reads/edits of secret/credential files and obviously destructive +// Bash. The whole rule set lives in Node (the secret-redact.sh → secret-redact.mjs pattern), +// for three reasons the shell version could not give: +// 1. ONE parser. The shell guard read its payload with jq, else a regex that stopped at the +// first escaped quote — `echo "x"; cat .env` arrived as `echo \`, so every rule after it +// silently missed (2 review-found bypasses, reproduced here as tests). +// 2. NO pipeline hazards. `printf … | grep -q` under `pipefail` fails when grep exits early +// and printf takes SIGPIPE, so on a LARGE command the deny did not fire. +// 3. FAIL CLOSED. Exit 1 is a non-blocking hook error in Claude Code; `set -e` turned every +// internal hiccup into a silent pass. Here any error denies with a reason (exit 2). +// +// SCOPE: pattern matching, not a sandbox. Regex cannot parse shell, so interpreter-driven +// access (`python -c 'open(".env")…'`, `node -e …`) is DELIBERATELY out of scope. This layer +// sits behind the permission system and secret-redact.sh: best-effort hardening, never a +// boundary. + +// ── File rules. Paths are normalized to forward slashes (Claude Code sends native Windows +// paths) and matched case-insensitively (NTFS is; a `.ENV` is still a secret on POSIX). +const FILE_RULES = [ + { re: /\.env($|\.)/i, what: "env file" }, + { + re: /\.pem($|\.)|(^|\/)id_rsa($|\.)|(^|\/)id_ed25519($|\.)|\.key($|\.)/i, + what: "credential/key file", + }, + { re: /\/secrets\/|\/\.ssh\//i, what: "path under secrets/ or .ssh/" }, + { + re: /(^|\/)(\.aws\/credentials|\.netrc|_netrc|\.npmrc|\.git-credentials)$/i, + what: "credential store", + }, +]; + +// ── Command rules. A command word starts at the line start, after a separator, or after +// whitespace (so `sudo rm`, `env rm` and `/bin/rm` are all caught). +const B = "(^|[^A-Za-z0-9_.-])([^\\s;&|]*/)?"; +const SEG = "([^;&|]*\\s)?"; // further args inside the SAME command segment +const TARGET = "[\"']?(/|~|\\$HOME|\\$\\{HOME\\})"; // an absolute/home path operand +const RECUR = "(-[A-Za-z]*[rR][A-Za-z]*|--recursive)"; +// Git readers that can print file or history content (RA-05, HI-07), behind an optional +// `env `/`command `/`VAR=val ` prefix, an absolute path, and git's own global options. +const gitpfx = "([A-Za-z0-9_]+=\\S+\\s+|(env|command)\\s+)*(\\S*/)?git\\s+"; +const gitopt = + "(-C\\s+\\S+\\s+|--no-pager\\s+|-c\\s+\\S+\\s+|--git-dir=\\S+\\s+|--work-tree=\\S+\\s+)*"; +const gitsub = "(show|log|diff|stash|cat-file|archive|grep|blame|show-index|bundle)(\\s|$)"; +const READER = `(^|[;&|])\\s*((cat|less|more|head|tail|nl|xxd|od|strings|base64|rg|grep|ag)\\s|${gitpfx}${gitopt}${gitsub})`; +// \b anchors the extensions so `.key` matches a real key file but NOT `Object.keys`, and +// `.env` matches `.env`/`.env.prod` but NOT `.environment`. +const SECRET_TOKEN = + "(\\.env(\\.[A-Za-z0-9_-]+)?\\b|id_rsa\\b|id_ed25519\\b|\\.pem\\b|\\.key\\b|/secrets/|/\\.ssh/|\\.netrc\\b|_netrc\\b|\\.npmrc\\b|\\.git-credentials\\b|\\.aws/credentials\\b)"; +// A protected path as a redirection target, or as an argument to a mutating command. Each +// alternative embeds the token, so a bare `echo hi > out.txt` is never blocked. +const WRITE = [ + `>>?\\s*["']?[^\\s<>|;&]*${SECRET_TOKEN}`, + `(^|[;&|])\\s*(${gitpfx})?(tee(\\s+-a)?|cp|mv|install)\\s+[^;&|]*${SECRET_TOKEN}`, + `(^|[;&|])\\s*sed\\s+[^;&|]*-i[^;&|]*${SECRET_TOKEN}`, + `(^|[;&|])\\s*dd\\s+([^;&|]*\\s)?of=\\S*${SECRET_TOKEN}`, +].join("|"); + +/** @type {{all: RegExp[], reason: string}[]} — first match wins; protected paths first, so + * `dd if=x of=.env` reads as a secret write rather than as a generic `dd of=`. */ +const COMMAND_RULES = [ + { + // Close the Bash secret-READ bypass (P0-04): the Read tool denies .env/keys, but a shell + // `cat .env` / `git show HEAD:.env` sidesteps that. A reader command AND a protected + // path token — so prose in a quoted arg (a commit message naming ".env") is not a hit. + all: [new RegExp(READER), new RegExp(SECRET_TOKEN)], + reason: "reading a protected secret path via Bash is blocked. Read it yourself if intended.", + }, + { + // Close the Bash secret-WRITE bypass (HI-06). + all: [new RegExp(WRITE)], + reason: "writing to a protected secret path via Bash is blocked. Edit it yourself if intended.", + }, + { + // Recursive delete of an absolute/home path, flags in any order or grouping. + all: [ + new RegExp( + `${B}rm\\s+${SEG}${RECUR}(\\s[^;&|]*)?\\s${TARGET}|${B}rm\\s+${SEG}${TARGET}[^;&|]*\\s${RECUR}(\\s|$)|${B}rm\\s+${SEG}--no-preserve-root`, + ), + ], + reason: "destructive rm (recursive delete of an absolute/home path) detected.", + }, + { + // `--force-with-lease` / `--force-if-includes` are the SAFE variants and stay allowed. + all: [ + new RegExp( + `git\\s${SEG}push\\s${SEG}(--force([\\s=]|$)|-[A-Za-z0-9]*f[A-Za-z0-9]*(\\s|$)|\\+\\S+(\\s|$))`, + ), + ], + reason: "force-push blocked (--force-with-lease is allowed). Ask the user first.", + }, + { + all: [new RegExp(`git\\s${SEG}reset\\s${SEG}--hard(\\s|$)`)], + reason: "`git reset --hard` discards uncommitted work. Ask the user first.", + }, + { + all: [new RegExp(`git\\s${SEG}clean\\s${SEG}(-[A-Za-z0-9]*f[A-Za-z0-9]*|--force)(\\s|$)`)], + reason: "`git clean -f` deletes untracked files for good. Ask the user first.", + }, + { + all: [new RegExp(`${B}find\\s[^;&|]*\\s(-delete|-exec\\s+(\\S*/)?rm)(\\s|$)`)], + reason: "`find … -delete` / `-exec rm` detected. Ask the user first.", + }, + { + all: [new RegExp(`${B}chmod\\s+${SEG}(-[A-Za-z]*R[A-Za-z]*|--recursive)(\\s|$)`)], + reason: "recursive chmod detected. Ask the user first.", + }, + { + all: [new RegExp(`${B}dd\\s+${SEG}of=`)], + reason: "`dd … of=` overwrites its target. Ask the user first.", + }, + { + // Case-insensitive for the SQL keywords; the bare `TRUNCATE
    SourceIDGradeNote
    Cognitive Architectures for Language Agents
    Theodore R. Sumers, Shunyu Yao, Karthik Narasi, 2023
    2309.02427confirmedRetrieved via arXiv metadata API; title/authors match claim exactly. Unifies memory, planning/reasoning, action, and learning modules into a single CoALA framework for language agents, giving the cognitive-substrate work's memory/im…
    ` form stays + // uppercase-only so the coreutils `truncate` command is not a false positive. + all: [/\bdrop\s+(table|database|schema)\b|\btruncate\s+table\b|TRUNCATE\s+[A-Za-z0-9_"]/i], + reason: "destructive SQL detected. Confirm with the user.", + }, + { + // Pipe-to-shell (curl … | sh). Boundary-aware so `… | shellcheck` is not caught. + all: [/\|\s*(sh|bash|zsh)(\s|$)/], + reason: "piping content to a shell is blocked.", + }, +]; + +/** + * PURE decision over one tool call — the testable core. + * @param {{toolName?: string, filePath?: string, command?: string}} call + * @returns {{block: boolean, reason?: string}} + */ +export function protectPathsDecision({ toolName = "", filePath = "", command = "" } = {}) { + const path = String(filePath).replaceAll("\\", "/"); + if (path) { + // A plugin install carries no `permissions.deny` block, so for Read this guard is the + // only thing between the agent and `.env`. + const verb = /^(Read|Grep|Glob|NotebookRead)$/.test(String(toolName)) ? "read" : "modify"; + for (const { re, what } of FILE_RULES) { + if (re.test(path)) + return { + block: true, + reason: `refusing to ${verb} ${what} (${filePath}). Handle it yourself if intended.`, + }; + } + } + const cmd = String(command); + if (cmd) { + for (const rule of COMMAND_RULES) { + if (rule.all.every((re) => re.test(cmd))) return { block: true, reason: rule.reason }; + } + } + return { block: false }; +} + +/** @param {string} reason */ +function deny(reason) { + // Structured decision for current Claude Code; exit-2 + stderr is the version-agnostic + // fallback that older versions (and `forge doctor`) rely on. + process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: reason, + }, + }), + ); + process.stderr.write(`BLOCKED by protect-paths guard: ${reason}\n`); + process.exit(2); +} + +async function main() { + let raw = ""; + process.stdin.setEncoding("utf8"); + for await (const chunk of process.stdin) raw += chunk; + let data; + try { + data = raw.trim() ? JSON.parse(raw) : {}; + } catch { + deny("cannot parse the hook payload — blocking to fail closed"); + return; + } + const inp = data.tool_input ?? {}; + const d = protectPathsDecision({ + toolName: data.tool_name, + filePath: inp.file_path ?? inp.notebook_path ?? inp.path ?? "", + command: inp.command ?? "", + }); + if (d.block) deny(String(d.reason)); +} + +// Run only as the hook entrypoint; importing it (tests) must evaluate no payload. +if (process.argv[1] && /protect-paths\.mjs$/i.test(process.argv[1])) { + main().catch((err) => deny(`internal error (${err?.message ?? err}) — blocking to fail closed`)); +} diff --git a/global/guards/protect-paths.sh b/global/guards/protect-paths.sh index edde9d7..2762572 100755 --- a/global/guards/protect-paths.sh +++ b/global/guards/protect-paths.sh @@ -1,91 +1,22 @@ #!/usr/bin/env bash -# PreToolUse hook: block edits to secret/credential files and obviously destructive Bash. +# PreToolUse hook: block reads/edits of secret/credential files and obviously destructive Bash. +# Thin launcher: the payload parsing and the whole rule set live in protect-paths.mjs (Node), +# the same split secret-redact.sh uses — one real JSON parser instead of jq-or-a-regex, and no +# shell pipeline that can lose a match to SIGPIPE under `pipefail`. # Exit 2 = block the tool call and feed the reason back to Claude (works across versions). -set -euo pipefail +# FAIL CLOSED: exit 1 is a NON-blocking hook error in Claude Code, so a guard that cannot +# evaluate the call must deny, never fall through. +set -uo pipefail -input="$(cat)" +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +MJS="$DIR/protect-paths.mjs" +INPUT="$(cat)" -# Extract fields without requiring jq (fallback to grep). -if command -v jq >/dev/null 2>&1; then - tool="$(printf '%s' "$input" | jq -r '.tool_name // empty')" - fpath="$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')" - cmd="$(printf '%s' "$input" | jq -r '.tool_input.command // empty')" -else - tool="$(printf '%s' "$input" | grep -o '"tool_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/' || true)" - fpath="$(printf '%s' "$input" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/' || true)" - cmd="$(printf '%s' "$input" | grep -o '"command"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/' || true)" +if command -v node >/dev/null 2>&1 && [ -f "$MJS" ]; then + # Propagate the verdict verbatim — an exit 2 here is what blocks the tool call. + printf '%s' "$INPUT" | node "$MJS" + exit "$?" fi -deny() { - # Structured decision for current Claude Code; exit-2 + stderr as the version-agnostic fallback. - if command -v jq >/dev/null 2>&1; then - jq -n --arg r "$1" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}' - fi - echo "BLOCKED by protect-paths guard: $1" >&2 - exit 2 -} - -# Protect secret/credential files from writes. -case "$fpath" in - *.env|*/.env|*.env.*|*/.env.*) deny "refusing to modify env file ($fpath). Edit it yourself if intended." ;; - *.pem|*/id_rsa|*/id_ed25519|*.key) deny "refusing to modify credential/key file ($fpath)." ;; - */secrets/*|*/.ssh/*) deny "refusing to modify path under secrets/ or .ssh/ ($fpath)." ;; -esac - -# Guard clearly destructive shell commands. -if [ -n "${cmd:-}" ]; then - case "$cmd" in - *"rm -rf /"*|*"rm -rf ~"*|*"rm -rf --no-preserve-root"*) deny "destructive rm detected." ;; - *"git push --force"*|*"git push -f"*) deny "force-push blocked. Ask the user first." ;; - *"DROP TABLE"*|*"DROP DATABASE"*|*"TRUNCATE "*) deny "destructive SQL detected. Confirm with the user." ;; - esac - # Close the Bash secret-READ bypass (P0-04): the Read tool denies .env/keys, but a shell - # `cat .env` / `git show HEAD:.env` sidesteps that. Match a reader command anchored to a - # real command boundary (start, or after ; | &) so prose inside a quoted arg (a commit - # message mentioning ".env") isn't a false positive, AND require a protected path token. - # Best-effort defence in depth — a content scan like `rg TOKEN .` with no named path can't - # be caught here; that's what secret-redact.sh is for. - # - # SCOPE: this is a POSIX-ERE regex guard, not a sandbox. Regex cannot parse shell, so - # interpreter-driven access/writes — `python -c 'open(".env","w")…'`, `node -e …`, `perl -e` - # — are DELIBERATELY out of scope here. This layer sits behind the permission system and - # secret-redact.sh; treat every match/miss as best-effort hardening, never a boundary. - # - # Git subcommands that can print file/history content (RA-05, HI-07): show, log, diff, - # stash (show -p), cat-file, archive, grep, blame, show-index, bundle. Subcommand may be - # followed by a space or end the command string. - # HI-07 (best-effort hardening): tolerate an optional `env `/`command ` or `VAR=val ` - # prefix and an optional absolute/relative path before `git` (e.g. `/usr/bin/git`), then - # skip git's own global options (`-C `, `--no-pager`, `-c k=v`, `--git-dir=…`, - # `--work-tree=…`) between `git` and the subcommand. A wrapper we don't model can still slip. - gitpfx='([[:alnum:]_]+=[^[:space:]]+[[:space:]]+|(env|command)[[:space:]]+)*([^[:space:]]*/)?git[[:space:]]+' - gitopt='(-C[[:space:]]+[^[:space:]]+[[:space:]]+|--no-pager[[:space:]]+|-c[[:space:]]+[^[:space:]]+[[:space:]]+|--git-dir=[^[:space:]]+[[:space:]]+|--work-tree=[^[:space:]]+[[:space:]]+)*' - gitsub='(show|log|diff|stash|cat-file|archive|grep|blame|show-index|bundle)([[:space:]]|$)' - reader="(^|[;&|])[[:space:]]*((cat|less|more|head|tail|nl|xxd|od|strings|base64|rg|grep|ag)[[:space:]]|${gitpfx}${gitopt}${gitsub})" - # \b anchors the extensions so `.key` matches a real key file but NOT `Object.keys`, - # and `.env` matches `.env`/`.env.prod` but NOT `.environment`. - secret='(\.env(\.[A-Za-z0-9_-]+)?\b|id_rsa\b|id_ed25519\b|\.pem\b|\.key\b|/secrets/|/\.ssh/)' - if printf '%s' "$cmd" | grep -qE "$reader" && printf '%s' "$cmd" | grep -qE "$secret"; then - deny "reading a protected secret path via Bash is blocked. Read it yourself if intended." - fi - # Close the Bash secret-WRITE bypass (HI-06): the Edit/Write guard covers tool writes, but a - # shell `echo x > .env`, `printf … >> .env`, `tee .env`, `sed -i … .env`, `cp/mv/install X - # .env`, `dd of=.env`, or a truncation (`> .env`, `: > .env`) mutates a protected file past - # it. Detect a protected-path token appearing (a) as a redirection target (`>`/`>>` then the - # path) or (b) as an argument to a known mutating command. Interpreter writes are out of - # scope (see SCOPE note above). Each alternative embeds "$secret", so a bare `echo hi > - # out.txt` (no protected token) is never blocked. - w_redir=">>?[[:space:]]*[\"']?[^[:space:]<>|;&]*${secret}" - w_cmd="(^|[;&|])[[:space:]]*(${gitpfx})?(tee([[:space:]]+-a)?|cp|mv|install)[[:space:]]+[^;&|]*${secret}" - w_sed="(^|[;&|])[[:space:]]*sed[[:space:]]+[^;&|]*-i[^;&|]*${secret}" - w_dd="(^|[;&|])[[:space:]]*dd[[:space:]]+([^;&|]*[[:space:]])?of=[^[:space:]]*${secret}" - if printf '%s' "$cmd" | grep -qE "${w_redir}|${w_cmd}|${w_sed}|${w_dd}"; then - deny "writing to a protected secret path via Bash is blocked. Edit it yourself if intended." - fi - # Pipe-to-shell (e.g. curl … | sh). Boundary-aware so legit `… | shellcheck` is not caught. - if [[ "$cmd" =~ \|[[:space:]]*(sh|bash|zsh)([[:space:]]|$) ]]; then - deny "piping content to a shell is blocked." - fi -fi - -exit 0 +echo "BLOCKED by protect-paths guard: node unavailable or protect-paths.mjs missing — the guard cannot evaluate this tool call, so it is blocked (install Node 20+ to restore it)." >&2 +exit 2 diff --git a/global/guards/run.mjs b/global/guards/run.mjs index b7dd4dd..03fe1da 100755 --- a/global/guards/run.mjs +++ b/global/guards/run.mjs @@ -27,7 +27,9 @@ export const NO_BASH_HINT = "FORGE_BASH (or CLAUDE_CODE_GIT_BASH_PATH) at your bash executable"; /** - * Windows dirs that ship a `bash.exe` which is NOT Git Bash (the WSL launcher in System32). + * Windows dirs that ship a `bash.exe` which is NOT Git Bash: the WSL launcher in System32, + * and the Store app-execution aliases in `…\Microsoft\WindowsApps` (a zero-byte reparse + * point for the same WSL launcher — picking it made every guard exit 127, i.e. fail open). * @param {string} dir * @param {NodeJS.ProcessEnv} env */ @@ -35,6 +37,7 @@ function isSystemDir(dir, env) { /** @param {string} p */ const norm = (p) => String(p).toLowerCase().replaceAll("/", "\\").replace(/\\+$/, ""); const d = norm(dir); + if (d.endsWith("\\microsoft\\windowsapps")) return true; return [env.SystemRoot, env.windir, "C:\\Windows"] .filter(Boolean) .some((r) => d === norm(r) || d.startsWith(`${norm(r)}\\`)); diff --git a/global/guards/session-learner.sh b/global/guards/session-learner.sh index 150917d..0b8115a 100755 --- a/global/guards/session-learner.sh +++ b/global/guards/session-learner.sh @@ -24,9 +24,12 @@ MAXBYTES="${SESSION_LEARN_MAXBYTES:-60000}" OUTDIR="$HOME/.claude/skills/learned" LOG="$OUTDIR/.learn.log" -stdin_data="$(cat)" -tp="$(printf '%s' "$stdin_data" | grep -o '"transcript_path":"[^"]*"' | head -1 | cut -d'"' -f4)" -cwd="$(printf '%s' "$stdin_data" | grep -o '"cwd":"[^"]*"' | head -1 | cut -d'"' -f4)" +INPUT="$(cat)" +# A real JSON parser (jq, else node). The old grep read the RAW JSON string, so a Windows +# transcript path (its backslashes escaped inside JSON) never matched a real file and the +# learner silently never ran there. +tp="$(forge_field transcript_path)" +cwd="$(forge_field cwd)" [ -n "$tp" ] && [ -f "$tp" ] || exit 0 # Gate 3: long sessions only. diff --git a/global/settings.template.json b/global/settings.template.json index 2f2c73f..54d18c0 100644 --- a/global/settings.template.json +++ b/global/settings.template.json @@ -21,10 +21,13 @@ "Bash(git status:*)", "Bash(git diff)", "Bash(git diff:*)", - "Bash(git branch:*)", + "Bash(git branch)", + "Bash(git branch -a)", + "Bash(git branch -v)", + "Bash(git branch --show-current)", + "Bash(git branch --list:*)", "Bash(git add:*)", "Bash(ls:*)", - "Bash(fd:*)", "Bash(tree:*)", "Bash(wc:*)", "Bash(npm run lint)", @@ -67,11 +70,21 @@ "Read(./**/id_rsa)", "Read(./**/id_ed25519)", "Read(./secrets/**)", + "Read(./**/.netrc)", + "Read(~/.netrc)", + "Read(./**/.npmrc)", + "Read(~/.npmrc)", + "Read(./**/.git-credentials)", + "Read(~/.git-credentials)", + "Read(~/.aws/credentials)", "Bash(rm -rf /:*)", "Bash(rm -rf ~:*)", "Bash(git push --force:*)", "Bash(git push -f:*)", - "Bash(sudo:*)" + "Bash(sudo:*)", + "Bash(git branch -D:*)", + "Bash(git branch -d:*)", + "Bash(git diff --output:*)" ] }, "hooks": { @@ -166,6 +179,19 @@ ] } ] + }, + { + "matcher": "Read", + "hooks": [ + { + "type": "command", + "command": "node", + "args": [ + "~/.forge/guards/run.mjs", + "~/.forge/guards/protect-paths.sh" + ] + } + ] } ], "PostToolUse": [ diff --git a/global/statusline.sh b/global/statusline.sh index af5ca03..3d1392e 100644 --- a/global/statusline.sh +++ b/global/statusline.sh @@ -7,28 +7,57 @@ set -uo pipefail input="$(cat)" +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +HOOKFIELD="$DIR/guards/hookfield.mjs" -if command -v jq >/dev/null 2>&1; then +# Every field in ONE pass through a real JSON parser. node is always present (the status +# line itself is launched as `node guards/run.mjs statusline.sh`); jq is the fallback for a +# legacy `bash statusline.sh` wiring. There is no regex path: the old one had none either — +# without jq the line collapsed to " · · ?", losing model, cost, diff and +# cache health entirely (three review-found failures). +dir="" +model="" +cost="" +add="" +del="" +over="" +cread="" +cwrite="" +if command -v node > /dev/null 2>&1 && [ -f "$HOOKFIELD" ]; then + ok="" + { + IFS= read -r -d '' ok && + IFS= read -r -d '' dir && + IFS= read -r -d '' model && + IFS= read -r -d '' cost && + IFS= read -r -d '' add && + IFS= read -r -d '' del && + IFS= read -r -d '' over && + IFS= read -r -d '' cread && + IFS= read -r -d '' cwrite + } < <(printf '%s' "$input" | node "$HOOKFIELD" -0 \ + "workspace.current_dir|cwd" \ + "model.display_name|model.id" \ + cost.total_cost_usd \ + cost.total_lines_added \ + cost.total_lines_removed \ + exceeds_200k_tokens \ + "current_usage.cache_read_input_tokens|cost.cache_read_input_tokens" \ + "current_usage.cache_creation_input_tokens|cost.cache_creation_input_tokens") || true + [ "$ok" = "1" ] || model="" +elif command -v jq > /dev/null 2>&1; then dir="$(printf '%s' "$input" | jq -r '.workspace.current_dir // .cwd // empty')" - model="$(printf '%s' "$input" | jq -r '.model.display_name // .model.id // "?"')" + model="$(printf '%s' "$input" | jq -r '.model.display_name // .model.id // empty')" cost="$(printf '%s' "$input" | jq -r '.cost.total_cost_usd // empty')" add="$(printf '%s' "$input" | jq -r '.cost.total_lines_added // empty')" del="$(printf '%s' "$input" | jq -r '.cost.total_lines_removed // empty')" over="$(printf '%s' "$input" | jq -r '.exceeds_200k_tokens // false')" cread="$(printf '%s' "$input" | jq -r '.current_usage.cache_read_input_tokens // .cost.cache_read_input_tokens // empty')" cwrite="$(printf '%s' "$input" | jq -r '.current_usage.cache_creation_input_tokens // .cost.cache_creation_input_tokens // empty')" -else - dir="$PWD" - model="?" - cost="" - add="" - del="" - over="false" - cread="" - cwrite="" fi [ -n "${dir:-}" ] || dir="$PWD" +[ -n "${model:-}" ] || model="?" short="${dir/#$HOME/\~}" # Palette — the exact forgekit brand tokens (brand.json.colors.dark) rendered in diff --git a/hooks/hooks.json b/hooks/hooks.json index bc0cea3..90b0293 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -91,6 +91,19 @@ ] } ] + }, + { + "matcher": "Read", + "hooks": [ + { + "type": "command", + "command": "node", + "args": [ + "${CLAUDE_PLUGIN_ROOT}/global/guards/run.mjs", + "${CLAUDE_PLUGIN_ROOT}/global/guards/protect-paths.sh" + ] + } + ] } ], "PostToolUse": [ diff --git a/test/guards.test.js b/test/guards.test.js index f7d8c54..33e7c55 100644 --- a/test/guards.test.js +++ b/test/guards.test.js @@ -6,6 +6,8 @@ import { dirname, join } from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; +import { protectPathsDecision } from "../global/guards/protect-paths.mjs"; + const guards = join(dirname(fileURLToPath(import.meta.url)), "..", "global", "guards"); function runGuard(script, input, opts = {}) { @@ -286,3 +288,102 @@ test("cortex.sh stop (detached) processes the REAL session from the Stop payload "nothing fell back to the shared 'default' session", ); }); + +// ── B6. The rule set is now a pure function in protect-paths.mjs, so the matrix below runs +// without a process per case; the end-to-end cases above pin that the shim still exits 2. +test("protect-paths rules: destructive commands the literal substrings missed (B6)", () => { + const blocked = [ + "git reset --hard HEAD~1", + "git -C /repo reset --hard", + "git clean -fdx", + "git clean --force", + "find . -name '*.log' -delete", + "find /var -type f -exec rm {} ;", + "chmod -R 777 /srv", + "dd if=/dev/zero of=/dev/sda", + "drop table users;", + "psql -c 'DROP DATABASE prod'", + "rm -fr /", + "sudo rm -Rf ~", + "rm --recursive --force $HOME", + "git push --force origin main", + "git push -f", + "git push origin +main", + ]; + for (const command of blocked) { + const d = protectPathsDecision({ toolName: "Bash", command }); + assert.equal(d.block, true, `must block: ${command}`); + } + const allowed = [ + "git push --force-with-lease origin main", + "git push --force-if-includes", + "git push origin main", + "git clean -n", + "git reset --soft HEAD~1", + "truncate -s 0 build.log", + "chmod 644 src/a.js", + "chmod -r secret.txt", // remove read bit on ONE file: not recursive + "find . -name '*.log' -print", + "rm -rf node_modules", + "dd if=/dev/zero bs=1M count=1", + ]; + for (const command of allowed) { + const d = protectPathsDecision({ toolName: "Bash", command }); + assert.equal(d.block, false, `must not block: ${command} (${d.reason})`); + } +}); + +test("protect-paths protects the credential stores and Read itself (B6)", () => { + for (const file_path of [ + "/home/u/.aws/credentials", + "/home/u/.netrc", + "/home/u/.npmrc", + "/home/u/.git-credentials", + "C:\\Users\\u\\.aws\\credentials", // Windows-native path (backslashes) + "C:\\proj\\.env", + ]) { + for (const tool_name of ["Write", "Read"]) { + const r = runGuard("protect-paths.sh", { tool_name, tool_input: { file_path } }); + assert.equal(r.code, 2, `must block ${tool_name} of ${file_path}`); + assert.match(r.err, tool_name === "Read" ? /refusing to read/ : /refusing to modify/); + } + } + // Bash readers/writers of the same stores are blocked too. + for (const command of ["cat ~/.netrc", "cat .npmrc", "echo x > ~/.git-credentials"]) { + assert.equal(protectPathsDecision({ toolName: "Bash", command }).block, true, command); + } + // …and an ordinary source file is still untouched. + assert.equal( + runGuard("protect-paths.sh", { tool_name: "Read", tool_input: { file_path: "src/a.js" } }).code, + 0, + ); +}); + +test("protect-paths parses the payload with a real parser, not a regex (B6)", () => { + // The old grep fallback (used whenever jq was absent — stock Git for Windows, minimal + // images) cut the command at the first escaped quote, so everything after it was invisible. + for (const command of [ + 'echo "x"; cat .env', + 'echo "hello world" && cat .env', + 'git diff -- ".env"', + ]) { + const r = runGuard("protect-paths.sh", { tool_name: "Bash", tool_input: { command } }); + assert.equal(r.code, 2, `must block: ${command}`); + } + // A LARGE command used to lose its deny to SIGPIPE: `printf | grep -q` under pipefail + // reported failure when grep exited early, so the rule "did not match". + const big = `cat .env\n${Array.from({ length: 20000 }, (_, i) => `# note ${i}`).join("\n")}`; + const r = runGuard("protect-paths.sh", { tool_name: "Bash", tool_input: { command: big } }); + assert.equal(r.code, 2, "a 200 KB command still blocks"); +}); + +test("protect-paths fails CLOSED on an unparsable payload (B6)", () => { + // Exit 1 is a NON-blocking hook error in Claude Code, so an internal failure used to let + // the tool call through. + const r = spawnSync("bash", [join(guards, "protect-paths.sh")], { + input: "not json at all", + encoding: "utf8", + }); + assert.equal(r.status, 2, "an unparsable payload blocks"); + assert.match(r.stderr, /fail closed/i); +}); diff --git a/test/hook_launcher.test.js b/test/hook_launcher.test.js index be81334..c33224b 100644 --- a/test/hook_launcher.test.js +++ b/test/hook_launcher.test.js @@ -330,3 +330,28 @@ test("package: the launcher, the guards and both hook manifests ship in the npm ]) assert.ok(files.includes(want), `${want} missing from the package (${files.length} files)`); }); + +// B6: the Store app-execution alias `…\Microsoft\WindowsApps\bash.exe` is the SAME WSL +// launcher as System32's — it cannot run a `C:\…` guard path, so every guard exited 127, +// which Claude Code reads as a non-blocking hook error: the guards failed OPEN. +test("resolveBash never picks the WindowsApps WSL alias (B6)", () => { + const env = { + PATH: [ + "C:\\Users\\u\\AppData\\Local\\Microsoft\\WindowsApps", + "C:\\Program Files\\Git\\cmd", + ].join(";"), + SystemRoot: "C:\\Windows", + LOCALAPPDATA: "C:\\Users\\u\\AppData\\Local", + }; + const aliasOnly = (p) => /WindowsApps\\bash\.exe$/i.test(p); + assert.deepEqual( + resolveBash({ env, platform: "win32", exists: aliasOnly }), + { path: null, via: "none" }, + "no usable bash is honest; the alias is never returned", + ); + const withGit = (p) => + aliasOnly(p) || /Git\\(bin|usr\\bin)\\bash\.exe$/i.test(p) || /Git\\cmd\\git\.exe$/i.test(p); + const r = resolveBash({ env, platform: "win32", exists: withGit }); + assert.equal(r.via, "git-for-windows"); + assert.match(r.path, /Git\\bin\\bash\.exe$/); +}); diff --git a/test/settings_template.test.js b/test/settings_template.test.js index fa1c852..b7c4c07 100644 --- a/test/settings_template.test.js +++ b/test/settings_template.test.js @@ -126,3 +126,29 @@ test("protect-paths + secret-redact matchers agree across both manifests (HI-08) ); } }); + +// B6: a plugin install ships no `permissions.deny` block, so without a Read matcher the +// agent could read `.env`/keys through the Read tool with nothing in the way. +test("PreToolUse protect-paths also covers Read in both manifests (B6)", () => { + for (const [name, manifest] of [ + ["settings.template.json", template], + ["hooks.json", pluginHooks], + ]) { + const tools = (manifest.hooks?.PreToolUse ?? []) + .filter((g) => (g.hooks ?? []).some((h) => hookText(h).includes("protect-paths.sh"))) + .flatMap((g) => (g.matcher ?? "").split("|")); + assert.ok(tools.includes("Read"), `${name}: protect-paths must run on Read (got: ${tools})`); + } +}); + +test("the credential stores the guard protects are denied for Read too (B6)", () => { + const deny = template.permissions?.deny ?? []; + for (const rule of [ + "Read(~/.aws/credentials)", + "Read(~/.netrc)", + "Read(./**/.npmrc)", + "Read(~/.git-credentials)", + ]) { + assert.ok(deny.includes(rule), `missing deny rule: ${rule}`); + } +}); diff --git a/tsconfig.json b/tsconfig.json index b373822..66d7c6a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,5 +13,11 @@ "noUnusedLocals": true, "types": ["node"] }, - "include": ["src/**/*.js", "scripts/**/*.mjs", "global/guards/run.mjs"] + "include": [ + "src/**/*.js", + "scripts/**/*.mjs", + "global/guards/run.mjs", + "global/guards/hookfield.mjs", + "global/guards/protect-paths.mjs" + ] } From 5f0615ad0f339cf91a3829114d0e93630dd08e94 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:19:17 +0200 Subject: [PATCH 44/72] fix(gate): bind completion-gate evidence so the agent cannot mint it (B7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provenance stamp and the block-once marker are files under .forge/, so a hand-written {"tests":{"status":"PASS"}} satisfied the strong leg and a pre-written .blocked switched the gate off. Both now carry a MAC over what they claim, keyed by a machine-local key outside the repo (mode 0600, created on first use); unsigned evidence is ignored. Not a boundary — an agent with shell access can read the key — but forging is no longer a side effect of writing one file in the project. Also: a comment-only touch to a test file is no longer test evidence (the added lines must contain code); a package.json test script that masks failures (`node --test || true`) reports INCOMPLETE, not PASS; skillgate runs the heuristic ALONGSIDE the external scanner instead of letting a clean scanner exit replace it; imagine reads the runner's own (last) TAP summary and cross-checks the exit code. verify: classifySuiteFailure is now pure, so the "killed by a signal ⇒ INCOMPLETE" case is checked on every OS — the old fixture needed a POSIX shebang script that Windows cannot run, which is why that test failed there (ME-02). Co-Authored-By: Claude Opus 5 --- src/gate.js | 88 ++++++++++++++++-- src/imagine.js | 40 +++++++-- src/skillgate.js | 51 ++++++----- src/verify.js | 200 ++++++++++++++++++++++++++++++++--------- test/imagine.test.js | 23 +++++ test/skillgate.test.js | 34 +++++++ test/stop_gate.test.js | 74 ++++++++++++++- test/verify.test.js | 81 ++++++++++++++++- 8 files changed, 508 insertions(+), 83 deletions(-) diff --git a/src/gate.js b/src/gate.js index cc7f281..4316952 100644 --- a/src/gate.js +++ b/src/gate.js @@ -25,7 +25,7 @@ import { statePath } from "./handoff.js"; import { fingerprintFile, readBaseline, readDirtySnapshot } from "./session.js"; import { isTestFile } from "./substrate.js"; import { IGNORE_DIRS } from "./util.js"; -import { computeCodeState } from "./verify.js"; +import { computeCodeState, evidenceMac, provenanceMac } from "./verify.js"; // gitRaw keeps the exact bytes — porcelain's first column is a SPACE for unstaged // entries, and a trim() would eat it and shift the path slice by one. @@ -130,6 +130,64 @@ export function changedSet(root, baseHead, { sinceMs, preDirty } = {}) { return [...out].filter((p) => !IGNORED_PREFIX(p)).sort(); } +// Blank lines and comment-only lines are not test code — a `// touched` line appended to an +// existing test file is a touch, not a test (B7). Deliberately language-agnostic: `//`, `#`, +// `*`, `/*`, `--`, ` src + test -- 225 --> src bench -- 7 --> src examples -- 4 --> src + test -- 2 --> global test -- 2 --> scripts scripts --> src src --> global test --> bench - test --> global ``` diff --git a/test/brain.test.js b/test/brain.test.js index 3075db3..c6537f2 100644 --- a/test/brain.test.js +++ b/test/brain.test.js @@ -46,7 +46,10 @@ test("the broadcast index withholds a fact the ledger's evidence refuted (and sa const root = fixture(); const store = brainStore(root); const today = epochDay(); - assert.equal(remember(store, "deploy", "Run `npm run deploy:prod` directly, CI is optional").ok, true); + assert.equal( + remember(store, "deploy", "Run `npm run deploy:prod` directly, CI is optional").ok, + true, + ); assert.match(brainBlock(root), /deploy/, "a fresh fact is broadcast"); const dir = repoLedger(root); const claim = loadClaims(dir).find((c) => c.kind === "fact"); From 6ebaf67d5419cd9d7a6938a63870d5e46971799a Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:52:07 +0200 Subject: [PATCH 56/72] docs(cost-eval): restore the "not a bound" correction dropped in the merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two areas edited this file: research rewrote the 62% status paragraph and primitives corrected the composed-reduction claim (E4 — a measured stage can be negative, so the figure is not a lower bound). Resolving the conflict in favour of research's paragraph silently took primitives' correction with it. Restored; both changes now stand. Co-Authored-By: Claude Opus 5 --- reports/cost-eval.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/reports/cost-eval.md b/reports/cost-eval.md index a5c9d0e..0c66fd2 100644 --- a/reports/cost-eval.md +++ b/reports/cost-eval.md @@ -62,7 +62,8 @@ Route and context events are emitted via `recordRoute` / a future context-assemb - Stage rates are **workload-dependent**: factors describe the recorded traffic of one repo, not a general claim (spec §2 — repeat-heavy warm-ledger workloads differ from cold starts). -- The composed reduction is a **lower bound from measured stages only**; unmeasured stages - contribute nothing rather than a target. +- The composed reduction covers **measured stages only** and is **not a bound**: unmeasured + stages contribute nothing rather than a target, and a measured stage can be negative (it + raised cost), so measuring another stage can lower the figure. - Until the paired-run harness with the correctness guard has run, per-stage factors from live metrics are unguarded observational numbers, not eval results. From fec795007108f5052389f9090732c74459c456bf Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:57:10 +0200 Subject: [PATCH 57/72] fix(recall): migrate a fact off its pre-Unicode slug instead of forking it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Unicode-aware slug (9e24b92) changed the key of any fact whose name stripped to "" under the old rule — those live on disk as the shared "fact.md" fallback. Re-adding such a fact wrote the new key and left the old file in place, so list() and MEMORY.md served a stale twin forever, with no migration step. add() now drops any other file holding the same fact NAME before writing. Found by the independent review of the merged diff; only the legacy file store (FORGE_LEDGER_ONLY=0) was affected — the ledger supersedes by (name, text) and was never at risk. Co-Authored-By: Claude Opus 5 --- src/recall.js | 12 ++++++++++++ test/recall.test.js | 17 ++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/recall.js b/src/recall.js index 2685a19..f04f4b9 100644 --- a/src/recall.js +++ b/src/recall.js @@ -41,6 +41,18 @@ export function add(store, name, body) { if (!ledgerOnly()) { const dir = factsDir(store); mkdirSync(dir, { recursive: true }); + // Slug migration: `slug()` became Unicode-aware, so a fact written by an older version + // can sit under a different (lossier) key — often the shared "fact" fallback, because + // a non-Latin name used to strip to "". Writing the new key alone would leave the old + // file behind as a second entry that list()/MEMORY.md keep serving, so the reader gets + // the stale value forever. One fact per NAME: drop any other file holding this name. + if (existsSync(dir)) { + for (const file of readdirSync(dir)) { + if (!file.endsWith(".md") || file === `${slug}.md`) continue; + const prev = readFact(store, file.slice(0, -3)); + if (prev?.name === name) rmSync(join(dir, file)); + } + } writeFileSync(join(dir, `${slug}.md`), `# ${name}\n\n${body.trim()}\n`); } reindex(store); diff --git a/test/recall.test.js b/test/recall.test.js index 2ec74fb..282763b 100644 --- a/test/recall.test.js +++ b/test/recall.test.js @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtempSync, readFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; @@ -66,3 +66,18 @@ test("add regression (E5): two non-ASCII fact names no longer overwrite each oth assert.equal(slugs.length, 2, `both facts kept: ${slugs.join(", ")}`); assert.ok(!slugs.includes("fact")); }); + +test("add: a fact stored under an older, lossier slug is migrated, not duplicated", () => { + // The Unicode-aware slug changed this fact's key: it used to strip to "" and land on the + // shared "fact" fallback. Re-adding it must MOVE the fact, not leave the pre-upgrade file + // behind — a stale twin that list()/MEMORY.md would keep serving (found by review). + const s = store(); + const dir = join(s, "facts"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "fact.md"), "# مفتاح الواجهة\n\nthe api base is https://old.example\n"); + assert.equal(add(s, "مفتاح الواجهة", "the api base is https://new.example").ok, true); + const slugs = list(s); + assert.equal(slugs.length, 1, `one entry per name, got: ${slugs.join(", ")}`); + assert.ok(!existsSync(join(dir, "fact.md")), "the pre-upgrade file is gone"); + assert.match(readFileSync(join(dir, `${slugs[0]}.md`), "utf8"), /new\.example/); +}); From 2b8941c8079696a6211ac8b57fcc4bb53b425783 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:01:39 +0200 Subject: [PATCH 58/72] docs(architecture): stop claiming legacy stores are the ledger read path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The P2 read-flip made the ledger the default and only store (util.js ledgerOnly() defaults true) — legacy lessons/recall/brain files are no longer written or read unless FORGE_LEDGER_ONLY=0. ARCHITECTURE.md still described the pre-flip state ("legacy stores remain the read path") and the repo-layout comments for ledger_bridge.js/ledger_read.js still framed the bridge as active by default; both now match the code. Co-Authored-By: Claude Opus 5 --- ARCHITECTURE.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b39217d..dc18249 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -161,8 +161,9 @@ Mechanically: evidence and tombstones are append-only, hash-deduped logs; confid (`val`) is a decayed Beta posterior moved only by oracles; merge is a join-semilattice (property-tested: commutative, associative, idempotent), so ledgers converge in any order. `forge init` emits the union-merge `.gitattributes` rule; `forge ledger merge` -folds in any other ledger tree. The legacy stores remain the read path — the ledger is -where their events converge. Surface: `forge ledger stats | verify | show | blame | +folds in any other ledger tree. The ledger is now the default and only store — legacy +files are no longer written or read (`FORGE_LEDGER_ONLY=0` is the one-release escape +hatch back to them). Surface: `forge ledger stats | verify | show | blame | query | ratify | retract | merge | import` (`--personal` for the per-user ledger). Decision recorded in [`docs/adr/0006-proof-carrying-memory.md`](docs/adr/0006-proof-carrying-memory.md). @@ -523,8 +524,8 @@ forgekit/ emit/ # one module per tool (claude, codex, cursor, gemini, aider, copilot, windsurf, zed, continue) + mcp ledger.js # PCM core: content-addressed claims, oracle taxonomy, decayed Beta val, Eq. 3 retrieval, semilattice merge (ADR-0006) ledger_store.js # git-native on-disk ledger (.forge/ledger/): sharded claims, append-only evidence/tombstone logs, normal-form verify - ledger_bridge.js # legacy-store bridge: cortex/recall/brain shadow-writes + idempotent `ledger import` - ledger_read.js # merged legacy∪ledger read path: cortex lesson/fact injection, `recall list`, brain's AGENTS.md index all see teammate knowledge from `ledger merge` + ledger_bridge.js # legacy-store bridge, dormant by default (ledger-only); `FORGE_LEDGER_ONLY=0` re-enables cortex/recall/brain shadow-writes + idempotent `ledger import` + ledger_read.js # ledger-only read path by default (`FORGE_LEDGER_ONLY=0` merges legacy∪ledger instead): cortex lesson/fact injection, `recall list`, brain's AGENTS.md index all see teammate knowledge from `ledger merge` reuse.js # proof-carrying artifact cache: fingerprint (MinHash+LSH), exact→near→adapt→miss ladder, atlas revalidation embed.js # optional embeddings tier (ADR-0005): FORGE_EMBED=cmd:|http:, swaps MinHash/Jaccard for cosine in `reuse query`/`ledger query`, disk-cached at .forge/embed-cache.jsonl, silent fallback to MinHash context.js # budgeted context assembly + completeness gate: R(edit) set cover, compression ladder, computed missing-set From ee6385f5bc93c065aa78fec05bcb80301163b138 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:01:43 +0200 Subject: [PATCH 59/72] docs(readme): correct routing claim that an LLM vote can raise the tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit route.js's reconcileRoute() never applies a higher-band vote (whitepaper §5.1) — it only lowers the tier, confidence-gated and bounded, and records what it would have picked as the advisory `llm.escalateTo` field, which nothing acts on automatically. The old routeTask() this replaced did apply a free raise, which is what the README bullet was still describing. Co-Authored-By: Claude Opus 5 --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 23b0f9b..82b63f9 100644 --- a/README.md +++ b/README.md @@ -235,8 +235,10 @@ from a fresh repository graph. lessons are selected under a token budget. Missing required context becomes a question rather than invented context. - **Model-tier recommendation.** A deterministic rubric combines task text and repository - signals. An optional LLM proposal can raise the tier or lower it only inside bounded rails. - Forgekit advises which tier to request; it does not itself proxy or fail over model traffic. + signals. An optional LLM proposal can only lower the tier, confidence-gated and bounded; a + vote for a higher tier is never applied automatically — it surfaces only as an advisory + `escalateTo` recommendation. Forgekit advises which tier to request; it does not itself proxy + or fail over model traffic. - **Proof-gated reuse.** Cached code is served only after evidence clears a confidence floor and declared dependencies still resolve in the current repository graph. - **Lifecycle guardrails.** Claude Code hooks cover prompt preflight, protected paths, cost From 413e4be08895c33478cea9b34a73f025ecd5f7f6 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:28:23 +0200 Subject: [PATCH 60/72] feat(impact)!: walk reverse dependencies by default, wide relations on request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sibling and forward relations ported from the empirical refutation's repaired oracle do what the paper says — they are why a sibling that shares a dependency is no longer missed — but they are a recall instrument. On this repo they take the median answer from 15 files to 78 of ~450 (max 196): recall 1.00, precision 0.09, and the substrate's 25-file blast threshold would trip on nearly every edit. impact() now defaults to DEFAULT_IMPACT_RELATIONS (reverse only). Callers pass `relations` for more; `forge impact --all-relations` is the CLI door, and predictImpact threads it through. The rules and their frozen parameters are untouched — only the default changed. The A2 tests now request the wide walk explicitly, and a new test pins the default so it cannot drift back. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 9 +++++++++ docs/GUIDE.md | 8 ++++++++ src/atlas.js | 12 ++++++++++-- src/cli.js | 14 +++++++++++--- src/substrate.js | 7 ++++++- test/atlas_relations.test.js | 30 ++++++++++++++++++++++++------ 6 files changed, 68 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a542352..cd5206b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -623,6 +623,15 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Changed +- **`forge impact` stays focused by default; the wide walk is `--all-relations`.** The + sibling and forward relations ported from the empirical refutation's repaired oracle are + a recall instrument: on this repo they take the median answer from 15 files to 78 of ~450 + (max 196) — recall 1.00, precision 0.09 — and the substrate's 25-file blast threshold + would trip on almost every edit. `impact()` now walks reverse dependencies only unless a + caller passes `relations` (`IMPACT_RELATIONS` for all three), and `forge impact + --all-relations` asks for the wide walk. The relations themselves are unchanged, at their + frozen parameters; only which ones run by default changed. + - **`forge route calibrate` stops calling itself "outcome-calibrated routing".** Nothing in it comes from an outcome: the fixture is 24 hand-written task phrases with hand-assigned complexities, and forge records nothing that could replace them — a `route` metrics event diff --git a/docs/GUIDE.md b/docs/GUIDE.md index bebb5ec..236bee3 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -282,6 +282,14 @@ impacts all co-members) and a data-driven threshold from PageRank centrality and ledger incident history. `--basic` reverts to the fixed-threshold mode. Run `forge atlas build` first. +By default the walk follows **reverse dependencies only** — the files that actually +reference the target. `--all-relations` additionally walks the empirical refutation's +repaired **sibling** and **forward** rules at their frozen parameters (a file that shares +a dependency with the target, and what the target itself depends on). That is a recall +instrument, not an everyday view: on forgekit itself the median answer goes from 15 files +to 78 of ~450 (recall 1.00, precision 0.09), so reach for it when you need "what could +conceivably be affected", not "what references this". + ```console $ forge impact verifyToken Forge impact — blast radius (hazard-aware) diff --git a/src/atlas.js b/src/atlas.js index 896889c..c32a416 100644 --- a/src/atlas.js +++ b/src/atlas.js @@ -1225,6 +1225,13 @@ export const SIBLING = Object.freeze({ }); export const FORWARD = Object.freeze({ maxHops: 2, weight: 0.5 }); export const IMPACT_RELATIONS = Object.freeze(["reverse", "sibling", "forward"]); +/** What `impact()` walks unless a caller asks for more. The sibling/forward rules above are + * the paper's repair and they work — but they are a RECALL instrument: on this repo the + * median answer goes from 15 files to 78 of ~450 (max 196), recall 1.00, precision 0.093. + * An everyday "what does this change touch?" wants the focused answer, and a gate whose + * blast threshold is 25 files would otherwise trip on almost every edit. So the wider walk + * is opt-in: `impact(atlas, f, { relations: IMPACT_RELATIONS })`, or `--all-relations`. */ +export const DEFAULT_IMPACT_RELATIONS = Object.freeze(["reverse"]); const round4 = (x) => Number(x.toFixed(4)); @@ -1235,7 +1242,8 @@ const round4 = (x) => Number(x.toFixed(4)); * @param {number} [opts.threshold] * @param {number} [opts.maxHops] reverse-dependency hop cap * @param {number} [opts.decay] - * @param {readonly string[]} [opts.relations] subset of IMPACT_RELATIONS (default: all) + * @param {readonly string[]} [opts.relations] subset of IMPACT_RELATIONS + * (default: DEFAULT_IMPACT_RELATIONS — reverse only; pass IMPACT_RELATIONS for the wide walk) * @param {boolean} [opts.llm] * @param {(p:string)=>string} [opts.run] * @param {(file:string, target:string)=>boolean} [opts.verify] @@ -1249,7 +1257,7 @@ export function impact( threshold = 0.1, maxHops = 6, decay = 0.85, - relations = IMPACT_RELATIONS, + relations = DEFAULT_IMPACT_RELATIONS, llm, run, verify, diff --git a/src/cli.js b/src/cli.js index d871f05..f652fee 100755 --- a/src/cli.js +++ b/src/cli.js @@ -1711,16 +1711,24 @@ HANDLERS.impact = async (argv) => { const { predictImpact } = await import("./substrate.js"); const json = argv.includes("--json"); const basic = argv.includes("--basic"); + // Default is the focused reverse walk. --all-relations adds the paper's sibling/forward + // rules: recall 1.00, but on this repo the median answer goes from 15 files to 78. + const all = argv.includes("--all-relations"); + const FLAGS = new Set(["--json", "--basic", "--all-relations"]); const target = argv .slice(1) - .filter((a) => a !== "--json" && a !== "--basic") + .filter((a) => !FLAGS.has(a)) .join(" "); if (!target) { - console.error("usage: forge impact [--json] [--basic]"); + console.error("usage: forge impact [--json] [--basic] [--all-relations]"); process.exitCode = 1; return; } - const r = predictImpact(process.cwd(), target, { basic }); + const { IMPACT_RELATIONS } = await import("./atlas.js"); + const r = predictImpact(process.cwd(), target, { + basic, + ...(all ? { relations: IMPACT_RELATIONS } : {}), + }); if (json) { console.log(JSON.stringify(r, null, 2)); return; diff --git a/src/substrate.js b/src/substrate.js index 26bd72a..d54c01d 100644 --- a/src/substrate.js +++ b/src/substrate.js @@ -165,11 +165,13 @@ export function loadRankData(root) { * @param {string} [opts.model] * @param {number} [opts.timeoutMs] * @param {boolean} [opts.basic] skip hazard-aware enhancements + * @param {readonly string[]} [opts.relations] which relations to walk; omitted means + * impact()'s own default (reverse only). Pass IMPACT_RELATIONS for the wide walk. */ export function predictImpact( root, target, - { threshold = 0.1, llm, model, timeoutMs, basic } = {}, + { threshold = 0.1, llm, model, timeoutMs, basic, relations } = {}, ) { const cached = loadAtlas(root); const atlas = cached && !atlasIsStale(root, cached) ? cached : buildAtlas({ root }); @@ -177,6 +179,9 @@ export function predictImpact( const rankData = basic ? {} : loadRankData(root); return impactGraph(atlas, target, { threshold, + // undefined → impact()'s own default (reverse only). `forge impact --all-relations` + // passes IMPACT_RELATIONS to add the paper's sibling/forward walk. + ...(relations ? { relations } : {}), llm: useLLM, run: useLLM ? buildRunner({ model, timeoutMs }) : undefined, verify: makeImpactVerify(root), diff --git a/test/atlas_relations.test.js b/test/atlas_relations.test.js index b2cc724..71e7565 100644 --- a/test/atlas_relations.test.js +++ b/test/atlas_relations.test.js @@ -5,7 +5,13 @@ // FROZEN_PARAMETERS.json). import assert from "node:assert/strict"; import { test } from "node:test"; -import { build, impact } from "../src/atlas.js"; +import { build, IMPACT_RELATIONS, impact } from "../src/atlas.js"; + +// These pin the sibling/forward CAPABILITY, so they ask for it explicitly: the shipped +// DEFAULT is reverse-only (the wide walk quadruples the radius — see the default test at +// the end of this file and `forge impact --all-relations`). +const ALL = IMPACT_RELATIONS; + import { sibFiles, writeRepo } from "./fixtures/impact_repos.mjs"; const mod = (name) => ({ id: `module:${name}`, name, kind: "module", file: `${name}.js` }); @@ -23,7 +29,7 @@ const HOP = 0.85 * 0.85; test("A2: serializer.js finds its sibling deserializer.js through the shared wire_format.js", () => { const atlas = build({ root: writeRepo(sibFiles) }); - const r = impact(atlas, "src/serializer.js"); + const r = impact(atlas, "src/serializer.js", { relations: ALL }); assert.equal(item(r, "src/app.js")?.relation, "reverse"); assert.equal(item(r, "src/deserializer.js")?.relation, "sibling", JSON.stringify(r.relations)); assert.equal(item(r, "src/wire_format.js")?.relation, "forward"); @@ -41,7 +47,7 @@ test("A2: sibling and forward confidences use the frozen weights", () => { ["a", "b", "c", "d", "f"], [imp("a", "c"), imp("b", "c"), imp("c", "d"), imp("d", "f")], ); - const r = impact(atlas, "a.js", { threshold: 0.01 }); + const r = impact(atlas, "a.js", { threshold: 0.01, relations: ALL }); const b = item(r, "b.js"); assert.equal(b?.relation, "sibling"); assert.ok(near(b.confidence, HOP * HOP * 0.7), `sibling ${b.confidence}`); @@ -58,7 +64,7 @@ test("A2: sibling and forward nodes are terminal — never expanded further", () ["a", "b", "c", "d", "e", "g"], [imp("a", "c"), imp("b", "c"), imp("a", "d"), imp("e", "b"), imp("g", "d")], ); - const r = impact(atlas, "a.js", { threshold: 0.01 }); + const r = impact(atlas, "a.js", { threshold: 0.01, relations: ALL }); assert.ok(item(r, "b.js"), "sibling found"); assert.ok(item(r, "d.js"), "forward found"); assert.equal(item(r, "e.js"), undefined, "a sibling's dependents are not expanded"); @@ -69,7 +75,7 @@ test("A2: a bridge used by more than 100 other files is a hub and yields no sibl const withUsers = (n) => { const names = ["a", "hub", ...Array.from({ length: n }, (_, i) => `u${i}`)]; const edges = [imp("a", "hub"), ...names.slice(2).map((u) => imp(u, "hub"))]; - return impact(graph(names, edges), "a.js", { threshold: 0.01 }); + return impact(graph(names, edges), "a.js", { threshold: 0.01, relations: ALL }); }; const under = withUsers(99); // hub in-degree 100 (a + 99): at the cap → still a bridge assert.equal(under.relations.sibling, 99); @@ -87,6 +93,18 @@ test("A2: docs and configs are never siblings", () => { kind: "references", confidence: 1, }); - const r = impact(atlas, "a.js", { threshold: 0.01 }); + const r = impact(atlas, "a.js", { threshold: 0.01, relations: ALL }); assert.equal(item(r, "README.md"), undefined); }); + +test("the DEFAULT walk is reverse-only — the wide relations are opt-in", () => { + // The sibling/forward rules are a recall instrument: on forgekit itself they take the + // median answer from 15 files to 78 of ~450. `forge impact` stays focused by default. + const atlas = build({ root: writeRepo(sibFiles) }); + const byDefault = impact(atlas, "src/serializer.js").impactedFiles.filter( + (f) => f !== "src/serializer.js", + ); + assert.deepEqual(byDefault, ["src/app.js"], "default names only real importers"); + const wide = impact(atlas, "src/serializer.js", { relations: ALL }).impactedFiles; + assert.ok(wide.includes("src/deserializer.js"), "the sibling is still reachable on request"); +}); From 59ad964f02b7c1f7eafe317ba2cebfa411cc2631 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:34:13 +0200 Subject: [PATCH 61/72] fix(commit-gate): treat a git error on stderr as unscanned even when git exits 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught what a Windows-only run could not: `git diff --cached` over a staged blob whose object is missing prints `error: unable to read …` and still exits 0 on the Linux runner's git, so the scan saw an EMPTY diff and read it as "this file added no lines" — the exact fail-open B3 closed for oversized diffs. Which git versions exit non-zero for this differs by platform, so gitStrict now trusts the message, not the status: an `error:`/`fatal:` line on stderr throws, which routes the file into `unscanned` and refuses the commit. Warnings (CRLF conversion on a Windows checkout) stay routine and never block. Co-Authored-By: Claude Opus 5 --- src/commit_gate.js | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/commit_gate.js b/src/commit_gate.js index 037dce8..6e4eaf0 100644 --- a/src/commit_gate.js +++ b/src/commit_gate.js @@ -23,7 +23,7 @@ // "couldn't look" must never read as "nothing there" (the old 1 MiB default buffer // turned any leak + one big file into a silent pass). -import { execFileSync } from "node:child_process"; +import { spawnSync } from "node:child_process"; import { BRAND } from "./brand.js"; import { CLASSES, classifyPath } from "./gate.js"; import { hasSecret, redactSecrets } from "./secrets.js"; @@ -43,12 +43,19 @@ const lineBlockSecret = (text) => hasSecret(text) && redactSecrets(text) !== tex // file, never into an empty diff. const MAX_DIFF_BYTES = 256 * 1024 * 1024; function gitStrict(root, args) { - return execFileSync("git", args, { - cwd: root, - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - maxBuffer: MAX_DIFF_BYTES, - }); + const r = spawnSync("git", args, { cwd: root, encoding: "utf8", maxBuffer: MAX_DIFF_BYTES }); + if (r.error) throw r.error; // spawn failure or ENOBUFS — the caller falls back per file + if (r.status !== 0) throw new Error(`git exited ${r.status}`); + // git can report a hard error on stderr and STILL exit 0 — a staged blob whose object is + // missing or unreadable prints `error: unable to read …` and yields an EMPTY diff, which + // would read as "this file added no lines" and let a credential through unscanned. Which + // git versions exit non-zero for this differs by platform, so trust the message, not the + // status. Warnings are routine (CRLF conversion on a Windows checkout) and never fatal. + const bad = String(r.stderr || "") + .split("\n") + .find((l) => /^(error|fatal):/i.test(l.trim())); + if (bad) throw new Error(`git reported: ${bad.trim()}`); + return r.stdout; } function gitRaw(root, args) { try { From 2fb13874628cd188b12f13ca7cf800eb4c2615c9 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:37:42 +0200 Subject: [PATCH 62/72] test(commit-gate): report git's actual status/stderr when the B3 fail-closed case fails The assertion said only 'expected true'. On Linux and macOS the diff of a staged blob whose object was deleted evidently still succeeds, and the message gave no way to tell why. It now carries git's exit status, stderr and the gate's findings. Co-Authored-By: Claude Opus 5 --- test/commit_gate.test.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/test/commit_gate.test.js b/test/commit_gate.test.js index b55fda5..554a111 100644 --- a/test/commit_gate.test.js +++ b/test/commit_gate.test.js @@ -250,9 +250,17 @@ test("a staged file git cannot diff is refused as unscanned, never passed (B3 fa const sha = String(git("ls-files", "-s", "cfg.js")).split(/\s+/)[1]; rmSync(join(root, ".git", "objects", sha.slice(0, 2), sha.slice(2)), { force: true }); const r = commitGate(root, { env: env() }); - assert.equal(r.allow, false, "an unreadable diff is not a clean diff"); + // Diagnostics in the message: which git behaviour this platform actually shows, so a + // failure here says WHY (git's status/stderr for the corrupted blob) instead of "false". + const probe = spawnSync( + "git", + ["diff", "--cached", "--unified=0", "--no-color", "--text", "--no-ext-diff", "--no-textconv"], + { cwd: root, encoding: "utf8" }, + ); + const why = `git status=${probe.status} stderr=${JSON.stringify(String(probe.stderr).slice(0, 200))} stdoutLen=${String(probe.stdout).length} findings=${JSON.stringify(r.findings)}`; + assert.equal(r.allow, false, `an unreadable diff is not a clean diff — ${why}`); const f = r.findings.find((x) => x.kind === "secret-scan"); - assert.ok(f, "reported as an unscanned file"); + assert.ok(f, `reported as an unscanned file — ${why}`); assert.deepEqual(f.files, ["cfg.js"], "only the unreadable file is unscanned"); assert.match(renderCommitGate(r), /could not read the staged lines of: cfg\.js/); assert.equal(cli(root).status, 1); From d892bbda5ee2da8013ab6a9599b2a84373ab9d26 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:41:16 +0200 Subject: [PATCH 63/72] test(commit-gate): delete the worktree copy too, so git must read the missing object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the Linux and macOS runners git answered `diff --cached` for the corrupted blob from the worktree file (status 0, empty stderr, full diff), so the staged file was scanned normally and the fail-closed path never ran — the test passed on Windows and failed everywhere else. Removing the worktree copy forces git to read the object it no longer has. Co-Authored-By: Claude Opus 5 --- test/commit_gate.test.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/commit_gate.test.js b/test/commit_gate.test.js index 554a111..268eaaa 100644 --- a/test/commit_gate.test.js +++ b/test/commit_gate.test.js @@ -246,9 +246,14 @@ test("a staged file git cannot diff is refused as unscanned, never passed (B3 fa writeFileSync(join(root, "cfg.js"), leak()); writeFileSync(join(root, "README.md"), "# app\n\ndocumented\n"); git("add", "-A"); - // Corrupt the object store under the staged blob: every diff of it now errors. + // Corrupt the object store under the staged blob: every diff of it now errors. The + // worktree copy goes too — with it in place and its stat matching the index, git can + // answer `diff --cached` from the file and never touch the missing object (it does on + // the Linux/macOS runners: status 0, empty stderr, a full diff), which would make this + // test pass for the wrong reason on one platform and fail on another. const sha = String(git("ls-files", "-s", "cfg.js")).split(/\s+/)[1]; rmSync(join(root, ".git", "objects", sha.slice(0, 2), sha.slice(2)), { force: true }); + rmSync(join(root, "cfg.js"), { force: true }); const r = commitGate(root, { env: env() }); // Diagnostics in the message: which git behaviour this platform actually shows, so a // failure here says WHY (git's status/stderr for the corrupted blob) instead of "false". From 10ce67f37042fac7cc68b482451e96579da9c544 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:51:25 +0200 Subject: [PATCH 64/72] ci(security): pass .gitleaks.toml explicitly and print the findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scan ran `gitleaks detect --source .` with no --config, so the repo's allowlist (synthetic PEM/token fixtures, bibliography citation keys) may never have been applied, and --redact without --verbose printed only "leaks found: 20" — a blocking gate that names nothing is unactionable. The job now loads the config explicitly and lists each finding's path, line and rule, with the secret still redacted. Co-Authored-By: Claude Opus 5 --- .github/workflows/security.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 6c08fc9..cd696b1 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -41,4 +41,9 @@ jobs: "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ | tar -xz -C /tmp gitleaks # `detect` walks the whole git log (fetch-depth:0 above); non-zero exit on any find. - /tmp/gitleaks detect --source . --no-banner --redact --exit-code 1 + # --config is EXPLICIT: the repo's .gitleaks.toml carries the allowlist for the + # synthetic fixtures and bibliography citation keys, and a scan that silently ran + # without it reported findings nobody could act on. --verbose prints each finding + # (path, line, rule; the secret itself stays redacted) so a red gate names what to fix. + /tmp/gitleaks detect --source . --config .gitleaks.toml \ + --no-banner --redact --verbose --exit-code 1 From 2174c42aa44aab4af7664eabb39fd41e7d42e422 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:52:46 +0200 Subject: [PATCH 65/72] fix(cortex): caller_fanout from the FILE when the edit has no symbol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit featuresForEdit() asked grepFanout() about edit.symbol, so every caller holding only a file path — which is every hook fired on an edit event — got grepFanout(root, undefined) === 0. The feature was permanently dead for them: a module with twenty importers scored exactly like one nobody references. Without a symbol the fan-out now comes from the FILE: how many code modules name this one as a whole word, tests excluded (a test that references it is coverage, not blast radius). That rule already existed inside the pre-edit hook; it now lives once, in cortex_features. referencingFiles(), and the hook calls it instead of keeping a copy, so the two definitions of "caller" cannot drift. A file nobody names still scores 0 — the signal did not become free. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 10 ++++++ src/cortex_features.js | 56 +++++++++++++++++++++++++++++- src/cortex_hook_main.js | 27 +++------------ test/cortex_features.test.js | 67 ++++++++++++++++++++++++++++++++++-- 4 files changed, 135 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd5206b..989533a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,16 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- **`caller_fanout` is no longer dead for callers that only have a path.** + `featuresForEdit()` asked `grepFanout()` about `edit.symbol`, so every caller holding + only a file path — which is every hook fired on an edit event — got `grepFanout(root, + undefined) === 0`: a module with twenty importers scored exactly like one nobody + references. Without a symbol the feature now falls back to the FILE's own fan-out (how + many code modules name this one as a whole word), which is the honest answer such a + caller can have. The "who references this module" rule — module stem, directory for + `index`/`__init__`/`mod`/`main`, tests separated from callers — now lives once in + `cortex_features.referencingFiles()` and the pre-edit hook uses it instead of its own + copy, so the two can never drift. - **`forge impact` actually resolves imports.** JS/TS import specifiers were stored as raw strings and matched against symbol names, so `"./util.js"` could only ever resolve by its last dotted segment: on this repo, 3 of 502 relative import statements resolved and all diff --git a/src/cortex_features.js b/src/cortex_features.js index bcbb193..3dc5693 100644 --- a/src/cortex_features.js +++ b/src/cortex_features.js @@ -4,8 +4,10 @@ // zero-dep grep gives a rough fan-out today; adopting a graph MCP (agent-lsp/Serena) drops // a precise call graph straight in without touching the predictor. import { execFileSync } from "node:child_process"; +import { basename, dirname, isAbsolute, join, relative } from "node:path"; import { mergedLessons } from "./ledger_read.js"; import { confidenceOf, matchScore } from "./lessons.js"; +import { toPosix } from "./util.js"; /** * Pure: edit + gathered signals → the predictor feature vector (all features in [0,1]). @@ -75,12 +77,64 @@ export function grepFanout(root, symbol) { return out ? out.trim().split("\n").filter(Boolean).length : 0; } +export const TEST_PATH_RE = + /(^|\/)(tests?|__tests__|spec)\/|[._-](test|spec)\.[^/]+$|(^|\/)test_[^/]+\.py$/i; +export const CALLER_EXT_RE = + /\.(m?[jt]sx?|cjs|py|go|rs|java|kt|rb|php|cs|c|cc|cpp|h|hpp|swift|vue|svelte)$/i; + +/** + * The name a module is referenced BY: its file stem, or its directory for the + * conventional entry names (`src/auth/index.js` is imported as `auth`). A stem under 3 + * characters is too noisy to grep for and is refused ("" — no fan-out rather than a + * wrong one). + * @param {string} file repo-relative or absolute path + */ +export function moduleStem(file) { + if (!file) return ""; + const rel = toPosix(file); + let stem = basename(rel).replace(/\.[^.]+$/, ""); + if (/^(index|__init__|mod|main)$/i.test(stem)) stem = basename(dirname(rel)); + return stem.length >= 3 ? stem : ""; +} + +/** + * Files that name this module as a whole word, split into code `callers` and `tests` + * (the file itself always excluded). One bounded `git grep`; outside a git work tree + * both lists are empty — absent evidence is never inferred as signal. SEAM: a real call + * graph drops in here without touching either caller. + * @param {string} root + * @param {string} file repo-relative or absolute path + * @returns {{callers:string[], tests:string[], rel:string}} + */ +export function referencingFiles(root, file) { + const abs = !file ? "" : isAbsolute(file) ? file : join(root, file); + const rel = abs ? toPosix(relative(root, abs)) : ""; + const stem = moduleStem(rel); + if (!stem) return { callers: [], tests: [], rel }; + const hits = tryExec("git", ["grep", "-l", "-I", "-w", "-F", "-e", stem], root) + .split("\n") + .map((f) => toPosix(f.trim())) + .filter((f) => f && f !== rel); + return { + callers: hits.filter((f) => !TEST_PATH_RE.test(f) && CALLER_EXT_RE.test(f)), + tests: hits.filter((f) => TEST_PATH_RE.test(f)), + rel, + }; +} + /** Build the feature vector for a real edit from actual repo state (best-effort, degrades). */ export function featuresForEdit(root, edit, { nowDay = 0 } = {}) { // Ledger-aware read: the merged view (legacy ∪ ledger) so this works under // FORGE_LEDGER_ONLY (no legacy files) and also sees merged teammate lessons. const activeLessons = mergedLessons(root, nowDay).filter((l) => l.status === "active"); - const callerCount = grepFanout(root, edit.symbol); + // caller_fanout from the SYMBOL when the caller knows one, else from the FILE. Callers + // that only have a path (every hook fired on an edit event) used to get grepFanout + // (root, undefined) === 0, so the feature was dead for them: a file with 20 importers + // scored the same as one nobody references. The file's own fan-out — how many modules + // name this one — is the honest answer the caller CAN have. + const callerCount = edit.symbol + ? grepFanout(root, edit.symbol) + : referencingFiles(root, edit.file).callers.length; return computeFeatures(edit, { activeLessons, nowDay, diff --git a/src/cortex_hook_main.js b/src/cortex_hook_main.js index 58dc848..2546908 100644 --- a/src/cortex_hook_main.js +++ b/src/cortex_hook_main.js @@ -271,11 +271,11 @@ async function preEditAdvisory(root, input, today) { // working set, and the edit payload itself for the signature check. Outside a git work // tree nothing is inferred (no test gap, no fan-out): absent evidence never raises risk. async function liveEditFeatures(root, file, input, today) { - const { computeFeatures, gitChurn } = await import("./cortex_features.js"); + const { computeFeatures, gitChurn, referencingFiles } = await import("./cortex_features.js"); const { toPosix } = await import("./util.js"); const { execFileSync } = await import("node:child_process"); const { readFileSync } = await import("node:fs"); - const { basename, dirname, isAbsolute, join, relative } = await import("node:path"); + const { isAbsolute, join } = await import("node:path"); const git = (args) => { try { return execFileSync("git", args, { @@ -294,22 +294,10 @@ async function liveEditFeatures(root, file, input, today) { .map((f) => toPosix(f.trim())) .filter(Boolean); const abs = isAbsolute(file) ? file : join(root, file); - const rel = toPosix(relative(root, abs)); const inRepo = git(["rev-parse", "--is-inside-work-tree"]).trim() === "true"; - let callers = []; - let tests = []; - if (inRepo) { - // The module's import name: its stem, or its directory for index/__init__/mod/main. - let stem = basename(rel).replace(/\.[^.]+$/, ""); - if (/^(index|__init__|mod|main)$/i.test(stem)) stem = basename(dirname(rel)); - if (stem.length >= 3) { - const hits = lines(git(["grep", "-l", "-I", "-w", "-F", "-e", stem])).filter( - (f) => f !== rel, - ); - tests = hits.filter((f) => TEST_PATH_RE.test(f)); - callers = hits.filter((f) => !TEST_PATH_RE.test(f) && CALLER_EXT_RE.test(f)); - } - } + // One shared rule for "who references this module" (cortex_features.referencingFiles), + // so the hook and featuresForEdit can never drift on what counts as a caller. + const { callers, tests } = inRepo ? referencingFiles(root, abs) : { callers: [], tests: [] }; const inDiff = new Set(lines(git(["diff", "--name-only", "--relative", "HEAD"]))); const before = () => { try { @@ -332,11 +320,6 @@ async function liveEditFeatures(root, file, input, today) { ); } -const TEST_PATH_RE = - /(^|\/)(tests?|__tests__|spec)\/|[._-](test|spec)\.[^/]+$|(^|\/)test_[^/]+\.py$/i; -const CALLER_EXT_RE = - /\.(m?[jt]sx?|cjs|py|go|rs|java|kt|rb|php|cs|c|cc|cpp|h|hpp|swift|vue|svelte)$/i; - // A declaration header (function / class / def / fn / func, or a const-bound function). // Rough on purpose — one line, no parser — and only used to ask "does this edit remove or // rewrite an existing declaration?" (adding a new one breaks no caller). diff --git a/test/cortex_features.test.js b/test/cortex_features.test.js index c4f16c8..b49e9e4 100644 --- a/test/cortex_features.test.js +++ b/test/cortex_features.test.js @@ -1,10 +1,17 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; -import { computeFeatures, featuresForEdit, gitChurn, grepFanout } from "../src/cortex_features.js"; +import { + computeFeatures, + featuresForEdit, + gitChurn, + grepFanout, + moduleStem, + referencingFiles, +} from "../src/cortex_features.js"; import { newLesson } from "../src/lessons.js"; test("computeFeatures normalizes and derives every feature", () => { @@ -106,3 +113,59 @@ test("gitChurn counts only commits inside the time window", () => { assert.equal(gitChurn(root, "fresh.js"), 1); assert.ok(gitChurn(root, "ancient.js", { days: 6000 }) >= 2, "a wider window sees the history"); }); + +// --- caller_fanout from the FILE when the caller has no symbol -------------------------- + +/** A module with `callers` importers, one test, and one same-named doc (never a caller). */ +const fanoutRepo = (callers = 6) => { + const { root, commit } = gitRepo(); + mkdirSync(join(root, "src"), { recursive: true }); + mkdirSync(join(root, "test"), { recursive: true }); + writeFileSync(join(root, "src", "pricing.js"), "export function pricing(q) {\n return q;\n}\n"); + for (let i = 0; i < callers; i++) + writeFileSync( + join(root, "src", `caller${i}.js`), + `import { pricing } from "./pricing.js";\nexport const v${i} = pricing(${i});\n`, + ); + writeFileSync(join(root, "test", "pricing.test.js"), 'import "../src/pricing.js";\n'); + writeFileSync(join(root, "PRICING.md"), "# pricing\n\nThe pricing module.\n"); + commit("fixture"); + return root; +}; + +test("featuresForEdit derives caller_fanout from the FILE when the edit has no symbol", () => { + // The production callers (the pre-edit hook) only ever have a path. Asking grepFanout + // about `undefined` pinned caller_fanout at 0 for every one of them — the feature was + // dead. Six importers must now register. + const root = fanoutRepo(6); + const bySymbol = featuresForEdit(root, { file: "src/pricing.js", symbol: "pricing" }); + const byFile = featuresForEdit(root, { file: "src/pricing.js" }); + assert.ok(bySymbol.caller_fanout > 0, "symbol path still works"); + assert.equal(byFile.caller_fanout, 0.6, "6 code importers / 10 — the file's own fan-out"); + assert.equal( + featuresForEdit(root, { file: "src/unreferenced.js" }).caller_fanout, + 0, + "a file nobody names still has no fan-out — the signal did not become free", + ); +}); + +test("referencingFiles splits callers from tests and never counts the file itself", () => { + const root = fanoutRepo(2); + const { callers, tests } = referencingFiles(root, "src/pricing.js"); + assert.deepEqual(callers.sort(), ["src/caller0.js", "src/caller1.js"]); + assert.deepEqual( + tests, + ["test/pricing.test.js"], + "a test referencing it is coverage, not fan-out", + ); + assert.ok(!callers.includes("src/pricing.js"), "the definition is not its own caller"); + assert.ok(!callers.includes("PRICING.md"), "prose that names the module is not a caller"); +}); + +test("moduleStem uses the directory for entry-point names and refuses short stems", () => { + assert.equal(moduleStem("src/pricing.js"), "pricing"); + assert.equal(moduleStem("src/auth/index.js"), "auth"); + assert.equal(moduleStem("pkg/mod.rs"), "pkg"); + assert.equal(moduleStem("src/db.js"), "", "a 2-char stem greps too widely to be signal"); + assert.equal(moduleStem(""), ""); +}); From b53323ee3ee7d408131163f259a76d50f9ba20a2 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:54:22 +0200 Subject: [PATCH 66/72] fix(security): allowlist bibliography citation keys by shape, unblocking the secret gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 20 gitleaks findings — on master as well as here — were the same false positive: `"citation_key": "graves2014ntm"` in the two literature datasets, where the field name beside a quoted alphanumeric value trips generic-api-key. Two sibling bibliography files were already allowlisted by path; these were missed, so the gate has been red and unactionable. Allowlisted by SHAPE (an author+year BibTeX key on the matched line), not by file: a real credential added to references.json or gap_map.json is still caught. All 32 keys in those files match the pattern. Co-Authored-By: Claude Opus 5 --- .gitleaks.toml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.gitleaks.toml b/.gitleaks.toml index 56391d6..f80dae6 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -17,8 +17,18 @@ paths = [ # committed, so a full-history scan always matches them — they are NOT real credentials. '''^test/secrets\.test\.js$''', ] -# Obvious placeholder credentials used as test fixtures to exercise the secret -# redactor/scanner — sequential-hex tokens that are unmistakably fake, not real secrets. +# Matched against the whole LINE, so a pattern can name the field it exonerates instead of +# just the value — which is what lets the citation-key entry below stay narrow. +regexTarget = "line" regexes = [ + # Obvious placeholder credential used as a test fixture to exercise the secret + # redactor/scanner — a sequential-hex token that is unmistakably fake. '''ghp_0123456789abcdef0123456789abcdef0123''', + # The same bibliography false positive as the paths above, in the literature datasets + # (research/cognitive-substrate/literature/{references,gap_map}.json): a line like + # `"citation_key": "graves2014ntm"` trips generic-api-key because the field name sits + # beside a quoted alphanumeric value. Allowlisted by SHAPE — an author+year BibTeX key — + # rather than by file, so a real credential added to either file is still caught. All 32 + # keys in those files match; one that does not will be reported, which is the point. + '''"citation_key"\s*:\s*"[A-Za-z][A-Za-z0-9]*[0-9]{4}[A-Za-z0-9]*"''', ] From d1125f1938d36eabcdb5556da7cf3ef91f2b5d7f Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:55:59 +0200 Subject: [PATCH 67/72] fix(ledger): a CRLF checkout must not fork a claim id canonicalize() NFC-normalized strings but passed line endings through, so the same logical claim written on a Windows worktree (core.autocrlf -> \r\n) and on a Linux one (\n) produced different canonical bytes and therefore different content addresses: one fact stored as two claims that could never merge, evidence split between them forever. Every string in a canonical document -- key and value alike -- now takes one rule: NFC, and \r\n -> \n. What is deliberately NOT folded is stated at the call site, because canonicalization must never silently rewrite a claim's content: a lone \r (in the captured terminal output a diagnosis body carries, a bare carriage return is a progress-bar control character, not a line ending -- the same conservative rule normalizeError already applies), whitespace and indentation, blank lines, case, and every Unicode fold past NFC. A claim minted before this change whose body contains \r\n re-addresses and will be reported by `forge ledger verify`. Such a claim was already the duplicate half of a pair; re-minting or merging from a replica lands it on the shared address. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 13 +++++++++++++ src/ledger.js | 37 +++++++++++++++++++++++++++++++------ test/ledger.test.js | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 989533a..4e8e5a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,19 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- **A CRLF checkout no longer forks a claim id.** `canonicalize()` NFC-normalized strings but + passed line endings through, so the same logical claim written on a Windows worktree + (`core.autocrlf` → `\r\n`) and on a Linux one (`\n`) produced different canonical bytes and + therefore different content addresses: one fact stored as two claims that could never merge, + with the evidence split between them forever. Every string in a canonical document — key and + value alike — now passes through one rule: NFC, and `\r\n` → `\n`. Deliberately left alone, + each documented at the call site: a LONE `\r` (in the captured terminal output a `diagnosis` + body carries, a bare carriage return is a progress-bar control character, not a line ending — + same conservative rule as `normalizeError()`), whitespace and indentation, blank lines, case, + and every Unicode fold beyond NFC (no NFKC: `fi` stays distinct from `fi`). **Migration note:** + a claim minted before this change whose body contains `\r\n` re-addresses, so it no longer + matches its filename and `forge ledger verify` reports it. Such a claim was already the + duplicate half of a pair; re-mint it (or merge from a replica) to land on the shared address. - **`caller_fanout` is no longer dead for callers that only have a path.** `featuresForEdit()` asked `grepFanout()` about `edit.symbol`, so every caller holding only a file path — which is every hook fired on an edit event — got `grepFanout(root, diff --git a/src/ledger.js b/src/ledger.js index d19bc40..52db09f 100644 --- a/src/ledger.js +++ b/src/ledger.js @@ -72,18 +72,42 @@ export const DEFAULT_HALF_LIFE_DAYS = 45; * band starts at the mirror threshold (1 − DORMANT_VAL) — stats uses both. */ export const DORMANT_VAL = 0.35; +/** + * Every string in a canonical document — key or value — passes through here. + * + * NORMALIZED, both deliberately: + * - Unicode NFC. The same text typed on macOS (NFD) and on Linux (NFC) is one fact. + * - CRLF → LF. A checkout's line endings are a property of the MACHINE, not of the + * claim: `core.autocrlf` hands the same file to a Windows worktree with \r\n and to + * a Linux one with \n, so the same logical claim minted on each side used to land on + * two different content addresses and never merge — one fact, two "copies", evidence + * split between them forever. + * + * DELIBERATELY LEFT ALONE — each one can carry meaning, and canonicalization must never + * silently rewrite a claim's content: + * - A LONE \r. In captured terminal output (which is exactly what a `diagnosis` body + * holds) a bare carriage return is a progress-bar control character, not a line + * ending. Folding it into \n would edit the evidence. Same conservative rule as + * `normalizeError()` in src/diagnose.js. + * - Leading/trailing and interior whitespace, blank lines, indentation — " x" and "x" + * are different claims, and a code snippet's indentation is its content. + * - Case, punctuation, and every other Unicode fold beyond NFC (no NFKC: "fi" ≠ "fi"). + * - Non-string values: numbers, booleans and null serialize as JSON.stringify does. + */ +const canonText = (s) => s.normalize("NFC").replace(/\r\n/g, "\n"); + /** * 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. + * whitespace, NFC + LF-normalized strings (see `canonText`), 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 (typeof value === "string") return JSON.stringify(canonText(value)); if (Array.isArray(value)) return `[${value.map((v) => (v === undefined ? "null" : canonicalize(v))).join(",")}]`; if (typeof value === "object") { @@ -91,11 +115,12 @@ export function canonicalize(value) { // an NFD key sort where its NFC twin doesn't, so a claim written with one spelling failed // its own address check once re-parsed (the NFC bytes sort differently). Two raw keys that // collapse to one NFC key are a malformed input; the first in raw-key order wins, - // deterministically. + // deterministically. Keys take the same normalization as values — one rule for every + // string in the document, so a key can't fork an id the way a value used to. const entries = new Map(); for (const k of Object.keys(value).sort()) { if (value[k] === undefined || typeof value[k] === "function") continue; - const nk = k.normalize("NFC"); + const nk = canonText(k); if (!entries.has(nk)) entries.set(nk, value[k]); } const keys = [...entries.keys()].sort(); diff --git a/test/ledger.test.js b/test/ledger.test.js index 403e504..55fe812 100644 --- a/test/ledger.test.js +++ b/test/ledger.test.js @@ -56,6 +56,40 @@ test("canonicalize: keys are NFC-normalized BEFORE sorting — NFD and NFC spell assert.equal(claimId("fact", reparsed.body, reparsed.scope), m.claim.id, "id survives a reload"); }); +test("canonicalize: a CRLF checkout and an LF checkout mint ONE claim id", () => { + // `core.autocrlf` hands the same file to a Windows worktree with \r\n and to a Linux one + // with \n. Before this, the same logical claim minted on each side landed on two content + // addresses and never merged: one fact, two "copies", evidence split between them forever. + const lf = "the parser rejects a trailing comma\nreproduced on node 20 and 22"; + const crlf = lf.replace(/\n/g, "\r\n"); + const body = (text) => ({ name: "parser", text }); + assert.equal( + claimId("fact", body(crlf), { level: "repo" }), + claimId("fact", body(lf), { level: "repo" }), + "line endings are a property of the machine, not of the claim", + ); + const a = mintClaim({ kind: "fact", body: body(crlf), t: 1 }); + const b = mintClaim({ kind: "fact", body: body(lf), t: 1 }); + assert.ok(a.ok && b.ok); + assert.equal(a.claim.id, b.claim.id, "two teammates converge on one claim"); + assert.equal(canonicalize(body(crlf)), canonicalize(body(lf)), "and on one byte string"); + // The same rule for a key, so a CRLF-spelled key can't fork an id either. + assert.equal(canonicalize({ "a\r\nb": 1 }), canonicalize({ "a\nb": 1 })); +}); + +test("canonicalize: text that legitimately differs still gets two ids (no over-folding)", () => { + const id = (text) => claimId("fact", { name: "n", text }, { level: "repo" }); + const base = "line one\nline two"; + assert.notEqual(id(base), id("line one\nline three"), "different words, different claims"); + // A LONE \r is a control character in captured terminal output (a progress bar), not a + // line ending — folding it into \n would edit the evidence a diagnosis claim carries. + assert.notEqual(id("a\rb"), id("a\nb"), "a bare carriage return is content, not formatting"); + assert.notEqual(id(" indented"), id("indented"), "whitespace is content"); + assert.notEqual(id("Fix"), id("fix"), "case is content"); + assert.notEqual(id("line one\n\nline two"), id(base), "a blank line is content"); + assert.notEqual(id("file"), id("file"), "NFC only — no compatibility folding (NFKC)"); +}); + 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. From 703da31d574c30d22bef019b1c8563ade0d0d6be Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:01:07 +0200 Subject: [PATCH 68/72] feat(diagnose,route): let a real failure consume the routing escalateTo reconcileRoute records the tier a proposer's higher vote would have picked and deliberately does not apply it -- whitepaper 5.1 allows spending more only when an EXTERNAL check on the output fails. But nothing ever read the target back, so the doom-loop directive told agents to "escalate ONE model tier" without naming one, and escalateTo was dead on arrival. The two halves now join on the task key meterRoute already writes (contentHash(task) in .forge/metrics.jsonl, now the one recipe in cost_report.routeRef). meterRoute stores the advisory target with it; diagnose() reads it at THRASH_K recurrences of a single failure signature -- the one place in forge where an external check has demonstrably failed -- and names the tier: "escalate to opus (the tier routing already flagged for this task)", plus escalateTo in --json. The vote still triggers nothing by itself: below the threshold the recorded target buys nothing, and above it the FAILURE is what earned the escalation -- the vote only answers which tier. Fail-safe and opt-in: the join key comes from `forge diagnose --task ""` (and the new `task` argument on the forge_diagnose MCP tool). No task, no record for that exact task, or a read error -> the previous wording, verbatim. Co-Authored-By: Claude Opus 5 --- ARCHITECTURE.md | 2 +- CHANGELOG.md | 12 +++++ README.md | 7 +-- docs/GUIDE.md | 13 ++++- docs/cognitive-substrate/README.md | 6 ++- src/cli.js | 13 +++-- src/cortex_mcp.js | 1 + src/cost_report.js | 41 ++++++++++++-- src/diagnose.js | 47 ++++++++++++++-- src/mcp_tools.js | 5 ++ src/route.js | 25 ++++++--- src/substrate.js | 2 +- test/diagnose.test.js | 86 ++++++++++++++++++++++++++++++ 13 files changed, 231 insertions(+), 29 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index dc18249..847b898 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -610,7 +610,7 @@ flowchart LR scripts["scripts
    2 files"] docs["docs
    1 file"] examples["examples
    1 file"] - test -- 225 --> src + test -- 227 --> src bench -- 7 --> src examples -- 4 --> src test -- 2 --> global diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e8e5a5..938ba2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,18 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- **`llm.escalateTo` is no longer advisory-and-inert — a real failure now consumes it.** + Routing recorded the tier a proposer's higher vote would have picked and deliberately did + not apply it (whitepaper §5.1: spend more only when an EXTERNAL check fails), but nothing + ever read it back, so the doom-loop directive told agents to "escalate ONE model tier" + without naming one. `meterRoute()` now stores that target alongside the task ref it + already wrote, and `diagnose()` — the one place an external check has demonstrably failed, + `THRASH_K` recurrences of a single failure signature — names it: "escalate to opus (the + tier routing already flagged for this task)", plus `escalateTo` in `--json`. The model's + vote still triggers nothing on its own; it only answers *which* tier once a real failure + has earned an escalation. Fail-safe and opt-in: `forge diagnose --task ""` (and the + `task` argument on the `forge_diagnose` MCP tool) is what supplies the join key — without + it, or with no routing record for that exact task, the wording is unchanged. - **A CRLF checkout no longer forks a claim id.** `canonicalize()` NFC-normalized strings but passed line endings through, so the same logical claim written on a Windows worktree (`core.autocrlf` → `\r\n`) and on a Linux one (`\n`) produced different canonical bytes and diff --git a/README.md b/README.md index 82b63f9..7316fa4 100644 --- a/README.md +++ b/README.md @@ -236,9 +236,10 @@ from a fresh repository graph. rather than invented context. - **Model-tier recommendation.** A deterministic rubric combines task text and repository signals. An optional LLM proposal can only lower the tier, confidence-gated and bounded; a - vote for a higher tier is never applied automatically — it surfaces only as an advisory - `escalateTo` recommendation. Forgekit advises which tier to request; it does not itself proxy - or fail over model traffic. + vote for a higher tier is never applied automatically — it is recorded as an advisory + `escalateTo` recommendation, which names the tier only once an external check has actually + failed (the doom-loop diagnosis at its thrash threshold). Forgekit advises which tier to + request; it does not itself proxy or fail over model traffic. - **Proof-gated reuse.** Cached code is served only after evidence clears a confidence floor and declared dependencies still resolve in the current repository graph. - **Lifecycle guardrails.** Claude Code hooks cover prompt preflight, protected paths, cost diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 236bee3..ceac209 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -988,6 +988,13 @@ Below the threshold it just records and says keep going. Advisory — halting th loop is the agent's move, not an exit code. Because the claim rides the team ledger, the same loop becomes a one-per-team event, not one-per-session. +Pass `--task ""` — the same text you gave `forge route` — and the directive +names the tier instead of "ONE model tier", when routing recorded an advisory `escalateTo` +for that task (a proposer voted for a higher band and was, correctly, not obeyed). This is +the only place that target is ever consumed: three recurrences of one failure signature is +an external check failing, which is the only thing that may buy a bigger model (§5.1). With +no `--task`, or no routing record for it, the wording is unchanged. + ### `forge imagine ""` — consequence simulation The static half of the paper's Eq. 4: entities → blast radius → predicted breaks with @@ -1488,8 +1495,10 @@ fresh labelled data; a text-model vote reports no probability and so cannot move you set it to 0); a vote for a **higher** band is never applied, because the tier may escalate only when a verifier fails, never on the model's own assessment (whitepaper §5.1). The tier it would have picked is reported in `--json` as `llm.escalateTo` — an **advisory recommendation -only**: nothing in Forge acts on it automatically; escalating after a verifier failure is still -yours (or the doom-loop diagnosis's) to do. Impact edges must be real + grep-confirmed; goal-drift moves +only**: nothing acts on it at routing time. It is recorded against the task, and the doom-loop +diagnosis consumes it: `forge diagnose --task ""` names that tier once the +same failure signature has recurred `THRASH_K` times, instead of saying "escalate one tier". +Deciding to escalate is still a real failure's job, never the model's vote. Impact edges must be real + grep-confirmed; goal-drift moves off→on only. Any failure falls back to the deterministic path, so the flag is safe to leave off or on. `--json` exposes `llm.provenance` per faculty (`llm-cleared` / `llm-tightened` / `llm-lowered` / `llm-raise-deferred` / `llm-overruled` / …). Set `llm.bidirectional: false` in diff --git a/docs/cognitive-substrate/README.md b/docs/cognitive-substrate/README.md index 8202514..03891d6 100644 --- a/docs/cognitive-substrate/README.md +++ b/docs/cognitive-substrate/README.md @@ -213,8 +213,10 @@ but never past a hard floor: strong-signal (algorithmic/architectural) floor, so a "distributed rate-limiter" can't be talked down to the cheap tier. A vote for a _higher_ band is **not applied** — escalation follows a verifier failure, never the model's self-assessment (§5.1). The tier it would have - picked is reported as `llm.escalateTo`, an advisory recommendation that nothing acts on - automatically; + picked is reported as `llm.escalateTo`, an advisory recommendation that nothing acts on at + routing time: it is recorded against the task, and `forge diagnose --task ""` names it + once the same failure signature has recurred `THRASH_K` times — a real external check + failing, which is the only thing that may buy a bigger model (§5.1); - **the assumption gate** — can _clear_ a false ask **or** _add_ one, but never clears a task with no concrete anchor, or one naming symbols/files the repo doesn't define (those floors guard clearing only — they never raise an ask the rubric didn't). The rubric's completeness diff --git a/src/cli.js b/src/cli.js index f652fee..82815c4 100755 --- a/src/cli.js +++ b/src/cli.js @@ -2193,12 +2193,13 @@ HANDLERS.diagnose = async (argv) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; }; - const args = argv.filter( - (a, i) => !a.startsWith("--") && argv[i - 1] !== "--file" && argv[i - 1] !== "--symbol", - ); + const VALUE_FLAGS = ["--file", "--symbol", "--task"]; + const args = argv.filter((a, i) => !a.startsWith("--") && !VALUE_FLAGS.includes(argv[i - 1])); const errorText = args.slice(1).join(" "); if (!errorText) { - console.error('usage: forge diagnose "" [--file f] [--symbol s] [--json]'); + console.error( + 'usage: forge diagnose "" [--file f] [--symbol s] [--task ""] [--json]', + ); process.exitCode = 1; return; } @@ -2206,6 +2207,10 @@ HANDLERS.diagnose = async (argv) => { errorText, file: flagVal("--file"), symbol: flagVal("--symbol"), + // The task this failure came out of — the same text `forge route` was given. When a + // routing decision for it is on record, the escalation directive names that decision's + // tier instead of "one tier". Omitted → unchanged behaviour. + task: flagVal("--task"), }); if (json) return console.log(JSON.stringify(r, null, 2)); heading(`${BRAND.brand} diagnose — doom-loop check\n`); diff --git a/src/cortex_mcp.js b/src/cortex_mcp.js index fa980c9..6b85a4a 100644 --- a/src/cortex_mcp.js +++ b/src/cortex_mcp.js @@ -122,6 +122,7 @@ async function callTool(name, args = {}) { errorText: String(args.errorText ?? ""), file: args.file, symbol: args.symbol, + task: args.task, }); return JSON.stringify(r, null, 2); } diff --git a/src/cost_report.js b/src/cost_report.js index 62434ae..832d73b 100644 --- a/src/cost_report.js +++ b/src/cost_report.js @@ -11,6 +11,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { read, record } from "./metrics.js"; import { MODELS } from "./model_tiers.js"; +import { contentHash } from "./util.js"; /** Saving weight per cache-hit tier — must stay consistent with reuse.js savedEstimate * (exact = full regeneration avoided; near/adapt still spend adaptation tokens). */ @@ -204,11 +205,43 @@ export function recordGate(root, { halted, ref } = {}) { return record(root, { stage: "gate", outcome: halted ? "halt" : "pass", ref }); } -/** Record one routed generation with its tier and real token counts. +/** The metrics `ref` for a task: a short content hash of the task text, never the text + * itself (metrics are telemetry, not a prompt log). The ONE recipe — `meterRoute` writes + * it and `lastRouteEscalation` reads it, so the two can never disagree on the key. + * @param {string} task */ +export const routeRef = (task) => contentHash(String(task)).slice(0, 12); + +/** Record one routed generation with its tier and real token counts. `escalateTo` is the + * routing verdict's ADVISORY escalation target (a proposer voted higher and was not + * applied) — recorded so a later EXTERNAL failure can name the tier instead of guessing. * @param {string} root - * @param {{tier?: string, tokensIn?: number, tokensOut?: number, ref?: string}} [opts] */ -export function recordRoute(root, { tier, tokensIn, tokensOut, ref } = {}) { - return record(root, { stage: "route", tier, tokensIn, tokensOut, ref }); + * @param {{tier?: string, tokensIn?: number, tokensOut?: number, ref?: string, + * escalateTo?: string}} [opts] */ +export function recordRoute(root, { tier, tokensIn, tokensOut, ref, escalateTo } = {}) { + return record(root, { stage: "route", tier, tokensIn, tokensOut, ref, escalateTo }); +} + +/** + * The advisory escalation tier routing recorded for this exact task, or "" if there is + * none. Most recent wins — a task routed twice escalates to what the latest decision said. + * Best-effort like every metrics read: a missing or corrupt log is "no target", never a + * throw. Whitepaper §5.1 keeps the trigger elsewhere: this only ANSWERS "which tier", + * it never decides that an escalation is warranted. + * @param {string} root + * @param {string} task the same task text that was routed + * @returns {string} + */ +export function lastRouteEscalation(root, task) { + if (!task) return ""; + try { + const ref = routeRef(task); + const hit = read(root, { stage: "route" }) + .filter((e) => e.ref === ref && typeof e.escalateTo === "string" && e.escalateTo) + .pop(); + return hit ? hit.escalateTo : ""; + } catch { + return ""; + } } /** diff --git a/src/diagnose.js b/src/diagnose.js index d231a65..e7bc6b8 100644 --- a/src/diagnose.js +++ b/src/diagnose.js @@ -9,6 +9,7 @@ // so the same doom loop is a one-per-team event instead of one-per-session. import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; +import { lastRouteEscalation } from "./cost_report.js"; import { hasSecret, mintClaim } from "./ledger.js"; import { putClaim, repoLedger } from "./ledger_store.js"; import { contentHash, epochDay, gitAuthor } from "./util.js"; @@ -99,14 +100,20 @@ export function recordFailure(root, { errorText, file = "", symbol = "", t = Dat * escalation directive. Idempotent by construction: the claim is content-addressed, * so the 4th/5th hit re-resolves to the SAME claim instead of minting duplicates. * @param {string} root - * @param {{errorText: string, file?: string, symbol?: string, note?: string, + * @param {{errorText: string, file?: string, symbol?: string, note?: string, task?: string, * t?: number, nowDay?: number}} opts * `note` is the human root-cause statement if the caller has one; defaults to the - * normalized error head. `nowDay` (epoch days) is the claim's mint day. + * normalized error head. `task` is the task text this failure came out of — when it + * matches a routing decision this repo recorded, the directive names that decision's + * escalation tier instead of saying "one tier" (see `escalationTier` below). + * `nowDay` (epoch days) is the claim's mint day. * @returns {{thrash: boolean, signature: string, count: number, claimId?: string, - * escalate?: string, reason?: string}} + * escalate?: string, escalateTo?: string, reason?: string}} */ -export function diagnose(root, { errorText, file = "", symbol = "", note = "", t, nowDay }) { +export function diagnose( + root, + { errorText, file = "", symbol = "", note = "", task = "", t, nowDay }, +) { const rec = recordFailure(root, { errorText, file, symbol, ...(t !== undefined && { t }) }); const { signature, count, head } = rec; if (count < THRASH_K) return { thrash: false, signature, count }; @@ -124,15 +131,45 @@ export function diagnose(root, { errorText, file = "", symbol = "", note = "", t const put = putClaim(repoLedger(root), minted.claim); if (!put.ok) return { thrash: true, signature, count, reason: put.reason }; const short = minted.claim.id.slice(0, 8); + const tier = escalationTier(root, task); return { thrash: true, signature, count, claimId: minted.claim.id, + ...(tier ? { escalateTo: tier } : {}), escalate: `Same failure signature ${signature.slice(0, 12)} hit ${count}× — this is thrash, not progress. ` + `STOP retrying this fix. State the diagnosis out loud (claim ${short} — \`forge ledger show ${short}\`, ` + - `add what you already tried to its triedFixes), then escalate ONE model tier with the diagnosis as ` + + `add what you already tried to its triedFixes), then escalate ${ + tier ? `to ${tier} (the tier routing already flagged for this task)` : "ONE model tier" + } with the diagnosis as ` + `the head of the new prompt. The escalation must carry the diagnosis — never just "try again, but more expensive".`, }; } + +/** + * Which tier to escalate to, when routing already answered that question for this task. + * + * Whitepaper §5.1: spend more only when an EXTERNAL check on the output fails, never on a + * model's self-assessment. `reconcileRoute()` enforces the first half — a proposer that + * votes for a higher band does NOT get it; the tier that vote would have picked is parked + * as an advisory `escalateTo` and metered with the task's `ref` (`meterRoute`). This is + * the second half, and the only consumer: THRASH_K recurrences of one failure signature IS + * an external check failing, repeatedly, so an escalation has been earned HERE, by the + * failure — the vote never triggers one, it only answers "to which tier" once the failure + * has. Without that record the directive says "ONE model tier", exactly as before. + * + * Fail-safe and non-widening: no task text, no matching route record, or any read error → + * "" → today's behaviour byte for byte. + * @param {string} root + * @param {string} task + * @returns {string} a tier key, or "" for "the caller decides, as before" + */ +function escalationTier(root, task) { + try { + return lastRouteEscalation(root, task); + } catch { + return ""; + } +} diff --git a/src/mcp_tools.js b/src/mcp_tools.js index 27a2520..84cbc96 100644 --- a/src/mcp_tools.js +++ b/src/mcp_tools.js @@ -148,6 +148,11 @@ export const TOOLS = [ errorText: { type: "string", description: "the error message" }, file: { type: "string", description: "file where the error occurred" }, symbol: { type: "string", description: "symbol involved" }, + task: { + type: "string", + description: + "the task this failure came out of (same text given to forge_route) — lets the escalation directive name the tier routing already flagged instead of 'one tier'", + }, }, required: ["errorText"], }, diff --git a/src/route.js b/src/route.js index 46f718c..649af2c 100644 --- a/src/route.js +++ b/src/route.js @@ -7,7 +7,7 @@ import { join } from "node:path"; import { adjudicate, asText, buildRunner, llmEnabled } from "./adjudicate.js"; import { matchingLessons } from "./cortex.js"; import { gitChurn, grepFanout } from "./cortex_features.js"; -import { recordRoute } from "./cost_report.js"; +import { recordRoute, routeRef } from "./cost_report.js"; import { choice, jevEnabled, systemOne } from "./jev.js"; import { mergedLessons } from "./ledger_read.js"; import { setOverlap } from "./math.js"; @@ -15,7 +15,7 @@ import { MODELS } from "./model_tiers.js"; import { preflightRepo, referencedEntities } from "./preflight.js"; import { promotionGate } from "./promote.js"; import { activeProvider, envModelOverride } from "./providers.js"; -import { clamp01, contentHash, epochDay } from "./util.js"; +import { clamp01, epochDay } from "./util.js"; // --------------------------------------------------------------------------- // Text-complexity rubric: similarity-weighted k-NN regression over a labeled @@ -452,8 +452,11 @@ function proposalConfidence(proposal) { * - higher band → NOT applied (whitepaper §5.1: spend more only when an external check * on the output fails, never on a model's self-assessment). The tier the * vote would have picked is returned as `escalateTo` — an ADVISORY - * recommendation only: nothing in forge acts on it automatically (no - * verifier-failure path consumes it yet) ("llm-raise-deferred"); + * recommendation: nothing acts on it at routing time. `meterRoute` + * records it against the task, and the ONE consumer is `diagnose()` at + * its thrash threshold — an external check that has failed THRASH_K + * times. The vote never triggers an escalation; it only names the tier + * once a real failure has earned one ("llm-raise-deferred"); * - lower band → lowered to that band's ceiling — only when bidirectional, only when * the vote clears `minConfidence`, and never below `signalFloor` when * the rubric has a strong topic signal ("llm-lowered"); otherwise the @@ -672,7 +675,7 @@ export function routeTask( : []), ...(path === "llm-raise-deferred" ? [ - `model judged ${proposal.band} — not applied; advisory only: consider ${verdict.escalateTo} if a verifier fails (nothing escalates automatically)`, + `model judged ${proposal.band} — not applied; advisory only: ${verdict.escalateTo} is the target if a check on the output fails (nothing escalates automatically; \`forge diagnose --task\` uses it at the thrash threshold)`, ] : []), ]), @@ -689,13 +692,21 @@ export function routeTask( * telemetry, not a prompt log). No token counts here — this is an advisory routing * decision, not a priced generation, and the cost report excludes unpriced events * rather than estimating them. + * The verdict's advisory `llm.escalateTo` rides along when there is one: it is the tier a + * proposer's higher vote WOULD have picked and that routing deliberately did not apply + * (§5.1). Recording it is what lets a later external failure — `diagnose()` at its thrash + * threshold — name that tier instead of guessing one. Nothing reads it before then. * @param {string} root * @param {string} task - * @param {{tier?: string}} rec the routeTask result (only .tier is read) + * @param {{tier?: string, llm?: {escalateTo?: string}|null}} rec the routeTask result */ export function meterRoute(root, task, rec) { try { - recordRoute(root, { tier: rec?.tier, ref: contentHash(String(task)).slice(0, 12) }); + recordRoute(root, { + tier: rec?.tier, + ref: routeRef(task), + ...(rec?.llm?.escalateTo ? { escalateTo: rec.llm.escalateTo } : {}), + }); } catch {} } diff --git a/src/substrate.js b/src/substrate.js index d54c01d..d22d6d8 100644 --- a/src/substrate.js +++ b/src/substrate.js @@ -424,7 +424,7 @@ export function substrateCheck( // verdict — safe to surface, never blindly trusted (whitepaper tabayyun gate). llmVerified: [ "assumption refinement (verdict vs verdict, confidence-gated; clears a false ask only past the no-anchor + repo-grounding floors)", - "routing (band-to-band; a confident lower vote only, never below the strong-signal floor; a higher vote is never applied, only reported as an advisory escalateTo that nothing acts on)", + "routing (band-to-band; a confident lower vote only, never below the strong-signal floor; a higher vote is never applied, only recorded as an advisory escalateTo that names the tier IF an external check later fails — the doom-loop diagnosis is its only consumer)", "impact edges (graph + grep verified)", "goal-drift rescue (off→on, goal-referenced)", ], diff --git a/test/diagnose.test.js b/test/diagnose.test.js index 548765a..0b0ddb5 100644 --- a/test/diagnose.test.js +++ b/test/diagnose.test.js @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; +import { lastRouteEscalation } from "../src/cost_report.js"; import { diagnose, failureSignature, @@ -16,6 +17,7 @@ import { THRASH_K, } from "../src/diagnose.js"; import { loadClaims, repoLedger } from "../src/ledger_store.js"; +import { meterRoute } from "../src/route.js"; const fixture = () => mkdtempSync(join(tmpdir(), "forge-diagnose-")); @@ -189,6 +191,90 @@ test("diagnose prefers the caller's root-cause note over the error head", () => assert.equal(claim.id, r.claimId); }); +// --------------------------------------------------------------------------- +// Escalation TARGET: whitepaper §5.1 — a model's vote never escalates on its own, but +// once an EXTERNAL check has failed THRASH_K times the routing record may name the tier. +// --------------------------------------------------------------------------- + +const TASK = "make the worker pool stop deadlocking under load"; +/** A routed task whose proposer voted higher — the advisory target routing did NOT apply. */ +const routedWithEscalation = (root, task = TASK, escalateTo = "opus") => + meterRoute(root, task, { tier: "sonnet", llm: { band: "complex", escalateTo } }); + +test("diagnose names the tier routing flagged — and only after the failure earns it", () => { + const root = fixture(); + routedWithEscalation(root); + const f = { errorText: "TypeError: boom", file: "src/pool.js", symbol: "run", task: TASK }; + // Below the threshold the recorded vote buys nothing: no escalation has been earned yet. + for (let i = 1; i < THRASH_K; i++) { + const early = diagnose(root, { ...f, t: i, nowDay: 10 }); + assert.equal(early.thrash, false); + assert.equal(early.escalateTo, undefined, "a model vote escalates nothing on its own"); + assert.equal(early.escalate, undefined); + } + const r = diagnose(root, { ...f, t: THRASH_K, nowDay: 10 }); + assert.equal(r.thrash, true); + assert.equal(r.escalateTo, "opus", "the tier routing parked as advisory, now earned"); + assert.match(r.escalate, /escalate to opus \(the tier routing already flagged for this task\)/); + assert.match(r.escalate, /STOP retrying/i); + assert.match(r.escalate, /diagnosis as the head/i); + assert.doesNotMatch(r.escalate, /ONE model tier/i); +}); + +test("diagnose escalation target is fail-safe: no task, no record, or another task", () => { + const root = fixture(); + routedWithEscalation(root); + const f = { errorText: "TypeError: boom", file: "src/pool.js", symbol: "run" }; + const thrash = (extra) => { + const r2 = fixture(); + meterRoute(r2, TASK, { tier: "sonnet", llm: { escalateTo: "opus" } }); + let out; + for (let i = 1; i <= THRASH_K; i++) out = diagnose(r2, { ...f, ...extra, t: i, nowDay: 10 }); + return out; + }; + for (const [label, extra] of [ + ["no task at all", {}], + ["a task nobody routed", { task: "a completely different task" }], + ["an empty task", { task: "" }], + ]) { + const r = thrash(extra); + assert.equal(r.thrash, true, label); + assert.equal(r.escalateTo, undefined, `${label} → no target`); + assert.match(r.escalate, /escalate ONE model tier/i, `${label} → the old directive, verbatim`); + } + // A routed task whose proposer did NOT vote higher records no target either. + const plain = fixture(); + meterRoute(plain, TASK, { tier: "sonnet", llm: null }); + let r; + for (let i = 1; i <= THRASH_K; i++) r = diagnose(plain, { ...f, task: TASK, t: i, nowDay: 10 }); + assert.equal(r.escalateTo, undefined, "no higher vote → nothing to consume"); + assert.match(r.escalate, /escalate ONE model tier/i); +}); + +test("meterRoute/lastRouteEscalation join on the task, and the latest decision wins", () => { + const root = fixture(); + meterRoute(root, TASK, { tier: "sonnet", llm: { escalateTo: "opus" } }); + assert.equal(lastRouteEscalation(root, TASK), "opus"); + assert.equal(lastRouteEscalation(root, `${TASK} `), "", "the key is the exact task text"); + assert.equal(lastRouteEscalation(root, ""), ""); + assert.equal(lastRouteEscalation(fixture(), TASK), "", "no metrics file → no target"); + meterRoute(root, TASK, { tier: "opus", llm: { escalateTo: "fable" } }); + assert.equal(lastRouteEscalation(root, TASK), "fable", "re-routed → the newer target"); +}); + +test("forge diagnose --task names the routed escalation tier", () => { + const cwd = fixture(); + meterRoute(cwd, TASK, { tier: "sonnet", llm: { escalateTo: "opus" } }); + const args = ["diagnose", "TypeError: boom", "--file", "src/pool.js", "--task", TASK]; + let out; + for (let i = 0; i < THRASH_K; i++) out = runCli(args, cwd); + assert.equal(out.status, 0); + assert.match(out.stdout, /escalate to opus/); + const j = JSON.parse(runCli([...args, "--json"], cwd).stdout); + assert.equal(j.escalateTo, "opus"); + assert.equal(j.file, undefined, "--task's value is never swallowed as error text"); +}); + // --------------------------------------------------------------------------- // CLI — forge diagnose "" [--file f] [--symbol s] [--json] // --------------------------------------------------------------------------- From ba6d5c5c029bcbc7320883c06100ea7016d303da Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:13:06 +0200 Subject: [PATCH 69/72] fix(bench): re-label the impact cases from ground truth and re-measure Four of the six label sets in bench/impact_cases.mjs had drifted from the source, so the published precision/recall/F1 were scored against a fixture that no longer described the repo: isStale was missing src/substrate.js (an aliased import) and test/atlas_resolve.test.js, mergeStates src/ledger_sync.js, claimText three files, contentHash four. Every case is re-derived with `git grep -n -w -F -e -- 'src/*' 'test/*'` with each hit opened and read, and the fixture now records the per-line evidence plus the deliberate omissions (a name that appears only in a comment or an assertion message is not a reference). A new test in test/eval.test.js re-runs that derivation against the committed labels and fails the moment they disagree, so the fixture cannot rot silently again -- it fails on the old labels, naming the two files isStale was missing. contentHash's documented false negative is gone: a named import now resolves to the exact symbol node (src/atlas.js:17 -> src/util.js: contentHash:65), so atlas.js is predicted at one hop despite the `const hash = contentHash;` alias. The case stays for its fan-out. Re-measured with `npm run bench`: precision 0.17, recall 1.00, F1 0.29 (edited-file-only baseline recall 0.27), against the precision 0.90 / F1 0.92 published since eb68ea9. That precision is the transitive closure scored against direct-only labels -- impact() walks reverse dependencies transitively by default, and at one hop the six cases return exactly their labeled sets -- not a graph that is wrong about who calls what. reports/benchmarks.md now says that where the table is. The TODO(impact-numbers) markers are resolved and removed, and the other medians README and the landing page quote are re-synced to this run's environment block. Co-Authored-By: Claude Opus 5 --- ARCHITECTURE.md | 2 +- CHANGELOG.md | 20 +++++++ README.md | 28 +++++----- bench/impact_cases.mjs | 121 +++++++++++++++++++++++++++-------------- landing/index.html | 2 +- reports/benchmarks.md | 112 ++++++++++++++++++++++---------------- test/eval.test.js | 61 ++++++++++++++++++++- 7 files changed, 241 insertions(+), 105 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 847b898..f54cea9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -613,10 +613,10 @@ flowchart LR test -- 227 --> src bench -- 7 --> src examples -- 4 --> src + test -- 2 --> bench test -- 2 --> global test -- 2 --> scripts scripts --> src src --> global - test --> bench ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 938ba2e..a516c70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,26 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- **The impact benchmark's labels are ground truth again, and the numbers they feed are + re-measured.** Four of the six label sets in `bench/impact_cases.mjs` had gone stale against + the source — `isStale` was missing `src/substrate.js` (an aliased import) and + `test/atlas_resolve.test.js`, `mergeStates` was missing `src/ledger_sync.js`, `claimText` + three files, `contentHash` four — so the published precision/recall/F1 were scored against a + fixture that no longer described the repo. Every case is re-derived with + `git grep -n -w -F -e -- 'src/*' 'test/*'` with each hit read, and the per-line + evidence (plus the deliberate comment/string-only omissions) is recorded in the fixture. A + new test re-runs that derivation and fails the moment labels and source disagree, so this + cannot rot silently again. `contentHash`'s documented false negative is gone: a named import + now resolves to the exact symbol node, so `src/atlas.js` is predicted at one hop despite the + `const hash = contentHash;` alias. **Re-measured with `npm run bench`: precision 0.17, + recall 1.00, F1 0.29** (edited-file-only baseline recall 0.27), replacing the + precision 0.90 / F1 0.92 this repo had published since commit `eb68ea9`. The precision is + the transitive closure being scored against direct-only labels — `impact()` walks reverse + dependencies transitively by default, and at one hop the six cases return their labeled + sets — not a graph that is wrong about who calls what; `reports/benchmarks.md` now says so + where the table is. The `TODO(impact-numbers)` markers in `README.md` and + `reports/benchmarks.md` are resolved and removed, and the other medians those two files and + the landing page quote are re-synced to the same run's environment block. - **`llm.escalateTo` is no longer advisory-and-inert — a real failure now consumes it.** Routing recorded the tier a proposer's higher vote would have picked and deliberately did not apply it (whitepaper §5.1: spend more only when an EXTERNAL check fails), but nothing diff --git a/README.md b/README.md index 7316fa4..ce05635 100644 --- a/README.md +++ b/README.md @@ -199,12 +199,14 @@ Every number is a median from `npm run bench` on this repo, recorded with its en block in [`reports/benchmarks.md`](reports/benchmarks.md) — the project rule is _a number is an assumption until measured_. -- **Blast radius in 0.43 ms** (warm code-graph). On 6 hand-labeled cases from this repo's - real import graph, recall is 0.97 against 0.33 for looking at the edited file alone, but - precision is 0.34 at commit `1a82388`, not the 0.90 reported earlier, and on nine real Python - repositories the research prototype's impact oracle reached recall 0.022 - ([refutation](research/empirical-refutation/)). -- **A full pre-action gate in 118 ms** (median on this repo, warm) — assumption check, routing, +- **Blast radius in 0.40 ms** (warm code-graph). On 6 hand-labeled cases from this repo's + real import graph, recall is 1.00 against 0.27 for looking at the edited file alone, and + precision is 0.17 — `impact` walks reverse dependencies transitively by default, so it + returns everything downstream while the labels name only the direct referencers (restricted + to one hop the same cases return their labeled sets). The precision 0.90 this line used to + quote does not reproduce. On nine real Python repositories the research prototype's impact + oracle reached recall 0.022 ([refutation](research/empirical-refutation/)). +- **A full pre-action gate in 886 ms** (median on this repo, warm) — assumption check, routing, reuse lookup, context assembly, blast radius, scope, and goal anchor in one deterministic pass, no LLM call. On Claude Code it runs on **every prompt, automatically**. - **The white paper's 62.1% routing saving is refuted.** It was measured on the 30 tasks the @@ -213,7 +215,7 @@ an assumption until measured_. judge accepted it cost $1.06 against $1.76, but only 6 and 3 of 64 outputs were accepted ([refutation](research/empirical-refutation/)). `forge cost --stages` reports only _your_ measured stages. -- **Conflict-free team memory** — merging two 500-claim ledger replicas takes **158 ms**; the +- **Conflict-free team memory** — merging two 500-claim ledger replicas takes **4308 ms**; the merge is order-independent and property-tested, so teammate ledgers converge to the same state no matter who syncs first, over plain git. The substrate is advisory by default. Set `FORGE_ENFORCE=1` to block only its strongest @@ -327,8 +329,8 @@ methodology. Parser-stable snapshot labels used by the generated project pages are: -- **A full pre-action gate in 118 ms median** — deterministic, warm repository graph, LLM disabled; -- **Blast radius in 0.43 ms median** — warm impact query; and +- **A full pre-action gate in 886 ms median** — deterministic, warm repository graph, LLM disabled; +- **Blast radius in 0.40 ms median** — warm impact query; and - **20.2% more cost than always-premium** — the held-out routing result. The 62.1% saving the white paper reported came from a 30-task demonstration with thresholds tuned on those same tasks; on 80 pre-registered held-out tasks the same router spent 20.2% *more* (table below). @@ -338,10 +340,10 @@ The boundaries in the table below are part of each result. | Measurement | Recorded result | Boundary | | --- | ---: | --- | -| Warm impact query | 0.43 ms median | 30 runs on one JavaScript repository with a memoized adjacency index; not model latency | -| Deterministic substrate check | 118 ms median | 3 runs on one repository, warm graph, LLM disabled | -| Impact quality | precision 0.34, recall 0.97, F1 0.50 at commit `1a82388` (the precision 0.90 and F1 0.92 reported earlier do not reproduce) | 6 hand-labelled symbols in this repository, scored by `evalImpact`; edited-file-only baseline recall 0.33 | -| Ledger replica merge | 158 ms median | 3 runs merging two synthetic 500-claim replicas with 250 claims shared | +| Warm impact query | 0.40 ms median | 30 runs on one JavaScript repository with a memoized adjacency index; not model latency | +| Deterministic substrate check | 886 ms median | 3 runs on one repository, warm graph, LLM disabled | +| Impact quality | precision 0.17, recall 1.00, F1 0.29 (the precision 0.90 / F1 0.92 reported before 2026-09-21 do not reproduce) | 6 hand-labelled symbols in this repository, scored by `evalImpact` against labels re-derived by `git grep`; `impact` walks reverse dependencies transitively by default, so precision measures the transitive closure against direct-only labels; edited-file-only baseline recall 0.27 | +| Ledger replica merge | 4308 ms median | 3 runs merging two synthetic 500-claim replicas with 250 claims shared | | Python router live demonstration | 62.1% calculated cost reduction versus always-premium | 30 hand-labelled tasks, thresholds tuned to the set, real measured LLM tokens, approximate public prices; demonstration, not field benchmark | | Python router, held-out evaluation | total spend 20.2% **higher** than always-premium; gate F1 0.37 | 80 tasks from real GitHub issues and PRs, thresholds frozen, pre-registered; refutes the row above | | Python impact oracle | precision 0.633, recall 1.000, F1 0.753 | 5 mutations in the bundled demo package; mutation-derived test failures as ground truth | diff --git a/bench/impact_cases.mjs b/bench/impact_cases.mjs index a11bd14..2a38893 100644 --- a/bench/impact_cases.mjs +++ b/bench/impact_cases.mjs @@ -1,60 +1,83 @@ // Labeled impact-oracle cases derived from THIS repo's real import graph. // -// Labeling rule (hand-verified by reading the actual source, then double-checked with -// grep): `expected` = the defining file PLUS every file with a DIRECT reference to the -// target symbol — an `import { X }` with a use, or a call site of `X(`. The defining -// file is always labeled: an edit to the target trivially impacts its own file (same -// convention as the existing fixture in test/eval.test.js and the paper's mutation +// Labeling rule (derived by grep, then hand-verified by opening every hit): `expected` = +// the defining file PLUS every file with a DIRECT reference to the target symbol — an +// `import { X }` (static, dynamic, or aliased) with a use, or a call site of `X(`. The +// defining file is always labeled: an edit to the target trivially impacts its own file +// (same convention as the existing fixture in test/eval.test.js and the paper's mutation // methodology, where the mutated file itself counts). Transitive dependents are // deliberately NOT labeled: the oracle predicts them (its job — err toward inclusion), // so they count against precision here, exactly like the paper's mutation-derived -// scoring penalized over-approximation. Every label is grep-checkable by anyone. +// scoring penalized over-approximation. +// +// The labels are GROUND TRUTH, never the graph's own output: each one is reproducible +// with `git grep -n -w -F -e -- 'src/*' 'test/*'` and reading each hit. A hit +// that is only a comment or a string (a name inside an assertion message, a doc line) +// is NOT a reference and is not labeled — those are listed below where they occur, so a +// re-check can confirm the omission was deliberate rather than missed. // // These cases are evaluated against an atlas built over a copy of this repo that // EXCLUDES bench/ — otherwise the harness's own imports of these symbols would // perturb the measurement it is taking. // -// Verified references, per case (as of the commit this file lands in): +// Verified references, per case (re-derived at the commit this file lands in): // -// normalizeSpec (src/reuse.js) -// - src/reuse.js defines it; fingerprint() and artifactClaim() call it -// - test/reuse.test.js imports { normalizeSpec } and calls it directly +// normalizeSpec (src/reuse.js) — 2 files +// - src/reuse.js defines it (:70); fingerprint() (:89) and artifactClaim() (:147) call it +// - test/reuse.test.js imports { normalizeSpec } (:17) and calls it directly +// (src/reuse.js:40 also names it in a header comment — same file, already labeled.) // -// evalImpact (src/eval.js) -// - src/eval.js defines it (no other same-file caller) -// - test/eval.test.js imports { evalImpact } and calls it — the only referencer +// evalImpact (src/eval.js) — 2 files +// - src/eval.js defines it (:28) (no other same-file caller) +// - test/eval.test.js imports { evalImpact } (:7) and calls it (:34) — the only referencer // -// isStale (src/atlas.js) -// - src/atlas.js defines it -// - src/verify.js imports { isStale } from ./atlas.js and calls it -// - src/doctor.js imports { isStale } from ./atlas.js and calls it -// - test/atlas.test.js imports { isStale } and calls it +// isStale (src/atlas.js) — 6 files +// - src/atlas.js defines it (:1026) +// - src/verify.js imports { isStale } (:11) and calls it (:456) +// - src/doctor.js imports { isStale } (:18) and calls it (:249) +// - src/substrate.js imports it ALIASED (`isStale as atlasIsStale`, :11) and calls +// it twice (:177, :269) — an aliased import is still a reference +// - test/atlas.test.js imports { isStale } (:6) and calls it +// - test/atlas_resolve.test.js imports { isStale } (:11) and calls it (:187, :190) // -// mergeStates (src/ledger.js) -// - src/ledger.js defines it -// - src/ledger_store.js imports { mergeStates } (importState calls it) -// - test/ledger.test.js imports { mergeStates } and calls it +// mergeStates (src/ledger.js) — 4 files +// - src/ledger.js defines it (:796) +// - src/ledger_store.js imports { mergeStates } (:33); importState calls it (:614) +// - src/ledger_sync.js imports { mergeStates } (:22) and calls it (:218) +// - test/ledger.test.js imports { mergeStates } (:14) and calls it +// (src/ledger_sync.js:3 also names it in the module header — same file, already labeled.) // -// claimText (src/ledger.js) -// - src/ledger.js defines it; sketchOf() calls it (same-file caller) -// - src/context.js imports { claimText } and calls it -// - src/dash.js imports { claimText } and calls it -// - src/cli.js dynamic-imports { claimText } and calls it -// - test/ledger.test.js imports { claimText } and calls it -// (test/dash.test.js mentions the name only inside an assertion message — a string, +// claimText (src/ledger.js) — 8 files +// - src/ledger.js defines it (:610); sketchOf() (:636), termsOf() (:637) and :880 call it +// - src/context.js imports { claimText } (:13) and calls it (:185) +// - src/dash.js imports { claimText } (:16) and calls it (:58, :389, :400) +// - src/deja.js imports { claimText } (:19) and calls it (:179) +// - src/ledger_store.js imports { claimText } (:26) and calls it (:663) +// - src/cli.js dynamic-imports { claimText } (:874, :1644) and calls it +// - src/cortex_mcp.js dynamic-imports { claimText } (:91) and calls it (:96, :106) +// - test/ledger.test.js imports { claimText } (:8) and calls it +// (test/dash.test.js:69 mentions the name only inside an assertion message — a string, // not a reference — so it is NOT labeled as a dependent.) // -// contentHash (src/util.js) — the deliberately hard case: wide fan-out plus one -// reference the regex atlas is KNOWN to miss (src/atlas.js binds it to an alias, -// `const hash = contentHash;`, with no call parentheses — and the JS import regex -// captures module paths, not named bindings — so no edge exists; that is a real, -// documented false negative, kept in the labels on purpose). -// - src/util.js defines it -// - src/atlas.js imports { contentHash }, aliases it (const hash = contentHash) -// - src/cortex_hook.js imports { contentHash } and calls it -// - src/diagnose.js imports { contentHash } and calls it -// - src/ledger.js imports { contentHash } and calls it -// - src/reuse.js imports { contentHash } and calls it +// contentHash (src/util.js) — 10 files. The widest fan-out in the set, and the case that +// used to carry a documented FALSE NEGATIVE: src/atlas.js binds it to an alias, +// `const hash = contentHash;` at :187, with no call parentheses, and the old import regex +// captured module paths rather than named bindings, so no edge reached atlas.js. That is +// FIXED — a named import now resolves to the exact symbol node +// (`src/atlas.js:17 imports → src/util.js:contentHash:65`), and atlas.js is predicted at +// one hop. The case is kept for its fan-out, not for the miss. +// - src/util.js defines it (:65); slug() calls it (:28) +// - src/atlas.js imports { contentHash } (:17), aliases it (:187) +// - src/cortex_hook.js imports it (:9) and calls it (:98) +// - src/cost_report.js imports it (:14); routeRef() calls it (:212) +// - src/diagnose.js imports it (:15); failureSignature() calls it (:57) +// - src/embed.js imports it (:35) and calls it (:202) +// - src/ledger.js imports it (:18) and calls it (:136, :141, :947, :962, :963) +// - src/ledger_store.js imports it (:43) and calls it (:414, :598) +// - src/reuse.js imports it (:15) and calls it (:94, :116, :400) +// - src/uiinteract.js imports it (:19) and calls it (:46) +// (src/ledger_store.js:586 also names it in a doc comment — same file, already labeled.) +// No test file references contentHash directly. export const IMPACT_CASES = [ { @@ -69,12 +92,19 @@ export const IMPACT_CASES = [ }, { target: "isStale", - expected: ["src/atlas.js", "src/verify.js", "src/doctor.js", "test/atlas.test.js"], + expected: [ + "src/atlas.js", + "src/verify.js", + "src/doctor.js", + "src/substrate.js", + "test/atlas.test.js", + "test/atlas_resolve.test.js", + ], editedFile: "src/atlas.js", }, { target: "mergeStates", - expected: ["src/ledger.js", "src/ledger_store.js", "test/ledger.test.js"], + expected: ["src/ledger.js", "src/ledger_store.js", "src/ledger_sync.js", "test/ledger.test.js"], editedFile: "src/ledger.js", }, { @@ -83,7 +113,10 @@ export const IMPACT_CASES = [ "src/ledger.js", "src/context.js", "src/dash.js", + "src/deja.js", + "src/ledger_store.js", "src/cli.js", + "src/cortex_mcp.js", "test/ledger.test.js", ], editedFile: "src/ledger.js", @@ -94,9 +127,13 @@ export const IMPACT_CASES = [ "src/util.js", "src/atlas.js", "src/cortex_hook.js", + "src/cost_report.js", "src/diagnose.js", + "src/embed.js", "src/ledger.js", + "src/ledger_store.js", "src/reuse.js", + "src/uiinteract.js", ], editedFile: "src/util.js", }, diff --git a/landing/index.html b/landing/index.html index d5864d1..86168ef 100644 --- a/landing/index.html +++ b/landing/index.html @@ -1783,7 +1783,7 @@ -
    Open source cognitive substrateforgekit v0.32.1 · beta

    One operating
    memory. Every
    coding agent.

    ForgeKit gives every AI coding tool the same memory, foresight, and guardrails—without locking your work inside one vendor or one chat window.

    Runtime deps
    0
    Native targets
    9
    License
    MIT
    FK / PREFLIGHTSYSTEM READY
    01
    REQUESTRefactor authentication flow
    00:118
    1. 01Memory recalledPASS
    2. 02Blast radius mappedPASS
    3. 03Guardrails checkedPASS
    TRACE FK-031-7D4PROCEED →
    01 / The substrateState before action

    The missing layer between
    your intent and your agent.

    Models are capable. Their operating context is fragile. ForgeKit supplies the durable layer that travels with the repository and shows up before the next action.

    ACTIVE CAPABILITY / 01

    Context that survives the chat.

    Forge keeps decisions, lessons, and project state in the repository—so Claude, Codex, Cursor, and the next agent all inherit the same working memory.

    3 records recalled
    TYPERECORDSTATE
    decisionUse SQLite for local-first state94%
    lessonRun schema checks before generation88%
    preferenceKeep the CLI dependency-free82%
    02 / The protocolOne request · five checks · one trace

    Action should leave evidence.

    Forge turns agent behavior into a reviewable sequence. Each meaningful move begins with context and ends with proof.

    1. 01Recall

      Load relevant decisions and lessons.

    2. 02Classify

      Measure scope, cost, and reversibility.

    3. 03Foresee

      Map downstream surfaces before editing.

    4. 04Gate

      Pause risky or under-specified actions.

    5. 05Trace

      Record what changed and how it was verified.

    03 / One sourceNine native targets

    Change the agent. Keep the operating system.

    One source emits each tool’s native configuration. Your rules and memory stay with the project—not the provider.

    • 01Claude Code
    • 02Codex
    • 03Cursor
    • 04Gemini
    • 05Aider
    • 06Copilot
    • 07Windsurf
    • 08Zed
    • 09Continue

    Plus MCP configuration for Roo Code and VS Code-compatible clients.

    04 / Evidence ledgerMeasured, not invented

    Fast enough to stay in the loop.

    ForgeKit publishes the measurements behind its claims. The numbers below come from repository benchmarks and evaluation reports—not a marketing dashboard.

    Pre-action gate
    118ms
    End-to-end benchmark
    Blast-radius scan
    0.43ms
    Heuristic analysis
    Held-out routing cost
    +20.2%
    vs always-premium, 80 tasks
    Runtime dependencies
    0
    Node.js standard library
    Inspect the evidence
    05 / Honest limitsProfessional, not magical

    The guardrail is not the road.

    ForgeKit improves agent judgment; it does not replace yours. The project labels its assumptions so you can decide where to trust, test, or intervene.

    • 01

      Claude Code is the deepest-tested integration. Other targets have less real-world exercise today.

    • 02

      Blast-radius analysis is heuristic. It guides review; it is not a formal dependency proof.

    • 03

      Guardrails are not a sandbox. Keep permissions, review, and backups appropriate to the work.

    06 / Start hereAbout sixty seconds

    Give the next agent a better starting point.

    Install ForgeKit, run forge init in your repository, and keep one shared operating context across every tool.

    Open the quickstart
    forgekit / install
     /plugin marketplace add CodeWithJuber/forgekit
    +
    Open source cognitive substrateforgekit v0.32.1 · beta

    One operating
    memory. Every
    coding agent.

    ForgeKit gives every AI coding tool the same memory, foresight, and guardrails—without locking your work inside one vendor or one chat window.

    Runtime deps
    0
    Native targets
    9
    License
    MIT
    FK / PREFLIGHTSYSTEM READY
    01
    REQUESTRefactor authentication flow
    00:118
    1. 01Memory recalledPASS
    2. 02Blast radius mappedPASS
    3. 03Guardrails checkedPASS
    TRACE FK-031-7D4PROCEED →
    01 / The substrateState before action

    The missing layer between
    your intent and your agent.

    Models are capable. Their operating context is fragile. ForgeKit supplies the durable layer that travels with the repository and shows up before the next action.

    ACTIVE CAPABILITY / 01

    Context that survives the chat.

    Forge keeps decisions, lessons, and project state in the repository—so Claude, Codex, Cursor, and the next agent all inherit the same working memory.

    3 records recalled
    TYPERECORDSTATE
    decisionUse SQLite for local-first state94%
    lessonRun schema checks before generation88%
    preferenceKeep the CLI dependency-free82%
    02 / The protocolOne request · five checks · one trace

    Action should leave evidence.

    Forge turns agent behavior into a reviewable sequence. Each meaningful move begins with context and ends with proof.

    1. 01Recall

      Load relevant decisions and lessons.

    2. 02Classify

      Measure scope, cost, and reversibility.

    3. 03Foresee

      Map downstream surfaces before editing.

    4. 04Gate

      Pause risky or under-specified actions.

    5. 05Trace

      Record what changed and how it was verified.

    03 / One sourceNine native targets

    Change the agent. Keep the operating system.

    One source emits each tool’s native configuration. Your rules and memory stay with the project—not the provider.

    • 01Claude Code
    • 02Codex
    • 03Cursor
    • 04Gemini
    • 05Aider
    • 06Copilot
    • 07Windsurf
    • 08Zed
    • 09Continue

    Plus MCP configuration for Roo Code and VS Code-compatible clients.

    04 / Evidence ledgerMeasured, not invented

    Fast enough to stay in the loop.

    ForgeKit publishes the measurements behind its claims. The numbers below come from repository benchmarks and evaluation reports—not a marketing dashboard.

    Pre-action gate
    886ms
    End-to-end benchmark
    Blast-radius scan
    0.40ms
    Heuristic analysis
    Held-out routing cost
    +20.2%
    vs always-premium, 80 tasks
    Runtime dependencies
    0
    Node.js standard library
    Inspect the evidence
    05 / Honest limitsProfessional, not magical

    The guardrail is not the road.

    ForgeKit improves agent judgment; it does not replace yours. The project labels its assumptions so you can decide where to trust, test, or intervene.

    • 01

      Claude Code is the deepest-tested integration. Other targets have less real-world exercise today.

    • 02

      Blast-radius analysis is heuristic. It guides review; it is not a formal dependency proof.

    • 03

      Guardrails are not a sandbox. Keep permissions, review, and backups appropriate to the work.

    06 / Start hereAbout sixty seconds

    Give the next agent a better starting point.

    Install ForgeKit, run forge init in your repository, and keep one shared operating context across every tool.

    Open the quickstart
    forgekit / install
     /plugin marketplace add CodeWithJuber/forgekit
      /plugin install forgekit
     

    Recommended · ambient guards on every prompt

    diff --git a/reports/benchmarks.md b/reports/benchmarks.md index 4fc2622..b52e2f9 100644 --- a/reports/benchmarks.md +++ b/reports/benchmarks.md @@ -64,15 +64,29 @@ order-of-magnitude, not three-significant-digit truths. Cases live in [`bench/impact_cases.mjs`](../bench/impact_cases.mjs), scored by `evalImpact()` (`src/eval.js`). Labeling rule: `expected` = the defining file plus every -file with a **direct, hand-verified reference** (an `import { X }` with a use, or a call -site) — each one listed, per file, in the fixture's comments, checkable with grep. +file with a **direct reference** (an `import { X }` — static, dynamic or aliased — with a +use, or a call site) — each one listed, per file and per line, in the fixture's comments. +The labels are **ground truth, not the graph's own output**: every one is re-derived with +`git grep -n -w -F -e -- 'src/*' 'test/*'` and confirmed by reading each hit, and a +name that appears only in a comment or inside a string (an assertion message, a doc line) is +not a reference and is not labeled — the omissions are listed too, so a re-check can tell +"deliberate" from "missed". + Transitive dependents are *not* labeled, so the oracle's transitive predictions count against precision — the same over-approximation penalty the paper's mutation-derived -scoring applied. The set deliberately includes one case (`contentHash`) with a reference -the regex atlas is **known to miss** (`src/atlas.js` binds it to an alias without calling -it: `const hash = contentHash;` — no call parentheses, and the JS import regex captures -module paths, not named bindings), so recall is measured against a documented false -negative rather than a curated-to-be-perfect set. +scoring applied. **That penalty is now most of the number.** `impact()` walks reverse +dependencies transitively by default (`maxHops: 6`), so for `contentHash` it returns 89 +files where 10 are directly labeled. Restricted to one hop it returns exactly the 10 +labeled files plus one documentation edge — i.e. the precision figure below measures the +gap between "everything downstream" and "the direct referencers", not a graph that is +wrong about who calls what. Read precision here as *how much wider than the direct set the +default answer is*, and recall as *does it ever miss a direct referencer* (it does not). + +`contentHash` used to carry a genuine false negative — `src/atlas.js` binds it to an alias, +`const hash = contentHash;`, with no call parentheses, and the old import regex captured +module paths rather than named bindings, so nothing reached `atlas.js`. Import specifiers +now resolve to the exact symbol (`src/atlas.js:17 imports → src/util.js:contentHash:65`) and +`atlas.js` is predicted at one hop; the case is kept for its fan-out, not for the miss. What these numbers do **not** mean: n = 6 cases, one JavaScript repo, symbols chosen to be uniquely named (the atlas resolves ambiguous names to nothing — a separate, known @@ -80,15 +94,19 @@ limitation). They are not comparable to the paper's numbers, which came from mut testing a Python codebase against a real test suite. The two appear side by side below, labeled, and are never blended. -> **The impact-quality table in the generated section below is stale (noted 2026-09-21).** -> Re-running `evalImpact` on the same six cases at commit `1a82388` gives precision **0.34**, -> recall **0.97**, F1 **0.50** (edited-file-only baseline recall 0.33), not the precision 0.90 / -> F1 0.92 recorded below; the atlas now predicts several unlabelled files per case. Neither -> figure is field evidence: on nine real Python repositories the paper's prototype oracle reached -> recall **0.022** ([research/empirical-refutation/](../research/empirical-refutation/)), and its -> "recall 1.00" row below comes from five mutations of its own demo package. The generated block -> is rewritten only by `npm run bench`. -> +> **History of this row.** The precision 0.90 / F1 0.92 this file carried until 2026-09-21 came +> from a much smaller atlas (145 files) and a reverse walk that stopped at the direct +> referencers. Two things changed since: `impact()` now walks reverse dependencies +> transitively by default, and four of the six label sets had gone stale against the source. +> Re-labelling every case from `git grep` and re-running `npm run bench` gives the generated +> table below — precision **0.17**, recall **1.00**, F1 **0.29** (edited-file-only baseline +> recall 0.27), where the precision loss is the transitive closure being scored against +> direct-only labels, not a graph that is wrong about who calls what (at one hop the six cases +> return their labeled sets). Neither figure is field evidence: on nine real Python +> repositories the paper's prototype oracle reached recall **0.022** +> ([research/empirical-refutation/](../research/empirical-refutation/)), and its "recall 1.00" +> row below comes from five mutations of its own demo package. The generated block is rewritten +> only by `npm run bench`. @@ -96,49 +114,49 @@ labeled, and are never blended. ```json { - "node": "v22.22.2", - "cpu": "Intel(R) Xeon(R) Processor @ 2.80GHz", + "node": "v24.19.0", + "cpu": "AMD EPYC Processor (with IBPB)", "cores": 4, - "memGB": 16, - "platform": "linux", + "memGB": 8, + "platform": "win32", "arch": "x64", - "commit": "eb68ea97dbbf226580fd9a03cef26806c15bc2e9", - "date": "2026-07-07T19:36:54.342Z" + "commit": "703da31d574c30d22bef019b1c8563ade0d0d6be", + "date": "2026-09-21T22:09:58.366Z" } ``` ### Measured results -| suite | benchmark | median | p95 | runs | notes | -|-----------|---------------------------------------------|---------|---------|------|----------------------------------------| -| atlas | full build (this repo) | 131 ms | 140 ms | 5 | 145 files, 2777 symbols, 7892 edges | -| atlas | incremental rebuild (unchanged) | 55.7 ms | 75.9 ms | 5 | per-file hash cache hit | -| atlas | impact("claimText") (warm adjacency) | 0.43 ms | 0.51 ms | 30 | 5 files impacted | -| ledger | mint+put 1000 claims | 834 ms | 994 ms | 5 | 1,199/s | -| ledger | loadClaims at 1000 claims | 55.8 ms | 56.1 ms | 5 | full state from disk | -| ledger | mergeDirs 2×500-claim replicas (250 shared) | 158 ms | 188 ms | 3 | +250 claims, +313 records | -| ledger | val() over 1000 claims | 0.28 ms | 0.42 ms | 20 | 3,547,798/s (mean val 0.53) | -| reuse | fingerprint 2000 specs | 142 ms | 152 ms | 5 | 14,043/s | -| reuse | lookup exact @ 100 artifacts | 0.12 ms | 0.16 ms | 10 | tier=exact | -| reuse | lookup near (LSH) @ 100 artifacts | 9.96 ms | 13.5 ms | 5 | tier=near, j=0.98 | -| reuse | lookup exact @ 1000 artifacts | 0.43 ms | 0.76 ms | 10 | tier=exact | -| reuse | lookup near (LSH) @ 1000 artifacts | 107 ms | 110 ms | 5 | tier=near, j=0.95 | -| context | assemble() (this repo, 3-symbol task) | 3.80 ms | 8.94 ms | 10 | 2617/6000 tokens, 9 required, complete | -| substrate | substrateCheck (allowBuild, llm off) | 118 ms | 120 ms | 3 | 18 impacted files, route simple | +| suite | benchmark | median | p95 | runs | notes | +|-----------|---------------------------------------------|----------|---------|------|----------------------------------------| +| atlas | full build (this repo) | 530 ms | 622 ms | 5 | 455 files, 10498 symbols, 29728 edges | +| atlas | incremental rebuild (unchanged) | 339 ms | 359 ms | 5 | per-file hash cache hit | +| atlas | impact("claimText") (warm adjacency) | 0.40 ms | 1.08 ms | 30 | 51 files impacted | +| ledger | mint+put 1000 claims | 1854 ms | 1986 ms | 5 | 539/s | +| ledger | loadClaims at 1000 claims | 213 ms | 230 ms | 5 | full state from disk | +| ledger | mergeDirs 2×500-claim replicas (250 shared) | 4308 ms | 4409 ms | 3 | +250 claims, +313 records | +| ledger | val() over 1000 claims | 0.076 ms | 0.16 ms | 20 | 13,140,604/s (mean val 0.51) | +| reuse | fingerprint 2000 specs | 116 ms | 156 ms | 5 | 17,171/s | +| reuse | lookup exact @ 100 artifacts | 5.71 ms | 10.5 ms | 10 | tier=miss | +| reuse | lookup near (LSH) @ 100 artifacts | 4.76 ms | 5.18 ms | 5 | tier=miss, j=- | +| reuse | lookup exact @ 1000 artifacts | 52.8 ms | 88.5 ms | 10 | tier=miss | +| reuse | lookup near (LSH) @ 1000 artifacts | 46.7 ms | 89.8 ms | 5 | tier=miss, j=- | +| context | assemble() (this repo, 3-symbol task) | 12.6 ms | 30.0 ms | 10 | 4070/6000 tokens, 9 required, complete | +| substrate | substrateCheck (allowBuild, llm off) | 886 ms | 908 ms | 3 | 99 impacted files, route simple | ### Impact-oracle quality (hand-labeled cases, this repo) | case (target) | precision | recall | F1 | predicted | truth | |---------------|-----------|--------|------|-----------|-------| -| normalizeSpec | 1.00 | 1.00 | 1.00 | 2 | 2 | -| evalImpact | 1.00 | 1.00 | 1.00 | 2 | 2 | -| isStale | 1.00 | 1.00 | 1.00 | 4 | 4 | -| mergeStates | 1.00 | 1.00 | 1.00 | 3 | 3 | -| claimText | 1.00 | 1.00 | 1.00 | 5 | 5 | -| contentHash | 0.38 | 0.83 | 0.53 | 13 | 6 | -| mean of 6 | 0.90 | 0.97 | 0.92 | | | +| normalizeSpec | 0.12 | 1.00 | 0.21 | 17 | 2 | +| evalImpact | 0.29 | 1.00 | 0.44 | 7 | 2 | +| isStale | 0.20 | 1.00 | 0.33 | 30 | 6 | +| mergeStates | 0.16 | 1.00 | 0.28 | 25 | 4 | +| claimText | 0.16 | 1.00 | 0.27 | 51 | 8 | +| contentHash | 0.11 | 1.00 | 0.21 | 87 | 10 | +| mean of 6 | 0.17 | 1.00 | 0.29 | | | -Edited-file-only baseline recall over the same cases: **0.33**. +Edited-file-only baseline recall over the same cases: **0.27**. Two methodologies, side by side — different codebases, different ground-truth derivations, so the rows are comparable in spirit only and are never blended: @@ -146,7 +164,7 @@ derivations, so the rows are comparable in spirit only and are never blended: | series | precision | recall | F1 | ground truth | |--------------------------------------------|-----------|--------|------|-----------------------------------------------| | paper prototype (Python, mutation-derived) | 0.63 | 1.00 | 0.75 | mutation testing against a real suite | -| this repo (regex atlas, hand-labeled) | 0.90 | 0.97 | 0.92 | 6 hand-labeled cases (bench/impact_cases.mjs) | +| this repo (regex atlas, hand-labeled) | 0.17 | 1.00 | 0.29 | 6 hand-labeled cases (bench/impact_cases.mjs) | diff --git a/test/eval.test.js b/test/eval.test.js index 2e2e664..d35c936 100644 --- a/test/eval.test.js +++ b/test/eval.test.js @@ -1,11 +1,16 @@ import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { IMPACT_CASES } from "../bench/impact_cases.mjs"; import { build } from "../src/atlas.js"; import { evalImpact, score } from "../src/eval.js"; +const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); + test("score computes precision/recall/f1", () => { const s = score(["a", "b", "c"], ["b", "c", "d"]); assert.equal(s.tp, 2); @@ -35,3 +40,57 @@ test("evalImpact: the oracle recalls more of the true blast radius than edited-f assert.ok(r.oracle.recall >= r.baseline.recall, "oracle recall ≥ baseline"); assert.ok(r.oracle.recall > 0.5, `oracle finds most dependents (recall ${r.oracle.recall})`); }); + +// --------------------------------------------------------------------------- +// The impact benchmark's labels must stay GROUND TRUTH. They are a hand-derived +// fixture (bench/impact_cases.mjs) that the published precision/recall/F1 are scored +// against, so a label set that drifts from the source silently corrupts a number in +// reports/benchmarks.md and README.md. This re-derives the referencers mechanically and +// fails the moment the two disagree — which is how four of the six sets went stale. +// --------------------------------------------------------------------------- + +// Files that name a target only in PROSE — a comment, a doc line, or a string such as an +// assertion message. Not references, deliberately not labeled. Listing them here (rather +// than filtering by heuristic) is what makes "unlabeled" mean "someone decided". +const MENTION_ONLY = { + claimText: ["test/dash.test.js"], // :69, inside an assertion message +}; + +// This file names every target as DATA (the table above, assertion text), so `git grep` +// finds it for all of them. It excludes itself from the "unlabeled referencer" check +// rather than listing itself under every key — it is the checker, not a dependent. +const SELF = "test/eval.test.js"; + +const gitGrepFiles = (symbol) => { + const r = spawnSync("git", ["grep", "-l", "-w", "-F", "-e", symbol, "--", "src/*", "test/*"], { + cwd: REPO_ROOT, + encoding: "utf8", + }); + if (r.status !== 0 && r.status !== 1) return null; // no git / not a work tree → skip + return (r.stdout || "") + .split("\n") + .map((f) => f.trim()) + .filter(Boolean); +}; + +test("bench impact labels are ground truth: every label is grep-backed, nothing real is missing", (t) => { + if (gitGrepFiles("IMPACT_CASES") === null) return t.skip("git grep unavailable"); + for (const c of IMPACT_CASES) { + const hits = new Set(gitGrepFiles(c.target)); + const labeled = new Set(c.expected); + assert.ok(hits.size > 0, `${c.target}: grep found nothing — is the symbol gone?`); + assert.ok(labeled.has(c.editedFile), `${c.target}: the defining file is always labeled`); + + const ghosts = [...labeled].filter((f) => !hits.has(f)); + assert.deepEqual(ghosts, [], `${c.target}: labeled file(s) that no longer name it`); + + const allowed = new Set(MENTION_ONLY[c.target] ?? []); + const unlabeled = [...hits].filter((f) => f !== SELF && !labeled.has(f) && !allowed.has(f)); + assert.deepEqual( + unlabeled, + [], + `${c.target}: file(s) reference it but are not labeled — re-read each one and either ` + + "add it to expected in bench/impact_cases.mjs or record it in MENTION_ONLY here", + ); + } +}); From a752e897d0a7fc777dbe848637f980a7cca23726 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:19:39 +0200 Subject: [PATCH 70/72] fix(ledger): accept a pre-CRLF-fold claim address on read (migration) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fold that stopped a CRLF checkout forking a claim id also re-addresses every claim already stored with \r\n in its body: recomputing the id under the new rule made the file fail its own address check, so loadClaims returned NOTHING for it — the fold deleted data instead of migrating it, against the repo's own rule (test/ledger.test.js: "Bump v and write a migration before changing canonicalization"). Verified on the real upgrade path: a claim minted by the pre-fold code went from 1 entry to 0. walkClaimFiles now accepts the legacy address too. Writes are unchanged, so the pre-fold form dies out as claims are rewritten, and a claim matching neither address is still quarantined. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 11 +++++++++++ src/ledger.js | 23 ++++++++++++++++++----- src/ledger_store.js | 12 ++++++++++-- test/ledger_store.test.js | 29 +++++++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a516c70..3d612ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,17 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- **A claim minted before the CRLF fold is migrated, not deleted.** Folding ` +` into + ` +` changes a claim's content address, so a claim written by an earlier version on a + Windows checkout carried the pre-fold address in its filename and failed its own address + check on load — `loadClaims` returned nothing for it, and `forge ledger verify` reported + it as an id mismatch. The read path now accepts the pre-fold address as well, so the + claim stays readable and its evidence log keeps resolving; every WRITE uses the current + rule, so the old form dies out as claims are rewritten. Content that matches neither + address is still refused, which is what the check is for. + - **The impact benchmark's labels are ground truth again, and the numbers they feed are re-measured.** Four of the six label sets in `bench/impact_cases.mjs` had gone stale against the source — `isStale` was missing `src/substrate.js` (an aliased import) and diff --git a/src/ledger.js b/src/ledger.js index 52db09f..19bb88e 100644 --- a/src/ledger.js +++ b/src/ledger.js @@ -95,6 +95,12 @@ export const DORMANT_VAL = 0.35; * - Non-string values: numbers, booleans and null serialize as JSON.stringify does. */ const canonText = (s) => s.normalize("NFC").replace(/\r\n/g, "\n"); +// The rule BEFORE the CRLF fold. A claim minted on a CRLF checkout carries the old address +// in its FILENAME, and recomputing it under the new rule made the file fail its own address +// check: the claim did not degrade, it vanished — loadClaims returned nothing for it. The +// store therefore accepts either address on READ, so no existing claim is lost; nothing +// writes this one. Remove when no ledger in the wild predates the fold. +const canonTextLegacy = (s) => s.normalize("NFC"); /** * Deterministic canonical JSON: lexicographically sorted keys, no insignificant @@ -104,12 +110,12 @@ const canonText = (s) => s.normalize("NFC").replace(/\r\n/g, "\n"); * @param {*} value * @returns {string} */ -export function canonicalize(value) { +export function canonicalize(value, norm = canonText) { if (value === null || typeof value === "number" || typeof value === "boolean") return JSON.stringify(value); - if (typeof value === "string") return JSON.stringify(canonText(value)); + if (typeof value === "string") return JSON.stringify(norm(value)); if (Array.isArray(value)) - return `[${value.map((v) => (v === undefined ? "null" : canonicalize(v))).join(",")}]`; + return `[${value.map((v) => (v === undefined ? "null" : canonicalize(v, norm))).join(",")}]`; if (typeof value === "object") { // Normalize keys BEFORE sorting: sorting the raw spelling and normalizing afterwards made // an NFD key sort where its NFC twin doesn't, so a claim written with one spelling failed @@ -120,11 +126,11 @@ export function canonicalize(value) { const entries = new Map(); for (const k of Object.keys(value).sort()) { if (value[k] === undefined || typeof value[k] === "function") continue; - const nk = canonText(k); + const nk = norm(k); if (!entries.has(nk)) entries.set(nk, value[k]); } const keys = [...entries.keys()].sort(); - return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalize(entries.get(k))}`).join(",")}}`; + return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalize(entries.get(k), norm)}`).join(",")}}`; } return "null"; // undefined / function at the top level } @@ -136,6 +142,13 @@ export function claimId(kind, body, scope = {}) { return contentHash(canonicalize({ body, kind, scope })); } +/** The address a claim minted BEFORE the CRLF fold carries in its filename. READ PATH ONLY: + * walkClaimFiles accepts it so an existing claim survives the upgrade, and nothing writes + * it. A file matching only this is pre-fold data, not a tampered claim. */ +export function legacyClaimId(kind, body, scope = {}) { + return contentHash(canonicalize({ body, kind, scope }, canonTextLegacy)); +} + /** Stamp a record with its content hash (the dedupe key in every append-only log). */ export function sealRecord(record) { return { ...record, h: contentHash(canonicalize(record)) }; diff --git a/src/ledger_store.js b/src/ledger_store.js index 9c67df7..4ee204e 100644 --- a/src/ledger_store.js +++ b/src/ledger_store.js @@ -29,6 +29,7 @@ import { emptyState, hasSecret, isDormant, + legacyClaimId, liveClaims, mergeStates, mintClaim, @@ -197,8 +198,15 @@ function* walkClaimFiles(dir) { const path = join(claimsRoot, shard, f); const id = f.replace(/\.json$/, ""); const parsed = readJsonSafe(path); - // Verify the address: a tampered/corrupt claim is surfaced as claim:null. - const valid = parsed && claimId(parsed.kind, parsed.body, parsed.scope) === id; + // Verify the address: a tampered/corrupt claim is surfaced as claim:null. A claim + // minted before the CRLF fold carries the PRE-fold address in its filename, so that + // address counts too — otherwise the fold would delete, not migrate: every such claim + // failed its own check and disappeared from loadClaims. Only reads accept it; every + // write uses the current rule, so the pre-fold form dies out as claims are rewritten. + const valid = + parsed && + (claimId(parsed.kind, parsed.body, parsed.scope) === id || + legacyClaimId(parsed.kind, parsed.body, parsed.scope) === id); yield { id, path, diff --git a/test/ledger_store.test.js b/test/ledger_store.test.js index d7dcc79..133d2f7 100644 --- a/test/ledger_store.test.js +++ b/test/ledger_store.test.js @@ -6,6 +6,8 @@ import { join } from "node:path"; import { test } from "node:test"; import { canonicalize, + claimId, + legacyClaimId, liveClaims, mintClaim, outcomeRecord, @@ -736,3 +738,30 @@ test("loadState (C11): the snapshot cache is derived — an external edit is nev writeFileSync(join(dir, ".state-cache.json"), "{not json"); assert.equal(loadClaims(dir).length, 1); }); + +test("a claim minted before the CRLF fold survives the upgrade (migration, not deletion)", () => { + // canonicalize() now folds \r\n → \n, which CHANGES the content address. A claim written + // by an older version on a CRLF checkout carries the pre-fold address in its filename, so + // recomputing it under the new rule made the file fail its own check — loadClaims returned + // NOTHING for it. The read path accepts the legacy address so the claim is still there. + const dir = tmp(); + const body = { name: "build", text: "step one\r\nstep two" }; + const legacyId = legacyClaimId("fact", body, {}); + assert.notEqual(legacyId, claimId("fact", body, {}), "the fold really does re-address it"); + mkdirSync(join(dir, "claims", legacyId.slice(0, 2)), { recursive: true }); + writeFileSync( + join(dir, "claims", legacyId.slice(0, 2), `${legacyId}.json`), + JSON.stringify({ kind: "fact", body, scope: {}, v: 1 }), + ); + const loaded = loadClaims(dir); + assert.equal(loaded.length, 1, "the pre-fold claim is readable, not orphaned"); + assert.equal(loaded[0].body.text, "step one\r\nstep two", "its bytes are untouched"); + // The escape hatch stays narrow: content that matches NEITHER address is still refused. + const evil = `${"a".repeat(63)}b`; + mkdirSync(join(dir, "claims", evil.slice(0, 2)), { recursive: true }); + writeFileSync( + join(dir, "claims", evil.slice(0, 2), `${evil}.json`), + '{"kind":"fact","body":{"name":"evil","text":"tampered"},"scope":{},"v":1}', + ); + assert.equal(loadClaims(dir).length, 1, "a tampered claim is still refused"); +}); From bc64f275d451ccb8b9eb95dca521f697f58caf8a Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:20:22 +0200 Subject: [PATCH 71/72] docs(readme): say which machine the wall-clock benchmark rows came from `npm run bench` regenerated every row on this session's machine, so the published gate median moved 118 ms -> 886 ms and the ledger merge 158 ms -> 4308 ms. Both figures are real and the environment block records the box, but a reader meeting them in the README saw a 27x regression with no way to know it is a 4-core Windows VM standing in for a Linux host. The boundary column and the two prose bullets now say so. The impact-quality row needs no such note: precision/recall/F1 are machine-independent. Co-Authored-By: Claude Opus 5 --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ce05635..377ea7b 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,8 @@ an assumption until measured_. to one hop the same cases return their labeled sets). The precision 0.90 this line used to quote does not reproduce. On nine real Python repositories the research prototype's impact oracle reached recall 0.022 ([refutation](research/empirical-refutation/)). -- **A full pre-action gate in 886 ms** (median on this repo, warm) — assumption check, routing, +- **A full pre-action gate in 886 ms** (median on this repo, warm, on a 4-core Windows VM — this + row is machine-bound; see the environment block) — assumption check, routing, reuse lookup, context assembly, blast radius, scope, and goal anchor in one deterministic pass, no LLM call. On Claude Code it runs on **every prompt, automatically**. - **The white paper's 62.1% routing saving is refuted.** It was measured on the 30 tasks the @@ -215,7 +216,8 @@ an assumption until measured_. judge accepted it cost $1.06 against $1.76, but only 6 and 3 of 64 outputs were accepted ([refutation](research/empirical-refutation/)). `forge cost --stages` reports only _your_ measured stages. -- **Conflict-free team memory** — merging two 500-claim ledger replicas takes **4308 ms**; the +- **Conflict-free team memory** — merging two 500-claim ledger replicas takes **4308 ms** on that + same VM (I/O-bound, 4–6x a Linux host); the merge is order-independent and property-tested, so teammate ledgers converge to the same state no matter who syncs first, over plain git. The substrate is advisory by default. Set `FORGE_ENFORCE=1` to block only its strongest @@ -341,9 +343,9 @@ The boundaries in the table below are part of each result. | Measurement | Recorded result | Boundary | | --- | ---: | --- | | Warm impact query | 0.40 ms median | 30 runs on one JavaScript repository with a memoized adjacency index; not model latency | -| Deterministic substrate check | 886 ms median | 3 runs on one repository, warm graph, LLM disabled | +| Deterministic substrate check | 886 ms median | 3 runs on one repository, warm graph, LLM disabled, on a 4-core Windows VM — wall-clock rows are machine-bound and were ~150 ms on the Linux host that produced the pre-2026-09-22 snapshot; re-run `npm run bench` on your own hardware | | Impact quality | precision 0.17, recall 1.00, F1 0.29 (the precision 0.90 / F1 0.92 reported before 2026-09-21 do not reproduce) | 6 hand-labelled symbols in this repository, scored by `evalImpact` against labels re-derived by `git grep`; `impact` walks reverse dependencies transitively by default, so precision measures the transitive closure against direct-only labels; edited-file-only baseline recall 0.27 | -| Ledger replica merge | 4308 ms median | 3 runs merging two synthetic 500-claim replicas with 250 claims shared | +| Ledger replica merge | 4308 ms median | 3 runs merging two synthetic 500-claim replicas with 250 claims shared, on the same 4-core Windows VM (I/O-bound: 4–6x the Linux host's figure) | | Python router live demonstration | 62.1% calculated cost reduction versus always-premium | 30 hand-labelled tasks, thresholds tuned to the set, real measured LLM tokens, approximate public prices; demonstration, not field benchmark | | Python router, held-out evaluation | total spend 20.2% **higher** than always-premium; gate F1 0.37 | 80 tasks from real GitHub issues and PRs, thresholds frozen, pre-registered; refutes the row above | | Python impact oracle | precision 0.633, recall 1.000, F1 0.753 | 5 mutations in the bundled demo package; mutation-derived test failures as ground truth | From 675984eafe17608f939539d04a6f9e49e707df4e Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:30:21 +0200 Subject: [PATCH 72/72] feat(ledger): re-address pre-CRLF-fold claims with `ledger verify --fix` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accepting the legacy address on read stopped the fold from deleting data, but it left the other half open: the pre-fold claim and a teammate's freshly minted copy of the same fact are still two entries, which is the fork the fold exists to prevent. migrateAddresses() moves each such claim to its current address and takes its evidence/provenance/tombstone logs with it, unioning into an existing twin rather than overwriting — the logs are append-only sets deduped by content hash, so union is the merge. Idempotent, and it only touches a claim whose filename matches the legacy address exactly; anything else is left for verify to report. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 9 +++++++- docs/GUIDE.md | 8 +++++++ src/cli.js | 12 +++++++++- src/ledger_store.js | 46 +++++++++++++++++++++++++++++++++++++++ test/ledger_store.test.js | 24 ++++++++++++++++++++ 5 files changed, 97 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d612ff..0e776b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- **`forge ledger verify --fix` re-addresses pre-CRLF-fold claims.** Accepting the old + address on read keeps such a claim alive, but it and a teammate's freshly minted copy of + the same fact remain two entries until their bytes agree — the fork the fold exists to + prevent. The flag moves each claim to its current address and takes its evidence and + provenance logs with it, unioning into an existing twin instead of overwriting (the logs + are append-only sets deduped by content hash, so union IS the merge). Idempotent. + - **TypeSafe System One (Jev) as the fast proposer.** Where forge's LLM layer asked a text model for a judgment that is really a classification or a yes/no — `route`'s complexity band and preflight's assumption gate — it can now ask Jev instead: typed `choice`/`noul` answers @@ -25,7 +32,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed -- **A claim minted before the CRLF fold is migrated, not deleted.** Folding ` +- **A claim minted before the CRLF fold is migrated, not deleted.** Folding ` ` into ` ` changes a claim's content address, so a claim written by an earlier version on a diff --git a/docs/GUIDE.md b/docs/GUIDE.md index ceac209..8113326 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -819,6 +819,14 @@ Forge ledger — proof-carrying memory stored in .forge/ledger/ (git-committable, conflict-free merge) ``` +`forge ledger verify` re-derives every claim's address and every log line's hash, and names +what it had to skip. `forge ledger verify --fix` additionally re-addresses claims written +before canonicalization folded CRLF into LF: those carry their pre-fold id in the filename, +which reads still accept, so nothing is broken without it — but the old form and a teammate's +freshly minted copy of the same fact stay two entries until you run it. It moves each claim's +evidence and provenance logs with it, unions them into an existing twin rather than +overwriting, and is idempotent. + `forge ledger blame ` is the accountability view — every mint, every oracle outcome, every retraction, and per-author trust: diff --git a/src/cli.js b/src/cli.js index 82815c4..9c60d4a 100755 --- a/src/cli.js +++ b/src/cli.js @@ -721,8 +721,18 @@ HANDLERS.ledger = async (argv) => { return; } if (sub === "verify") { + // --fix re-addresses claims still stored under their pre-CRLF-fold id. Reads accept + // that address either way, so this is not a repair — it is what stops one fact living + // at two addresses once a teammate on another platform mints its current form. + const migration = args.includes("--fix") ? ls.migrateAddresses(dir) : null; const r = ls.verify(dir); - if (json) return console.log(JSON.stringify(r, null, 2)); + if (json) return console.log(JSON.stringify(migration ? { ...r, migration } : r, null, 2)); + if (migration) { + const { migrated, merged, failed } = migration; + console.log( + ` migrated ${migrated.length} claim(s) to their current address, merged ${merged.length} into an existing twin${failed.length ? `, ${failed.length} failed` : ""}`, + ); + } 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; diff --git a/src/ledger_store.js b/src/ledger_store.js index 4ee204e..c3ed698 100644 --- a/src/ledger_store.js +++ b/src/ledger_store.js @@ -15,6 +15,7 @@ import { readFileSync, readSync, renameSync, + rmSync, statSync, writeFileSync, } from "node:fs"; @@ -686,6 +687,51 @@ export function reindex(dir, _nowDay = 0) { * skips, verify names. * @returns {{ok:boolean, claims:number, outcomes:number, issues:string[]}} */ +/** + * Re-address every claim still stored under its PRE-CRLF-fold id (see legacyClaimId). + * Reads already accept that address, so nothing is broken without this — but the old and + * the newly-minted form of one fact stay TWO entries until their bytes agree, which is the + * fork the fold exists to prevent. This moves the claim file to its current address and + * takes its logs with it, unioning into an existing log rather than overwriting one (the + * logs are append-only sets deduped by content hash, so a union is the merge). + * Idempotent: a second run finds nothing to do. + * @param {string} dir ledger dir + * @returns {{migrated: string[], merged: string[], failed: string[]}} + */ +export function migrateAddresses(dir) { + const migrated = []; + const merged = []; + const failed = []; + for (const { id, path, claim } of [...walkClaimFiles(dir)]) { + if (!claim) continue; + const current = claimId(claim.kind, claim.body, claim.scope); + if (current === id) continue; // already at its current address + if (legacyClaimId(claim.kind, claim.body, claim.scope) !== id) continue; // not ours to touch + try { + const target = claimPath(dir, current); + const already = existsSync(target); + if (!already) { + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, claimBytes({ ...claim, id: current })); + } + for (const log of LOGS) { + const from = logPath(dir, log, id); + if (!existsSync(from)) continue; + const lines = readFileSync(from, "utf8"); + const to = logPath(dir, log, current); + mkdirSync(dirname(to), { recursive: true }); + for (const line of lines.split(/\r?\n/)) if (line.trim()) appendLine(to, line); + rmSync(from, { force: true }); + } + rmSync(path, { force: true }); + (already ? merged : migrated).push(current); + } catch { + failed.push(id); + } + } + return { migrated, merged, failed }; +} + export function verify(dir) { const issues = []; let claims = 0; diff --git a/test/ledger_store.test.js b/test/ledger_store.test.js index 133d2f7..e17f8cc 100644 --- a/test/ledger_store.test.js +++ b/test/ledger_store.test.js @@ -22,6 +22,7 @@ import { loadClaims, loadState, mergeDirs, + migrateAddresses, pruneLedger, pruneToAttic, putClaim, @@ -765,3 +766,26 @@ test("a claim minted before the CRLF fold survives the upgrade (migration, not d ); assert.equal(loadClaims(dir).length, 1, "a tampered claim is still refused"); }); + +test("migrateAddresses moves a pre-CRLF-fold claim to its current address, logs and all", () => { + // Accepting the legacy address on read keeps the claim alive, but the old form and a + // freshly minted twin are still two entries. This is the other half: re-address it. + const dir = tmp(); + const body = { name: "build", text: "step one\r\nstep two" }; + const legacyId = legacyClaimId("fact", body, {}); + const currentId = claimId("fact", body, {}); + mkdirSync(join(dir, "claims", legacyId.slice(0, 2)), { recursive: true }); + writeFileSync( + join(dir, "claims", legacyId.slice(0, 2), `${legacyId}.json`), + JSON.stringify({ kind: "fact", body, scope: {}, v: 1 }), + ); + appendEvidence(dir, legacyId, ev("confirm", "run:1")); + const r = migrateAddresses(dir); + assert.deepEqual(r.migrated, [currentId], "the claim moved to its current address"); + assert.equal(existsSync(join(dir, "claims", legacyId.slice(0, 2), `${legacyId}.json`)), false); + const loaded = loadClaims(dir); + assert.equal(loaded.length, 1, "still exactly one claim"); + assert.equal(loaded[0].id, currentId); + assert.equal(loaded[0].evidence.length, 1, "its evidence came with it"); + assert.deepEqual(migrateAddresses(dir).migrated, [], "idempotent: nothing left to move"); +});