@polycode-projects/the-mechanical-code-talker 6.0.19 → 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 (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 +27 -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 +257 -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
@@ -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
  }
@@ -19,6 +19,7 @@
19
19
  // Pure and import-free of core.mjs, exactly like trust.mjs beside it.
20
20
 
21
21
  import { findIsaChain, buildSubClassSuccessors, SUBCLASS_PREDICATE, TYPE_PREDICATE } from "../syllogise.mjs";
22
+ import { compareFactsByContent } from "./fact-order.mjs";
22
23
 
23
24
  /** The negative-polarity CURIE prefix. A separate prefix, never an
24
25
  * "mgx:not-<lemma>" mint: fact-phrase.mjs's predicatePhrase reads "mgx:not-fly" as
@@ -74,7 +75,7 @@ export const NEG_CAPABLE_OF_PREDICATE = negatedPredicate(CAPABLE_OF_PREDICATE);
74
75
  * order it lists them in. One constant each, in one place, so the verbosity of
75
76
  * every case-4 answer is tuned by editing two lines. */
76
77
  export const CAPABILITY_REPORT_CAP = 6;
77
- const byTrustThenName = (a, b) => (b.trust || 0) - (a.trust || 0) || String(a.subject).localeCompare(String(b.subject));
78
+ const byTrustThenName = (a, b) => (b.trust || 0) - (a.trust || 0) || compareFactsByContent(a, b);
78
79
 
79
80
  const asSet = (v) => (v instanceof Set ? v : new Set(Array.isArray(v) ? v : [v]));
80
81
 
@@ -155,7 +156,9 @@ export function resolveCapabilityPolarity(subject, object, facts, { maxHops = 3
155
156
  }
156
157
 
157
158
  // hop count first, trust only within a rank — the whole point of the design
158
- candidates.sort((a, b) => a.hops - b.hops || (b.fact.trust || 0) - (a.fact.trust || 0));
159
+ candidates.sort((a, b) => a.hops - b.hops
160
+ || (b.fact.trust || 0) - (a.fact.trust || 0)
161
+ || compareFactsByContent(a.fact, b.fact));
159
162
  const hops = candidates[0].hops;
160
163
  const winning = candidates.filter((c) => c.hops === hops);
161
164
  const positive = winning.filter((c) => c.polarity === "positive").map((c) => c.fact);
@@ -198,19 +201,22 @@ export function capabilityBaseRate(subject, object, facts, { maxHops = 3 } = {})
198
201
  const rows = Array.isArray(facts) ? facts : [];
199
202
  const isaRows = rows.filter((f) => f.predicate === SUBCLASS_PREDICATE || f.predicate === TYPE_PREDICATE);
200
203
 
201
- const parents = isaRows.filter((f) => subjects.has(f.subject)).map((f) => f.object);
204
+ // The class the whole report is about, so it cannot come from whichever
205
+ // parent row happened to be stored first.
206
+ const parents = isaRows.filter((f) => subjects.has(f.subject)).sort(compareFactsByContent).map((f) => f.object);
202
207
  if (!parents.length) return null;
203
208
  const klass = parents[0];
204
209
 
205
210
  const siblings = [...new Set(
206
- isaRows.filter((f) => f.object === klass && !subjects.has(f.subject)).map((f) => f.subject),
211
+ isaRows.filter((f) => f.object === klass && !subjects.has(f.subject))
212
+ .sort(compareFactsByContent).map((f) => f.subject),
207
213
  )];
208
214
 
209
215
  const capabilityOf = (name) => {
210
- const hit = rows.find(
216
+ const hit = rows.filter(
211
217
  (f) => f.subject === name && objects.has(f.object)
212
218
  && (f.predicate === CAPABLE_OF_PREDICATE || f.predicate === NEG_CAPABLE_OF_PREDICATE),
213
- );
219
+ ).sort(compareFactsByContent)[0];
214
220
  if (!hit) return { name, polarity: "unknown", fact: null };
215
221
  return { name, polarity: hit.predicate === NEG_CAPABLE_OF_PREDICATE ? "negative" : "positive", fact: hit };
216
222
  };
@@ -0,0 +1,29 @@
1
+ // memory/fact-order.mjs — the order a fact listing falls back to once ranking
2
+ // runs out of things to say. Every rank over fact rows (bias, trust, relevance)
3
+ // ends in ties, and a stable sort settles a tie by array index, which is
4
+ // arrival order: two peers holding one fact set then render the same facts on
5
+ // different lines.
6
+ //
7
+ // The tiebreak here is a pure function of the fact's own content — the same
8
+ // (subject, predicate, object) triple hash.mjs content-addresses a Fact by,
9
+ // plus the provenance that separates two sources asserting one triple. NUL
10
+ // delimits the parts for hash.mjs's reason: it never occurs inside a
11
+ // normalized term or predicate, so "a b|c" and "a|b c" cannot collide on one
12
+ // key. Codepoint order throughout, never localeCompare — two locales have to
13
+ // land on the same order.
14
+ //
15
+ // The store's own precedent is p2p-room.mjs's sortFactIndividualsById, which
16
+ // sorts Fact individuals by content-addressed id after every merge for exactly
17
+ // this reason. This is that discipline carried through to the read side.
18
+
19
+ /** A fact row's content-derived sort key. */
20
+ export const factOrderKey = (f) => [
21
+ f?.subject ?? "", f?.predicate ?? "", f?.object ?? "", f?.provenance ?? "",
22
+ ].join("\0");
23
+
24
+ /** Order two fact rows by content. The last comparison in any fact ranking. */
25
+ export function compareFactsByContent(a, b) {
26
+ const ka = factOrderKey(a);
27
+ const kb = factOrderKey(b);
28
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
29
+ }
@@ -44,6 +44,9 @@ const MERGE_PREDICATE_STEMS = [
44
44
  "mgx:createdBy",
45
45
  "mgx:mannerOf", "mgx:relatedTo", "mgx:synonym", "mgx:antonym", "mgx:similarTo", "mgx:symbolOf",
46
46
  "mgx:knows-about",
47
+ // One claim can be attributed to many speakers at once. Two outlets naming two
48
+ // different people corroborate the claim; they do not disagree about it.
49
+ "mgx:attributedTo",
47
50
  ];
48
51
 
49
52
  export const MERGE_PREDICATES = new Set(