@polycode-projects/the-mechanical-code-talker 6.0.18 → 6.0.20

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 (76) hide show
  1. package/README.md +20 -23
  2. package/bin/tmct.mjs +16 -33
  3. package/corpus/LICENSES.json +0 -21
  4. package/corpus/README.md +10 -13
  5. package/corpus/reference/manifest.json +19 -19
  6. package/corpus/reference/shards/ref-01.jsonl.gz +0 -0
  7. package/corpus/reference/shards/ref-04.jsonl.gz +0 -0
  8. package/corpus/reference/shards/ref-08.jsonl.gz +0 -0
  9. package/corpus/reference/shards/ref-10.jsonl.gz +0 -0
  10. package/corpus/reference/shards/ref-11.jsonl.gz +0 -0
  11. package/corpus/reference/shards/ref-17.jsonl.gz +0 -0
  12. package/corpus/reference/shards/ref-20.jsonl.gz +0 -0
  13. package/corpus/reference/shards/ref-25.jsonl.gz +0 -0
  14. package/corpus/reference/shards/ref-2c.jsonl.gz +0 -0
  15. package/corpus/tier2/generate.mjs +6 -142
  16. package/corpus/tier2/manifest.json +0 -42
  17. package/package.json +6 -4
  18. package/src/adapters/corpus/child-seed.mjs +74 -0
  19. package/src/adapters/corpus/conceptnet.mjs +45 -26
  20. package/src/adapters/corpus/research-source.mjs +6 -2
  21. package/src/adapters/corpus/wikidata-live.mjs +92 -51
  22. package/src/adapters/memory/blocks.mjs +7 -1
  23. package/src/adapters/memory/core.mjs +505 -107
  24. package/src/adapters/memory/corpus-bands.mjs +27 -10
  25. package/src/adapters/memory/inspect.mjs +24 -5
  26. package/src/adapters/memory/rows.mjs +359 -30
  27. package/src/adapters/memory/shacl.mjs +10 -3
  28. package/src/domain/ask.mjs +27 -10
  29. package/src/domain/cli-verbs.mjs +3 -4
  30. package/src/domain/completions/group.mjs +8 -3
  31. package/src/domain/completions/infer.mjs +7 -2
  32. package/src/domain/completions/prune.mjs +5 -1
  33. package/src/domain/completions/rank.mjs +7 -2
  34. package/src/domain/digest/compose.mjs +5 -1
  35. package/src/domain/digest/select.mjs +12 -6
  36. package/src/domain/domain.mjs +15 -8
  37. package/src/domain/el-classify.mjs +11 -2
  38. package/src/domain/fact-phrase.mjs +86 -4
  39. package/src/domain/hash.mjs +9 -0
  40. package/src/domain/memory/bias.mjs +8 -4
  41. package/src/domain/memory/capability.mjs +12 -6
  42. package/src/domain/memory/fact-order.mjs +29 -0
  43. package/src/domain/memory/resolution.mjs +3 -0
  44. package/src/domain/news-feed.mjs +862 -92
  45. package/src/domain/reference-pack.mjs +5 -0
  46. package/src/domain/sense-gate.mjs +220 -0
  47. package/src/domain/sense-scope.mjs +116 -0
  48. package/src/domain/sense-split.mjs +1 -1
  49. package/src/domain/syllogise.mjs +60 -21
  50. package/src/domain/tableau.mjs +23 -14
  51. package/src/domain/term-ledger.mjs +16 -1
  52. package/src/domain/worlds-pack.mjs +5 -1
  53. package/src/services/adventure-autoplay.mjs +6 -1
  54. package/src/services/adventure-editor.mjs +43 -21
  55. package/src/services/adventure-viz.mjs +26 -9
  56. package/src/services/adventure.mjs +40 -10
  57. package/src/services/chat.mjs +270 -125
  58. package/src/services/extensions.mjs +51 -58
  59. package/src/services/extract-facts.mjs +906 -66
  60. package/src/services/init.mjs +4 -4
  61. package/src/services/ledger-viz.mjs +9 -4
  62. package/src/services/memory-panel-viz.mjs +4 -5
  63. package/src/services/mud-editor.mjs +40 -16
  64. package/src/services/mud-viz.mjs +8 -2
  65. package/src/services/mudiii-turn.mjs +5 -3
  66. package/src/services/mudiii-viz.mjs +8 -2
  67. package/src/services/news.mjs +306 -21
  68. package/src/services/research-viz.mjs +1 -1
  69. package/src/services/sprite-catalog-viz.mjs +10 -5
  70. package/src/surfaces/web/adventure-browser-entry.mjs +6 -12
  71. package/src/surfaces/web/memory-ask-browser.bundle.js +152 -151
  72. package/src/surfaces/web/mud-browser-entry.mjs +7 -11
  73. package/src/surfaces/web/research-browser-entry.mjs +5 -2
  74. package/corpus/tier2/aws.jsonl +0 -39
  75. package/corpus/tier2/java.jsonl +0 -31
  76. package/corpus/tier2/python.jsonl +0 -30
