@polycode-projects/the-mechanical-code-talker 6.0.17 → 6.0.19

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.
@@ -0,0 +1,220 @@
1
+ // sense-gate.mjs — the disjointness gate on isa DERIVATION.
2
+ //
3
+ // WordNet-derived bands flatten every sense of a word onto one label. "region"
4
+ // carries a geographic sense (region ⊑ location) and an anatomical one
5
+ // (region ⊑ body part), and both rows store the same six characters. Each row
6
+ // is true of its own sense, so the corpus is right to hold both. subClassOf
7
+ // transitivity then walks straight across the join:
8
+ // russia ⊑ country ⊑ geographical area ⊑ region ⊑ body part.
9
+ //
10
+ // This module refuses that step, and only that step. It answers one question,
11
+ // `declines(subject, object)`, for a candidate DERIVED isa edge. An asserted
12
+ // row never reaches it: the gate constrains what the closure concludes, never
13
+ // what a corpus or a teacher records. A refusal costs the MATERIALISED
14
+ // shortcut row and nothing else. The stated chain stays whole, and every
15
+ // walker over it (findIsaChain, the ancestor closers) still reaches what it
16
+ // reached before.
17
+ //
18
+ // How a term is placed. Every candidate is resolved to the top classes it
19
+ // sits under, by an upward breadth-first walk over the ASSERTED isa edges,
20
+ // stopping at the FIRST level where any top appears and returning every top
21
+ // found at that level. Nearest-level, not full reachability: the graph these
22
+ // bands build is cyclic (place ⊑ passage ⊑ section ⊑ area ⊑ place is one of
23
+ // many), so "every top above x" resolves to nearly all of them for nearly
24
+ // every x and discriminates nothing. The nearest level is the sense the
25
+ // term's own short chain commits to. Russia reaches `place` in two hops and
26
+ // `body part` in four.
27
+ //
28
+ // When it declines. Both ends must resolve, the two top sets must not share a
29
+ // member, and EVERY cross pair must be a declared disjoint pair. One
30
+ // unresolved end, one shared top, or one pair the table does not separate,
31
+ // and the derivation goes through. A term genuinely sitting under two tops
32
+ // keeps both branches, so the gate cuts a crossing only when the evidence on
33
+ // both sides is unambiguous.
34
+ //
35
+ // Pure over the fact set. One memo per gate, no clock, no arrival order; the
36
+ // walk sorts every frontier by codepoint, so two ingestion orders of the same
37
+ // facts return the same verdicts.
38
+
39
+ import { normFactTerm } from "./hash.mjs";
40
+
41
+ /**
42
+ * The top classes the gate resolves a term to, each with the labels the
43
+ * committed bands actually spell it. A label is a synonym FOR the top, not a
44
+ * subclass of it: `location` names the same region of the ontology as
45
+ * `place`, so both spellings resolve to one top and never separate.
46
+ *
47
+ * `region` is deliberately absent. It is the sense-mixed node itself — making
48
+ * it a top would place `geographical area` under it and settle nothing.
49
+ */
50
+ export const TOP_CLASSES = [
51
+ { top: "place", labels: ["place", "location"] },
52
+ {
53
+ top: "body part",
54
+ labels: ["body part", "organ", "internal organ", "external body part", "body covering", "limb", "blood vessel"],
55
+ },
56
+ { top: "living thing", labels: ["living thing", "organism"] },
57
+ { top: "artifact", labels: ["artifact", "instrumentality"] },
58
+ { top: "substance", labels: ["substance", "matter"] },
59
+ { top: "event", labels: ["event", "happening"] },
60
+ { top: "communication", labels: ["communication", "message"] },
61
+ { top: "time period", labels: ["time period"] },
62
+ { top: "feeling", labels: ["feeling", "emotion"] },
63
+ ];
64
+
65
+ /**
66
+ * The pairs of tops that DO overlap, so the disjoint table can be every other
67
+ * pair. Each one names something the ontology really holds both ways: a
68
+ * cathedral is an artifact and a place; a book is an artifact and a
69
+ * communication; concrete is an artifact and a substance; bone is a body part
70
+ * and a substance; timber is a living thing and a substance; a speech is an
71
+ * event and a communication; a cry is a communication and a feeling.
72
+ *
73
+ * Listing the overlaps rather than the exclusions keeps the judgement small:
74
+ * nine tops make thirty-six pairs, and the seven below are the ones that need
75
+ * an argument. The rest follow.
76
+ */
77
+ export const OVERLAPPING_TOP_PAIRS = [
78
+ ["artifact", "communication"],
79
+ ["artifact", "place"],
80
+ ["artifact", "substance"],
81
+ ["body part", "substance"],
82
+ ["communication", "event"],
83
+ ["communication", "feeling"],
84
+ ["living thing", "substance"],
85
+ ];
86
+
87
+ const pairKey = (a, b) => (a < b ? `${a}␟${b}` : `${b}␟${a}`);
88
+
89
+ /**
90
+ * Every disjoint pair of tops, as `[a, b]` with `a < b`, sorted — all pairs
91
+ * of `topClasses` except `overlappingPairs`. Derived rather than typed out,
92
+ * so the table is symmetric and duplicate-free by construction.
93
+ */
94
+ export function disjointTopPairs(topClasses = TOP_CLASSES, overlappingPairs = OVERLAPPING_TOP_PAIRS) {
95
+ const overlaps = new Set((overlappingPairs || []).map(([a, b]) => pairKey(a, b)));
96
+ const tops = (topClasses || []).map((t) => t.top);
97
+ const pairs = [];
98
+ for (let i = 0; i < tops.length; i += 1) {
99
+ for (let j = i + 1; j < tops.length; j += 1) {
100
+ const [a, b] = tops[i] < tops[j] ? [tops[i], tops[j]] : [tops[j], tops[i]];
101
+ if (overlaps.has(pairKey(a, b))) continue;
102
+ pairs.push([a, b]);
103
+ }
104
+ }
105
+ pairs.sort((p, q) => (p[0] < q[0] ? -1 : p[0] > q[0] ? 1 : p[1] < q[1] ? -1 : p[1] > q[1] ? 1 : 0));
106
+ return pairs;
107
+ }
108
+
109
+ /** How far up the gate will look for a top before giving the term up as
110
+ * unplaced, and how many classes one term's walk may visit. Both bound the
111
+ * cost of a single `topsOf` on a graph whose upper levels fan out hard; a
112
+ * term whose nearest top sits past either bound is simply unplaced, and an
113
+ * unplaced term never blocks anything. */
114
+ export const DEFAULT_MAX_HOPS = 6;
115
+ export const DEFAULT_MAX_VISITED = 3000;
116
+
117
+ /** Codepoint order. Deliberately not localeCompare — the walk below must
118
+ * return the same set on any machine and any ICU version. */
119
+ const compareStrings = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
120
+
121
+ /**
122
+ * Builds the gate over one fact set. `subClassEdges` and `typeEdges` are
123
+ * `[[child, parent], …]` lists of ASSERTED isa rows only (a caller holding
124
+ * entailed rows must filter them out first — feeding the closure's own output
125
+ * back in is what the gate exists to stop). Type edges act as a first hop, so
126
+ * an individual with a taught `rdf:type` places the same way a class does.
127
+ *
128
+ * Returns `{ declines, topsOf, tops, disjointPairs }`. `declines(subject,
129
+ * object)` is the whole contract; `topsOf(term)` is exposed for tests and for
130
+ * a caller that wants to explain a refusal.
131
+ */
132
+ export function buildSenseGate({
133
+ subClassEdges = [], typeEdges = [], maxHops = DEFAULT_MAX_HOPS,
134
+ maxVisited = DEFAULT_MAX_VISITED, topClasses = TOP_CLASSES,
135
+ overlappingPairs = OVERLAPPING_TOP_PAIRS,
136
+ } = {}) {
137
+ const topOfLabel = new Map(); // normalized label -> its top's canonical name
138
+ for (const entry of topClasses || []) {
139
+ for (const label of entry.labels || []) {
140
+ const n = normFactTerm(label);
141
+ if (n) topOfLabel.set(n, entry.top);
142
+ }
143
+ }
144
+ const allTops = (topClasses || []).map((t) => t.top);
145
+ const pairs = disjointTopPairs(topClasses, overlappingPairs);
146
+ const disjoint = new Set(pairs.map(([a, b]) => pairKey(a, b)));
147
+
148
+ const parentSets = new Map(); // child -> Set(direct asserted superclass)
149
+ const addEdge = (child, parent) => {
150
+ if (!child || !parent || child === parent) return;
151
+ if (!parentSets.has(child)) parentSets.set(child, new Set());
152
+ parentSets.get(child).add(parent);
153
+ };
154
+ for (const [child, parent] of subClassEdges || []) addEdge(child, parent);
155
+ for (const [instance, cls] of typeEdges || []) addEdge(instance, cls);
156
+ // Sorted once, read many: `maxVisited` can truncate a level part-way, and
157
+ // truncating a Set in arrival order would make the verdict depend on which
158
+ // order the facts were ingested in.
159
+ const parents = new Map();
160
+ for (const [child, set] of parentSets) parents.set(child, [...set].sort(compareStrings));
161
+
162
+ const memo = new Map(); // term -> sorted array of top names at its nearest level
163
+ function topsOf(term) {
164
+ if (!term) return [];
165
+ if (memo.has(term)) return memo.get(term);
166
+ let found = [];
167
+ const own = topOfLabel.get(term);
168
+ if (own) {
169
+ found = [own];
170
+ } else {
171
+ const seen = new Set([term]);
172
+ let frontier = [term];
173
+ for (let hop = 1; hop <= maxHops && frontier.length && seen.size < maxVisited; hop += 1) {
174
+ const next = [];
175
+ for (const node of frontier) {
176
+ for (const parent of parents.get(node) || []) {
177
+ if (seen.has(parent)) continue;
178
+ seen.add(parent);
179
+ next.push(parent);
180
+ if (seen.size >= maxVisited) break;
181
+ }
182
+ if (seen.size >= maxVisited) break;
183
+ }
184
+ next.sort(compareStrings);
185
+ const hits = new Set();
186
+ for (const node of next) {
187
+ const top = topOfLabel.get(node);
188
+ if (top) hits.add(top);
189
+ }
190
+ if (hits.size) { found = [...hits].sort(compareStrings); break; }
191
+ frontier = next;
192
+ }
193
+ }
194
+ memo.set(term, found);
195
+ return found;
196
+ }
197
+
198
+ /** True when EVERY cross pair of the two top sets is a declared disjoint
199
+ * pair — one shared top or one undeclared pair and the answer is false. */
200
+ function separated(subjectTops, objectTops) {
201
+ for (const a of subjectTops) {
202
+ for (const b of objectTops) {
203
+ if (a === b) return false;
204
+ if (!disjoint.has(pairKey(a, b))) return false;
205
+ }
206
+ }
207
+ return true;
208
+ }
209
+
210
+ function declines(subject, object) {
211
+ if (!subject || !object || subject === object) return false;
212
+ const subjectTops = topsOf(subject);
213
+ if (!subjectTops.length) return false;
214
+ const objectTops = topsOf(object);
215
+ if (!objectTops.length) return false;
216
+ return separated(subjectTops, objectTops);
217
+ }
218
+
219
+ return { declines, topsOf, tops: allTops, disjointPairs: pairs };
220
+ }
@@ -20,11 +20,17 @@
20
20
  // `entailed:*` Source, prior 0.3, so it never outranks a stated fact and is
