@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
@@ -43,6 +43,23 @@ import { pickPhrase } from "./answer-variants.mjs";
43
43
  export { normalizeQuery, applyNegationFrames };
44
44
  import { defaultNlp } from "./interpret/nlp-registry.mjs";
45
45
 
46
+ /** Codepoint order, never localeCompare. Every string sorted through this is a
47
+ * label, id or attribute value read off the graph. Several of these sorts get
48
+ * read at `[0]` (the newest commit a query substitutes in, the entry-point
49
+ * module a where-defined answer names) or cut to a top-N, so the comparator
50
+ * picks WHICH individual the answer is about. A locale-sensitive compare would
51
+ * let two readers ask one graph the same question and be told about different
52
+ * commits. */
53
+ const byCodepoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
54
+
55
+ /** Commits newest first, ties broken on the commit's own id. The date is
56
+ * ISO-8601, so a codepoint compare IS the date compare, and undated commits
57
+ * sort last. The id tiebreak is what makes `[0]` a pure function of the
58
+ * graph: two commits sharing a date would otherwise be separated by whichever
59
+ * edge happened to be walked first. */
60
+ const byNewestCommit = (dateOf) => (a, b) =>
61
+ byCodepoint(dateOf(b), dateOf(a)) || byCodepoint(String(a.id), String(b.id));
62
+
46
63
  // Per-graph, per-kind memo; a local copy of codegraph.mjs's private
47
64
  // edgesOfKind. Named differently from codegraph.mjs's own cache: the inlined
48
65
  // viewer bundle concatenates a stripped codegraph.mjs + this file into one
@@ -2045,7 +2062,7 @@ function computeFind(graph, entityType, term) {
2045
2062
  * the architecture map prints, so the two surfaces agree. */
2046
2063
  function packageIndividuals(graph) {
2047
2064
  return [...packageCounts(modulesOf(graph)).entries()]
2048
- .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
2065
+ .sort((a, b) => b[1] - a[1] || byCodepoint(a[0], b[0]))
2049
2066
  .map(([dir]) => ({ id: `pkg:${dir}`, label: dir, class: "Package" }));
2050
2067
  }
2051
2068
 
@@ -2364,7 +2381,7 @@ export function degreeMetric(graph, ind, metric) {
2364
2381
  function evalRecentCommits(graph) {
2365
2382
  const commits = graph.individuals.filter((i) => i.class === "Commit");
2366
2383
  const dateOf = (c) => String((c.attributes || []).find((a) => a.key === "date")?.value || "");
2367
- commits.sort((a, b) => dateOf(b).localeCompare(dateOf(a)));
2384
+ commits.sort(byNewestCommit(dateOf));
2368
2385
  return { compositeKind: "recentCommits", matches: commits };
2369
2386
  }
2370
2387
 
@@ -2407,7 +2424,7 @@ function evalCommitFilter(graph, ast) {
2407
2424
  if (op === "after") return d > pivotDate;
2408
2425
  return d === pivotDate; // "on"
2409
2426
  })
2410
- .sort((a, b) => dateOf(b).localeCompare(dateOf(a)) || String(a.id).localeCompare(String(b.id)));
2427
+ .sort(byNewestCommit(dateOf));
2411
2428
  // A kind-headed window reads what the qualifying commits touched at that
2412
2429
  // grain; the bare shape answers with the commits themselves.
2413
2430
  const touched = entityType && entityType !== "Commit" && entityType !== "Change"
@@ -2450,7 +2467,7 @@ function evalTemporal(graph, ast, opts) {
2450
2467
  // reverseOverSet(touches) collects touching commits across both grains.
2451
2468
  const commits = reverseOverSet(graph, "touches", "Commit", ids);
2452
2469
  const dateOf = (c) => String((c.attributes || []).find((a) => a.key === "date")?.value || "");
2453
- commits.sort((a, b) => dateOf(b).localeCompare(dateOf(a)));
2470
+ commits.sort(byNewestCommit(dateOf));
2454
2471
  return { compositeKind: "temporal", matches: commits, entityType: ast.entityType, innerCount: inner.length };
2455
2472
  }
2456
2473
 
@@ -3899,7 +3916,7 @@ function rankEntryPointModules(graph, term) {
3899
3916
  fixture: isTestFixturePath(ind.label) ? 1 : 0,
3900
3917
  }))
3901
3918
  .sort((a, b) => (b.named - a.named) || (a.depth - b.depth) || (a.fixture - b.fixture)
3902
- || String(a.ind.label).localeCompare(String(b.ind.label)))
3919
+ || byCodepoint(String(a.ind.label), String(b.ind.label)))
3903
3920
  .map((x) => x.ind);
3904
3921
  }
3905
3922
 
@@ -3939,7 +3956,7 @@ function subclassClosure(graph, ind, kind) {
3939
3956
  found.set(next.id, next);
3940
3957
  queue.push(...childrenOf(next));
3941
3958
  }
3942
- return [...found.values()].sort((a, b) => String(a.label).localeCompare(String(b.label)));
3959
+ return [...found.values()].sort((a, b) => byCodepoint(String(a.label), String(b.label)));
3943
3960
  }
3944
3961
 