@@ -226,9 +226,14 @@ function withoutParentheticals(text) {
226
226
 
227
227
  // Words that end the noun phrase: a relative clause or a trailing modifier
228
228
  // carries detail, not the category ("a place where ships shelter" → place).
229
+ // "about"/"around"/"roughly"/"approximately" open a comparison or a topic
230
+ // that reads like more of the head phrase but is not ("a small marsupial
231
+ // about the size of a large cat" is a marsupial, not a size; "a book about
232
+ // dogs" is a book).
229
233
  const ISA_CLAUSE_CUT = new Set([
230
234
  "that", "which", "where", "who", "whose", "whom", "when", "used", "found",
231
235
  "made", "with", "in", "on", "for", "from", "by", "to", "and", "or",
236
+ "about", "around", "roughly", "approximately",
232
237
  ]);
233
238
  // Classifier heads carry no category of their own ("a member of the cat
234
239
  // family" names family membership, not what the thing is) — no isa beats a
@@ -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
+ }
@@ -0,0 +1,116 @@
1
+ // sense-scope.mjs — the same-sense discipline a READ-TIME walk applies when it
2
+ // picks which neighbours of a term to show.
3
+ //
4
+ // sense-gate.mjs already places a term under its nearest top classes and says
5
+ // when two terms are provably in different senses. It uses that to refuse a
6
+ // DERIVED isa edge, and it refuses only on proof: both ends placed, every cross
7
+ // pair declared disjoint. That burden is right for a derivation, because
8
+ // refusing one deletes an entailment the graph would otherwise hold.
9
+ //
10
+ // Selection carries the opposite burden. Nothing is deleted when a background
11
+ // row goes unshown, so a walk may ask for evidence that a neighbour belongs
12
+ // before it spends a line on it. This module asks for exactly that: the
13
+ // neighbour's top classes must MEET the anchor's, not merely fail to be
14
+ // provably disjoint.
15
+ //
16
+ // The difference is the whole bug. "russia" places under `place`; "passage"
17
+ // places under `artifact`. Those two tops overlap by declaration (a cathedral
18
+ // is both), so the derivation gate lets them stand and the store holds an
19
+ // entailed "russia is a kind of passage". A hop-bounded walk out of russia then
20
+ // treats `passage` as a one-hop neighbour and comes back down the anatomy side
21
+ // of it: "orifice is a kind of passage", "duct is a kind of passage", on a card
22
+ // about a prisoner release. Requiring a shared top stops the walk at `passage`
23
+ // while `country`, `district` and `geographical area` — all of which meet
24
+ // `place` — carry straight on.
25
+ //
26
+ // Unplaced is not out of sense. A term the bands never classify (a person's
27
+ // name, a coined product name, most of what a headline is actually about) has
28
+ // no tops to meet, so it is admitted.
29
+ //
30
+ // An unplaced ANCHOR is the harder case: it pools no tops, so by default the
31
+ // scope keeps to nothing and every neighbour walks in as filler. Two ways out,
32
+ // and a caller picks between them. `hasPlacedSense` says whether an anchor set
33
+ // is placed at all, so a caller holding a second, better placed set can anchor
34
+ // there instead. `admitAllWhenUnplaced: false` says these anchors ARE the
35
+ // sense, placed or not: an unplaced anchor set then keeps to the unplaced, so a
36
+ // walk under it goes sparse instead of pulling in the whole placed graph. A
37
+ // graph with no bands at all places nothing, refuses nothing, and reads exactly
38
+ // as it did.
39
+ //
40
+ // Pure over the fact set. The gate underneath sorts every frontier and memoizes
41
+ // per term, and an anchor set is a membership test over the tops it pools, so
42
+ // two ingestion orders of the same facts admit the same terms.
43
+
44
+ import { normFactTerm } from "./hash.mjs";
45
+ import { buildSenseGate } from "./sense-gate.mjs";
46
+
47
+ const SUBCLASS_PREDICATE = "rdfs:subClassOf";
48
+ const TYPE_PREDICATE = "rdf:type";
49
+
50
+ /** Whether a row states its isa edge rather than concluding it. An entailment
51
+ * head, a hypothetical's environment, or a justification chain all mark the
52
+ * graph's own output; feeding that back to the gate that constrains it is how
53
+ * a bad shortcut ends up vouching for itself. Read here rather than imported,
54
+ * so this module stands alone for any reader that wants the same discipline. */
55
+ function statesItsIsaEdge(row) {
56
+ const head = String(row?.provenance || "").trim().split(/\s+/)[0] || "";
57
+ if (head.startsWith("entailed:")) return false;
58
+ if (Array.isArray(row?.environments) && row.environments.length) return false;
59
+ if (Array.isArray(row?.justification) && row.justification.length) return false;
60
+ return true;
61
+ }
62
+
63
+ /** Builds the scope over one fact set, reading only the asserted isa rows.
64
+ *
65
+ * Returns `{ topsOf, hasPlacedSense, sameSenseAs }`. `topsOf(term)` is the
66
+ * gate's own placement, exposed so a caller can explain a refusal.
67
+ * `sameSenseAs(anchors)` takes one term or an iterable of them and answers a
68
+ * `(term) => boolean` predicate: true when the term may stay in the anchors'
69
+ * neighbourhood. Several anchors pool their tops, so a walk seeded from more
70
+ * than one term keeps to the senses of all of them. `hasPlacedSense(anchors)`
71
+ * says whether that pool holds anything, which is whether the scope those
72
+ * anchors build refuses any term at all. Pass `admitAllWhenUnplaced: false`
73
+ * and an unplaced anchor set refuses every placed term instead of admitting
74
+ * everything. */
75
+ export function buildSenseScope(rows) {
76
+ const subClassEdges = [];
77
+ const typeEdges = [];
78
+ for (const row of rows || []) {
79
+ if (!row || !statesItsIsaEdge(row)) continue;
80
+ const child = normFactTerm(row.subject);
81
+ const parent = normFactTerm(row.object);
82
+ if (!child || !parent) continue;
83
+ if (row.predicate === SUBCLASS_PREDICATE) subClassEdges.push([child, parent]);
84
+ else if (row.predicate === TYPE_PREDICATE) typeEdges.push([child, parent]);
85
+ }
86
+ const gate = buildSenseGate({ subClassEdges, typeEdges });
87
+
88
+ const topsOf = (term) => gate.topsOf(normFactTerm(term));
89
+
90
+ const anchorList = (anchors) => (typeof anchors === "string" ? [anchors] : [...(anchors || [])]);
91
+
92
+ function topsAcross(list) {
93
+ const tops = new Set();
94
+ for (const anchor of list) for (const top of topsOf(anchor)) tops.add(top);
95
+ return tops;
96
+ }
97
+
98
+ const hasPlacedSense = (anchors) => topsAcross(anchorList(anchors)).size > 0;
99
+
100
+ function sameSenseAs(anchors, { admitAllWhenUnplaced = true } = {}) {
101
+ const list = anchorList(anchors);
102
+ const anchorTops = topsAcross(list);
103
+ if (!anchorTops.size && admitAllWhenUnplaced) return () => true;
104
+ const anchorTerms = new Set(list.map((a) => normFactTerm(a)).filter(Boolean));
105
+ return (term) => {
106
+ const norm = normFactTerm(term);
107
+ if (!norm || anchorTerms.has(norm)) return true;
108
+ const tops = topsOf(norm);
109
+ if (!tops.length) return true;
110
+ for (const top of tops) if (anchorTops.has(top)) return true;
111
+ return false;
112
+ };
113
+ }
114
+
115
+ return { topsOf, hasPlacedSense, sameSenseAs };
116
+ }
@@ -260,7 +260,7 @@ export function clusterSenses(objects, {
260
260
  let best = depth(label);
261
261
  for (const m of sorted) { const d = depth(m); if (d > best) { best = d; label = m; } }
262
262
  return { objects: sorted, label };
263
- }).sort((x, y) => x.label.localeCompare(y.label));
263
+ }).sort((x, y) => compareStrings(x.label, y.label));
264
264
 
