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

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 (72) 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 +4 -4
  18. package/src/adapters/corpus/child-seed.mjs +74 -0
  19. package/src/adapters/corpus/conceptnet.mjs +45 -26
  20. package/src/adapters/memory/blocks.mjs +7 -1
  21. package/src/adapters/memory/core.mjs +453 -103
  22. package/src/adapters/memory/corpus-bands.mjs +33 -10
  23. package/src/adapters/memory/inspect.mjs +24 -5
  24. package/src/adapters/memory/rows.mjs +106 -9
  25. package/src/adapters/memory/shacl.mjs +10 -3
  26. package/src/domain/ask.mjs +27 -10
  27. package/src/domain/cli-verbs.mjs +3 -4
  28. package/src/domain/completions/group.mjs +8 -3
  29. package/src/domain/completions/infer.mjs +7 -2
  30. package/src/domain/completions/prune.mjs +5 -1
  31. package/src/domain/completions/rank.mjs +7 -2
  32. package/src/domain/digest/compose.mjs +5 -1
  33. package/src/domain/digest/select.mjs +12 -6
  34. package/src/domain/domain.mjs +15 -8
  35. package/src/domain/el-classify.mjs +11 -2
  36. package/src/domain/fact-phrase.mjs +86 -4
  37. package/src/domain/hash.mjs +9 -0
  38. package/src/domain/memory/bias.mjs +8 -4
  39. package/src/domain/memory/capability.mjs +12 -6
  40. package/src/domain/memory/fact-order.mjs +29 -0
  41. package/src/domain/memory/resolution.mjs +3 -0
  42. package/src/domain/news-feed.mjs +422 -56
  43. package/src/domain/reference-pack.mjs +5 -0
  44. package/src/domain/sense-scope.mjs +116 -0
  45. package/src/domain/sense-split.mjs +1 -1
  46. package/src/domain/syllogise.mjs +21 -13
  47. package/src/domain/tableau.mjs +23 -14
  48. package/src/domain/worlds-pack.mjs +5 -1
  49. package/src/services/adventure-autoplay.mjs +6 -1
  50. package/src/services/adventure-editor.mjs +43 -21
  51. package/src/services/adventure-viz.mjs +26 -9
  52. package/src/services/adventure.mjs +40 -10
  53. package/src/services/chat.mjs +253 -113
  54. package/src/services/extensions.mjs +51 -58
  55. package/src/services/extract-facts.mjs +670 -95
  56. package/src/services/init.mjs +4 -4
  57. package/src/services/ledger-viz.mjs +9 -4
  58. package/src/services/memory-panel-viz.mjs +4 -5
  59. package/src/services/mud-editor.mjs +40 -16
  60. package/src/services/mud-viz.mjs +8 -2
  61. package/src/services/mudiii-turn.mjs +5 -3
  62. package/src/services/mudiii-viz.mjs +8 -2
  63. package/src/services/news.mjs +277 -11
  64. package/src/services/research-viz.mjs +1 -1
  65. package/src/services/sprite-catalog-viz.mjs +10 -5
  66. package/src/surfaces/web/adventure-browser-entry.mjs +6 -12
  67. package/src/surfaces/web/memory-ask-browser.bundle.js +152 -151
  68. package/src/surfaces/web/mud-browser-entry.mjs +7 -11
  69. package/src/surfaces/web/research-browser-entry.mjs +5 -2
  70. package/corpus/tier2/aws.jsonl +0 -39
  71. package/corpus/tier2/java.jsonl +0 -31
  72. 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,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
  }
@@ -32,6 +32,14 @@
32
32
  import { normFactTerm, factIdForTriple } from "./hash.mjs";
33
33
  import { buildSenseGate } from "./sense-gate.mjs";