21
21
  // retractable by provenance).
22
22
  //
23
+ // The two isa rules take a fifth: the SENSE gate (sense-gate.mjs), which
24
+ // refuses a conclusion that only holds because a corpus band flattened two
25
+ // word senses onto one label — russia ⊑ … ⊑ region ⊑ body part. It screens
26
+ // derivations only; a stated fact is never blocked.
27
+ //
23
28
  // Two further capabilities below are LIVE-CHASE ONLY, never part of the
24
29
  // batch pass: cardinality monotonicity (`proveCardinalityAtLeast`) and
25
30
  // max-cardinality-0 as encoded negation (`proveMaxCardinalityZeroDenial`).
26
31
 
27
32
  import { normFactTerm, factIdForTriple } from "./hash.mjs";
33
+ import { buildSenseGate } from "./sense-gate.mjs";
28
34
 
29
35
  /** The two persisting entry points (syllogise, retractSubClassOf) take the
30
36
  * memory store's read/write functions through a required `store` option —
@@ -122,8 +128,14 @@ function normalizeFocus(focus) {
122
128
  * (not already present), each `{ subject, object, via }`, bounded by `budget`
123
129
  * and `depth`, focus-filtered, tautology- and dedup-screened, in a deterministic
124
130
  * order. No I/O — this is the whole inference kernel, unit-testable in isolation.
131
+ *
132
+ * `gate` (sense-gate.mjs's `buildSenseGate`, or null) is the SENSE screen: a
133
+ * candidate a⊑c is dropped when the gate places a and c under top classes the
134
+ * ontology declares disjoint, so a band that flattens two word senses onto one
135
+ * label cannot license a walk across the join. Null (the default) leaves the
136
+ * kernel's behaviour exactly as it was.
125
137
  */