265
265
  return { split: clusters.length > 1, clusters, pairs };
266
266
  }
@@ -20,11 +20,25 @@
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";
34
+
35
+ /** Codepoint order, never localeCompare. Every candidate list below is sorted
36
+ * and then cut at the budget, so the comparator decides WHICH entailments a
37
+ * pass derives. It also decides which premise each one cites: dedup keeps the
38
+ * first candidate per key, so a derivation's `via` slot holds whichever middle
39
+ * term sorted first. Sorting either by the reader's OS locale would let two
40
+ * machines forward-chain one graph into two different fact sets. */
41
+ const byCodepoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
28
42
 
29
43
  /** The two persisting entry points (syllogise, retractSubClassOf) take the
30
44
  * memory store's read/write functions through a required `store` option —
@@ -122,8 +136,14 @@ function normalizeFocus(focus) {
122
136
  * (not already present), each `{ subject, object, via }`, bounded by `budget`
123
137
  * and `depth`, focus-filtered, tautology- and dedup-screened, in a deterministic
124
138
  * order. No I/O — this is the whole inference kernel, unit-testable in isolation.
139
+ *
140
+ * `gate` (sense-gate.mjs's `buildSenseGate`, or null) is the SENSE screen: a
141
+ * candidate a⊑c is dropped when the gate places a and c under top classes the
142
+ * ontology declares disjoint, so a band that flattens two word senses onto one
143
+ * label cannot license a walk across the join. Null (the default) leaves the
144
+ * kernel's behaviour exactly as it was.
125
145
  */
