@polycode-projects/the-mechanical-code-talker 1.9.1 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +441 -217
  2. package/bin/tmct.mjs +126 -1
  3. package/corpus/seon/README.md +1 -2
  4. package/package.json +4 -2
  5. package/src/answer-variants.mjs +8 -36
  6. package/src/ask-browser-entry.mjs +5 -23
  7. package/src/ask-browser.bundle.js +1 -2
  8. package/src/ask-nlp.mjs +9 -23
  9. package/src/ask-vocab.mjs +139 -589
  10. package/src/ask.mjs +627 -1729
  11. package/src/chat.mjs +1684 -2874
  12. package/src/cli-args.mjs +14 -28
  13. package/src/codegraph.mjs +236 -644
  14. package/src/completions/complete.mjs +18 -62
  15. package/src/completions/graph-adapter.mjs +14 -60
  16. package/src/completions/group.mjs +12 -68
  17. package/src/completions/infer.mjs +38 -126
  18. package/src/completions/prune.mjs +17 -70
  19. package/src/completions/rank.mjs +16 -69
  20. package/src/completions/search.mjs +8 -31
  21. package/src/concept.mjs +32 -88
  22. package/src/conformance.mjs +11 -15
  23. package/src/corpus/conceptnet.mjs +31 -89
  24. package/src/corpus/templates.mjs +19 -45
  25. package/src/corpus/unknown-ingest.mjs +31 -92
  26. package/src/embed.mjs +10 -22
  27. package/src/extensions.mjs +50 -154
  28. package/src/finish.mjs +35 -91
  29. package/src/grammar/ace.mjs +16 -40
  30. package/src/grammar/assert.mjs +1 -1
  31. package/src/grammar/lexicon-core.json +1 -1
  32. package/src/grammar/lexicon.mjs +9 -27
  33. package/src/graph-merge.mjs +2 -3
  34. package/src/hash.mjs +6 -14
  35. package/src/index.mjs +6 -10
  36. package/src/init.mjs +38 -125
  37. package/src/interpret/fuzzy.mjs +10 -29
  38. package/src/interpret/merge.mjs +9 -27
  39. package/src/interpret/normalize.mjs +137 -585
  40. package/src/interpret/pipeline.mjs +23 -71
  41. package/src/interpret/strategies/ace.mjs +7 -31
  42. package/src/interpret/strategies/constructions.mjs +14 -41
  43. package/src/interpret/strategies/grammar.mjs +21 -60
  44. package/src/interpret/strategies/keywords.mjs +42 -131
  45. package/src/interpret/strategies/noise-strip.mjs +18 -89
  46. package/src/memory/bias.mjs +11 -54
  47. package/src/memory/blocks.mjs +18 -69
  48. package/src/memory/core.mjs +171 -591
  49. package/src/memory/fold.mjs +0 -0
  50. package/src/memory/inspect.mjs +7 -25
  51. package/src/memory/shacl.mjs +10 -39
  52. package/src/memory/trust.mjs +26 -127
  53. package/src/memory-ask-browser-entry.mjs +7 -30
  54. package/src/memory-ask-browser.bundle.js +1 -1
  55. package/src/paraphrase.mjs +20 -53
  56. package/src/planning.mjs +15 -157
  57. package/src/prose-nlp.mjs +4 -17
  58. package/src/prose.mjs +19 -67
  59. package/src/providers/bootstrap.mjs +1 -2
  60. package/src/providers/fixture.mjs +1 -2
  61. package/src/providers/graph-service.mjs +28 -59
  62. package/src/repository-interface.mjs +6 -8
  63. package/src/router/drive.mjs +183 -0
  64. package/src/router/goal-reasoner.mjs +66 -231
  65. package/src/router/guardrail.mjs +20 -58
  66. package/src/router/planner.mjs +15 -46
  67. package/src/router/registry.mjs +13 -43
  68. package/src/router/resolver.mjs +46 -131
  69. package/src/router/results.mjs +231 -0
  70. package/src/schema-docs.mjs +10 -27
  71. package/src/server-http.mjs +10 -19
  72. package/src/server.mjs +22 -28
  73. package/src/sessions.mjs +15 -30
  74. package/src/source-slice.mjs +5 -7
  75. package/src/source.mjs +10 -20
  76. package/src/syllogise.mjs +187 -575
  77. package/src/telemetry.mjs +3 -3
  78. package/src/toml-config.mjs +4 -4
  79. package/src/tui/app.mjs +9 -19
  80. package/src/viz.mjs +66 -123
  81. package/src/wink-model.mjs +10 -24
@@ -1,32 +1,9 @@
1
- // memory/core.mjs — tmct's OWN conversational memory graph (ROADMAP item 9).
2
- //
3
- // A dedicated OWL-labelled store at <repo>/.tmct/memory/graph.json raw JSON in
4
- // the exact `entities` shape buildEntities produces, so codegraph.mjs's
5
- // parseEntities() loads it unchanged ({ individuals, byId, relations, proseIndex }).
6
- // It is DISTINCT from any provider-supplied code graph: tmct never writes a
7
- // provider's graph (docs/adapter-contract.md); memory writes land ONLY here.
8
- //
9
- // What goes in:
10
- // - every parsed inbound request becomes an "a-visitor-said" individual
11
- // (class `Utterance`, role=visitor) and every response an "a-tmct-said"
12
- // individual (role=tmct), each carrying text/ts/role attributes, an
13
- // `mgx:saidInSession` edge to its Session anchor, and — for a response —
14
- // an `mgx:inReplyTo` edge to the visitor utterance it answers;
15
- // - grammar-derived OWL triples via appendFact() (subject/predicate/object +
16
- // provenance), reified RDF-style (rdf:subject / rdf:predicate / rdf:object
17
- // on a `Fact` individual) — the Phase-2 ACE parser's write point.
18
- //
19
- // OWL labelling: individuals are rdf-ish typed twice — the payload-level `class`
20
- // field (Utterance / Fact / Session, counted in `classes[]` like every other
21
- // graph class) AND an `rdf:type` attribute naming the OWL term
22
- // (owl:NamedIndividual for utterances, rdf:Statement for reified facts), with
23
- // the owl/rdf/rdfs prefixes declared in the payload's `prefixes` block —
24
- // consistent with graph-build.mjs's JSON-label-only vocabulary style.
25
- //
26
- // Every append is crash-safe (fresh read → mutate → temp-file + rename, the
27
- // sessions.mjs discipline) and IDEMPOTENT: utterance ids are deterministic
28
- // (utt:<session>#<ts>#<role>) and fact ids hash the triple, so the per-turn
29
- // re-append sessions.mjs performs replaces rather than duplicates.
1
+ // memory/core.mjs — tmct's OWN conversational memory graph: a dedicated
2
+ // OWL-labelled store at <repo>/.tmct/memory/graph.json, distinct from any
3
+ // provider-supplied code graph. Utterances, Facts (reified RDF triples via
4
+ // appendFact), and Sessions are all typed twice — payload `class` and an
5
+ // `rdf:type` attribute. Every append is crash-safe and idempotent (utterance
6
+ // ids are deterministic, fact ids hash the triple).
30
7
 
31
8
  import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
32
9
  import { dirname, join } from "node:path";
@@ -43,37 +20,29 @@ export const UTTERANCE_CLASS = "Utterance";
43
20
  export const FACT_CLASS = "Fact";
44
21
  export const MEMORY_SESSION_CLASS = "Session";
45
22
  export const SOURCE_CLASS = "Source";
46
- // PLAN_TAUGHT_RELATIONS.md Phase 3: a taught RULE (a composed/filtered/
47
- // recursive relation-shape) — a sibling of Fact, never a taught concept itself.
23
+ // A taught RULE (a composed/filtered/recursive relation-shape) — a sibling
24
+ // of Fact, never a taught concept itself.
48
25
  export const RULE_CLASS = "Rule";
49
26
 
50
27
  export const SAID_IN_SESSION_PROP = "mgx:saidInSession";
51
28
  export const IN_REPLY_TO_PROP = "mgx:inReplyTo";
52
29
 
53
- // The provenance-link predicate family (PLAN_PROVENANCE_TRUST step (b)): one
54
- // umbrella object property with two workhorse subproperties, minted in the owned
55
- // mgx: namespace to match tmct-core.ttl's object-property style.
30
+ // The provenance-link predicate family: one umbrella object property with two
31
+ // workhorse subproperties, minted in the owned mgx: namespace to match
32
+ // tmct-core.ttl's object-property style.
56
33
  export const DERIVED_FROM_PROP = "mgx:derivedFrom"; // umbrella: Fact → Source|Fact
57
34
  export const STATED_BY_PROP = "mgx:statedBy"; // a Source directly asserts a Fact
58
35
  export const CANONICALISED_FROM_PROP = "mgx:canonicalisedFrom"; // a canonical Fact ← its raw form
59
36
  export const CREATED_AT_PROP = "mgx:createdAt"; // first-write-wins ISO-8601 on every individual
60
- // DERIVED at read/render time for most individuals (codegraph.mjs's derivedUpdatedAt: an
61
- // individual's own createdAt, or the max createdAt over every edge touching it) — this constant
62
- // exists for the handful of call sites that mutate an individual's OWN attributes in place
63
- // without necessarily touching an edge (upsertSession, recomputeFactTrust,
64
- // recomputeSourceReliability), where the derived rule alone can't see the change (PLAN_VIZ.md §2).
37
+ // For call sites that mutate an individual's own attributes without touching
38
+ // an edge (upsertSession, recomputeFactTrust, recomputeSourceReliability),
39
+ // where codegraph.mjs's derived-updatedAt rule alone can't see the change.
65
40
  export const UPDATED_AT_PROP = "mgx:updatedAt";
66
41
  export const SOURCE_RELIABILITY_PROP = "mgx:sourceReliability"; // actor-level (session-scoped) trust nudge on a Source, [0.5,1.5]