3945
3962
  /** A graph's own vocabulary nodes carry their definition text under one of
@@ -4171,7 +4188,7 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
4171
4188
  const c = graph.byId.get(e.subject);
4172
4189
  if (c && c.class === "Commit") commits.push(c);
4173
4190
  }
4174
- commits.sort((a, b) => dateOf(b).localeCompare(dateOf(a)));
4191
+ commits.sort(byNewestCommit(dateOf));
4175
4192
  }
4176
4193
  // The dated commit this answer named is a discourse `event` referent, so a
4177
4194
  // later "was that before X was touched" binds it (see evalCommitFilter).
@@ -4203,7 +4220,7 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
4203
4220
  const c = graph.byId.get(e.subject);
4204
4221
  if (c && c.class === "Commit") commits.push(c);
4205
4222
  }
4206
- commits.sort((a, b) => dateOf(b).localeCompare(dateOf(a)));
4223
+ commits.sort(byNewestCommit(dateOf));
4207
4224
  // The dated commit behind the "who last touched X" answer is the same
4208
4225
  // `event` referent the when-shape registers, so either phrasing feeds a
4209
4226
  // later temporal comparison. Registered only when the commit carries a date.
@@ -5371,7 +5388,7 @@ function substituteLastCommitPhrase(graph, query) {
5371
5388
  const commits = graph.individuals.filter((i) => i.class === "Commit");
5372
5389
  if (!commits.length) return q;
5373
5390
  const dateOf = (c) => String((c.attributes || []).find((a) => a.key === "date")?.value || "");
5374
- const newest = [...commits].sort((a, b) => dateOf(b).localeCompare(dateOf(a)))[0];
5391
+ const newest = [...commits].sort(byNewestCommit(dateOf))[0];
5375
5392
  if (!newest) return q;
5376
5393
  const out = q.replace(LAST_COMMIT_PHRASE_RE, `commit ${newest.label}`);
5377
5394
  const bareTrimmed = out.trim().replace(/[?.!]+$/, "");
@@ -5559,7 +5576,7 @@ function evalWorldRelation(graph, ast) {
5559
5576
  // never placed outright.
5560
5577
  const pairs = [...stated.values(), ...[...taught.values()].filter((p) => !stated.has(p.subject.id))]
5561
5578
  .sort((a, b) => (
5562
- String(a.subject.id).localeCompare(String(b.subject.id)) || String(a.object).localeCompare(String(b.object))
5579
+ byCodepoint(String(a.subject.id), String(b.subject.id)) || byCodepoint(String(a.object), String(b.object))
5563
5580
  ));
5564
5581
  return {
5565
5582
  compositeKind: "worldRelation",
@@ -58,12 +58,11 @@ export const CLI_VERBS = [
58
58
  prose: ["initialize a repo for tmct (default: cwd): .tmct/,"],
59
59
  flags: [
60
60
  { flag: "[--force]", prose: ["tmct.toml, .tmct/TOOLS.md (the cold-tool catalog),", "tier-1 corpus seed, provenance record"] },
61
- { flag: "[--corpus <id|path>]", prose: ["also seed a corpus — a tier-2 manifest id (aws|python|java|", "general) or a jsonl file path — opt-in, offline, $0"] },
61
+ { flag: "[--corpus <id|path>]", prose: ["also seed a corpus — a bundle name (code|conceptnet|child|", "namenet|general) or a jsonl file path — opt-in, offline, $0"] },
62
62
  { flag: "[--ontology <name|path>]", prose: ["activate+seed an ontology bundle (a recognized name or a path)"] },
63
63
  { flag: "[--lexicon <name|path>]", prose: ["activate a lexicon bundle (recognized name or a path;", "merged read-time, never seeded — see mergedLexiconExtra)"] },
64
64
  { flag: "[--graph <path>]", prose: ["set graph_file/graph_files in tmct.toml (repeatable)"] },
65
65
  { flag: "[--config <path>]", prose: ["write to an alternate tmct.toml location"] },
66
- { flag: "[--detect]", prose: ["suggest a tier-2 corpus from the repo's manifests", "(pyproject.toml → python, pom.xml → java); never seeds unasked"] },
67
66
  { flag: "[--with-persona <name>]", prose: ["write an explicit [extensions]/[bias] preset into tmct.toml", "(\"code\" — today's implicit default, made explicit)"] },
68
67
  { flag: "[--persona-size <medium|large>]", prose: ["grow the default \"human\" persona's fact count", "beyond Small (the default): \"medium\" activates", "human-medium.jsonl (~1,608 facts total), \"large\" also", "activates human-large.jsonl (~13,600 facts total,", "with genuine multi-hop hypernym chains) — additive", "size tiers of the SAME bundle, not separate personas"] },
69
68
  { flag: "[--memory-backend <default|memory|sqlite>]", prose: ["write tmct.toml's [memory] backend", "(same flag name as `tmct chat`) — a later `tmct chat`", "in this repo picks it up with no flag needed"] },
@@ -216,9 +215,9 @@ export const CLI_VERBS = [
216
215
  mode: "corpus",
217
216
  errorLabel: "corpus load",
218
217
  usage: "tmct corpus load <band> [--table <name>] [--source <path>] [--dry-run]",
219
- prose: ["load a shared, read-only corpus band (wikidata-slice, wordnet-complete,"],
218
+ prose: ["load a shared, read-only corpus band (wordnet-complete, or a"],
220
219
  flags: [
221
- { flag: "[--table <name>]", prose: ["conceptnet-full) into a DynamoDB row-backend table from a jsonl of", "wire-row-shaped facts (default table from TMCT_DYNAMO_TABLE); a", "source whose digest already matches the band's manifest is a no-op"] },
220
+ { flag: "[--table <name>]", prose: ["consumer's own) into a DynamoDB row-backend table from a jsonl of", "wire-row-shaped facts (default table from TMCT_DYNAMO_TABLE); a", "source whose digest already matches the band's manifest is a no-op"] },
222
221
  { flag: "[--source <path>]", prose: ["the band's jsonl (a scripts/corpus-bands/ build output, or any jsonl", "in the same wire-row shape)"] },
223
222
  { flag: "[--dry-run]", prose: ["report the row count and source digest without writing anything"] },
224
223
  ],
@@ -9,6 +9,11 @@ import { requireInjected } from "./injected.mjs";
9
9
 
10
10
  const LABEL_TOKEN_COUNT = 5;
11
11
 
12
+ // Codepoint order, never localeCompare — hit ids and content tokens trace
13
+ // back to the memory store, and two readers must land on the same order
14
+ // regardless of locale.
15
+ const byCodepoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
16
+
12
17
  /** Plain union-find (path halving, union-by-index) — small N here (a single broad search's
13
18
  * hit count). */