126
- export function deriveSubClassClosure(edges, { depth = 32, budget = 50, focus = null } = {}) {
138
+ export function deriveSubClassClosure(edges, { depth = 32, budget = 50, focus = null, gate = null } = {}) {
127
139
  const present = new Set(); // "a\0b" for every edge already known
128
140
  const succ = new Map(); // a -> Set(b): the live successor relation
129
141
  for (const [a, b] of edges || []) {
@@ -150,6 +162,7 @@ export function deriveSubClassClosure(edges, { depth = 32, budget = 50, focus =
150
162
  const key = `${a}${SEP}${c}`;
151
163
  if (present.has(key) || derivedKeys.has(key)) continue; // dedup / novelty screen
152
164
  if (!inFocus(a, b, c)) continue; // focus-connection screen
165
+ if (gate?.declines(a, c)) continue; // sense screen
153
166
  additions.push([a, b, c, key]);
154
167
  }
155
168
  }
@@ -183,7 +196,7 @@ export function deriveSubClassClosure(edges, { depth = 32, budget = 50, focus =
183
196
  * `allEdges` is already closed (a prior pass ran to fixpoint), the output
184
197
  * equals the full kernel's novel output, order included.
185
198
  */
186
- export function deriveSubClassClosureDelta(allEdges, deltaEdges, { depth = 32, budget = 50, focus = null } = {}) {
199
+ export function deriveSubClassClosureDelta(allEdges, deltaEdges, { depth = 32, budget = 50, focus = null, gate = null } = {}) {
187
200
  const present = new Set(); // "a\0b" for every edge already known
188
201
  const succ = new Map(); // a -> Set(b): the live successor relation
189
202
  const pred = new Map(); // b -> Set(a): its inverse, for the R∘Δ join
@@ -216,6 +229,7 @@ export function deriveSubClassClosureDelta(allEdges, deltaEdges, { depth = 32, b
216
229
  const key = `${a}${SEP}${c}`;
217
230
  if (present.has(key) || derivedKeys.has(key)) return; // dedup / novelty screen
218
231
  if (!inFocus(a, b, c)) return; // focus-connection screen
232
+ if (gate?.declines(a, c)) return; // sense screen
219
233
  additions.push([a, b, c, key]);
220
234
  };
221
235
  for (const [a, b] of delta) {
@@ -336,8 +350,13 @@ export function buildRelevanceFrontier(rows, seedTerms) {
336
350
  * a delta caller that pre-filters `typeEdges` to the relevant slice passes the
337
351
  * FULL list here, so an already-stored conclusion outside the slice is still
338
352
  * recognized as known rather than re-derived.
353
+ *
354
+ * `gate` is the same SENSE screen `deriveSubClassClosure` takes, read over the
355
+ * individual and the class it would inherit: an entity placed under one top
356
+ * never picks up a type from a disjoint one because a band flattened two
357
+ * senses onto a class somewhere in its chain.
339
358
  */
340
- export function deriveTypePropagation(typeEdges, subClassEdges, { budget = 50, focus = null, presentTypeEdges = typeEdges } = {}) {
359
+ export function deriveTypePropagation(typeEdges, subClassEdges, { budget = 50, focus = null, presentTypeEdges = typeEdges, gate = null } = {}) {
341
360
  const ancestorsOf = buildAncestorCloser(subClassEdges);
342
361
 
343
362
  const present = new Set(); // "x\0C" for every rdf:type edge already known
@@ -357,6 +376,7 @@ export function deriveTypePropagation(typeEdges, subClassEdges, { budget = 50, f
357
376
  const key = `${x}${SEP}${d}`;
358
377
  if (present.has(key)) continue; // dedup / novelty screen
359
378
  if (!inFocus(x, c, d)) continue; // focus-connection screen
379
+ if (gate?.declines(x, d)) continue; // sense screen
360
380
  candidates.push([x, c, d, key]);
361
381
  }
362
382
  }
@@ -820,7 +840,9 @@ export function findConsistencyViolations(typeEdges, subClassEdges, disjointEdge
820
840
  * even when none of its own three terms was named), `maxEnvironments`
821
841
  * (per-fact environment cap,
822
842
  * default DEFAULT_MAX_ENVIRONMENTS), `full` (force full evaluation even with
823
- * a valid watermark), `store` (REQUIRED the memory store's
843
+ * a valid watermark), `senseGate` (default true: screen scm-sco and cax-sco
844
+ * conclusions through sense-gate.mjs so a two-sense band word can't licence a
845
+ * walk across the join; false runs the kernels raw), `store` (REQUIRED — the memory store's
824
846
  * { loadMemory, readFactRows, appendFacts } read/write functions, injected so
825
847
  * this inference module never imports the store itself; optional
826
848
  * loadSyllogiseState/saveSyllogiseState enable delta mode).
@@ -830,7 +852,7 @@ export function findConsistencyViolations(typeEdges, subClassEdges, disjointEdge
830
852
  */
831
853
  export async function syllogise(repoDir, {
832
854
  depth = 32, budget = 50, focus = null, expandFocus = false,
833
- maxEnvironments = DEFAULT_MAX_ENVIRONMENTS, full = false, store,
855
+ maxEnvironments = DEFAULT_MAX_ENVIRONMENTS, full = false, store, senseGate = true,
834
856
  } = {}) {
835
857
  const { loadMemory, readFactRows, appendFacts } = requireStore(store, ["loadMemory", "readFactRows", "appendFacts"], "syllogise");
836
858
  const stateFnsPresent = typeof store?.loadSyllogiseState === "function" && typeof store?.saveSyllogiseState === "function";
@@ -857,6 +879,15 @@ export async function syllogise(repoDir, {
857
879
  const target = someValuesFromOf.get(restriction);
858
880
  if (target) restrictionEdges.push({ restriction, property, target });
859
881
  }
882
+ // The sense gate reads STATED isa rows only. Feeding it a previous pass's
883
+ // own conclusions would let one crossing licence the next, which is the
884
+ // failure it exists to stop.
885
+ const gate = senseGate
886
+ ? buildSenseGate({
887
+ subClassEdges: rows.filter((r) => isSubClassOf(r.predicate) && !isPurelyEntailed(r.provenance)).map((r) => [r.subject, r.object]),
888
+ typeEdges: rows.filter((r) => isType(r.predicate) && !isPurelyEntailed(r.provenance)).map((r) => [r.subject, r.object]),
889
+ })
890
+ : null;
860
891
  const callerFocus = normalizeFocus(focus);
861
892
  // An expanded focus is the same relevance walk a delta pass runs, seeded by
862
893
  // the caller's terms instead of a change set — still a focus (never reads
@@ -898,8 +929,8 @@ export async function syllogise(repoDir, {
898
929
  ? deltaRows.filter((r) => isSubClassOf(r.predicate)).map((r) => [r.subject, r.object])
899
930
  : [];
900
931
  const scmDerived = mode === "delta"
901
- ? (deltaSubEdges.length ? deriveSubClassClosureDelta(subClassEdges, deltaSubEdges, { depth, budget, focus: normalizedFocus }) : [])
902
- : deriveSubClassClosure(subClassEdges, { depth, budget, focus: normalizedFocus });
932
+ ? (deltaSubEdges.length ? deriveSubClassClosureDelta(subClassEdges, deltaSubEdges, { depth, budget, focus: normalizedFocus, gate }) : [])
933
+ : deriveSubClassClosure(subClassEdges, { depth, budget, focus: normalizedFocus, gate });
903
934
  // cax-sco sees the ENLARGED subClassOf edge set (stated ∪ this pass's own
904
935
  // scm-sco conclusions) so both rules complete in one `tmct syllogise` call.
905
936
  const enlargedSubClassEdges = subClassEdges.concat(scmDerived.map((d) => [d.subject, d.object]));
@@ -954,7 +985,7 @@ export async function syllogise(repoDir, {
954
985
 
955
986
  const remainingBudget = Math.max(0, budget - scmDerived.length);
956
987
  const caxDerived = remainingBudget > 0 && !deltaEmpty
957
- ? deriveTypePropagation(caxTypeEdges, enlargedSubClassEdges, { budget: remainingBudget, focus: kernelFocus, presentTypeEdges: typeEdges })
988
+ ? deriveTypePropagation(caxTypeEdges, enlargedSubClassEdges, { budget: remainingBudget, focus: kernelFocus, presentTypeEdges: typeEdges, gate })
958
989
  : [];
959
990
  // cax-dw sees the SAME enlarged subClassOf set (so its own ⊑-lift reaches a
960
991
  // chain scm-sco just grew this pass) — it doesn't need the enlarged TYPE
@@ -65,8 +65,23 @@ const NOISE_TERM_SETS = [
65
65
  FOREIGN_PARTICLE_TERMS,
66
66
  ];
67
67
 
68
+ /** A version or model string shatters into pieces that are mostly digits and
69
+ * punctuation — "Qwen3.8-2.4T" leaves "8-2" and "4t" behind. A term with no
70
+ * letter at all, or one carrying a digit whose digits and punctuation match or
71
+ * outnumber its letters, states a quantity or a version; it never names a
72
+ * subject a reference lookup could define. A letter-only term with interior
73
+ * stops ("u.s.") carries no digit, so it stays. */
74
+ function isShapeNoiseTerm(term) {
75
+ const letters = (term.match(/[a-z]/g) || []).length;
76
+ if (!letters) return true;
77
+ const digits = (term.match(/\d/g) || []).length;
78
+ if (!digits) return false;
79
+ const punctuation = (term.match(/[^a-z0-9\s]/g) || []).length;
80
+ return digits + punctuation >= letters;
81
+ }
82
+
68
83
  function isNoiseTerm(term) {
69
- return NOISE_TERM_SETS.some((set) => set.has(term));
84
+ return NOISE_TERM_SETS.some((set) => set.has(term)) || isShapeNoiseTerm(term);
70
85
  }
71
86
 
72
87
  /** Ledger entry field order fixed once here so `ledgerPayload` serializes
@@ -37,7 +37,7 @@ import { uuidv7 } from "../adapters/uuid.mjs";
37
37
  import * as defaultSource from "../adapters/source.mjs";
38
38
  import { loadTemplates, render as renderTemplate } from "../adapters/corpus/templates.mjs";
39
39
  import { rankByBiasThenTrust } from "../domain/memory/bias.mjs";
40
- import { HAS_A_PREDICATE, loadMemory as loadMemoryStore, normFactPredicate, normFactTerm as normFactTermStatic, readFactRows as readStoredFactRows, readRuleRows as readStoredRuleRows } from "../adapters/memory/core.mjs";
40
+ import { HAS_A_PREDICATE, foldedFactRows as foldStoreFactRows, loadMemory as loadMemoryStore, normFactPredicate, normFactTerm as normFactTermStatic, readFactRows as readStoredFactRows, readRuleRows as readStoredRuleRows } from "../adapters/memory/core.mjs";
41
41
  import { BACKEND_REJECTED_CODE, BACKEND_UNAVAILABLE_CODE } from "../adapters/memory/row-backend.mjs";
42
42
  import {
43
43
  CAPABILITY_REPORT_CAP, NEG_CAPABLE_OF_PREDICATE, NEG_SUBCLASS_PREDICATE, capabilityBaseRate,
@@ -8461,8 +8461,9 @@ async function memoryFacts(memoryDir) {
8461
8461
  *
8462
8462
  * `cache`: an optional, caller-owned plain object (`{ rows: null }`, e.g. one
8463
8463
  * runTurn call's own `factRowsCache`) — when `cache.rows` is already
8464
- * populated, it's returned directly, skipping loadMemory/readFactRows
8465
- * entirely; otherwise the result is computed as before and stashed onto
8464
+ * populated, it's returned directly, skipping the store read entirely;
8465
+ * otherwise the fold is taken (from the store's own held one when it has a
8466
+ * current one — see core's `foldedFactRows`) and stashed onto
8466
8467
  * `cache.rows` for the next caller sharing the same cache this turn.
8467
8468
  * Absent/null (the default) reproduces a fresh, uncached reload every call,
8468
8469
  * so every caller that doesn't pass one is byte-for-byte unaffected. Never
@@ -8473,8 +8474,8 @@ async function memoryFacts(memoryDir) {
8473
8474
  async function factRows(memoryDir, cache = null) {
8474
8475
  if (cache?.rows) return cache.rows;
8475
8476
  try {
8476
- const { loadMemory, readFactRows } = await import("../adapters/memory/core.mjs");
8477
- const rows = readFactRows(await loadMemory(memoryDir));
8477
+ const { foldedFactRows } = await import("../adapters/memory/core.mjs");
8478
+ const rows = await foldedFactRows(memoryDir);
8478
8479
  if (cache) { cache.rows = rows; cache.reloads = (cache.reloads || 0) + 1; }
8479
8480
  return rows;
8480
8481
  } catch {
@@ -18476,15 +18477,19 @@ function finishTurn(result, ctx) {
18476
18477
  return finish(result, ctx);
18477
18478
  }
18478
18479
 
18479
- /** An uncached readFactRows() snapshot of the memory store — one half of the
18480
- * before/after pair a turn's `factsTouched` diff is taken over. Deliberately
18481
- * bypasses the turn's factRowsCache: the cache is invalidated by only some
18482
- * write paths, and a diff read through it would miss the very writes it exists
18483
- * to report. Null (rather than []) when there is no store or it won't load, so
18484
- * the caller can tell "nothing to diff" from "diffed, nothing moved". */
18480
+ /** A readFactRows() snapshot of the memory store — one half of the before/after
18481
+ * pair a turn's `factsTouched` diff is taken over. Deliberately bypasses the
18482
+ * turn's factRowsCache: the cache is invalidated by only some write paths, and
18483
+ * a diff read through it would miss the very writes it exists to report. The
18484
+ * store's own held fold is a different thing and safe to read: every write
18485
+ * moves the stamp it is keyed to, so a snapshot taken after a write is that
18486
+ * write's own fold, and a turn that wrote nothing gets back the array it
18487
+ * started from — which diffs to nothing, correctly. Null (rather than []) when
18488
+ * there is no store or it won't load, so the caller can tell "nothing to diff"
18489
+ * from "diffed, nothing moved". */
18485
18490
  async function factRowSnapshot(memoryDir) {
18486
18491
  if (!memoryDir) return null;
18487
- try { return readStoredFactRows(await loadMemoryStore(memoryDir)); } catch { return null; }
18492
+ try { return await foldStoreFactRows(memoryDir); } catch { return null; }
18488
18493
  }
18489
18494
 
18490
18495
  /** The Fact rows this turn wrote, diffed against the snapshot taken before it,