67
42
 
68
- // The bare (session-less) singleton Source ids — the fallback for an
69
- // operator/teach provenance tag that carries no session-id segment (e.g. a
70
- // hand-authored "chat:"/"session:"/"operator" tag, or a direct API caller
71
- // that never threaded a session id through). Once a provenance tag DOES carry
72
- // a session-id segment (every real chat/teach write does — see
73
- // grammar/assert.mjs's provenanceTag / chat.mjs's teachProvenanceTag), each
74
- // session mints its OWN Source individual instead: `${ID}:<sessionId>`
75
- // (sourceIdFor below) — actor-level (session-scoped) trust, unconditional,
76
- // no config flag (PLAN_PROVENANCE_TRUST Part B).
43
+ // Bare (session-less) singleton Source ids — fallback for a provenance tag
44
+ // with no session-id segment. A tag that does carry one mints its own
45
+ // per-session Source instead (`${ID}:<sessionId>`, sourceIdFor below).
77
46
  export const OPERATOR_SOURCE_ID = "src:operator-chat";
78
47
  export const TEACH_SOURCE_ID = "src:teach-chat";
79
48
 
@@ -139,14 +108,9 @@ export function emptyMemory() {
139
108
  };
140
109
  }
141
110
 
142
- /** Resolve the on-disk path of a memory graph file for `dir`. `version === null`
143
- * (the default) is the LIVE graph (`graph.json`) the one path every mutator
144
- * funnels through (mutateMemory here, writeMemoryGraph in fold.mjs). A numeric
145
- * `version` resolves a SNAPSHOT copy (`graph.v{version}.json`, see
146
- * snapshotMemory below) — never the live file. The single source of truth for
147
- * "where does the memory graph live on disk", closing the desync risk of two
148
- * independent path-resolution copies (core.mjs's mutateMemory and fold.mjs's
149
- * writeMemoryGraph used to compute this path separately). */
111
+ /** Resolve the on-disk path of a memory graph file for `dir`. `version` null
112
+ * (default) is the live graph; a numeric version resolves a snapshot copy
113
+ * (see snapshotMemory below). The single source of truth for this path. */
150
114
  export function resolveMemoryGraphFile(dir, version = null) {
151
115
  if (isMemoryHandle(dir) || isSqliteHandle(dir)) {
152
116
  throw new Error("resolveMemoryGraphFile: dir is a memory/sqlite handle, not a file path (Backend A only)");
@@ -157,30 +121,11 @@ export function resolveMemoryGraphFile(dir, version = null) {
157
121
 
158
122
  const memoryGraphFile = (dir) => resolveMemoryGraphFile(dir);
159
123
 
160
- // ---- Storage-backend seam (PLAN_SEED.md §6) ---------------------------------
161
- //
162
- // Every dir-taking export in this file historically assumed `dir` was a plain
163
- // string repo path that resolveMemoryGraphFile joins into an on-disk file
164
- // (Backend A, unchanged below still the exact byte-identical default for
165
- // every existing caller that never opts into anything else).
166
- //
167
- // `dir` may now ALSO be a memory HANDLE: a small tagged object created by
168
- // createInMemoryStore() (Backend B, pure in-memory, zero disk I/O) or
169
- // createSqliteMemoryStore() (Backend C, a live node:sqlite connection kept
170
- // open for the session's lifetime). loadMemory/mutateMemory below recognize
171
- // both and dispatch the LOAD/PERSIST steps only; every other function in this
172
- // file (appendFact, appendFacts, appendUtterance(s), appendRule,
173
- // readFactRows, findRuleByName, resolveRelationChase(Reverse),
174
- // findContradictions) takes `memory`/`dir` exactly as before and never
175
- // branches on backend — they operate on the plain JS payload object
176
- // mutateMemory hands them, regardless of where it came from or where it goes
177
- // next. That is the whole point of the seam: id hashing, provenance/trust
178
- // computation, migrateLegacyProvenance, recomputeSourceReliability and
179
- // buildProseIndex are backend-agnostic logic, unchanged either way.
180
- //
181
- // snapshotMemory (manifest-versioned snapshots) and resolveMemoryGraphFile
182
- // stay Backend-A-only (a handle has no on-disk file to snapshot) — both throw
183
- // a clear error if given a handle rather than silently doing the wrong thing.
124
+ // ---- Storage-backend seam --------------------------------------------------
125
+ // `dir` is either a plain repo-path string (Backend A, file-backed) or a
126
+ // handle from createInMemoryStore() (Backend B) or createSqliteMemoryStore()
127
+ // (Backend C). Only loadMemory/mutateMemory dispatch on backend; every other
128
+ // function operates on the plain payload object they hand back.
184
129
 
185
130
  const BACKEND_MEMORY = "memory";
186
131
  const BACKEND_SQLITE = "sqlite";
@@ -195,80 +140,18 @@ function isMemoryOrSqliteHandle(dir) {
195
140
  return isMemoryHandle(dir) || isSqliteHandle(dir);
196
141
  }
197
142
 
198
- /**
199
- * Backend B — pure in-memory store (new). A plain JS object held by the
200
- * CALLER (never module-global state, which would break multiple concurrent
201
- * sessions in one process): `{ backend: "memory", payload }`. loadMemory
202
- * returns `payload` directly (the live reference, not a fresh parse — there
203
- * is nothing to parse); mutateMemory's persist step is a no-op assignment
204
- * (`handle.payload = out` — already the same object in every real caller,
205
- * since none of appendFact/appendFacts/appendUtterance(s)/appendRule ever
206
- * return a NEW object from their mutateMemory callback, they all mutate the
207
- * payload in place). ZERO readFile/writeFile/JSON.parse/JSON.stringify calls
208
- * ever happen for this backend — verified directly by this module's own
209
- * dispatch (no fs import is even reachable from this path) and by
210
- * test/memory-backend-memory.test.mjs's fs-spy assertions.
211
- *
212
- * Distinct from `--ephemeral` (createSession): ephemeral mode still does real
213
- * readFile/JSON.parse/writeFile round-trips against a throwaway mkdtemp temp
214
- * dir every turn — "disposable disk," not "no disk." Backend B is genuinely
215
- * disk-free.
216
- */
143
+ /** Backend B — pure in-memory store: `{ backend: "memory", payload }` held by
144
+ * the caller (never module-global). Zero file I/O; distinct from
145
+ * `--ephemeral`, which still round-trips a throwaway temp dir. */
217
146
  export function createInMemoryStore() {
218
147
  return { backend: BACKEND_MEMORY, payload: emptyMemory() };
219
148
  }
220
149
 
221
- // ---- Backend C — SQLite (new; schema shape adapted from seonix's src/store.mjs,
222
- // write model is NOT) ----------------------------------------------------------
223
- //
224
- // seonix (a sibling repo consuming tmct as a library) already has a working,
225
- // opt-in node:sqlite store (SEONIX_STORE=sqlite, node:sqlite lazily imported,
226
- // zero external dependency): an `ids`/`nodes`/`relations`/`edges`/`meta` table
227
- // set. Its WRITE MODEL is a full rebuild-and-atomic-swap on every write — correct
228
- // for seonix's problem (read-latency on a relatively static, rebuild-on-change
229
- // code graph), wrong for tmct's (write-heavy, one-fact-at-a-time accumulation
230
- // across a session's lifetime): lifting it as-is would just replace "rewrite the
231
- // whole JSON file per turn" with "rebuild the whole SQLite file per turn."
232
- //
233
- // tmct's Backend C reuses the SHAPE, not the write model: real per-row
234
- // INSERT/REPLACE/DELETE against a LIVE, OPEN connection kept for the session's
235
- // lifetime (see createSqliteMemoryStore/closeSqliteMemoryStore below),
236
- // diffed against whatever is already on that row so only touched
237
- // individuals/edges are ever written — not seonix's rebuild-and-swap.
238
- //
239
- // Schema, adapted (not ported) to tmct's actual payload shape (emptyMemory(),
240
- // above — { generated_at, memory, prefixes, vocabulary, classes,
241
- // objectProperties, individuals, proseIndex }, distinct from seonix's code-graph
242
- // `entities` shape): seonix's separate integer-interning `ids` table exists to
243
- // cover edge endpoints that AREN'T always node ids (e.g. an `inherits` edge's
244
- // `ext:<Base>` target). tmct's own edge groups (saidInSession, inReplyTo,
245
- // statedBy, canonicalisedFrom) only ever link two individuals-table ids, so
246
- // that interning table is dropped here — individuals/edges reference each
247
- // other by their natural TEXT id directly, a deliberate simplification over a
248
- // literal port. A Fact's reified rdf:subject/rdf:predicate/rdf:object live as
249
- // ATTRIBUTES on the individual (tmct's own reification style, no seonix
250
- // equivalent), so they ride inside that individual's own JSON blob column —
251
- // no separate fact-triple columns needed.
252
- //
253
- // Cached, incrementally patched reads (closes the PLAN_SEED.md §6 gap the
254
- // prior "honest shortcut" comment used to flag here): the READ side
255
- // (readSqlitePayload) reconstructs the FULL in-memory payload shape from real
256
- // SQL SELECTs only ONCE — the first call for a given handle, or the first
257
- // call after a failed/rolled-back write — and stashes the result on
258
- // `handle.cachedPayload`. Every later call returns a deep clone of that cache
259
- // directly, with ZERO SQL queries: no re-SELECT of individuals, no
260
- // per-relation edge SELECT. The WRITE side (persistSqlitePayload) was never a
261
- // shortcut: it already diffs the incoming payload against what is already in
262
- // each row/edge-group and only issues a real INSERT/REPLACE/DELETE for what
263
- // actually changed (write cost proportional to what changed this turn, not to
264
- // the total store size). It now ALSO applies that exact same diff to
265
- // `handle.cachedPayload` in lockstep — patching only the individuals/edges it
266
- // actually wrote to SQLite, in the same order SQLite itself would reorder
267
- // them (a changed row gets a fresh rowid and sorts last) — so the cache never
268
- // goes stale, and never needs a re-query to catch up either. If a write fails
269
- // mid-transaction (ROLLBACK), the partially-patched cache is not trusted: it
270
- // is invalidated so the NEXT read does an honest full rebuild instead of
271
- // risking a state that was never actually committed.
150
+ // ---- Backend C — SQLite: a live node:sqlite connection, per-row
151
+ // INSERT/REPLACE/DELETE diffed against what's already stored (write cost
152
+ // proportional to what changed, not total store size). Reads are cached on
153
+ // `handle.cachedPayload` and incrementally patched in lockstep with writes;
154
+ // a failed write invalidates the cache so the next read rebuilds honestly.
272
155
 
273
156
  const SQLITE_DDL = `
274
157
  CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL);
@@ -278,24 +161,13 @@ CREATE TABLE IF NOT EXISTS edges (prop TEXT NOT NULL, subject TEXT NOT NULL, obj
278
161
  CREATE INDEX IF NOT EXISTS edges_by_prop ON edges(prop);
279
162
  `;
280
163
 
281
- // Edge keys with dedicated columns; any other key on an edge example object
282
- // (none exist in core.mjs's own edge groups today, but a future/external
283
- // writer might add one) round-trips via the `extra` JSON column, same
284
- // discipline as seonix's own STD_EDGE_KEYS.
164
+ // Edge keys with dedicated columns; any other key round-trips via `extra`.
285
165
  const STD_EDGE_KEYS = new Set(["subject", "object", "subjectLabel", "objectLabel"]);
286
166
 
287
- /**
288
- * Open (creating if absent) a resident node:sqlite connection for `dbPath` and
289
- * return a Backend C handle: `{ backend: "sqlite", db, dbPath }`. `node:sqlite`
290
- * is imported LAZILY here calling this function is the ONLY way it is ever
291
- * loaded, so a caller that never opts into this backend never even imports it
292
- * (matching seonix's own SEONIX_STORE=sqlite gating discipline, and tmct's
293
- * minimal-deps philosophy: zero external dependency either way).
294
- *
295
- * The connection is meant to be opened ONCE per session and kept open for the
296
- * session's lifetime (not re-opened per call) — close it via
297
- * closeSqliteMemoryStore when the session ends.
298
- */
167
+ /** Open (creating if absent) a resident node:sqlite connection: a Backend C
168
+ * handle `{ backend: "sqlite", db, dbPath }`. `node:sqlite` is imported
169
+ * lazily only opting into this backend ever loads it. Meant to be opened
170
+ * once per session; close via closeSqliteMemoryStore at session end. */
299
171
  export async function createSqliteMemoryStore(dbPath) {
300
172
  const { DatabaseSync } = await import("node:sqlite");
301
173
  const db = new DatabaseSync(dbPath);
@@ -312,30 +184,10 @@ export function closeSqliteMemoryStore(handle) {
312
184
  if (isSqliteHandle(handle)) handle.db.close();
313
185
  }
314
186
 
315
- /**
316
- * Resolve an already-lowercased/trimmed backend token ("memory" | "sqlite" |
317
- * anything else, including "" or "default") into `{ dir, close }`: the exact
318
- * `dir` value every dir-taking export in this module (loadMemory, appendFact,
319
- * appendFacts, seedMemory via src/corpus/conceptnet.mjs, …) already accepts —
320
- * a plain repo-path string for Backend A, or a live Backend B/C handle — plus
321
- * an idempotent `close()` cleanup (a no-op for A/B; closes the sqlite
322
- * connection for C).
323
- *
324
- * The ONE shared resolver for the storage-backend seam (PLAN_SEED.md §6):
325
- * originally inlined only in chat.mjs's createSession, factored out here so
326
- * `tmct init`'s corpus seed (src/init.mjs) and bin/tmct.mjs's
327
- * `--corpus`/`--ontology`/`--lexicon` activation seed the SAME backend a
328
- * later `tmct chat` in that repo will read — a caller resolving "sqlite"
329
- * independently and picking a different db path (or a different in-memory
330
- * store) would silently split a repo's memory across two places that can
331
- * never see each other. `backendChoice` is a precedence result the CALLER
332
- * already computed (CLI flag > env > tmct.toml > default, or whatever subset
333
- * applies); this function does no precedence resolution of its own — it only
334
- * maps the resolved token to a handle. `repoRoot` is used only for Backend C's
335
- * db path (`<repoRoot>/.tmct/memory/graph.sqlite`); Backend A returns
336
- * `repoRoot` itself unchanged, byte-identical to every existing plain-string
337
- * caller.
338
- */
187
+ /** Resolve a backend token ("memory" | "sqlite" | anything else) into
188
+ * `{ dir, close }` the ONE shared resolver, so every entry point (init's
189
+ * corpus seed, bin/tmct.mjs, chat) picks the same backend for a repo rather
190
+ * than silently splitting its memory across two stores. */
339
191
  export async function openMemoryBackend(repoRoot, backendChoice) {
340
192
  if (backendChoice === BACKEND_MEMORY) {
341
193
  return { dir: createInMemoryStore(), close: async () => {} };
@@ -349,25 +201,13 @@ export async function openMemoryBackend(repoRoot, backendChoice) {
349
201
  return { dir: repoRoot, close: async () => {} };
350
202
  }
351
203
 
352
- /** Deep-clone a JSON-safe value. Used two ways here: (1) readSqlitePayload
353
- * hands every CALLER a clone of the cache, never the live cached object
354
- * itself, so this backend keeps the same "fresh object every call" contract
355
- * Backend A's JSON.parse(readFile()) always had — nothing outside this
356
- * module can mutate handle.cachedPayload by mutating what loadMemory
357
- * returned; and (2) persistSqlitePayload clones a value INTO the cache so
358
- * the cache never ends up aliasing a piece of the caller's own payload
359
- * object (which mutateMemory's caller may go on to mutate further). */
204
+ /** Deep-clone a JSON-safe value keeps every cache read/write from aliasing
205
+ * the caller's own payload object. */
360
206
  const cloneJson = (v) => (v === undefined ? v : structuredClone(v));
361
207
 
362
- /** The loadMemory-equivalent read for Backend C. First call for a handle (or
363
- * first call after a failed write invalidated the cache): a real, full
364
- * reconstruction from SQL SELECTs, same as before — then it is stashed on
365
- * `handle.cachedPayload`. Every later call, with no write in between, skips
366
- * SQL entirely and returns a clone of that cache (see the module-comment
367
- * above SQLITE_DDL for the full mechanism). A brand-new store (no meta rows
368
- * written yet) reconstructs to the same shape emptyMemory() returns, so a
369
- * fresh handle behaves like Backend A's ENOENT-bootstrap and Backend B's
370
- * fresh createInMemoryStore(). */
208
+ /** The loadMemory-equivalent read for Backend C: reconstructs from SQL once
209
+ * per handle (or after a failed write invalidates the cache), then returns a
210
+ * clone of `handle.cachedPayload` with zero SQL. */
371
211
  function readSqlitePayload(handle) {
372
212
  if (!handle.cachedPayload) handle.cachedPayload = buildSqlitePayloadFromRows(handle);
373
213
  return cloneJson(handle.cachedPayload);
@@ -414,14 +254,8 @@ function buildSqlitePayloadFromRows(handle) {
414
254
  };
415
255
  }
416
256
 
417
- // ---- handle.cachedPayload mirrors --------------------------------------
418
- // Applied by persistSqlitePayload in LOCKSTEP with the SQL statement sitting
419
- // right beside each call — same condition (only when the SQL write actually
420
- // runs), same effect, so the cache always ends up holding exactly what a
421
- // fresh SQL reconstruction would now produce. `cache.individuals`/
422
- // `cache.objectProperties` are mutated in place; persistSqlitePayload is
423
- // responsible for invalidating the whole cache on a rolled-back write (these
424
- // helpers assume the surrounding transaction succeeds).
257
+ // ---- handle.cachedPayload mirrors: applied in lockstep with each SQL write
258
+ // so the cache always matches a fresh SQL reconstruction. ----------------
425
259
 
426
260
  /** Mirrors `INSERT OR REPLACE INTO individuals(...)`: an existing id is
427
261
  * replaced IN PLACE (same array position, matching how SQL keeps that row's
@@ -451,17 +285,10 @@ function cacheGroupFor(cache, prop) {
451
285
  return g;
452
286
  }
453
287
 
454
- /** Mirrors `INSERT OR REPLACE INTO edges(...)`: SQLite deletes-then-reinserts
455
- * a changed/new row on conflict, so it gets a fresh (highest) rowid and sorts
456
- * LAST under readSqlitePayload's `ORDER BY rowid` moving the entry to the
457
- * end of `examples` here (rather than replacing it in place) reproduces that
458
- * ordering exactly, without a re-SELECT. Rebuilds the cached edge shape the
459
- * same way buildSqlitePayloadFromRows does (subject/object/labels + any
460
- * extra keys), from the same `extraKeys` persistSqlitePayload already
461
- * computed for the SQL `extra` column, so it never re-derives them. Same
462
- * NUL-delimited (subject,object) key discipline as the SQL diff beside it
463
- * (a space could be forged by a term that contains one; NUL never occurs in
464
- * a normalized term). */
288
+ /** Mirrors `INSERT OR REPLACE INTO edges(...)`: a changed/new row sorts LAST
289
+ * under `ORDER BY rowid`, so this moves the entry to the end of `examples`
290
+ * rather than replacing it in place. NUL-delimited (subject,object) key,
291
+ * matching the SQL diff beside it collision-proof, unlike a space. */
465
292
  function cacheUpsertEdge(group, edge, extraKeys) {
466
293
  const key = `${edge.subject}\u0000${edge.object}`;
467
294
  group.examples = group.examples.filter((e) => `${e.subject}\u0000${e.object}` !== key);
@@ -485,19 +312,10 @@ function cacheDropGroupsExcept(cache, seenProps) {
485
312
  cache.objectProperties = cache.objectProperties.filter((g) => seenProps.has(g?.prop));
486
313
  }
487
314
 
488
- /** Persist a mutated payload into a Backend C handle: real per-row
489
- * INSERT/REPLACE/DELETE, diffed against whatever is ALREADY in the table for
490
- * that individual id / (prop,subject,object) edge key — not seonix's
491
- * rebuild-and-swap. Every statement runs inside one transaction so a session
492
- * never observes (or leaves on disk) a half-applied mutation.
493
- *
494
- * Also patches `handle.cachedPayload` (if one exists yet — it always will in
495
- * practice, since mutateMemory always calls loadMemory before this) in the
496
- * same lockstep as the SQL diff below, so a subsequent loadMemory() never has
497
- * to re-query SQLite to see this write (see the module comment above
498
- * SQLITE_DDL). On a rolled-back write, the cache is invalidated rather than
499
- * left holding a partially-applied patch — the next read does an honest
500
- * full rebuild instead. */
315
+ /** Persist a mutated payload into a Backend C handle: per-row
316
+ * INSERT/REPLACE/DELETE diffed against what's already stored, in one
317
+ * transaction. Patches `handle.cachedPayload` in lockstep; a rolled-back
318
+ * write invalidates the cache instead of leaving a partial patch. */
501
319
  function persistSqlitePayload(handle, payload) {
502
320
  const db = handle.db;
503
321
  const empty = emptyMemory();
@@ -634,42 +452,12 @@ export const DEFAULT_RETENTION = 5;
634
452
 
635
453
  const resolveManifestFile = (dir) => join(dir, MEMORY_MANIFEST_REL);
636
454
 
637
- /** Snapshot the CURRENT live graph.json into a numbered `graph.v{N}.json`
638
- * (N = the manifest's version BEFORE this call increments it), then advance
639
- * the manifest and best-effort prune the oldest snapshot that falls outside
640
- * the retention window.
641
- *
642
- * `graph.json` itself is NEVER touched or renamed here — it stays the one
643
- * live file every mutator (mutateMemory / fold.mjs's writeMemoryGraph) reads
644
- * and writes; only a COPY of its pre-snapshot content becomes the new
645
- * numbered version. NOT called from mutateMemory, writeMemoryGraph, or
646
- * anywhere else in this codebase — it has zero callers today by design; a
647
- * future CLI command or maintenance hook calls it explicitly.
648
- *
649
- * Manifest bootstrap (no manifest.json yet): `{ version: 0, retentionVersions:
650
- * opts.retentionVersions ?? DEFAULT_RETENTION }` — the optional
651
- * `retentionVersions` lets a caller that already loaded tmct.toml's
652
- * `[memory] retention_versions` seed the bootstrap default without this
653
- * module doing its own config I/O (core.mjs has no toml-loading precedent;
654
- * toml-config.mjs stays the one place that reads tmct.toml). Once a
655
- * manifest.json exists on disk, ITS retentionVersions is authoritative and a
656
- * later opts.retentionVersions is ignored (the persisted setting wins over a
657
- * possibly-stale caller default).
658
- *
659
- * "No graph.json exists yet" is handled as a clean no-op: `{ skipped: true,
660
- * version: null }` — nothing to snapshot is not an error, it is the honest
661
- * bootstrap state (a brand-new repo that has never written a memory graph).
662
- *
663
- * Retention: after writing `graph.v{N}.json` and bumping the manifest to
664
- * N+1, the snapshot at `graph.v{N - retentionVersions}.json` (if it exists)
665
- * is deleted (best-effort — ENOENT is swallowed). Using N (the version just
666
- * written), not N+1, for the prune target keeps a clean sliding window of
667
- * exactly `retentionVersions` files on disk at all times, with no orphaned
668
- * v0 ever left behind once the window starts sliding.
669
- *
670
- * Returns `{ skipped, version, prunedVersion }` — `version` is the number of
671
- * the snapshot just written (or null if skipped); `prunedVersion` is the
672
- * number pruned, or null if nothing was in range to prune yet. */
455
+ /** Snapshot the current live graph.json into a numbered `graph.v{N}.json`,
456
+ * advance the manifest, and best-effort prune the snapshot that falls
457
+ * outside the retention window. graph.json itself is never touched only a
458
+ * copy becomes the new version. No graph.json yet -> `{ skipped: true }`.
459
+ * Once a manifest exists, its retentionVersions is authoritative over
460
+ * `opts.retentionVersions`. Returns `{ skipped, version, prunedVersion }`. */
673
461
  export async function snapshotMemory(dir, { retentionVersions } = {}) {
674
462
  if (isMemoryOrSqliteHandle(dir)) {
675
463
  throw new Error("snapshotMemory only supports the flat-JSON backend (Backend A) — a memory/sqlite handle has no on-disk graph.json to snapshot");
@@ -734,12 +522,9 @@ export async function loadMemory(dir) {
734
522
  return JSON.parse(text);
735
523
  }
736
524
 
737
- /** Persist a mutated payload back to `dir` the seam's other half. Backend A
738
- * (unchanged): atomic write of the whole file. Backend B: the payload IS the
739
- * handle's live object already (every real caller mutates in place); this
740
- * assignment is a documented no-op safety net, never I/O. Backend C: a real,
741
- * diffed per-row INSERT/UPDATE/DELETE against the live connection — see
742
- * persistSqlitePayload. */
525
+ /** Persist a mutated payload back to `dir`: an atomic file write (Backend A),
526
+ * a no-op assignment (Backend B, already the live object), or a diffed
527
+ * per-row SQL write (Backend C, persistSqlitePayload). */
743
528
  async function persistMemory(dir, payload) {
744
529
  if (isMemoryHandle(dir)) { dir.payload = payload; return; }
745
530
  if (isSqliteHandle(dir)) { persistSqlitePayload(dir, payload); return; }
@@ -747,43 +532,18 @@ async function persistMemory(dir, payload) {
747
532
  await atomicWriteJson(memoryGraphFile(dir), payload);
748
533
  }
749
534
 
750
- /** Fresh read mutate atomic write. Serialized per call; every public append
751
- * goes through here so a concurrent reader never sees a torn store. The lazy,
752
- * idempotent legacy-provenance migration rides this same cycle (step (b)): any
753
- * Fact still carrying only the old mgx:factProvenance string gets its Sources +
754
- * statedBy edges + trust materialised on the next write of any kind. Part B3's
755
- * actor-level (session-scoped) Source reliability rides the SAME cycle, after
756
- * migration (so it sees every Fact's Sources, migrated or not).
757
- *
758
- * `fn` may be async (PLAN_AGENTS.md §2.1's SHACL ingest gate: appendFact/
759
- * appendRule build their candidate individual, `await assertIndividualValid`
760
- * it, and only then upsert — all inside `fn`, so a rejection throws before
761
- * ANY mutation of `payload` happens and this function's write is never
762
- * reached). `await fn(payload)` is a documented no-op for every existing
763
- * SYNC caller (appendUtterance(s), appendFacts) — awaiting a non-Promise
764
- * value just resolves to it, byte-identical behaviour to calling it plain. */
765
- // ---- mutateMemory-scoped lookup index (PLAN_GRAPH_SCAN.md Phase 1) ----------
766
- // syncFactSources's per-fact bookkeeping (upsertSource, upsertIndividual,
767
- // upsertEdge's statedBy path, statedByObjectsFor, sourcesByIdMap) used to each
768
- // re-scan payload.individuals or the statedBy edge list from scratch, turning
769
- // one appendFacts batch of n facts into O(n^2) work. mutateMemory now builds
770
- // three lookup Maps once per call (one O(n) pass) and attaches them to payload
771
- // under a Symbol key — JSON.stringify skips Symbol-keyed properties
772
- // automatically, so persistMemory's graph.json write is byte-identical to
773
- // before. Every helper below checks for the Symbol slot: present → O(1) Map
774
- // lookup; absent (a bare payload object built outside mutateMemory, e.g. a
775
- // test fixture) → today's exact linear-scan fallback, so nothing outside
776
- // mutateMemory's own call chain can observe a behaviour change. The index is
777
- // discarded when mutateMemory returns — it never survives across calls, so
778
- // there is no invalidation logic to get wrong.
535
+ /** Fresh read -> mutate -> atomic write. Serialized per call; every public
536
+ * append goes through here, including the lazy legacy-provenance migration
537
+ * and actor-level Source reliability recompute. `fn` may be async (the
538
+ * SHACL ingest gate awaits validation before ever mutating `payload`). */
539
+ // Per-call lookup index (individualsById/sourcesById/statedByBySubject),
540
+ // attached to payload under a Symbol key (skipped by JSON.stringify) so
541
+ // upsertIndividual/upsertSource/upsertEdge/appendFacts get O(1) lookups
542
+ // instead of re-scanning; discarded when mutateMemory returns.
779
543
  const MEMORY_INDEX = Symbol("mutateMemory lookup index");
780
544
 
781
545
  /** Build the three lookup Maps from the just-loaded payload and attach them
782
- * under MEMORY_INDEX. Any code that pushes a new individual into
783
- * payload.individuals, or a new statedBy edge, must also write the matching
784
- * index entry in that same statement (see upsertIndividual/upsertSource/
785
- * upsertEdge/appendFacts below) — the same discipline appendFacts's own
786
- * local `byId` Map already used for the Fact upsert, generalised here. */
546
+ * under MEMORY_INDEX. */
787
547
  function buildMemoryIndex(payload) {
788
548
  const individualsById = new Map();
789
549
  const sourcesById = new Map();
@@ -839,27 +599,17 @@ function setAttr(ind, prop, key, value) {
839
599
 
840
600
  // ---- Sources (step (b)): first-class provenance individuals -----------------
841
601
 
842
- /** Deterministic Source id + type over the closed kind set. Returns null for an
843
- * unknown kind (an unmappable provenance tag no Source, honestly).
844
- *
845
- * operator/teach kinds are SESSION-SCOPED when `desc.sessionId` is present
846
- * (unconditional — every real chat/teach provenance tag carries one; see
847
- * provenanceTagToSource): `${OPERATOR_SOURCE_ID}:<sessionId>` /
848
- * `${TEACH_SOURCE_ID}:<sessionId>` instead of the bare singleton, so each
849
- * session's operator/teach facts attach to their OWN Source individual
850
- * rather than every session ever collapsing onto one. Session ids are
851
- * uuidv7s (hex + hyphens only — see uuid.mjs), so `:`/`@` never collide with
852
- * this id scheme's own delimiters. */
602
+ /** Deterministic Source id + type over the closed kind set; null for an
603
+ * unknown kind. operator/teach are session-scoped when `desc.sessionId` is
604
+ * present, so each session gets its own Source rather than collapsing onto
605
+ * one singleton. */
853
606
  function sourceIdFor(desc) {
854
607
  switch (desc?.kind) {
855
608
  case "operator": return { id: desc.sessionId ? `${OPERATOR_SOURCE_ID}:${desc.sessionId}` : OPERATOR_SOURCE_ID, type: "operator" };
856
609
  case "teach": return { id: desc.sessionId ? `${TEACH_SOURCE_ID}:${desc.sessionId}` : TEACH_SOURCE_ID, type: "teach" };
857
610
  case "provider": return { id: `src:provider:${desc.name}`, type: "provider" };
858
611
  case "corpus": return { id: `src:corpus:${desc.name}`, type: "corpus" };
859
- // One Source per source-file basename (the corpus precedent above), not per
860
- // extraction run — re-running scripts/extract-facts-from-text.mjs over the
861
- // SAME file collapses onto the same Source instead of minting a new one
862
- // every time, matching corpus's "one Source per named dataset" idiom.
612
+ // One Source per source-file basename, not per extraction run.
863
613
  case "extracted": return { id: `src:extracted:${desc.name}`, type: "extracted" };
864
614
  case "web": return { id: `src:learned:web:${fnv1aHex(String(desc.url || ""))}`, type: "web", url: String(desc.url || "") };
865
615
  case "entailed": return { id: `src:entailed:${desc.rule}`, type: "entailed", rule: String(desc.rule || "") };
@@ -894,12 +644,8 @@ function upsertSource(payload, desc, createdAtCandidate) {
894
644
  return info.id;
895
645
  }
896
646
 
897
- /** Parse the "chat" shape both provenanceTag (grammar/assert.mjs) and
898
- * teachProvenanceTag (chat.mjs) emit — `<source>[:<sessionId>][@<ts>]` after
899
- * their kind prefix has already been stripped — into { createdAt, sessionId? }.
900
- * `sessionId` is present only when the tag actually carried one (every real
901
- * chat/teach write does; a hand-authored/legacy tag without one degrades to
902
- * the bare singleton Source, honestly — see sourceIdFor). */
647
+ /** Parse the "chat" shape both provenanceTag and teachProvenanceTag emit —
648
+ * `<source>[:<sessionId>][@<ts>]` — into { createdAt, sessionId? }. */
903
649
  function parseChatTagRest(rest) {
904
650
  const at = rest.indexOf("@");
905
651
  const beforeAt = at >= 0 ? rest.slice(0, at) : rest;
@@ -910,30 +656,16 @@ function parseChatTagRest(rest) {
910
656
  }
911
657
 
912
658
  /**
913
- * Parse one legacy provenance TAG into a Source descriptor over the closed kind
914
- * set — the inverse the migration and the live write path both name Sources
915
- * through. The tag formats are exactly what the writers produce:
916
- * corpus:conceptnet /r/IsA → { kind:"corpus", name:"conceptnet" }
917
- * corpus-weak:conceptnet /r/RelatedTo → { kind:"corpusWeak", name:"conceptnet" }
918
- * ace:chat:<session>@<ts> { kind:"operator", createdAt:<ts>, sessionId:<session> }
919
- * teach:chat:<session>@<ts> { kind:"teach", createdAt:<ts>, sessionId:<session> }
920
- * web:<url> | url:<url> → { kind:"web", url:<url> }
921
- * extracted:<file-basename> { kind:"extracted", name:<file-basename> }
922
- * entailed:<rule> → { kind:"entailed", rule:<rule> }
923
- * chat:/session: refs map to the operator; an unknown tag → null (no Source).
924
- * `corpus-weak:` is the SAME corpus provenance shape as `corpus:`, just naming
925
- * a lower trust-prior kind (memory/trust.mjs SOURCE_PRIOR.corpusWeak) for
926
- * facts whose underlying relation is real but low-precision (e.g. ConceptNet's
927
- * undirected /r/RelatedTo) — trust stays computed from the Source's kind, never
928
- * hand-set on the Fact.
929
- * The session-id segment (Part B: session-scoped actor-level trust) feeds
930
- * sourceIdFor, which mints a PER-SESSION Source id when present, instead of
931
- * collapsing every session onto one singleton operator/teach Source.
932
- * `extracted:` is scripts/extract-facts-from-text.mjs's own audit tag, layered
933
- * ADDITIVELY (via appendFact's provenance union) on top of whatever the
934
- * runTurn recognizer already wrote (ace:/teach:) — see that script for why:
935
- * it distinguishes "this document evidenced this fact" from ordinary chat
936
- * speech, at its own trust-prior tier (memory/trust.mjs SOURCE_PRIOR.extracted).
659
+ * Parse one legacy provenance TAG into a Source descriptor over the closed
660
+ * kind set:
661
+ * corpus:conceptnet /r/IsA -> { kind:"corpus", name:"conceptnet" }
662
+ * corpus-weak:conceptnet /r/RelatedTo -> { kind:"corpusWeak", name:"conceptnet" }
663
+ * ace:chat:<session>@<ts> -> { kind:"operator", createdAt:<ts>, sessionId:<session> }
664
+ * teach:chat:<session>@<ts> -> { kind:"teach", createdAt:<ts>, sessionId:<session> }
665
+ * web:<url> | url:<url> -> { kind:"web", url:<url> }
666
+ * extracted:<file-basename> -> { kind:"extracted", name:<file-basename> }
667
+ * entailed:<rule> -> { kind:"entailed", rule:<rule> }
668
+ * chat:/session: refs map to the operator; an unknown tag -> null (no Source).
937
669
  */
938
670
  export function provenanceTagToSource(tag) {
939
671
  const t = String(tag || "").trim();
@@ -978,17 +710,9 @@ function statedByObjectsFor(payload, factId) {
978
710
  return (g?.examples || []).filter((e) => e?.subject === factId).map((e) => e.object);
979
711
  }
980
712
 
981
- /** Recompute + materialise a Fact's trust cache (mgx:trustScore + the auditable
982
- * mgx:trustInputs). Called exactly where a statedBy edge could have changed.
983
- * `trustOpts` (optional) is the entailed hook (trust.mjs `computeTrust`'s
984
- * `premiseTrusts`/`ruleConfidence`) — threaded through from appendFact/
985
- * appendFacts's own opts so a rule (e.g. syllogise.mjs's cax-dw) can make its
986
- * conclusion's trust premise-derived (`min(premiseTrusts) × ruleConfidence`)
987
- * instead of riding the bare entailed prior. Absent (the default, `{}`), this
988
- * is a no-op passthrough — every existing caller's score is byte-identical
989
- * (PLAN_INFERENCE_TESTING.md §4 stage 2's exit criterion). Also stamps
990
- * `mgx:updatedAt` (PLAN_VIZ.md §2) — this mutates the Fact's own attributes in place without
991
- * necessarily touching an edge, so the derived "max over edges" updatedAt rule can't see it. */
713
+ /** Recompute + materialise a Fact's trust cache (mgx:trustScore/mgx:trustInputs)
714
+ * and stamp mgx:updatedAt. `trustOpts` optionally threads the entailed hook's
715
+ * premiseTrusts/ruleConfidence through from appendFact/appendFacts. */
992
716
  function recomputeFactTrust(payload, fact, nowMs = Date.now(), trustOpts = {}) {
993
717
  const sourceIds = statedByObjectsFor(payload, fact.id);
994
718
  const createdAt = (fact.attributes || []).find((a) => a?.prop === CREATED_AT_PROP)?.value || "";
@@ -1054,33 +778,11 @@ const isSessionScopedSourceId = (id) =>
1054
778
 
1055
779
  /**
1056
780
  * Recompute + materialise mgx:sourceReliability on every session-scoped
1057
- * operator/teach Source (Part B3): for each such Source, count the facts it
1058
- * stated (`factsAsserted`) and how many of those are part of a live
1059
- * contradiction (`factsContradicted`, via findContradictions its own
1060
- * detection logic is untouched, this only READS its result), run
1061
- * sessionReliabilityFrom, and write the bounded result onto the Source.
1062
- *
1063
- * Contradiction membership is evaluated against the CURRENT trust scores at
1064
- * the point this runs (already materialised by this same mutation's
1065
- * syncFactSources/migrateLegacyProvenance calls) — it does NOT recursively
1066
- * re-evaluate contradictions after reliability changes shift trust scores
1067
- * (no fixed-point iteration; one pass is enough for a monotonic, self-
1068
- * correcting signal that only ever gets more accurate on the NEXT write).
1069
- *
1070
- * Every individual (Fact OR RULE) touched by a recomputed Source then has its
1071
- * OWN trust re-materialised (recomputeFactTrust — class-agnostic, same as
1072
- * syncFactSources: neither ever checks `.class`) so mgx:trustScore reflects
1073
- * the fresh reliability within THIS SAME mutation cycle — a session's
1074
- * reliability shift is visible immediately, not just on some future
1075
- * unrelated re-write. This refresh is scanned off the statedBy edge group
1076
- * DIRECTLY (every individual it names, any class), not off readFactRows
1077
- * (Fact-only) — a Rule can never be "contradicted" (findContradictions is a
1078
- * Fact-shape concept, so contradiction ACCOUNTING stays Fact-scoped above),
1079
- * but it rides the identical Source-derivation + trust pipeline a Fact does
1080
- * (appendRule's own doc comment), so it must not go stale here either.
1081
- *
1082
- * Called from mutateMemory itself (below), riding every mutation's existing
1083
- * bookkeeping cycle — not a separate write path.
781
+ * operator/teach Source: count facts stated vs. contradicted
782
+ * (findContradictions), run sessionReliabilityFrom, write the bounded result.
783
+ * One pass, no fixed-point iteration. Every individual (Fact or Rule) a
784
+ * recomputed Source touches then gets its own trust re-materialised
785
+ * (recomputeFactTrust) so the shift is visible within this same mutation.
1084
786
  */
1085
787
  function recomputeSourceReliability(payload) {
1086
788
  if (!Array.isArray(payload?.individuals) || !Array.isArray(payload?.objectProperties)) return;
@@ -1105,7 +807,7 @@ function recomputeSourceReliability(payload) {
1105
807
  const source = idx ? idx.individualsById.get(sid) : payload.individuals.find((i) => i?.id === sid);
1106
808
  if (!source) continue;
1107
809
  setAttr(source, SOURCE_RELIABILITY_PROP, "sourceReliability", String(sessionReliabilityFrom(counts)));
1108
- // Own-attribute mutation in place (PLAN_VIZ.md §2) — same reasoning as recomputeFactTrust.
810
+ // Own-attribute mutation in place — same reasoning as recomputeFactTrust.
1109
811
  setAttr(source, UPDATED_AT_PROP, "updatedAt", new Date().toISOString());
1110
812
  }
1111
813
 
@@ -1121,16 +823,7 @@ function recomputeSourceReliability(payload) {
1121
823
  }
1122
824
 
1123
825
  /** Upsert an individual by id (replace-in-place keeps ordering stable).
1124
- * Returns the individual object actually stored in payload.individuals the
1125
- * caller (e.g. upsertSource) should index THAT reference, not `ind` itself,
1126
- * since the indexed path below merges into the prior object in place rather
1127
- * than replacing the array slot. When a lookup index is present (built by
1128
- * mutateMemory), an existing individual is updated via Object.assign — same
1129
- * array position AND same object identity as before, so it stays trivially
1130
- * in sync with individualsById without a second Map write, and a brand-new
1131
- * individual is pushed + indexed in the same statement. Absent an index
1132
- * (a bare payload built outside mutateMemory), this is EXACTLY the original
1133
- * findIndex + replace-or-push code. */
826
+ * Returns the stored reference callers should index THAT, not `ind`. */
1134
827
  function upsertIndividual(payload, ind) {
1135
828
  const idx = memoryIndexOf(payload);
1136
829
  if (idx) {
@@ -1160,8 +853,8 @@ function upsertEdge(payload, { predicate, prop }, edge) {
1160
853
  group = { predicate, prop, count: 0, examples: [] };
1161
854
  payload.objectProperties.push(group);
1162
855
  }
1163
- // statedBy-only fast path (PLAN_GRAPH_SCAN.md Phase 1): statedByBySubject
1164
- // tracks, per fact, the small list of Source ids already stated it (almost
856
+ // statedBy-only fast path: statedByBySubject tracks, per fact, the small
857
+ // list of Source ids already stated it (almost
1165
858
  // always 0-1 during a seed), so the overwhelmingly common case — a brand
1166
859
  // new (subject,object) statedBy pair — can append directly without the
1167
860
  // find+filter scan of the WHOLE statedBy edge list below. Every other
@@ -1294,23 +987,10 @@ export async function appendUtterances(dir, utterances) {
1294
987
  }
1295
988
 
1296
989
  /** Normalize a fact TERM (subject/object) so every writer converges on one
1297
- * spelling and the graph stays queryable: ConceptNet's /c/en/foo_bar, a
1298
- * grammar's tmct:Foo_bar and a bare "Foo bar" all become "foo bar". The
1299
- * PREDICATE is deliberately NOT normalized this way - it is a controlled
1300
- * vocabulary term (rdfs:subClassOf) whose casing is meaningful.
1301
- *
1302
- * Tier-5 playtest fix (2026-07-09): also strips a leading "the"/"a"/"an" —
1303
- * found live via "remember that THE logger module is deprecated" (teach-side
1304
- * already stripped it before this ran, so storage was unaffected) followed by
1305
- * "what do you know about THE logger module" / "who maintains THE tasks
1306
- * handler" (recall-side queries, which do NOT pre-strip their own captured
1307
- * term before calling this) genuinely missing the just-taught fact — every
1308
- * recall regex in chat.mjs (KNOW_ABOUT_RE, WHO_OWNS_RE, ISA_ASK_RE, …) calls
1309
- * factTermVariants -> normFactTerm on the raw captured term, so fixing it
1310
- * ONCE here closes the gap for all of them instead of patching each site.
1311
- * Safe for storage too (idempotent — an already-stripped subject is
1312
- * unaffected); the article is a determiner, never semantically distinguishing
1313
- * for a code-entity or common-noun term in this domain. */
990
+ * spelling: ConceptNet's /c/en/foo_bar, tmct:Foo_bar, and bare "Foo bar" all
991
+ * become "foo bar". Also strips a leading "the"/"a"/"an" (idempotent safe
992
+ * for storage too). The predicate is never normalized this way its casing
993
+ * is meaningful controlled vocabulary. */
1314
994
  export function normFactTerm(t) {
1315
995
  let s = normText(t);
1316
996
  s = s.replace(/^\/c\/[a-z]{2,3}\//i, "");
@@ -1320,39 +1000,24 @@ export function normFactTerm(t) {
1320
1000
  return s.toLowerCase();
1321
1001
  }
1322
1002
 
1323
- // The fact-id contract: a Fact is content-addressed by its NUL-DELIMITED
1324
- // (s, p, o). NUL never occurs in a normalized term or a predicate URI, so it is
1325
- // a collision-proof separator (a space could be forged by a term that contains
1326
- // one). appendFact hashes the SAME `${s}\0${p}\0${o}` inline; appendFacts routes
1327
- // through here so the batch path can never drift to a space and silently re-key
1328
- // every seeded fact — the golden-equivalence test pins the two paths together.
1003
+ // A Fact is content-addressed by its NUL-delimited (s, p, o) — NUL never
1004
+ // occurs in a normalized term/predicate, so it's collision-proof unlike a
1005
+ // space. appendFact hashes the same `${s}\0${p}\0${o}` inline; keep both in sync.
1329
1006
  const factIdFor = (s, p, o) => `fact:${fnv1aHex(`${s}\0${p}\0${o}`)}`;
1330
1007
 
1331
- /** Content-address a fact's id from its (subject, predicate, object) WITHOUT
1332
- * writing it — the SAME normalize+NUL-join+hash contract appendFact/
1333
- * appendFacts use internally (factIdFor, above). Exposed so a caller that
1334
- * needs to name a premise's or a not-yet-written conclusion's id (e.g.
1335
- * syllogise.mjs's justification-tracking retraction machinery, PLAN_SYLLOGIST.md
1336
- * §3) can compute it deterministically without an extra read — ids are
1337
- * content-addressed, never sequence-assigned, so this is safe to call
1338
- * before, instead of, or in place of an actual append. Pure, no I/O. */
1008
+ /** Content-address a fact's id from (subject, predicate, object) without
1009
+ * writing it — same contract as factIdFor. Lets a caller (e.g.
1010
+ * syllogise.mjs's retraction machinery) name a not-yet-written fact's id
1011
+ * deterministically, without an extra read. Pure, no I/O. */
1339
1012
  export function factIdForTriple(subject, predicate, object) {
1340
1013
  return factIdFor(normFactTerm(subject), normText(predicate), normFactTerm(object));
1341
1014
  }
1342
1015
 
1343
- /** Append one grammar-derived OWL triple, RDF-reified: a `Fact` individual
1344
- * carrying rdf:subject / rdf:predicate / rdf:object (+ provenance). The
1345
- * Phase-2 ACE parser's write point. Same (s,p,o) → same id → upsert, never a
1346
- * duplicate. `premiseTrusts`/`ruleConfidence` (optional) engage trust.mjs's
1347
- * entailed hook see recomputeFactTrust; a rule-derived write (e.g.
1348
- * syllogise.mjs) passes these, a plain taught/asserted write omits them and
1349
- * is byte-identical to before.
1350
- *
1351
- * PLAN_AGENTS.md 2.1's SHACL ingest gate: the candidate Fact individual is
1352
- * validated against ontology/memory-shapes.ttl (memory/shacl.mjs) BEFORE
1353
- * upsertIndividual runs -- a violation throws here, inside mutateMemory's
1354
- * `fn`, so the write never happens (mutateMemory's atomic write is never
1355
- * reached; the on-disk graph is untouched). Returns { id }. */
1016
+ /** Append one grammar-derived OWL triple, RDF-reified as a `Fact` individual.
1017
+ * Same (s,p,o) -> same id -> upsert, never a duplicate. `premiseTrusts`/
1018
+ * `ruleConfidence` optionally engage trust.mjs's entailed hook. Validated
1019
+ * against ontology/memory-shapes.ttl (memory/shacl.mjs) before the write.
1020
+ * Returns { id }. */
1356
1021
  export async function appendFact(dir, { subject, predicate, object, provenance = "", createdAt = "", quantifier = "", premiseTrusts, ruleConfidence } = {}) {
1357
1022
  const s = normFactTerm(subject);
1358
1023
  const p = normText(predicate);
@@ -1397,35 +1062,13 @@ export async function appendFact(dir, { subject, predicate, object, provenance =
1397
1062
  return { id };
1398
1063
  }
1399
1064
 
1400
- /** Batch append of grammar/corpus-derived triples — ONE read-modify-write for a
1401
- * whole seed (the appendUtterances precedent, for facts). The per-fact
1402
- * appendFact does a full read mutate → prose-reindex → atomic-write PER FACT,
1403
- * so seeding N facts is O(N²) I/O (6 k facts ≈ 7 min); this collapses it to a
1404
- * single mutate.
1405
- *
1406
- * Every fact is normalized + prose-tokenized OUTSIDE the mutate, then a SINGLE
1407
- * mutateMemory upserts each Fact through an id→individual Map (O(1) upsert, so
1408
- * the growing individuals array is never rescanned per fact), reconciles each
1409
- * touched fact's Sources + trust via the SAME syncFactSources appendFact uses,
1410
- * and recountClasses ONCE at the end. The result is deep-equal (modulo array
1411
- * order) to looping appendFact: same fact ids, same mgx:factProvenance union,
1412
- * same statedBy Source edges, same mgx:trustScore, same first-write-wins
1413
- * createdAt. Malformed facts (missing subject/predicate/object) are SKIPPED (a
1414
- * bad row never aborts a 6 k-fact seed), not thrown as appendFact does.
1415
- * Each fact may also carry `premiseTrusts`/`ruleConfidence` (optional) —
1416
- * appendFact's own entailed-hook passthrough, batched: syllogise.mjs's
1417
- * materializing pass is this function's main caller, so this is the write
1418
- * path a rule's conclusion trust actually rides (recomputeFactTrust, above).
1419
- * A fact may also carry `justification` (optional, array of premise fact
1420
- * ids — PLAN_SYLLOGIST.md §3's persisted-justification step): stored
1421
- * verbatim as `mgx:factJustification` (space-joined; fact ids never contain
1422
- * a space), last-write-wins per id (a re-derivation via a DIFFERENT premise
1423
- * pair, after the original was retracted and this fact re-earned its place
1424
- * some other way, should overwrite the stale justification, not keep it) —
1425
- * never written at all for a plain taught/asserted fact (`justification`
1426
- * omitted), which stays byte-identical to before this field existed.
1427
- * Returns { ids, appended, skipped } — ids one per applied fact (in order),
1428
- * appended = ids.length, skipped = malformed count. */
1065
+ /** Batch append of grammar/corpus-derived triples — ONE read-modify-write for
1066
+ * a whole seed, collapsing looping appendFact's O(N²) I/O to a single
1067
+ * mutate (same resulting ids/provenance/trust). Malformed facts are skipped,
1068
+ * not thrown. Optional per-fact `premiseTrusts`/`ruleConfidence` (batched
1069
+ * entailed-hook passthrough) and `justification` (premise fact ids, stored
1070
+ * as mgx:factJustification, last-write-wins). Returns
1071
+ * { ids, appended, skipped }. */
1429
1072
  export async function appendFacts(dir, facts) {
1430
1073
  const prepared = [];
1431
1074
  let skipped = 0;
@@ -1515,7 +1158,7 @@ export async function appendFacts(dir, facts) {
1515
1158
  return { ids, appended: ids.length, skipped };
1516
1159
  }
1517
1160
 
1518
- // ---- Rules (PLAN_TAUGHT_RELATIONS.md Phase 3: storage foundation) -----------
1161
+ // ---- Rules ------------------------------------------------------------------
1519
1162
  // A taught RULE — a composed/filtered/recursive relation SHAPE, distinct from a
1520
1163
  // plain Fact triple. Same convention as a Fact's subject/predicate/object: every
1521
1164
  // slot is a plain string ATTRIBUTE, never an edge to a per-term individual.
@@ -1529,50 +1172,25 @@ export const RULE_NAME_PROP = "mgx:ruleName";
1529
1172
  export const RULE_KIND_PROP = "mgx:ruleKind";
1530
1173
 
1531
1174
  // Per-kind slot contract: JS slot key -> the mgx: attribute it's written under.
1532
- // filter's "base" slot deliberately reuses ruleBase1 (not a fresh "ruleBase")
1533
- // §3's own query-dispatcher design chases a filter rule's candidate set via
1534
- // "ruleBase1's candidate set (step (a) or (b) again)", the exact same attribute
1535
- // name compose2's first hop already uses, since both play the identical "base
1536
- // relation this rule builds on" role. Order within each array is the (slot1,
1537
- // slot2) order the content-address hash below uses — fixed and load-bearing.
1175
+ // filter's "base" slot deliberately reuses ruleBase1 (not a fresh "ruleBase")
1176
+ // the same attribute name compose2's first hop already uses, since both
1177
+ // play the identical "base relation this rule builds on" role. Order within
1178
+ // each array is the (slot1, slot2) order the content-address hash below
1179
+ // uses fixed and load-bearing.
1538
1180
  const RULE_SLOT_SPEC = {
1539
1181
  [RULE_KIND_COMPOSE2]: [["base1", "mgx:ruleBase1"], ["base2", "mgx:ruleBase2"]],
1540
1182
  [RULE_KIND_FILTER]: [["base", "mgx:ruleBase1"], ["property", "mgx:ruleFilterProperty"]],
1541
1183
  [RULE_KIND_RECURSIVE]: [["baseCase", "mgx:ruleBaseCase"], ["recStep", "mgx:ruleRecStep"]],
1542
1184
  };
1543
1185
 
1544
- // The rule-id contract, mirroring factIdFor's (:456) NUL-delimited discipline
1545
- // exactly: content-addressed over (kind, name, slot1, slot2), so re-teaching an
1546
- // IDENTICAL rule (same kind + name + slots) upserts, never duplicates; teaching
1547
- // a DIFFERENT rule under the SAME name (different slots) hashes to a distinct
1548
- // id — both individuals exist side by side, the same way two different Facts
1549
- // sharing a subject are two distinct Fact individuals, never a silent overwrite.
1186
+ // Content-addressed over (kind, name, slot1, slot2), mirroring factIdFor's
1187
+ // NUL-delimited discipline: identical rules upsert, different ones coexist.
1550
1188
  const ruleIdFor = (kind, name, slot1, slot2) => `rule:${fnv1aHex(`${kind}\0${name}\0${slot1}\0${slot2}`)}`;
1551
1189
 
1552
1190
  /** Append one taught RULE (compose2 | filter | recursive) — a sibling of
1553
- * appendFact (:462) for the relation-composition shapes PLAN_TAUGHT_RELATIONS.md
1554
- * items 3/4/6 need, storing a `Rule` individual instead of a `Fact`. Same
1555
- * load→mutate→write discipline via mutateMemory, same content-addressed-id
1556
- * upsert convention as appendFact.
1557
- *
1558
- * { name, kind, slots, provenance = "", createdAt = "" }: `kind` is the ONE
1559
- * closed vocabulary this store needs to know — compose2 | filter | recursive,
1560
- * three STRUCTURAL tags describing the SHAPE of what was taught (never a
1561
- * domain word, the same way "Fact"/"Rule" describe the store's own shape, not
1562
- * what's stored in it). `slots` is the matching per-kind object (RULE_SLOT_SPEC
1563
- * above). `name` and every slot value are normFactTerm-normalized, exactly like
1564
- * a Fact's subject/object.
1565
- *
1566
- * Provenance/trust ride the EXACT SAME syncFactSources/recomputeFactTrust
1567
- * pipeline appendFact uses, unmodified — neither function ever checks
1568
- * `individual.class`, so a Rule carrying the same mgx:factProvenance compat
1569
- * attribute + CREATED_AT_PROP gets the same Source-derivation + trust score an
1570
- * ordinary Fact would.
1571
- *
1572
- * PLAN_AGENTS.md 2.1's SHACL ingest gate: the candidate Rule individual is
1573
- * validated against ontology/memory-shapes.ttl (memory/shacl.mjs) BEFORE
1574
- * upsertIndividual runs, same discipline as appendFact -- a violation
1575
- * throws before mutateMemory's write is ever reached. Returns { id }. */
1191
+ * appendFact storing a `Rule` individual, same upsert/provenance/trust/SHACL
1192
+ * discipline (neither pipeline ever checks `individual.class`). `slots` is
1193
+ * the matching per-kind object (RULE_SLOT_SPEC above). Returns { id }. */
1576
1194
  export async function appendRule(dir, { name, kind, slots, provenance = "", createdAt = "" } = {}) {
1577
1195
  const spec = RULE_SLOT_SPEC[kind];
1578
1196
  if (!spec) throw new Error(`a rule kind must be one of ${RULE_KINDS.join(", ")}, got ${JSON.stringify(kind)}`);
@@ -1614,13 +1232,11 @@ export async function appendRule(dir, { name, kind, slots, provenance = "", crea
1614
1232
  return { id };
1615
1233
  }
1616
1234
 
1617
- /** Genericity lookup for the future query-dispatcher (PLAN_TAUGHT_RELATIONS.md
1618
- * §2's closing paragraph / §3 step (b)): "what kind of thing is name X" — scan
1619
- * for the Rule individual whose mgx:ruleName matches, the SAME lookup serving
1620
- * every taught rule name uniformly (no per-rule-name branch). This phase only
1621
- * proves the stored shape supports the lookup correctly; Phase 4/5/6 build the
1622
- * actual kind-dispatch (compose2/filter/recursive branching) on top of this.
1623
- * Returns the raw individual, or undefined if no Rule has that name. */
1235
+ /** Genericity lookup for the query-dispatcher: "what kind of thing is name X"
1236
+ * scan for the Rule individual whose mgx:ruleName matches, the SAME
1237
+ * lookup serving every taught rule name uniformly (no per-rule-name
1238
+ * branch). Returns the raw individual, or undefined if no Rule has that
1239
+ * name. */
1624
1240
  export function findRuleByName(memory, name) {
1625
1241
  const n = normFactTerm(name);
1626
1242
  return (memory?.individuals || []).find(
@@ -1628,41 +1244,25 @@ export function findRuleByName(memory, name) {
1628
1244
  );
1629
1245
  }
1630
1246
 
1631
- // ---- Relation chase (extracted from chat.mjs's (a0)/(a0.2) blocks,
1632
- // PLAN_TAUGHT_RELATIONS.md Phase 2/4/5; PLAN_COMPLETIONS.md Stage 1
1633
- // prerequisite) --------------------------------------------------------------
1247
+ // ---- Relation chase ---------------------------------------------------------
1634
1248
  //
1635
- // `resolveRelationChase` and `resolveRelationChaseReverse` were originally
1636
- // unexported closures inside chat.mjs's factReadBack, coupled to its own
1637
- // local `rows`/`memoryDir`/`byTrust`/`renderFactLine`/`factPhrase`/
1638
- // `factTermVariants` variables. Moved here — findRuleByName's natural
1639
- // sibling, since this file already owns Rule storage/lookup — as plain,
1640
- // standalone, importable functions so Stage 1's cross-group inference can
1641
- // reuse the SAME resolution logic outside chat.mjs's dispatch context. The
1642
- // closures they used to capture are now explicit parameters: `memory` (an
1643
- // already-loaded loadMemory() payload — callers load it once, not per
1644
- // recursive call) and a `helpers` bag carrying every chat.mjs-local piece
1645
- // they relied on (`relationFactsFor`, `renderFactLine`, `factPhrase`,
1646
- // `factTermVariants`, `byTrust`, the trust-bearing `rows` array, and
1647
- // `HAS_PROPERTY_PREDICATE`). No other chat.mjs coupling remains — dynamic
1648
- // imports of this file's own findRuleByName/RULE_KIND_* and of
1649
- // planning.mjs's findActionPath/findReachableSet are now direct references/
1650
- // static imports, since both now live alongside or are reachable from here
1651
- // without a cycle (planning.mjs imports nothing).
1249
+ // `resolveRelationChase` and `resolveRelationChaseReverse` are standalone,
1250
+ // importable functions taking an already-loaded `memory` (a loadMemory()
1251
+ // payload — callers load it once, not per recursive call) and a `helpers`
1252
+ // bag (`relationFactsFor`, `renderFactLine`, `factPhrase`, `factTermVariants`,
1253
+ // `byTrust`, the trust-bearing `rows` array, and `HAS_PROPERTY_PREDICATE`),
1254
+ // so callers outside chat.mjs's own dispatch context can reuse the same
1255
+ // resolution logic.
1652
1256
  //
1653
- // Behavior is unchanged from the original closures: same dispatch order
1654
- // (direct/alias fact hit compose2 rule chase filter rule chase honest
1655
- // miss), same OWA discipline (null / [] on a miss, never a guessed "no").
1257
+ // Dispatch order: direct/alias fact hit compose2 rule chase → filter rule
1258
+ // chase honest miss (OWA discipline: null / [] on a miss, never a guessed
1259
+ // "no").
1656
1260
 
1657
1261
  /**
1658
- * RELATION CHASE (chat.mjs's (a0) block) — given a relation/rule NAME and a
1659
- * fixed (subject, object) pair, resolve whether it holds: (i) a direct taught
1660
- * fact, (ii) the same pair reached via an alias-chased predicate (rdfs:subClassOf
1661
- * over relation-name strings, folded into `relationFactsFor`'s own candidate
1662
- * list), (iii) a hop-counted compose2 Rule chase (exactly 2 hops: base1 then
1663
- * base2), or (iv) a filter Rule chase (recursively resolve the base, then
1664
- * require the subject also carry the taught property). Returns
1665
- * `{ citation: string[] }` on a genuine hit, or null on an honest miss.
1262
+ * RELATION CHASE — given a relation/rule NAME and a fixed (subject, object)
1263
+ * pair, resolve whether it holds via (i) a direct taught fact, (ii) an
1264
+ * alias-chased predicate, (iii) a 2-hop compose2 Rule chase, or (iv) a filter
1265
+ * Rule chase. Returns `{ citation: string[] }` on a hit, null on an honest miss.
1666
1266
  */
1667
1267
  export async function resolveRelationChase(memory, name, subjectTerm, objectTerm, helpers) {
1668
1268
  const { relationFactsFor, renderFactLine, factPhrase, factTermVariants, byTrust, rows, HAS_PROPERTY_PREDICATE } = helpers;
@@ -1736,13 +1336,9 @@ export async function resolveRelationChase(memory, name, subjectTerm, objectTerm
1736
1336
  }
1737
1337
 
1738
1338
  /**
1739
- * RELATION "WHO" REVERSE CHASE (chat.mjs's (a0.2) block) — the mirror image of
1740
- * resolveRelationChase: given a relation/rule name and a FIXED OBJECT, return
1741
- * every `{ subject, citation }` pair that satisfies it, instead of a single
1742
- * yes/no for a fixed (subject, object) pair. Recursion is bounded the SAME way
1743
- * resolveRelationChase's own filter chase is: a filter rule's base is always
1744
- * either a plain relation (terminal) or another rule (one level deeper), never
1745
- * itself.
1339
+ * RELATION "WHO" REVERSE CHASE — the mirror image of resolveRelationChase:
1340
+ * given a relation/rule name and a fixed OBJECT, return every
1341
+ * `{ subject, citation }` pair that satisfies it, instead of one yes/no.
1746
1342
  */
1747
1343
  export async function resolveRelationChaseReverse(memory, name, objectTerm, helpers) {
1748
1344
  const { relationFactsFor, renderFactLine, factPhrase, factTermVariants, byTrust, rows, HAS_PROPERTY_PREDICATE } = helpers;
@@ -1868,33 +1464,21 @@ export function readFactRows(memory) {
1868
1464
  id: ind.id,
1869
1465
  subject: get("subject"), predicate: get("predicate"), object: get("object"),
1870
1466
  provenance: get("provenance"), // legacy compat string, verbatim
1871
- quantifier: get("quantifier"), // "" unless a plural class-membership teach set one (Feature A pt.3)
1467
+ quantifier: get("quantifier"), // "" unless a plural class-membership teach set one
1872
1468
  sourceIds, sourceTypes,
1873
1469
  trust: Number((ind.attributes || []).find((a) => a?.prop === TRUST_SCORE_PROP)?.value) || 0,
1874
- // [] unless a rule persisted its premise fact ids (PLAN_SYLLOGIST.md §3's
1875
- // justification-tracking step — scm-sco only, today; see syllogise.mjs).
1470
+ // [] unless a rule persisted its premise fact ids (justification-tracking,
1471
+ // scm-sco only today; see syllogise.mjs).
1876
1472
  justification: justificationRaw ? justificationRaw.split(" ").filter(Boolean) : [],
1877
1473
  });
1878
1474
  }
1879
1475
  return rows;
1880
1476
  }
1881
1477
 
1882
- /**
1883
- * Retract Fact individuals by id a real DELETE, the mechanism `syllogise.mjs`'s
1884
- * own header comment has always PROMISED ("fully RETRACTABLE by provenance when
1885
- * the source graph moves") but that, until PLAN_SYLLOGIST.md §3's retraction
1886
- * build, nothing in this file actually implemented: un-believing something used
1887
- * to mean re-running the whole batch pass and hoping dedup naturally sorted it
1888
- * out, with no targeted removal at all. Drops each matching Fact individual and
1889
- * scrubs any edge group (`statedBy`, etc.) that referenced it as subject OR
1890
- * object, so no dangling edge survives the delete — then recounts classes once.
1891
- * A Source left with zero remaining statedBy edges is NOT itself deleted (an
1892
- * orphaned Source individual is harmless — it materialises nothing and costs
1893
- * nothing to leave — so this stays a pure, minimal retraction, not a GC pass).
1894
- * Ids that don't resolve to a live Fact are silently skipped (an idempotent,
1895
- * honest no-op — never an error: a caller may retry a retraction against a
1896
- * concurrently-mutated store). Returns { removed } — the ids ACTUALLY deleted,
1897
- * a possibly-smaller set than the input. */
1478
+ /** Retract Fact individuals by id — a real DELETE (syllogise.mjs's
1479
+ * retractability mechanism). Scrubs any edge referencing the id as subject
1480
+ * or object; an orphaned Source is left in place (not a GC pass). Unknown
1481
+ * ids are silently skipped. Returns { removed } (may be smaller than input). */
1898
1482
  export async function removeFacts(dir, ids) {
1899
1483
  const idSet = new Set((ids || []).filter(Boolean));
1900
1484
  const removed = [];
@@ -1920,13 +1504,9 @@ export async function removeFacts(dir, ids) {
1920
1504
  * contradiction (below it the fact is too weak to contradict anything). */
1921
1505
  export const CONTRADICTION_TRUST_FLOOR = 0.5;
1922
1506
 
1923
- /**
1924
- * Facts that CONTRADICT: same (subject, predicate), DIFFERENT object, each above
1925
- * the trust floor. Returns groups (each a [rows] sorted by trust desc) so the
1926
- * answer/inspection layer surfaces BOTH with their provenance and NEVER silently
1927
- * picks the higher-trust one. Same (s,p,o) from two writers is corroboration,
1928
- * not contradiction — one Fact id, N statedBy edges — so it never appears here.
1929
- */
1507
+ /** Facts that CONTRADICT: same (subject, predicate), different object, each
1508
+ * above the trust floor. Returns groups (trust-desc) so callers surface both,
1509
+ * never silently pick one. Same (s,p,o) is corroboration, not contradiction. */
1930
1510
  export function findContradictions(memory, { floor = CONTRADICTION_TRUST_FLOOR } = {}) {
1931
1511
  const rows = readFactRows(memory).filter((r) => r.trust >= floor);
1932
1512
  const byKey = new Map();