14
19
  function unionFind(n) {
@@ -83,7 +88,7 @@ export function groupHits(hits, { overlapMin, store } = {}) {
83
88
  for (const memberIdx of componentIdx.values()) {
84
89
  const members = memberIdx
85
90
  .map((i) => byId.get(ids[i]))
86
- .sort((a, b) => a.id.localeCompare(b.id));
91
+ .sort((a, b) => byCodepoint(a.id, b.id));
87
92
  const memberIds = members.map((m) => m.id);
88
93
 
89
94
  // label tokens: rank by member coverage, then IDF, then token text (deterministic).
@@ -92,7 +97,7 @@ export function groupHits(hits, { overlapMin, store } = {}) {
92
97
  for (const t of new Set(tokensById[ids[i]])) coverage.set(t, (coverage.get(t) || 0) + 1);
93
98
  }
94
99
  const tokens = [...coverage.keys()]
95
- .sort((a, b) => (coverage.get(b) - coverage.get(a)) || (idf(b) - idf(a)) || a.localeCompare(b))
100
+ .sort((a, b) => (coverage.get(b) - coverage.get(a)) || (idf(b) - idf(a)) || byCodepoint(a, b))
96
101
  .slice(0, LABEL_TOKEN_COUNT);
97
102
 
98
103
  groups.push({
@@ -104,6 +109,6 @@ export function groupHits(hits, { overlapMin, store } = {}) {
104
109
  });
105
110
  }
106
111
 
107
- groups.sort((a, b) => a.memberIds[0].localeCompare(b.memberIds[0]));
112
+ groups.sort((a, b) => byCodepoint(a.memberIds[0], b.memberIds[0]));
108
113
  return groups;
109
114
  }
@@ -16,6 +16,11 @@ import { requireInjected } from "./injected.mjs";
16
16
  // supply their own copy in the `helpers` bag resolveRelationChase expects.
17
17
  const HAS_PROPERTY_PREDICATE = "mgx:hasProperty";
18
18
 
19
+ // Codepoint order, never localeCompare — group ids trace back to memory-store
20
+ // block ids, and two readers must land on the same relation order regardless
21
+ // of locale.
22
+ const byCodepoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
23
+
19
24
  // The taught ISA-family predicates (stored edges only, not their transitive closure).
20
25
  const ISA_PREDICATES = new Set(["rdfs:subClassOf", "rdf:type"]);
21
26
 
@@ -281,7 +286,7 @@ export async function inferRelations(groups, memory, { store } = {}) {
281
286
  relationNames: relationNameCandidates(rows),
282
287
  };
283
288
 
284
- const sorted = list.slice().sort((x, y) => x.id.localeCompare(y.id));
289
+ const sorted = list.slice().sort((x, y) => byCodepoint(x.id, y.id));
285
290
  const out = [];
286
291
 
287
292
  for (let i = 0; i < sorted.length; i += 1) {
@@ -311,6 +316,6 @@ export async function inferRelations(groups, memory, { store } = {}) {
311
316
  }
312
317
  }
313
318
 
314
- out.sort((x, y) => x.from.localeCompare(y.from) || x.to.localeCompare(y.to) || x.relation.localeCompare(y.relation));
319
+ out.sort((x, y) => byCodepoint(x.from, y.from) || byCodepoint(x.to, y.to) || byCodepoint(x.relation, y.relation));
315
320
  return out;
316
321
  }
@@ -8,6 +8,10 @@
8
8
 
9
9
  const DEFAULT_MAX_SENTENCES_PER_GROUP = 3;
10
10
 
11
+ // Codepoint order, never localeCompare — group ids trace back to memory-store
12
+ // block ids, and two readers must land on the same order regardless of locale.
13
+ const byCodepoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
14
+
11
15
  /** Every group id referenced as either side of an asserted relation. */
12
16
  function relatedGroupIdsOf(relations) {
13
17
  const set = new Set();
@@ -56,7 +60,7 @@ export function pruneCompletion(state = {}, { maxSentencesPerGroup = DEFAULT_MAX
56
60
 
57
61
  const relatedGroupIds = relatedGroupIdsOf(relations);
58
62
 
59
- const sortedGroups = groups.slice().sort((a, b) => a.id.localeCompare(b.id));
63
+ const sortedGroups = groups.slice().sort((a, b) => byCodepoint(a.id, b.id));
60
64
  for (const g of sortedGroups) {
61
65
  const ranked = Array.isArray(rankedByGroup[g.id]) ? rankedByGroup[g.id] : [];
62
66
  const groupFeedsInference = relatedGroupIds.has(g.id);
@@ -12,6 +12,11 @@ import { requireInjected } from "./injected.mjs";
12
12
  // (no abbreviation dictionary).
13
13
  const SENTENCE_SPLIT_RE = /(?<=[.!?])\s+(?=[A-Z0-9])/;
14
14
 
15
+ // Codepoint order, never localeCompare — sourceBlockId traces back to the
16
+ // memory store's block ids, and two readers must land on the same rank order
17
+ // regardless of locale.
18
+ const byCodepoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
19
+
15
20
  /**
16
21
  * Split raw block text into trimmed, non-empty sentences (order-preserving, no dedup).
17
22
  *
@@ -89,7 +94,7 @@ export function rankSentences(group, { overlapMin, query = null, store } = {}) {
89
94
  });
90
95
 
91
96
  scored.sort((a, b) => b.score - a.score
92
- || a.sourceBlockId.localeCompare(b.sourceBlockId)
93
- || a.sentence.localeCompare(b.sentence));
97
+ || byCodepoint(a.sourceBlockId, b.sourceBlockId)
98
+ || byCodepoint(a.sentence, b.sentence));
94
99
  return scored;
95
100
  }
@@ -12,6 +12,10 @@ import DEFAULT_CONFIG from "./config.json" with { type: "json" };
12
12
 
13
13
  const DESCRIPTION_FAMILIES = FAMILY_PRIORITY.filter((f) => f !== "isa" && f !== "other");
14
14
 
15
+ /** Codepoint order, never localeCompare — an ontology root is a class name
16
+ * read off stored facts, so two locales have to name the same roots. */
17
+ const byCodepoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
18
+
15
19
  /** Group the selector's `selected` items by family, preserving each item's
16
20
  * ranked order, and return `{ family -> rows[] }` over the fact rows. */
17
21
  function rowsByFamily(selected) {
@@ -82,7 +86,7 @@ export function closerRootsFor(chains, usedRows, count) {
82
86
  rootRows.get(root).push(row);
83
87
  }
84
88
  const roots = [...rootCount.keys()]
85
- .sort((a, b) => (rootCount.get(b) - rootCount.get(a)) || a.localeCompare(b))
89
+ .sort((a, b) => (rootCount.get(b) - rootCount.get(a)) || byCodepoint(a, b))
86
90
  .slice(0, Math.max(0, count));
87
91
  const rows = [];
88
92
  const seen = new Set();
@@ -25,7 +25,12 @@
25
25
  // the ranking stays auditable.
26
26
 
27
27
  import { SOURCE_PRIOR } from "../memory/trust.mjs";
28
+ import { compareFactsByContent } from "../memory/fact-order.mjs";
28
29
  import { clusterSenses } from "../sense-split.mjs";
30
+
31
+ /** Codepoint order, never localeCompare — a cluster label is a class name read
32
+ * off stored facts, so two locales have to land on the same dominant sense. */
33
+ const byCodepoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
29
34
  import DEFAULT_CONFIG from "./config.json" with { type: "json" };
30
35
 
31
36
  // A relation predicate -> the sentence-family key the digest groups it under.
@@ -148,7 +153,7 @@ export function selectFacts(term, rows, store = {}, opts = {}) {
148
153
  }
149
154
  let dominantLabel = null;
150
155
  let bestWeight = -1;
151
- for (const [label, weight] of [...clusterWeight].sort((a, b) => a[0].localeCompare(b[0]))) {
156
+ for (const [label, weight] of [...clusterWeight].sort((a, b) => byCodepoint(a[0], b[0]))) {
152
157
  if (weight > bestWeight) { bestWeight = weight; dominantLabel = label; }
153
158
  }
154
159
 
@@ -186,16 +191,17 @@ export function selectFacts(term, rows, store = {}, opts = {}) {
186
191
  else eligible.push(item);
187
192
  }
188
193
 
189
- // Breadth-first cut to budget: each family sorted by score (tie broken by
190
- // object label for determinism), then pulled round-robin in family priority
191
- // order so the digest covers relations before it repeats one.
194
+ // Breadth-first cut to budget: each family sorted by score (ties broken on
195
+ // the fact's own content, never on the order it arrived), then pulled
196
+ // round-robin in family priority order so the digest covers relations before
197
+ // it repeats one.
192
198
  const byFamily = new Map();
193
199
  for (const item of eligible) {
194
200
  if (!byFamily.has(item.family)) byFamily.set(item.family, []);
195
201
  byFamily.get(item.family).push(item);
196
202
  }
197
203
  for (const list of byFamily.values()) {
198
- list.sort((a, b) => b.score - a.score || a.row.object.localeCompare(b.row.object));
204
+ list.sort((a, b) => b.score - a.score || compareFactsByContent(a.row, b.row));
199
205
  }
200
206
  const orderedFamilies = FAMILY_PRIORITY.filter((f) => byFamily.has(f))
201
207
  .concat([...byFamily.keys()].filter((f) => !FAMILY_PRIORITY.includes(f)).sort());
@@ -222,7 +228,7 @@ export function selectFacts(term, rows, store = {}, opts = {}) {
222
228
  selected.sort((a, b) => {
223
229
  const fa = FAMILY_PRIORITY.indexOf(a.family);
224
230
  const fb = FAMILY_PRIORITY.indexOf(b.family);
225
- return (fa - fb) || (b.score - a.score) || a.row.object.localeCompare(b.row.object);
231
+ return (fa - fb) || (b.score - a.score) || compareFactsByContent(a.row, b.row);
226
232
  });
227
233
 
228
234
  return {
@@ -37,10 +37,17 @@ const optionalTerm = (value) => {
37
37
  return t === "" ? undefined : t;
38
38
  };
39
39
 
40
+ /** Codepoint order, never localeCompare. Every string this file sorts came out
41
+ * of the fact/Rule store. The planner walks actions, signatures and state rows
42
+ * in exactly the order these comparators leave them, so a locale-sensitive
43
+ * compare would let two machines holding one taught domain return different
44
+ * plans from it. */
45
+ const byCodepoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
46
+
40
47
  const rowSort = (a, b) =>
41
- a.subject.localeCompare(b.subject) ||
42
- a.predicate.localeCompare(b.predicate) ||
43
- a.object.localeCompare(b.object);
48
+ byCodepoint(a.subject, b.subject) ||
49
+ byCodepoint(a.predicate, b.predicate) ||
50
+ byCodepoint(a.object, b.object);
44
51
 
45
52
  const normRow = (row) => ({
46
53
  subject: normTerm(row.subject),
@@ -108,13 +115,13 @@ export function compileDomain(factRows, ruleRows) {
108
115
  });
109
116
  }
110
117
  }
111
- const actions = [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
118
+ const actions = [...byName.values()].sort((a, b) => byCodepoint(a.name, b.name));
112
119
  for (const action of actions) {
113
120
  action.signatures.sort((a, b) =>
114
- a.subjectClass.localeCompare(b.subjectClass) || a.targetClass.localeCompare(b.targetClass));
115
- action.preconds.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
116
- action.effects.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
117
- action.constraints.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
121
+ byCodepoint(a.subjectClass, b.subjectClass) || byCodepoint(a.targetClass, b.targetClass));
122
+ action.preconds.sort((a, b) => byCodepoint(JSON.stringify(a), JSON.stringify(b)));
123
+ action.effects.sort((a, b) => byCodepoint(JSON.stringify(a), JSON.stringify(b)));
124
+ action.constraints.sort((a, b) => byCodepoint(JSON.stringify(a), JSON.stringify(b)));
118
125
  }
119
126
 
120
127
  // Class membership from typing edges. A member is a subject with a typing
@@ -21,9 +21,18 @@ import {
21
21
  SUBCLASS_PREDICATE, ON_PROPERTY_PREDICATE, SOME_VALUES_FROM_PREDICATE, TYPE_PREDICATE,
22
22
  DEFAULT_MAX_ENVIRONMENTS, buildCardinalityRestrictions,
23
23
  } from "./syllogise.mjs";
24
+ import { compareFactsByContent } from "./memory/fact-order.mjs";
24
25
 
25
26
  const SEP = "␟"; // an in-key separator no fact term can contain — same convention as syllogise.mjs's own SEP
26
27
 
28
+ // Codepoint order, never localeCompare — a stored row's id is read on whatever
29
+ // machine holds the graph, and two locales have to land on the same order.
30
+ const byId = (a, b) => {
31
+ const ka = String(a.id);
32
+ const kb = String(b.id);
33
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
34
+ };
35
+
27
36
  /** The two reserved concept names every EL derivation is built from. Neither
28
37
  * can collide with a stored term: normFactTerm never produces them from a
29
38
  * class noun, and normalizeElTBox drops any row that literally names one. */
@@ -78,7 +87,7 @@ const isCardinalityPredicate = (p) => CARDINALITY_PREDICATES.has(lower(p));
78
87
  */
79
88
  export function normalizeElTBox(rows, { budget = 500 } = {}) {
80
89
  const input = (Array.isArray(rows) ? rows : []).filter((r) => r && r.id && r.subject && r.predicate && r.object !== undefined && r.object !== null);
81
- const sorted = [...input].sort((a, b) => String(a.id).localeCompare(String(b.id)));
90
+ const sorted = [...input].sort(byId);
82
91
  const truncated = sorted.length > budget;
83
92
  const used = truncated ? sorted.slice(0, budget) : sorted;
84
93
 
@@ -216,7 +225,7 @@ export function normalizeElTBox(rows, { budget = 500 } = {}) {
216
225
  for (const role of [...transitiveRoleRow.keys()].sort()) {
217
226
  roleAxioms.push({ kind: "transitive", role, from: [transitiveRoleRow.get(role).id] });
218
227
  }
219
- for (const r of [...subPropertyRows].sort((a, b) => `${a.subject}${SEP}${a.object}`.localeCompare(`${b.subject}${SEP}${b.object}`))) {
228
+ for (const r of [...subPropertyRows].sort(compareFactsByContent)) {
220
229
  roleAxioms.push({ kind: "sub", sub: r.subject, sup: r.object, from: [r.id] });
221
230
  }
222
231
 
@@ -60,6 +60,7 @@ export const FACT_PREDICATE_PHRASES = Object.freeze({
60
60
  "mgx:consumes": "eats",
61
61
  "mgx:vision-radius": "sees within",
62
62
  "mgx:guards": "guards",
63
+ "mgx:attributedTo": "is attributed to",
63
64
  });
64
65
 
65
66
  /** The closed participle set the relational teach frames read as "X is
@@ -70,6 +71,23 @@ export const FACT_PREDICATE_PHRASES = Object.freeze({
70
71
  * them back into English, so both read the one vocabulary. */
71
72
  export const TEACH_PARTICIPLE_SRC = "connected|related|associated|linked|based|derived|composed|made|used|known|located|found|involved|concerned";
72
73
 
74
+ /** The participles a news report's agentless passive states its subject's own
75
+ * condition with — "is banned from", "was deported to". One per verb in the
76
+ * extractor's closed newswire event band, so the same list that decides which
77
+ * events read also decides which passives read back as English. Kept apart
78
+ * from TEACH_PARTICIPLE_SRC because the teach lane parses that list into its
79
+ * own frames and nothing should widen those by writing here. */
80
+ export const NEWS_PASSIVE_PARTICIPLE_SRC = [
81
+ "hit", "struck", "killed", "injured", "wounded", "damaged", "destroyed", "devastated",
82
+ "banned", "halted", "blocked", "barred", "suspended", "imposed",
83
+ "arrested", "detained", "jailed", "charged", "convicted", "sentenced", "deported", "released", "freed",
84
+ "elected", "appointed", "ousted", "overthrown",
85
+ "signed", "adopted", "approved", "rejected", "vetoed",
86
+ "launched", "unveiled", "seized", "captured", "invaded", "attacked", "bombed", "targeted",
87
+ "discovered", "uncovered", "rescued", "evacuated",
88
+ "sparked", "triggered", "caused", "forced", "deployed", "restored", "expanded",
89
+ ].join("|");
90
+
73
91
  /** The MECHANICAL fallback for a predicate the table has no curated entry
74
92
  * for — specifically the minted "mgx:<lemma>" predicates ("mgx:eat",
75
93
  * "mgx:drive", …) — the mechanical INVERSE of the naive -s/-es/-ies fold the
@@ -132,14 +150,35 @@ const SINGULAR_NOUNS_ENDING_S = new Set([
132
150
  "news", "physics", "species", "series", "means", "measles", "mathematics", "politics", "economics",
133
151
  ]);
134
152
 
153
+ /** How much word has to sit in front of one of those nouns before the whole
154
+ * reads as a COMPOUND built on it. An English compound takes its number from
155
+ * its rightmost element, so "hackernews", "subspecies", "miniseries" and
156
+ * "geopolitics" are all as singular as the noun they end in, and the table
157
+ * above covers the family rather than one site's name. Three characters is
158
+ * where the real first elements start ("sub", "geo", "mini", "hacker") and
159
+ * where the words that merely END in one of those nouns stop: "sinews",
160
+ * "renews" and "demeans" leave two characters in front and stay plural. */
161
+ const COMPOUND_FIRST_ELEMENT_MIN_CHARS = 3;
162
+
163
+ /** Does a head noun end in one of the singular "-s" nouns above, as itself or
164
+ * as the last element of a compound built on it? */
165
+ function endsInSingularNounEndingS(head) {
166
+ for (const noun of SINGULAR_NOUNS_ENDING_S) {
167
+ if (head === noun) return true;
168
+ if (head.endsWith(noun) && head.length - noun.length >= COMPOUND_FIRST_ELEMENT_MIN_CHARS) return true;
169
+ }
170
+ return false;
171
+ }
172
+
135
173
  /**
136
174
  * Is a stored fact's SUBJECT text grammatically plural, for the one thing
137
175
  * this file needs it for: choosing a minted verb's surface form. Reads the
138
176
  * HEAD noun only — the word right after any leading article, before a
139
177
  * trailing "of ..." phrase, so "the group of scientists" agrees on "group"
140
178
  * ("the group of scientists reports"), not "scientists". From there: the
141
- * closed irregular table above, then the regular "-s" suffix, same
142
- * naive-morphology trade thirdPersonSingularSurface already takes above. A
179
+ * closed irregular table above, then the singular "-s" nouns and the compounds
180
+ * they head, then the regular "-s" suffix, same naive-morphology trade
181
+ * thirdPersonSingularSurface already takes above. A
143
182
  * subject this can't read (empty, or a plural-invariant noun like "sheep")
144
183
  * defaults to singular — English's own unmarked form, and also this file's
145
184
  * pre-existing default for every caller that passes no subject at all.
@@ -148,7 +187,7 @@ export function isSubjectPlural(subject) {
148
187
  const head = String(subject || "").trim().replace(/^(?:the|a|an)\s+/i, "").split(/\s+/)[0]?.toLowerCase() ?? "";
149
188
  if (!head) return false;
150
189
  if (IRREGULAR_PLURAL_NOUNS.has(head)) return true;
151
- if (SINGULAR_NOUNS_ENDING_S.has(head)) return false;
190
+ if (endsInSingularNounEndingS(head)) return false;
152
191
  return /[a-z]s$/.test(head) && !/ss$/.test(head);
153
192
  }
154
193
 
@@ -204,7 +243,7 @@ export function predicatePhrase(predicate, subject) {
204
243
  // a participle + preposition renders as its copula surface: mgx:connected-with
205
244
  // -> "is connected with" (the participle is already a participle, so no 3sg
206
245
  // fold — "connecteds" isn't a word)
207
- const part = new RegExp(`^mgx:(${TEACH_PARTICIPLE_SRC})-([a-z]+)$`, "i").exec(p);
246
+ const part = new RegExp(`^mgx:(${TEACH_PARTICIPLE_SRC}|${NEWS_PASSIVE_PARTICIPLE_SRC})-([a-z]+)$`, "i").exec(p);
208
247
  if (part) return `is ${part[1].toLowerCase()} ${part[2].toLowerCase()}`;
209
248
  // a shared-attribute predicate: mgx:same-goal-as -> "has the same goal as"
210
249
  const same = /^mgx:same-([a-z]+)-as$/i.exec(p);
@@ -218,10 +257,49 @@ export function predicatePhrase(predicate, subject) {
218
257
  const verb = isSubjectPlural(subject) ? minted[1] : thirdPersonSingularSurface(minted[1]);
219
258
  return `${verb}${minted[2] ? ` ${minted[2]}` : ""}`;
220
259
  }
260
+ // The lexicon's own minted verb predicates come pre-inflected: it builds
261
+ // "tmct:<3sg>[<Prep>]" from a declared verb, so "release" is stored as
262
+ // "tmct:releases" and "rely" + "on" as "tmct:reliesOn". Singular subjects
263
+ // read that surface as it stands. A plural one takes the bare form through
264
+ // baseVerbSurface, the documented inverse of the fold that made it, so
265
+ // "rescuers release" comes out of the same rule that gives "rescuers report".
266
+ // The camel-cased preposition is its own word in a sentence.
267
+ const declared = /^tmct:([a-z]+)([A-Z][a-z]+)?$/.exec(p);
268
+ if (declared) {
269
+ const verb = isSubjectPlural(subject) ? baseVerbSurface(declared[1]) : declared[1];
270
+ return `${verb}${declared[2] ? ` ${declared[2].toLowerCase()}` : ""}`;
271
+ }
221
272
  const colon = p.indexOf(":");
222
273
  return colon === -1 ? p : p.slice(colon + 1);
223
274
  }
224
275
 
276
+ /**
277
+ * The verb a stored predicate states an ACT with, as `{ lemma, particle }`,
278
+ * or null when the predicate states anything else. `mgx:free` reads
279
+ * `{ lemma: "free", particle: "" }`, the lexicon's pre-inflected
280
+ * `tmct:releases` reads `{ lemma: "release", particle: "" }`, and
281
+ * `mgx:strike-near` / `tmct:reliesOn` carry their particle beside the lemma.
282
+ *
283
+ * The branches below mirror predicatePhrase's own, in its order, so anything
284
+ * that reads as a curated phrase, a negation, a comparative, a passive
285
+ * participle or a shared attribute answers null here rather than a verb it
286
+ * never renders as. Two rows minted down different paths — one through the
287
+ * lexicon's declared verbs, one from a bare lemma — reduce to the same answer,
288
+ * which is what lets a caller ask whether they state their act in one word.
289
+ */
290
+ export function predicateVerb(predicate) {
291
+ const p = String(predicate ?? "");
292
+ if (FACT_PREDICATE_PHRASES[p] || p.startsWith("mgxneg:")) return null;
293
+ if (/^mgx:[a-z]+(?:-[a-z]+)*-than$/i.test(p)) return null;
294
+ if (new RegExp(`^mgx:(?:${TEACH_PARTICIPLE_SRC}|${NEWS_PASSIVE_PARTICIPLE_SRC})-[a-z]+$`, "i").test(p)) return null;
295
+ if (/^mgx:same-[a-z]+-as$/i.test(p)) return null;
296
+ const minted = /^mgx:([a-z]+)(?:-([a-z]+))?$/i.exec(p);
297
+ if (minted) return { lemma: minted[1].toLowerCase(), particle: minted[2]?.toLowerCase() ?? "" };
298
+ const declared = /^tmct:([a-z]+)([A-Z][a-z]+)?$/.exec(p);
299
+ if (declared) return { lemma: baseVerbSurface(declared[1]).toLowerCase(), particle: declared[2]?.toLowerCase() ?? "" };
300
+ return null;
301
+ }
302
+
225
303
  /** "a heart has a valve" from one { subject, predicate, object } fact row —
226
304
  * "scientists report a finding" for a plural row.subject, off the same
227
305
  * agreement rule in predicatePhrase above. */
@@ -244,6 +322,7 @@ export const FINDING_CAVEATS = Object.freeze({
244
322
  "clause-fallback": "(read from a clause fragment)",
245
323
  "pronoun-carry": "(subject carried from the previous sentence)",
246
324
  "identifier-token": "(identifier token)",
325
+ "reported-speech": "(read from reported speech)",
247
326
  });
248
327
 
249
328
  /**
@@ -276,10 +355,13 @@ export function findingCaveat(finding) {
276
355
  export function phraseRendererSource() {
277
356
  return [
278
357
  `const TEACH_PARTICIPLE_SRC = ${JSON.stringify(TEACH_PARTICIPLE_SRC)};`,
358
+ `const NEWS_PASSIVE_PARTICIPLE_SRC = ${JSON.stringify(NEWS_PASSIVE_PARTICIPLE_SRC)};`,
279
359
  `const thirdPersonSingularSurface = ${thirdPersonSingularSurface};`,
280
360
  `const baseVerbSurface = ${baseVerbSurface};`,
281
361
  `const IRREGULAR_PLURAL_NOUNS = new Set(${JSON.stringify([...IRREGULAR_PLURAL_NOUNS])});`,
282
362
  `const SINGULAR_NOUNS_ENDING_S = new Set(${JSON.stringify([...SINGULAR_NOUNS_ENDING_S])});`,
363
+ `const COMPOUND_FIRST_ELEMENT_MIN_CHARS = ${COMPOUND_FIRST_ELEMENT_MIN_CHARS};`,
364
+ `const endsInSingularNounEndingS = ${endsInSingularNounEndingS};`,
283
365
  `const isSubjectPlural = ${isSubjectPlural};`,
284
366
  `const predicatePhrase = ${predicatePhrase};`,
285
367
  `const factSentence = ${factSentence};`,
@@ -104,6 +104,14 @@ const TEXT_CAP = 2000; // an utterance's stored text (a whole answer fits; a p
104
104
  * on which module did the writing. */
105
105
  export const normText = (t) => String(t ?? "").replace(/\s+/g, " ").trim().slice(0, TEXT_CAP);
106
106
 
107
+ // A term that is exactly a fact group id ("fact:" + the 16 lowercase hex
108
+ // digits factIdFor mints — an 8-byte SHA-256 truncation, see factIdFor below)
109
+ // must survive normFactTerm whole. Without this guard the generic CURIE
110
+ // strip below removes "fact:" from it same as any other prefix, so a
111
+ // reference TO a fact and a taught word become the same term and collide
112
+ // on one id. No existing corpus/vocabulary term takes this exact shape.
113
+ const FACT_ID_TERM_RE = /^fact:[0-9a-f]{16}$/;
114
+
107
115
  /** Normalize a fact TERM (subject/object) so every writer converges on one
108
116
  * spelling: ConceptNet's /c/en/foo_bar, tmct:Foo_bar, and bare "Foo bar" all
109
117
  * become "foo bar". Also strips a leading "the"/"a"/"an" (idempotent — safe
@@ -111,6 +119,7 @@ export const normText = (t) => String(t ?? "").replace(/\s+/g, " ").trim().slice
111
119
  * is meaningful controlled vocabulary. */
112
120
  export function normFactTerm(t) {
113
121
  let s = normText(t);
122
+ if (FACT_ID_TERM_RE.test(s.toLowerCase())) return s.toLowerCase();
114
123
  s = s.replace(/^\/c\/[a-z]{2,3}\//i, "");
115
124
  s = s.replace(/^[a-z][\w.-]*:/i, "");
116
125
  s = s.replace(/_/g, " ").replace(/\s+/g, " ").trim();
@@ -2,6 +2,8 @@
2
2
  // computeTrust. `biasByBundle` is the `[bias]` table from tmct.toml. CRITICAL:
3
3
  // bias only REORDERS a hit list — it must never drop or hide one.
4
4
 
5
+ import { compareFactsByContent } from "./fact-order.mjs";
6
+
5
7
  /** Matches a corpus-kind Source id ("src:corpus:<bundleName>"); anything else
6
8
  * is not a corpus bundle and ranks at the neutral bias of 1. */
7
9
  const CORPUS_SOURCE_RE = /^src:corpus:(.+)$/;
@@ -23,12 +25,14 @@ export function biasForRow(row, biasByBundle = {}) {
23
25
  return Math.max(...ids.map((id) => biasForSourceId(id, biasByBundle)));
24
26
  }
25
27
 
26
- /** Rank fact rows by bias (desc), then trust (desc), then original order
27
- * (stable) — reorders only, never drops a row. */
28
+ /** Rank fact rows by bias (desc), then trust (desc), then content order
29
+ * reorders only, never drops a row. The last step is content and not array
30
+ * index on purpose: an index tiebreak is arrival order, so two peers holding
31
+ * one fact set would rank it two ways. */
28
32
  export function rankByBiasThenTrust(rows, biasByBundle = {}) {
29
33
  const list = Array.isArray(rows) ? rows : [];
30
34
  return list
31
- .map((row, index) => ({ row, index, bias: biasForRow(row, biasByBundle) }))
32
- .sort((a, b) => (b.bias - a.bias) || ((b.row?.trust ?? 0) - (a.row?.trust ?? 0)) || (a.index - b.index))
35
+ .map((row) => ({ row, bias: biasForRow(row, biasByBundle) }))
36
+ .sort((a, b) => (b.bias - a.bias) || ((b.row?.trust ?? 0) - (a.row?.trust ?? 0)) || compareFactsByContent(a.row, b.row))
33
37
  .map((x) => x.row);
34
38
  }