34
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);
42
+
35
43
  /** The two persisting entry points (syllogise, retractSubClassOf) take the
36
44
  * memory store's read/write functions through a required `store` option —
37
45
  * this module never imports the store. A missing function is a loud
@@ -168,7 +176,7 @@ export function deriveSubClassClosure(edges, { depth = 32, budget = 50, focus =
168
176
  }
169
177
  }
170
178
  if (!additions.length) break; // fixpoint reached
171
- 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]));
172
180
  let progressed = false;
173
181
  for (const [a, b, c, key] of additions) {
174
182
  if (derivedKeys.has(key)) continue; // an earlier addition this round covered it
@@ -237,7 +245,7 @@ export function deriveSubClassClosureDelta(allEdges, deltaEdges, { depth = 32, b
237
245
  for (const z of pred.get(a) || []) consider(z, a, b); // R∘Δ
238
246
  }
239
247
  if (!additions.length) break; // fixpoint reached
240
- 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]));
241
249
  let progressed = false;
242
250
  const nextDelta = [];
243
251
  for (const [a, b, c, key] of additions) {
@@ -380,7 +388,7 @@ export function deriveTypePropagation(typeEdges, subClassEdges, { budget = 50, f
380
388
  candidates.push([x, c, d, key]);
381
389
  }
382
390
  }
383
- 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]));
384
392
  const derived = [];
385
393
  const derivedKeys = new Set();
386
394
  for (const [x, c, d, key] of candidates) {
@@ -446,7 +454,7 @@ export function deriveDisjointViolations(typeEdges, subClassEdges, disjointEdges
446
454
  }
447
455
  }
448
456
  }
449
- 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]));
450
458
  const derived = [];
451
459
  const derivedKeys = new Set();
452
460
  for (const [x, c, d, e, key] of candidates) {
@@ -526,7 +534,7 @@ export function deriveSomeValuesFromApplication(propertyEdges, typeEdges, subCla
526
534
  }
527
535
  }
528
536
  }
529
- 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]));
530
538
  const derived = [];
531
539
  const derivedKeys = new Set();
532
540
  for (const [x, p, pKey, y, c, target, r, key] of candidates) {
@@ -575,7 +583,7 @@ export function buildCardinalityRestrictions(rows) {
575
583
  if (!onClass) continue;
576
584
  restrictions.push({ restriction, kind, n, onClass });
577
585
  }
578
- restrictions.sort((a, b) => a.restriction.localeCompare(b.restriction));
586
+ restrictions.sort((a, b) => byCodepoint(a.restriction, b.restriction));
579
587
  return restrictions;
580
588
  }
581
589
 
@@ -627,7 +635,7 @@ export function deriveSomeValuesFromSubsumption(restrictionEdges, subClassEdges,
627
635
  }
628
636
  }
629
637
  }
630
- 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]));
631
639
  const derived = [];
632
640
  const derivedKeys = new Set();
633
641
  for (const [c1, c2, y1, y2, key] of candidates) {
@@ -795,7 +803,7 @@ export function findConsistencyViolations(typeEdges, subClassEdges, disjointEdge
795
803
  }
796
804
  }
797
805
  }
798
- 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]));
799
807
  const derived = [];
800
808
  for (const [x, ta, tb, viaA, viaB] of candidates) {
801
809
  if (derived.length >= budget) break;
@@ -1146,7 +1154,7 @@ export async function syllogise(repoDir, {
1146
1154
  environments: environmentsOf(r), provenance: r.provenance,
1147
1155
  }));
1148
1156
  const alternateCandidates = [...conclusionCandidates, ...storedCandidates]
1149
- .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));
1150
1158
  let environmentsAdded = 0;
1151
1159
  let alternatesTruncated = false;
1152
1160
  let examined = 0;
@@ -1430,7 +1438,7 @@ function buildSupportEnumerator(rows) {
1430
1438
  if (rec) {
1431
1439
  const edges = [...(propertyEdgesOf.get(row.subject) || [])]
1432
1440
  .filter(([p]) => normFactTerm(p) === rec.propertyKey)
1433
- .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]));
1434
1442
  for (const [p, y] of edges) {
1435
1443
  if (out.length >= maxEnvironments) break;
1436
1444
  for (const c of [...(typesOf.get(y) || [])].sort()) {
@@ -1457,7 +1465,7 @@ function buildSupportEnumerator(rows) {
1457
1465
  if ((disjointOf.get(d) || new Set()).has(row.object)) pairs.push([c, d]);
1458
1466
  }
1459
1467
  }
1460
- 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]));
1461
1469
  for (const [c, d] of pairs) {
1462
1470
  if (out.length >= maxEnvironments) break;
1463
1471
  const dwStoredForward = disjointForward.has(`${d}${SEP}${row.object}`);
@@ -1653,7 +1661,7 @@ export async function retractSubClassOf(repoDir, subject, object, {
1653
1661
  }
1654
1662
  }
1655
1663
  const candidates = [...candidateIds].map((id) => byId.get(id))
1656
- .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));
1657
1665
  if (!candidates.length) break; // fixpoint — nothing cites what just fell
1658
1666
 
1659
1667
  // The surviving fact set for THIS round excludes every candidate's own
@@ -1719,7 +1727,7 @@ export async function retractSubClassOf(repoDir, subject, object, {
1719
1727
  if (appendFactsFn) {
1720
1728
  const regroundWrites = [...reground.entries()]
1721
1729
  .filter(([id]) => !removed.has(id))
1722
- .sort((a, b) => a[0].localeCompare(b[0]))
1730
+ .sort((a, b) => byCodepoint(a[0], b[0]))
1723
1731
  .map(([id, environments]) => {
1724
1732
  const row = byId.get(id);
1725
1733
  return {
@@ -40,6 +40,15 @@
40
40
 
41
41
  import { normFactTerm } from "./hash.mjs";
42
42
 
43
+ /** Codepoint order, never localeCompare. Every string sorted in this file is
44
+ * built from stored fact terms, and the sorts decide the ORDER RULES FIRE:
45
+ * which disjunct the or-rule branches on first, which axioms internalize
46
+ * first, which role assertions survive MAX_ROLE_ASSERTIONS. Under the step,
47
+ * branch and node ceilings a different firing order is a different verdict,
48
+ * so a locale-sensitive compare would let two machines read one KB and
49
+ * disagree about whether it is satisfiable. */
50
+ const byCodepoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
51
+
43
52
  // ---- concept-expression AST --------------------------------------------