126
- export function deriveSubClassClosure(edges, { depth = 32, budget = 50, focus = null } = {}) {
146
+ export function deriveSubClassClosure(edges, { depth = 32, budget = 50, focus = null, gate = null } = {}) {
127
147
  const present = new Set(); // "a\0b" for every edge already known
128
148
  const succ = new Map(); // a -> Set(b): the live successor relation
129
149
  for (const [a, b] of edges || []) {
@@ -150,12 +170,13 @@ export function deriveSubClassClosure(edges, { depth = 32, budget = 50, focus =
150
170
  const key = `${a}${SEP}${c}`;
151
171
  if (present.has(key) || derivedKeys.has(key)) continue; // dedup / novelty screen
152
172
  if (!inFocus(a, b, c)) continue; // focus-connection screen
173
+ if (gate?.declines(a, c)) continue; // sense screen
153
174
  additions.push([a, b, c, key]);
154
175
  }
155
176
  }
156
177
  }
157
178
  if (!additions.length) break; // fixpoint reached
158
- additions.sort((x, y) => x[0].localeCompare(y[0]) || x[2].localeCompare(y[2]) || x[1].localeCompare(y[1]));
179
+ additions.sort((x, y) => byCodepoint(x[0], y[0]) || byCodepoint(x[2], y[2]) || byCodepoint(x[1], y[1]));
159
180
  let progressed = false;
160
181
  for (const [a, b, c, key] of additions) {
161
182
  if (derivedKeys.has(key)) continue; // an earlier addition this round covered it
@@ -183,7 +204,7 @@ export function deriveSubClassClosure(edges, { depth = 32, budget = 50, focus =
183
204
  * `allEdges` is already closed (a prior pass ran to fixpoint), the output
184
205
  * equals the full kernel's novel output, order included.
185
206
  */
186
- export function deriveSubClassClosureDelta(allEdges, deltaEdges, { depth = 32, budget = 50, focus = null } = {}) {
207
+ export function deriveSubClassClosureDelta(allEdges, deltaEdges, { depth = 32, budget = 50, focus = null, gate = null } = {}) {
187
208
  const present = new Set(); // "a\0b" for every edge already known
188
209
  const succ = new Map(); // a -> Set(b): the live successor relation
189
210
  const pred = new Map(); // b -> Set(a): its inverse, for the R∘Δ join
@@ -216,6 +237,7 @@ export function deriveSubClassClosureDelta(allEdges, deltaEdges, { depth = 32, b
216
237
  const key = `${a}${SEP}${c}`;
217
238
  if (present.has(key) || derivedKeys.has(key)) return; // dedup / novelty screen
218
239
  if (!inFocus(a, b, c)) return; // focus-connection screen
240
+ if (gate?.declines(a, c)) return; // sense screen
219
241
  additions.push([a, b, c, key]);
220
242
  };
221
243
  for (const [a, b] of delta) {
@@ -223,7 +245,7 @@ export function deriveSubClassClosureDelta(allEdges, deltaEdges, { depth = 32, b
223
245
  for (const z of pred.get(a) || []) consider(z, a, b); // R∘Δ
224
246
  }
225
247
  if (!additions.length) break; // fixpoint reached
226
- additions.sort((x, y) => x[0].localeCompare(y[0]) || x[2].localeCompare(y[2]) || x[1].localeCompare(y[1]));
248
+ additions.sort((x, y) => byCodepoint(x[0], y[0]) || byCodepoint(x[2], y[2]) || byCodepoint(x[1], y[1]));
227
249
  let progressed = false;
228
250
  const nextDelta = [];
229
251
  for (const [a, b, c, key] of additions) {
@@ -336,8 +358,13 @@ export function buildRelevanceFrontier(rows, seedTerms) {
336
358
  * a delta caller that pre-filters `typeEdges` to the relevant slice passes the
337
359
  * FULL list here, so an already-stored conclusion outside the slice is still
338
360
  * recognized as known rather than re-derived.
361
+ *
362
+ * `gate` is the same SENSE screen `deriveSubClassClosure` takes, read over the
363
+ * individual and the class it would inherit: an entity placed under one top
364
+ * never picks up a type from a disjoint one because a band flattened two
365
+ * senses onto a class somewhere in its chain.
339
366
  */
340
- export function deriveTypePropagation(typeEdges, subClassEdges, { budget = 50, focus = null, presentTypeEdges = typeEdges } = {}) {
367
+ export function deriveTypePropagation(typeEdges, subClassEdges, { budget = 50, focus = null, presentTypeEdges = typeEdges, gate = null } = {}) {
341
368
  const ancestorsOf = buildAncestorCloser(subClassEdges);
342
369
 
343
370
  const present = new Set(); // "x\0C" for every rdf:type edge already known
@@ -357,10 +384,11 @@ export function deriveTypePropagation(typeEdges, subClassEdges, { budget = 50, f
357
384
  const key = `${x}${SEP}${d}`;
358
385
  if (present.has(key)) continue; // dedup / novelty screen
359
386
  if (!inFocus(x, c, d)) continue; // focus-connection screen
387
+ if (gate?.declines(x, d)) continue; // sense screen
360
388
  candidates.push([x, c, d, key]);
361
389
  }
362
390
  }
363
- candidates.sort((p, q) => p[0].localeCompare(q[0]) || p[2].localeCompare(q[2]) || p[1].localeCompare(q[1]));
391
+ candidates.sort((p, q) => byCodepoint(p[0], q[0]) || byCodepoint(p[2], q[2]) || byCodepoint(p[1], q[1]));
364
392
  const derived = [];
365
393
  const derivedKeys = new Set();
366
394
  for (const [x, c, d, key] of candidates) {
@@ -426,7 +454,7 @@ export function deriveDisjointViolations(typeEdges, subClassEdges, disjointEdges
426
454
  }
427
455
  }
428
456
  }
429
- candidates.sort((p, q) => p[0].localeCompare(q[0]) || p[3].localeCompare(q[3]) || p[2].localeCompare(q[2]) || p[1].localeCompare(q[1]));
457
+ candidates.sort((p, q) => byCodepoint(p[0], q[0]) || byCodepoint(p[3], q[3]) || byCodepoint(p[2], q[2]) || byCodepoint(p[1], q[1]));
430
458
  const derived = [];
431
459
  const derivedKeys = new Set();
432
460
  for (const [x, c, d, e, key] of candidates) {
@@ -506,7 +534,7 @@ export function deriveSomeValuesFromApplication(propertyEdges, typeEdges, subCla
506
534
  }
507
535
  }
508
536
  }
509
- candidates.sort((a, b) => a[0].localeCompare(b[0]) || a[6].localeCompare(b[6]) || a[1].localeCompare(b[1]) || a[3].localeCompare(b[3]));
537
+ candidates.sort((a, b) => byCodepoint(a[0], b[0]) || byCodepoint(a[6], b[6]) || byCodepoint(a[1], b[1]) || byCodepoint(a[3], b[3]));
510
538
  const derived = [];
511
539
  const derivedKeys = new Set();
512
540
  for (const [x, p, pKey, y, c, target, r, key] of candidates) {
@@ -555,7 +583,7 @@ export function buildCardinalityRestrictions(rows) {
555
583
  if (!onClass) continue;
556
584
  restrictions.push({ restriction, kind, n, onClass });
557
585
  }
558
- restrictions.sort((a, b) => a.restriction.localeCompare(b.restriction));
586
+ restrictions.sort((a, b) => byCodepoint(a.restriction, b.restriction));
559
587
  return restrictions;
560
588
  }
561
589
 
@@ -607,7 +635,7 @@ export function deriveSomeValuesFromSubsumption(restrictionEdges, subClassEdges,
607
635
  }
608
636
  }
609
637
  }
610
- candidates.sort((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1]));
638
+ candidates.sort((a, b) => byCodepoint(a[0], b[0]) || byCodepoint(a[1], b[1]));
611
639
  const derived = [];
612
640
  const derivedKeys = new Set();
613
641
  for (const [c1, c2, y1, y2, key] of candidates) {
@@ -775,7 +803,7 @@ export function findConsistencyViolations(typeEdges, subClassEdges, disjointEdge
775
803
  }
776
804
  }
777
805
  }
778
- candidates.sort((p, q) => p[0].localeCompare(q[0]) || p[1].localeCompare(q[1]) || p[2].localeCompare(q[2]));
806
+ candidates.sort((p, q) => byCodepoint(p[0], q[0]) || byCodepoint(p[1], q[1]) || byCodepoint(p[2], q[2]));
779
807
  const derived = [];
780
808
  for (const [x, ta, tb, viaA, viaB] of candidates) {
781
809
  if (derived.length >= budget) break;
@@ -820,7 +848,9 @@ export function findConsistencyViolations(typeEdges, subClassEdges, disjointEdge
820
848
  * even when none of its own three terms was named), `maxEnvironments`
821
849
  * (per-fact environment cap,
822
850
  * default DEFAULT_MAX_ENVIRONMENTS), `full` (force full evaluation even with
823
- * a valid watermark), `store` (REQUIRED the memory store's
851
+ * a valid watermark), `senseGate` (default true: screen scm-sco and cax-sco
852
+ * conclusions through sense-gate.mjs so a two-sense band word can't licence a
853
+ * walk across the join; false runs the kernels raw), `store` (REQUIRED — the memory store's
824
854
  * { loadMemory, readFactRows, appendFacts } read/write functions, injected so
825
855
  * this inference module never imports the store itself; optional
826
856
  * loadSyllogiseState/saveSyllogiseState enable delta mode).
@@ -830,7 +860,7 @@ export function findConsistencyViolations(typeEdges, subClassEdges, disjointEdge
830
860
  */
831
861
  export async function syllogise(repoDir, {
832
862
  depth = 32, budget = 50, focus = null, expandFocus = false,
833
- maxEnvironments = DEFAULT_MAX_ENVIRONMENTS, full = false, store,
863
+ maxEnvironments = DEFAULT_MAX_ENVIRONMENTS, full = false, store, senseGate = true,
834
864
  } = {}) {
835
865
  const { loadMemory, readFactRows, appendFacts } = requireStore(store, ["loadMemory", "readFactRows", "appendFacts"], "syllogise");
836
866
  const stateFnsPresent = typeof store?.loadSyllogiseState === "function" && typeof store?.saveSyllogiseState === "function";
@@ -857,6 +887,15 @@ export async function syllogise(repoDir, {
857
887
  const target = someValuesFromOf.get(restriction);
858
888
  if (target) restrictionEdges.push({ restriction, property, target });
859
889
  }
890
+ // The sense gate reads STATED isa rows only. Feeding it a previous pass's
891
+ // own conclusions would let one crossing licence the next, which is the
892
+ // failure it exists to stop.
893
+ const gate = senseGate
894
+ ? buildSenseGate({
895
+ subClassEdges: rows.filter((r) => isSubClassOf(r.predicate) && !isPurelyEntailed(r.provenance)).map((r) => [r.subject, r.object]),
896
+ typeEdges: rows.filter((r) => isType(r.predicate) && !isPurelyEntailed(r.provenance)).map((r) => [r.subject, r.object]),
897
+ })
898
+ : null;
860
899
  const callerFocus = normalizeFocus(focus);
861
900
  // An expanded focus is the same relevance walk a delta pass runs, seeded by
862
901
  // the caller's terms instead of a change set — still a focus (never reads
@@ -898,8 +937,8 @@ export async function syllogise(repoDir, {
898
937
  ? deltaRows.filter((r) => isSubClassOf(r.predicate)).map((r) => [r.subject, r.object])
899
938
  : [];
900
939
  const scmDerived = mode === "delta"
901
- ? (deltaSubEdges.length ? deriveSubClassClosureDelta(subClassEdges, deltaSubEdges, { depth, budget, focus: normalizedFocus }) : [])
902
- : deriveSubClassClosure(subClassEdges, { depth, budget, focus: normalizedFocus });
940
+ ? (deltaSubEdges.length ? deriveSubClassClosureDelta(subClassEdges, deltaSubEdges, { depth, budget, focus: normalizedFocus, gate }) : [])
941
+ : deriveSubClassClosure(subClassEdges, { depth, budget, focus: normalizedFocus, gate });
903
942
  // cax-sco sees the ENLARGED subClassOf edge set (stated ∪ this pass's own
904
943
  // scm-sco conclusions) so both rules complete in one `tmct syllogise` call.
905
944
  const enlargedSubClassEdges = subClassEdges.concat(scmDerived.map((d) => [d.subject, d.object]));
@@ -954,7 +993,7 @@ export async function syllogise(repoDir, {
954
993
 
955
994
  const remainingBudget = Math.max(0, budget - scmDerived.length);
956
995
  const caxDerived = remainingBudget > 0 && !deltaEmpty
957
- ? deriveTypePropagation(caxTypeEdges, enlargedSubClassEdges, { budget: remainingBudget, focus: kernelFocus, presentTypeEdges: typeEdges })
996
+ ? deriveTypePropagation(caxTypeEdges, enlargedSubClassEdges, { budget: remainingBudget, focus: kernelFocus, presentTypeEdges: typeEdges, gate })
958
997
  : [];
959
998
  // cax-dw sees the SAME enlarged subClassOf set (so its own ⊑-lift reaches a
960
999
  // chain scm-sco just grew this pass) — it doesn't need the enlarged TYPE
@@ -1115,7 +1154,7 @@ export async function syllogise(repoDir, {
1115
1154
  environments: environmentsOf(r), provenance: r.provenance,
1116
1155
  }));
1117
1156
  const alternateCandidates = [...conclusionCandidates, ...storedCandidates]
1118
- .sort((a, b) => a.subject.localeCompare(b.subject) || a.predicate.localeCompare(b.predicate) || a.object.localeCompare(b.object));
1157
+ .sort((a, b) => byCodepoint(a.subject, b.subject) || byCodepoint(a.predicate, b.predicate) || byCodepoint(a.object, b.object));
1119
1158
  let environmentsAdded = 0;
1120
1159
  let alternatesTruncated = false;
1121
1160
  let examined = 0;
@@ -1399,7 +1438,7 @@ function buildSupportEnumerator(rows) {
1399
1438
  if (rec) {
1400
1439
  const edges = [...(propertyEdgesOf.get(row.subject) || [])]
1401
1440
  .filter(([p]) => normFactTerm(p) === rec.propertyKey)
1402
- .sort((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1]));
1441
+ .sort((a, b) => byCodepoint(a[0], b[0]) || byCodepoint(a[1], b[1]));
1403
1442
  for (const [p, y] of edges) {
1404
1443
  if (out.length >= maxEnvironments) break;
1405
1444
  for (const c of [...(typesOf.get(y) || [])].sort()) {
@@ -1426,7 +1465,7 @@ function buildSupportEnumerator(rows) {
1426
1465
  if ((disjointOf.get(d) || new Set()).has(row.object)) pairs.push([c, d]);
1427
1466
  }
1428
1467
  }
1429
- pairs.sort((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1]));
1468
+ pairs.sort((a, b) => byCodepoint(a[0], b[0]) || byCodepoint(a[1], b[1]));
1430
1469
  for (const [c, d] of pairs) {
1431
1470
  if (out.length >= maxEnvironments) break;
1432
1471
  const dwStoredForward = disjointForward.has(`${d}${SEP}${row.object}`);
@@ -1622,7 +1661,7 @@ export async function retractSubClassOf(repoDir, subject, object, {
1622
1661
  }
1623
1662
  }
1624
1663
  const candidates = [...candidateIds].map((id) => byId.get(id))
1625
- .sort((a, b) => a.subject.localeCompare(b.subject) || a.predicate.localeCompare(b.predicate) || a.object.localeCompare(b.object));
1664
+ .sort((a, b) => byCodepoint(a.subject, b.subject) || byCodepoint(a.predicate, b.predicate) || byCodepoint(a.object, b.object));
1626
1665
  if (!candidates.length) break; // fixpoint — nothing cites what just fell
1627
1666
 
1628
1667
  // The surviving fact set for THIS round excludes every candidate's own
@@ -1688,7 +1727,7 @@ export async function retractSubClassOf(repoDir, subject, object, {
1688
1727
  if (appendFactsFn) {
1689
1728
  const regroundWrites = [...reground.entries()]
1690
1729
  .filter(([id]) => !removed.has(id))
1691
- .sort((a, b) => a[0].localeCompare(b[0]))
1730
+ .sort((a, b) => byCodepoint(a[0], b[0]))
1692
1731
  .map(([id, environments]) => {
1693
1732
  const row = byId.get(id);
1694
1733
  return {