44
53
 
45
54
  const atom = (name) => ({ t: "atom", name });
@@ -86,7 +95,7 @@ function pushNegation(expr, negate) {
86
95
  case "or": {
87
96
  const flip = expr.t === "and" ? "or" : "and";
88
97
  const cs = expr.cs.map((c) => pushNegation(c, negate));
89
- cs.sort((a, b) => canonicalKey(a).localeCompare(canonicalKey(b)));
98
+ cs.sort((a, b) => byCodepoint(canonicalKey(a), canonicalKey(b)));
90
99
  return { t: negate ? flip : expr.t, cs };
91
100
  }
92
101
  case "some":
@@ -428,7 +437,7 @@ function addLabel(node, expr, from) {
428
437
  }
429
438
 
430
439
  function sortedLabelEntries(node) {
431
- return [...node.labels.entries()].sort((a, b) => a[0].localeCompare(b[0]));
440
+ return [...node.labels.entries()].sort((a, b) => byCodepoint(a[0], b[0]));
432
441
  }
433
442
 
434
443
  /** A node clashes when its label set holds bottom, or holds both an
@@ -649,7 +658,7 @@ function applyOrRule(branch, kb) {
649
658
  for (const [key, { expr, from }] of sortedLabelEntries(node)) {
650
659
  if (expr.t !== "or") continue;
651
660
  if (node.branchedOn.has(key)) continue;
652
- const cs = [...expr.cs].sort((a, b) => canonicalKey(a).localeCompare(canonicalKey(b)));
661
+ const cs = [...expr.cs].sort((a, b) => byCodepoint(canonicalKey(a), canonicalKey(b)));
653
662
  if (cs.some((c) => node.labels.has(canonicalKey(c)))) {
654
663
  node.branchedOn.add(key);
655
664
  continue;
@@ -840,7 +849,7 @@ function serializeBranch(branch) {
840
849
  .map((node) => ({
841
850
  id: node.id,
842
851
  parent: node.parent,
843
- labels: [...node.labels.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([, { expr, from }]) => ({ expr, from })),
852
+ labels: [...node.labels.entries()].sort((a, b) => byCodepoint(a[0], b[0])).map(([, { expr, from }]) => ({ expr, from })),
844
853
  }));
845
854
  const edges = branch.edges.map(({ from, r, to, fromFacts }) => ({ from, r, to, fromFacts: fromFacts.slice() }));
846
855
  return { nodes, edges };
@@ -1147,7 +1156,7 @@ export function buildTableauKb(rows) {
1147
1156
  // truncation is deterministic rather than arrival-ordered.
1148
1157
  const roleAssertions = roleAssertionCandidates
1149
1158
  .filter((r) => isIndividualTerm(r.subject) && isIndividualTerm(r.object))
1150
- .sort((a, b) => a.subject.localeCompare(b.subject) || a.predicate.localeCompare(b.predicate) || a.object.localeCompare(b.object))
1159
+ .sort((a, b) => byCodepoint(a.subject, b.subject) || byCodepoint(a.predicate, b.predicate) || byCodepoint(a.object, b.object))
1151
1160
  .slice(0, MAX_ROLE_ASSERTIONS)
1152
1161
  .map((r) => ({ a: r.subject, r: r.predicate, b: r.object, from: [r.id] }));
1153
1162
 
@@ -1203,7 +1212,7 @@ export function buildTableauKb(rows) {
1203
1212
  for (const r of negTypeRows) assertions.push({ ind: r.subject, expr: toNNF(notE(atom(r.object))), from: [r.id] });
1204
1213
 
1205
1214
  for (const [unionId, memberRows] of unionMembersOf) {
1206
- const sorted = [...memberRows].sort((a, b) => String(a.object).localeCompare(String(b.object)));
1215
+ const sorted = [...memberRows].sort((a, b) => byCodepoint(String(a.object), String(b.object)));
1207
1216
  const cs = sorted.map((mr) => atom(mr.object));
1208
1217
  const ids = sorted.map((mr) => mr.id);
1209
1218
  const orExpr = toNNF(orE(cs));
@@ -1225,7 +1234,7 @@ export function buildTableauKb(rows) {
1225
1234
  // merge rule has a real carrier of the nominal to merge an outsider into.
1226
1235
  const nominalIndividuals = new Map(); // ind -> fact ids that declared it
1227
1236
  for (const [classId, memberRows] of oneOfMembersOf) {
1228
- const sorted = [...memberRows].sort((a, b) => String(a.object).localeCompare(String(b.object)));
1237
+ const sorted = [...memberRows].sort((a, b) => byCodepoint(String(a.object), String(b.object)));
1229
1238
  const nomExprs = sorted.map((mr) => ({ t: "nom", ind: mr.object }));
1230
1239
  const ids = sorted.map((mr) => mr.id);
1231
1240
  axioms.push(mkAxiom(atom(classId), toNNF(orE(nomExprs)), ids));
@@ -1238,7 +1247,7 @@ export function buildTableauKb(rows) {
1238
1247
 
1239
1248
  const differentFrom = differentFromRows
1240
1249
  .map((r) => ({ a: r.subject, b: r.object, from: [r.id] }))
1241
- .sort((a, b) => a.a.localeCompare(b.a) || a.b.localeCompare(b.b));
1250
+ .sort((a, b) => byCodepoint(a.a, b.a) || byCodepoint(a.b, b.b));
1242
1251
 
1243
1252
  // owl:inverseOf is stored symmetrically by the grammar (both directions
1244
1253
  // minted), but this reads either direction alone too, defensively.
@@ -1254,15 +1263,15 @@ export function buildTableauKb(rows) {
1254
1263
 
1255
1264
  const subPropertyEdges = subPropertyRows
1256
1265
  .map((r) => [r.subject, r.object])
1257
- .sort((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1]));
1266
+ .sort((a, b) => byCodepoint(a[0], b[0]) || byCodepoint(a[1], b[1]));
1258
1267
  const roleNames = new Set([...roles, ...subPropertyEdges.flat(), ...inverseOf.keys(), ...inverseOf.values()]);
1259
1268
  const roleClosure = buildRoleClosure(subPropertyEdges, roleNames);
1260
1269
 
1261
1270
  axioms.sort((a, b) =>
1262
- canonicalKey(a.sub).localeCompare(canonicalKey(b.sub)) ||
1263
- canonicalKey(a.sup).localeCompare(canonicalKey(b.sup)) ||
1264
- a.from.join(",").localeCompare(b.from.join(",")));
1265
- assertions.sort((a, b) => a.ind.localeCompare(b.ind) || canonicalKey(a.expr).localeCompare(canonicalKey(b.expr)));
1271
+ byCodepoint(canonicalKey(a.sub), canonicalKey(b.sub)) ||
1272
+ byCodepoint(canonicalKey(a.sup), canonicalKey(b.sup)) ||
1273
+ byCodepoint(a.from.join(","), b.from.join(",")));
1274
+ assertions.sort((a, b) => byCodepoint(a.ind, b.ind) || byCodepoint(canonicalKey(a.expr), canonicalKey(b.expr)));
1266
1275
 
1267
1276
  const individuals = [...new Set([
1268
1277
  ...assertions.map((a) => a.ind),
@@ -1318,7 +1327,7 @@ export function findTableauViolations(kb, subjects = null, opts = {}) {
1318
1327
  const premises = sortedUnique(result.closedClashes.flatMap((c) => c?.premises || []));
1319
1328
  violations.push({ subject, premises, kind: describeClashKind(result.closedClashes[0]) });
1320
1329
  }
1321
- violations.sort((a, b) => a.subject.localeCompare(b.subject));
1330
+ violations.sort((a, b) => byCodepoint(a.subject, b.subject));
1322
1331
  return violations;
1323
1332
  }
1324
1333
 
@@ -72,6 +72,10 @@ export function worldProvenanceTag(worldName) {
72
72
 
73
73
  const DEFAULT_CONTAINS_PREDICATE = "mgx:default-contains";
74
74
 
75
+ // Codepoint order, never localeCompare — two readers loading the same world
76
+ // facts on different locales must mint the same instance ids.
77
+ const byCodepoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
78
+
75
79
  /** Materialize a world's class-default contents: for every
76
80
  * `<room> mgx:default-contains <class>` fact, mint one placed, portable
77
81
  * instance of that class in that room unless the room already holds one. A
@@ -87,7 +91,7 @@ export function expandWorldDefaultContents(facts) {
87
91
  const rows = facts || [];
88
92
  const defaults = rows
89
93
  .filter((r) => r.predicate === DEFAULT_CONTAINS_PREDICATE)
90
- .sort((a, b) => `${a.subject}\0${a.object}`.localeCompare(`${b.subject}\0${b.object}`));
94
+ .sort((a, b) => byCodepoint(`${a.subject}\0${a.object}`, `${b.subject}\0${b.object}`));
91
95
  if (!defaults.length) return rows;
92
96
 
93
97
  const instanceIds = new Set(rows.filter((r) => r.predicate === "rdf:type").map((r) => r.subject));
@@ -33,6 +33,11 @@ import { foldWorldState, adventureTurn, worldActionRows } from "./adventure.mjs"
33
33
  const isTypedRow = (rows, subject, type) =>
34
34
  (rows || []).some((r) => r.subject === subject && r.predicate === "rdf:type" && r.object === type);
35
35
 
36
+ // Codepoint order, never localeCompare — an exit direction traces back to a
37
+ // world fact, and two readers must land on the same explore order regardless
38
+ // of locale.
39
+ const byCodepoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
40
+
36
41
  /** The room a subject's CURRENT placement resolves into — a room resolves to
37
42
  * itself; an object placed directly in a room resolves to that room; an
38
43
  * object one containment hop inside an OPEN container resolves to the
@@ -294,7 +299,7 @@ export async function runAdventureAutoplayTick(memoryDir, opts = {}) {
294
299
  // isn't known yet. Prefer an immediate unexposed exit from here (the
295
300
  // lowest-sorted direction); otherwise path toward the nearest exposed room
296
301
  // that still has one.
297
- const unexposedHere = unexposedExitsOf(here, exposedState, exposed).sort(([a], [b]) => a.localeCompare(b));
302
+ const unexposedHere = unexposedExitsOf(here, exposedState, exposed).sort(([a], [b]) => byCodepoint(a, b));
298
303
  if (unexposedHere.length) {
299
304
  const [direction, target] = unexposedHere[0];
300
305
  await runCommand(`go ${direction}`);
@@ -14,24 +14,29 @@
14
14
  // mgx:acts-toward, mgx:is-objective) — an editor has to show and change
15
15
  // exactly the facts a player is never told.
16
16
  //
17
- // No imports of its own logic: every export here is .toString()-splice-safe,
18
- // the same discipline adventure-viz.mjs's own render-glue functions hold (see
19
- // that module's header) this module's functions get spliced directly into
20
- // the adventure page's inline script the same way. The one exception,
21
- // wordBeforeCursor, re-exports viz-theme.mjs's own shared copy (byte-identical
17
+ // The two exports the adventure page splices into its inline script through
18
+ // `.toString()` renderWorldEditorText and wordBeforeCursor are entirely
19
+ // self-contained, the same discipline adventure-viz.mjs's own render-glue
20
+ // functions hold (see that module's header): a splice captures a function's
21
+ // own source text and none of its closure over sibling module bindings.
22
+ // wordBeforeCursor re-exports viz-theme.mjs's own shared copy (byte-identical
22
23
  // to what used to live here, and to mud-editor.mjs's own copy) rather than
23
24
  // keep a third copy of the same regex.
24
25
  //
25
26
  // Two predicate families get different sync strategies, on purpose:
26
27
  // - PLACEMENT/OPENNESS (mgx:currently-in/located-in/fixed-in/stands-
27
28
  // locked-in/hidden-in, mgx:is-open) are fold-versioned: foldWorldState
28
- // already treats the newest write as the current truth (adventure.mjs's
29
- // own "turn >= prior.turn" rule the same mechanism every in-game
30
- // action's commit() already writes through). Editing one of these facts
31
- // is handled as a plain new write superseding the old one, never a
29
+ // ranks these rows by the (epoch, turn) pair stamped on the subject, and
30
+ // reads the highest-ranked one as the current truth. Editing one of these
31
+ // facts is handled as a plain new write superseding the old one, never a
32
32
  // retraction, so planWorldEditorSync can never touch memory/core.mjs's
33
33
  // own removeFacts for this family, and can never race the fold logic the
34
- // rest of the engine depends on. One consequence, stated plainly: this
34
+ // rest of the engine depends on. The superseding write is stamped one
35
+ // turn past the world's own turn count, exactly the way every in-game
36
+ // action's commit() writes, so it OUTRANKS what it replaces instead of
37
+ // merely arriving after it — at equal turn the fold takes whichever row
38
+ // comes later in the array, and array order is nobody's to promise once
39
+ // a peer's facts have merged in. One consequence, stated plainly: this
35
40
  // editor can move an object or reopen/close a container, but it cannot
36
41
  // make a placed object vanish outright — the append-only truth model has
37
42
  // no way to write "nowhere" — a scope choice, not a defect.
@@ -47,6 +52,8 @@
47
52
  // document parses cleanly again. Additions are never gated this way —
48
53
  // they are non-destructive by construction.
49
54
 
55
+ import { snapshotSubject } from "../domain/world-snapshot.mjs";
56
+
50
57
  const PLACEMENT_KIND = "placement";
51
58
  const OPENNESS_KIND = "openness";
52
59
  const OTHER_KIND = "other";
@@ -308,20 +315,29 @@ export function editableOtherRows(rows) {
308
315
 
309
316
  /** Plan the fact-store writes one parsed edit implies, from already-parsed
310
317
  * `triples` (parseWorldEditorText's own output) against the world's current
311
- * `rows`/`state`. Returns `{ toAppend, toRemoveIds }` — pure, no I/O.
318
+ * `rows`/`state`. Returns `{ toAppend, toRemoveIds, editTurn }` — pure, no
319
+ * I/O. `toAppend` rows are store-ready: a fold-versioned one already carries
320
+ * its `subject@turnN` stamp, and `editTurn` is the turn every one of them
321
+ * was stamped at, for the caller's own provenance tag.
312
322
  *
313
323
  * Placement/openness triples are NEVER retracted (see this module's own
314
324
  * header): a triple only joins `toAppend` when it actually differs from the
315
325
  * subject's current folded value (or the subject has none yet) — otherwise
316
326
  * re-asserting an unchanged line would append a no-op duplicate on every
317
- * keystroke.
327
+ * keystroke. Each one is stamped one turn past the world's own turn count,
328
+ * which is what makes it outrank the placement it supersedes rather than
329
+ * tie with it and win on array position.
318
330
  *
319
- * "Other"-family triples (type/exits/container/puzzle) get a real add/
320
- * remove diff against `editableOtherRows(rows)` but the CALLER decides
321
- * whether `toRemoveIds` is safe to apply (skip it whenever
322
- * parseWorldEditorText reported any unrecognized line see this module's
323
- * header for why). */
331
+ * "Other"-family triples (type/exits/container/puzzle) keep their bare
332
+ * subject every reader takes those raw, and a stamped one names a subject
333
+ * no verb resolves. They get a real add/remove diff against
334
+ * `editableOtherRows(rows)` but the CALLER decides whether `toRemoveIds`
335
+ * is safe to apply (skip it whenever parseWorldEditorText reported any
336
+ * unrecognized line — see this module's header for why). */
324
337
  export function planWorldEditorSync(rows, state, triples) {
338
+ const editTurn = (state?.turnCount ?? 0) + 1;
339
+ const editEpoch = state?.epoch ?? 0;
340
+ const stampedForFold = (t) => ({ ...t, subject: snapshotSubject(t.subject, editTurn, editEpoch) });
325
341
  const toAppend = [];
326
342
  const seenPlacementSubjects = new Set();
327
343
  const otherTriples = [];
@@ -334,11 +350,11 @@ export function planWorldEditorSync(rows, state, triples) {
334
350
  seenPlacementSubjects.add(t.subject);
335
351
  if (t.kind === PLACEMENT_KIND) {
336
352
  const current = state.placements?.get(t.subject);
337
- if (!current || current.predicate !== t.predicate || current.object !== t.object) toAppend.push(t);
353
+ if (!current || current.predicate !== t.predicate || current.object !== t.object) toAppend.push(stampedForFold(t));
338
354
  } else if (t.kind === OPENNESS_KIND) {
339
355
  const current = state.openness?.get(t.subject);
340
356
  const wantOpen = t.object === "true";
341
- if (!current || current.open !== wantOpen) toAppend.push(t);
357
+ if (!current || current.open !== wantOpen) toAppend.push(stampedForFold(t));
342
358
  }
343
359
  }
344
360
 
@@ -355,7 +371,7 @@ export function planWorldEditorSync(rows, state, triples) {
355
371
  for (const [key, id] of currentKeys) {
356
372
  if (!newOtherKeys.has(key)) toRemoveIds.push(id);
357
373
  }
358
- return { toAppend, toRemoveIds };
374
+ return { toAppend, toRemoveIds, editTurn };
359
375
  }
360
376
 
361
377
  /** The additive half of planWorldEditorSync, for ONE already-parsed triple:
@@ -364,7 +380,13 @@ export function planWorldEditorSync(rows, state, triples) {
364
380
  * one sentence only ever says what it says, so nothing it leaves out is
365
381
  * evidence of anything. Re-asserting a fact the world already holds appends
366
382
  * nothing — `reason` says which of the two happened, in the caller's own
367
- * words. Pure. */
383
+ * words. Pure.
384
+ *
385
+ * The triple comes back with its bare subject, unlike planWorldEditorSync's
386
+ * rows: a taught sentence can MINT the thing it talks about, and the caller
387
+ * picks the fresh id, so only the caller knows which subject the fold stamp
388
+ * belongs on. world-teach.mjs stamps a fold-versioned one at
389
+ * `turnCount + 1` there, for the same reason planWorldEditorSync does. */
368
390
  export function planTaughtTriple(rows, state, triple) {
369
391
  if (!triple?.subject || !triple?.object) return { toAppend: [], reason: "nothing parsed" };
370
392
  if (triple.kind === PLACEMENT_KIND) {
@@ -114,16 +114,26 @@ const TICK_WAIT_MS = 900;
114
114
  * (a distinct icon from plain furniture, since Ashcombe's own cabinet and
115
115
  * portrait are typed "furniture" but read more clearly as a container on
116
116
  * screen), else its own rdf:type object, else the generic "portable"
117
- * fallback for anything a world places with no type fact at all. Pure,
118
- * self-contained no reference to adventure.mjs's own private isContainer/
119
- * isTyped, since those aren't exported. */
117
+ * fallback for anything a world places with no type fact at all. A room
118
+ * always wins when it's one of several rdf:type facts on one subject: every
119
+ * shipped world's outdoor/underground rooms carry `rdf:type room` PLUS a
120
+ * second `rdf:type outdoor-space`/`underground-space` fact purely as a
121
+ * border-paint marker for roomKindForRoom below, and that marker has no
122
+ * sprite of its own, so picking it as the class would fall through to the
123
+ * generic "portable" icon for something that is actually a room. Preferring
124
+ * "room" keeps the answer the same regardless of which of the two facts a
125
+ * store happens to list first. Pure, self-contained — no reference to
126
+ * adventure.mjs's own private isContainer/isTyped, since those aren't
127
+ * exported. */
120
128
  export function spriteClassForObject(rows, subject) {
121
129
  const isContainer = (rows || []).some(
122
130
  (r) => r.subject === subject && r.predicate === "mgx:is-container" && r.object === "true",
123
131
  );
124
132
  if (isContainer) return "container";
125
- const typeRow = (rows || []).find((r) => r.subject === subject && r.predicate === "rdf:type");
126
- return typeRow ? typeRow.object : "portable";
133
+ const typeRows = (rows || []).filter((r) => r.subject === subject && r.predicate === "rdf:type");
134
+ if (!typeRows.length) return "portable";
135
+ const room = typeRows.find((r) => r.object === "room");
136
+ return (room || typeRows[0]).object;
127
137
  }
128
138
 
129
139
  /** `rows` plus synthetic rdfs:subClassOf edges covering `subject`'s whole
@@ -351,9 +361,13 @@ export function roomSceneLayout(rows, state, here) {
351
361
  bases.push(o.subject);
352
362
  }
353
363
 
354
- wall.sort((a, b) => a.subject.localeCompare(b.subject));
355
- bases.sort((a, b) => a.localeCompare(b));
356
- for (const list of stackedOnBy.values()) list.sort((a, b) => a.localeCompare(b));
364
+ // Codepoint order, never localeCompare — inlined, not a module-level
365
+ // helper, because this function is spliced verbatim into the page's own
366
+ // inline script and can carry no outer-scope reference with it.
367
+ const byCodepoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
368
+ wall.sort((a, b) => byCodepoint(a.subject, b.subject));
369
+ bases.sort(byCodepoint);
370
+ for (const list of stackedOnBy.values()) list.sort(byCodepoint);
357
371
 
358
372
  const visitedForStack = new Set();
359
373
  function stackFrom(base) {
@@ -390,7 +404,10 @@ export function carriedItems(rows, state, holder = "player") {
390
404
  return [...state.placements]
391
405
  .filter(([, p]) => p.predicate === "mgx:located-in" && p.object === holder)
392
406
  .map(([subject]) => ({ subject, spriteClass: spriteClassForObject(rows, subject) }))
393
- .sort((a, b) => a.subject.localeCompare(b.subject));
407
+ // Codepoint order, never localeCompare — inlined, not a module-level
408
+ // helper, because this function is spliced verbatim into the page's own
409
+ // inline script and can carry no outer-scope reference with it.
410
+ .sort((a, b) => (a.subject < b.subject ? -1 : a.subject > b.subject ? 1 : 0));
394
411
  }
395
412
 
396
413
  /** A visited-rooms-only map: one node per room `visitedRoomIds` actually