@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
@@ -37,6 +37,12 @@ import { uuidv7 } from "../adapters/uuid.mjs";
37
37
  import * as defaultSource from "../adapters/source.mjs";
38
38
  import { loadTemplates, render as renderTemplate } from "../adapters/corpus/templates.mjs";
39
39
  import { rankByBiasThenTrust } from "../domain/memory/bias.mjs";
40
+ import { compareFactsByContent } from "../domain/memory/fact-order.mjs";
41
+ // Bare-string ordering for anything read out of the store that isn't a whole
42
+ // fact row: a taught action family's name, a node id. Codepoint order, never
43
+ // localeCompare — fact-order.mjs states the reason, and it applies to a
44
+ // store-derived string just as much as to the row it came off.
45
+ const byCodepoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
40
46
  import { HAS_A_PREDICATE, foldedFactRows as foldStoreFactRows, loadMemory as loadMemoryStore, normFactPredicate, normFactTerm as normFactTermStatic, readFactRows as readStoredFactRows, readRuleRows as readStoredRuleRows } from "../adapters/memory/core.mjs";
41
47
  import { BACKEND_REJECTED_CODE, BACKEND_UNAVAILABLE_CODE } from "../adapters/memory/row-backend.mjs";
42
48
  import {
@@ -79,6 +85,7 @@ import { getChildPackProvider } from "../adapters/corpus/child-pack.mjs";
79
85
  import { dialogueActForLane } from "../domain/dialogue-acts.mjs";
80
86
  import { splitChoiceQuestion, routeChoiceRelation, lemmaFoldVariants, headNounOf, stemTopicCandidates, stemConstraintTerms } from "../domain/choice-question.mjs";
81
87
  import { subClassParents, subClassChildren, descendantSet, ancestryChain, clusterSenses } from "../domain/sense-split.mjs";
88
+ import { buildSenseGate } from "../domain/sense-gate.mjs";
82
89
  import { ANSWER_STOP_SET } from "../domain/hub-terms.mjs";
83
90
  import { relatedForTerm } from "../domain/skos-view.mjs";
84
91
  import { ENUMERATION_LANES, answerAssertsASet, retrievalMarkerLine } from "../domain/retrieval-marker.mjs";
@@ -1050,7 +1057,7 @@ async function answerMemoryClassQuery(memoryDir, query) {
1050
1057
  try { ({ loadMemory, readFactRows } = await import("../adapters/memory/core.mjs")); } catch { return null; }
1051
1058
  let mem;
1052
1059
  try { mem = await loadMemory(memoryDir); } catch { return null; }
1053
- const rows = cls === "Fact" ? readFactRows(mem) : null;
1060
+ const rows = cls === "Fact" ? readFactRows(mem).slice().sort(compareFactsByContent) : null;
1054
1061
  const inds = rows || (mem.individuals || []).filter((i) => (i.class || "") === cls);
1055
1062
  if (countM) return { text: `${inds.length} ${inds.length === 1 ? plural.replace(/s$/, "") : plural}.`, kind: "count" };
1056
1063
  if (!inds.length) return { text: `I don't have any ${plural} stored yet.`, miss: true };
@@ -1071,13 +1078,6 @@ async function answerMemoryClassQuery(memoryDir, query) {
1071
1078
  // stealing the phrasing before a member count ever runs.
1072
1079
  const TAUGHT_CLASS_COUNT_RE = /^how\s+many\s+([a-z][\w-]*(?:\s+[a-z][\w-]*)*)\s*(.*)$/i;
1073
1080
 
1074
- /** A fact row's deterministic identity for ordering. */
1075
- const orderKeyOf = (f) => `${f.subject} ${f.predicate} ${f.object} ${f.provenance || ""}`;
1076
- const orderKeyCompare = (a, b) => {
1077
- const ka = orderKeyOf(a); const kb = orderKeyOf(b);
1078
- return ka < kb ? -1 : ka > kb ? 1 : 0;
1079
- };
1080
-
1081
1081
  /** The store's subclass graph, both directions, built once per turn.
1082
1082
  * Keyed on the rows array identity so a rebuilt row cache rebuilds the maps. */
1083
1083
  function classGraphFor(rows, cache) {
@@ -1097,7 +1097,7 @@ function taughtMembersUnder(isa, children, variants, biasByBundle) {
1097
1097
  for (const v of variants) for (const d of descendantSet(v, children)) classes.add(d);
1098
1098
  const isDirect = (f) => variants.has(f.object);
1099
1099
  const candidates = isa.filter((f) => isDirect(f) || classes.has(f.object));
1100
- const keyed = uniqueFacts(candidates).slice().sort(orderKeyCompare);
1100
+ const keyed = uniqueFacts(candidates);
1101
1101
  const direct = rankByBiasThenTrust(keyed.filter(isDirect), biasByBundle);
1102
1102
  const inherited = rankByBiasThenTrust(keyed.filter((f) => !isDirect(f)), biasByBundle);
1103
1103
  return { direct, inherited, members: [...direct, ...inherited], classes };
@@ -1307,11 +1307,7 @@ async function answerCollectionContents(memoryDir, query, biasByBundle = {}, cac
1307
1307
  const containerRaw = restricted ? restricted[2] : unrestricted[1];
1308
1308
  const rows = await factRows(memoryDir, cache);
1309
1309
  const containerVariants = factTermVariants(normFactTerm, containerRaw);
1310
- // Sorted by content-addressed key before ranking, same discipline as
1311
- // taughtMembersUnder: rankByBiasThenTrust's own tiebreak is array index, so
1312
- // an unsorted filter would render two peers' facts in their own arrival
1313
- // order instead of one shared order.
1314
- const members = rows.filter((f) => CONTAINMENT_PREDICATES.includes(f.predicate) && containerVariants.has(f.object)).sort(orderKeyCompare);
1310
+ const members = rows.filter((f) => CONTAINMENT_PREDICATES.includes(f.predicate) && containerVariants.has(f.object));
1315
1311
  if (!members.length) return null; // no containment rows at all — answerMembershipList keeps this noun
1316
1312
  const renderMembers = (list, noun) => {
1317
1313
  const ranked = rankByBiasThenTrust(uniqueFacts(list), biasByBundle);
@@ -3625,43 +3621,56 @@ const MINT_ISA_PREDICATES = new Set(["rdfs:subClassOf", "rdf:type"]);
3625
3621
  * zorp is a thing"), then chain the other new term off the now-grounded one. */
3626
3622
  const GENERIC_ANCHOR_NOUNS = new Set(["thing", "concept", "object", "entity"]);
3627
3623
 
3628
- /** Corpus source types treated as a real anchor for the isa-tier below.
3629
- * `corpus` is the band provenanceTagToSource (trust.mjs) assigns to
3630
- * corpus:<bundle>/child:<pack>:<term>/world:<name>/mud:<character> a
3631
- * shipped isa row from one of those, not the operator's own words. Named
3632
- * explicitly, rather than "everything except corpusWeak/reference/web/...",
3633
- * so a new corpus band added later has to be added here on purpose before it
3634
- * can anchor anything: corpusWeak only ever carries mgx:relatedTo (already
3635
- * excluded by the predicate filter, named here too so a future corpus-weak
3636
- * isa row can't anchor silently); reference/referenceLive/extracted/
3637
- * optimisticExtract/web isa rows can be extraction artifacts, a wider band a
3638
- * benchmark would need to earn separately; provider-sourced code symbols
3639
- * already ground via isGroundedTerm's own resolveSymbol branch. */
3640
- const CORPUS_ANCHOR_SOURCE_TYPES = new Set(["corpus"]);
3641
- const isCorpusAnchorRow = (f) => !!f.sourceTypes?.some((t) => CORPUS_ANCHOR_SOURCE_TYPES.has(t));
3624
+ /** Source types treated as a real anchor for the isa-tier below: a reference
3625
+ * work's own claim that a term denotes a class, as against the operator's
3626
+ * own words. provenanceTagToSource (trust.mjs) assigns them:
3627
+ * corpus corpus:<bundle>/child:<pack>:<term>/world:<name>/mud:<character>
3628
+ * reference reference:<pack>:<article>@<revid>, the shipped
3629
+ * revision-pinned pack (reference-pack.mjs)
3630
+ * referenceLive research:<source>:<term>, a live KB-adapter lookup, and the
3631
+ * live-Wikipedia supplement the news enrichment cycle's own
3632
+ * fetched definitions land here
3633
+ * Without that last band the cycle could define a term and still leave every
3634
+ * sentence naming it unreadable, so re-processing an article after a lookup
3635
+ * could never change its answer. reference sits beside referenceLive on its
3636
+ * own merit, not symmetry: it outranks referenceLive in SOURCE_PRIOR and is
3637
+ * where a preloaded bulk knowledge band would land, so anchoring the live
3638
+ * lookup while refusing the curated pinned pack would be incoherent.
3639
+ *
3640
+ * Named explicitly, rather than "everything except corpusWeak/web/...", so a
3641
+ * band added later has to be added here on purpose before it can anchor
3642
+ * anything. What stays out and why: corpusWeak only ever carries
3643
+ * mgx:relatedTo (already excluded by the predicate filter, named here so a
3644
+ * future corpus-weak isa row can't anchor silently); extracted/
3645
+ * optimisticExtract/web isa rows are readings of running prose rather than a
3646
+ * reference work's own statement, which is the line this set draws;
3647
+ * provider-sourced code symbols already ground via isGroundedTerm's own
3648
+ * resolveSymbol branch. */
3649
+ const ANCHOR_SOURCE_TYPES = new Set(["corpus", "reference", "referenceLive"]);
3650
+ const isAnchorRow = (f) => !!f.sourceTypes?.some((t) => ANCHOR_SOURCE_TYPES.has(t));
3642
3651
 
3643
3652
  /** Every term that appears as the subject or object of an isa-family fact
3644
3653
  * (MINT_ISA_PREDICATES), split into the two tiers the grounding checks below
3645
- * need: TAUGHT (isOperatorTaught) and CORPUS-ANCHORED (isCorpusAnchorRow). A
3646
- * row that is both (an operator teach merged onto an existing corpus row,
3647
- * same triple) lands in both Sets — no special case needed. Membership only:
3648
- * no ordering, no first-match, no wall clock, so the same fact set fed in
3649
- * any order produces identical Sets. */
3654
+ * need: TAUGHT (isOperatorTaught) and ANCHORED (isAnchorRow). A row that is
3655
+ * both (an operator teach merged onto an existing anchor-band row, same
3656
+ * triple) lands in both Sets — no special case needed. Membership only: no
3657
+ * ordering, no first-match, no wall clock, so the same fact set fed in any
3658
+ * order produces identical Sets. */
3650
3659
  function buildIsaTermIndex(rows) {
3651
3660
  const taught = new Set();
3652
- const corpusAnchored = new Set();
3661
+ const anchored = new Set();
3653
3662
  for (const f of rows) {
3654
3663
  if (!MINT_ISA_PREDICATES.has(f.predicate)) continue;
3655
3664
  const taughtRow = isOperatorTaught(f);
3656
- const corpusRow = isCorpusAnchorRow(f);
3657
- if (!taughtRow && !corpusRow) continue;
3665
+ const anchorRow = isAnchorRow(f);
3666
+ if (!taughtRow && !anchorRow) continue;
3658
3667
  for (const term of [f.subject, f.object]) {
3659
3668
  if (!term) continue;
3660
3669
  if (taughtRow) taught.add(term);
3661
- if (corpusRow) corpusAnchored.add(term);
3670
+ if (anchorRow) anchored.add(term);
3662
3671
  }
3663
3672
  }
3664
- return { taught, corpusAnchored };
3673
+ return { taught, anchored };
3665
3674
  }
3666
3675
  export { buildIsaTermIndex };
3667
3676
 
@@ -3706,8 +3715,8 @@ async function normalizedFactTerm(term) {
3706
3715
  * "store"; "every store is a container" then needs "store" to read as known
3707
3716
  * even though it's not in the static lexicon at all).
3708
3717
  *
3709
- * This is the narrowest of three isa-anchor tiers: isCorpusAnchoredTerm
3710
- * (right below) anchors on a shipped corpus isa row instead of an
3718
+ * This is the narrowest of three isa-anchor tiers: isAnchoredTerm
3719
+ * (right below) anchors on an anchor-band isa row instead of an
3711
3720
  * operator-taught one; isGroundedTerm (below that) folds in the static
3712
3721
  * lexicon and GENERIC_ANCHOR_NOUNS too. A caller that specifically needs
3713
3722
  * "the operator SAID this" — a proof chain citing its own warrant, a
@@ -3722,17 +3731,17 @@ async function isGroundedByFact(term, memoryDir, cache = null) {
3722
3731
  return (await isaTermIndex(memoryDir, cache)).taught.has(t);
3723
3732
  }
3724
3733
 
3725
- /** CORPUS-ANCHORED tier of the isa-anchor ladder: true when `term` is the
3726
- * subject or object of an isa-family fact whose source is the corpus band
3727
- * (CORPUS_ANCHOR_SOURCE_TYPES) — a shipped bundle's own claim that the term
3734
+ /** ANCHORED tier of the isa-anchor ladder: true when `term` is the subject or
3735
+ * object of an isa-family fact whose source is the anchor band
3736
+ * (ANCHOR_SOURCE_TYPES) — a reference source's own claim that the term
3728
3737
  * denotes a class, not necessarily anything the operator taught. See
3729
3738
  * isAnchorableTerm, just below isGroundedTerm, for where this tier and the
3730
3739
  * taught tier combine into "anchorable in any sense". */
3731
- async function isCorpusAnchoredTerm(term, memoryDir, cache = null) {
3740
+ async function isAnchoredTerm(term, memoryDir, cache = null) {
3732
3741
  if (!memoryDir) return false;
3733
3742
  const t = await normalizedFactTerm(term);
3734
3743
  if (!t) return false;
3735
- return (await isaTermIndex(memoryDir, cache)).corpusAnchored.has(t);
3744
+ return (await isaTermIndex(memoryDir, cache)).anchored.has(t);
3736
3745
  }
3737
3746
 
3738
3747
  /** A bare single alphabetic character ("a", "i", …) classify() resolves only
@@ -3772,14 +3781,14 @@ export { isGroundedTerm };
3772
3781
 
3773
3782
  /** "Anchorable in ANY sense" — everything isGroundedTerm already tests
3774
3783
  * (static lexicon, GENERIC_ANCHOR_NOUNS, a resolved code-graph symbol, a
3775
- * prior-taught isa fact) PLUS the corpus-anchored tier (isCorpusAnchoredTerm,
3776
- * above): a term a shipped corpus bundle's own isa row already names as a
3777
- * class. isGroundedTerm itself stays exactly as narrow as before (it's
3778
- * exported and pinned by test/services/chat-grounding.test.mjs) — every
3779
- * widening onto the corpus tier goes through this function instead. */
3784
+ * prior-taught isa fact) PLUS the anchored tier (isAnchoredTerm, above): a
3785
+ * term an anchor-band source's own isa row already names as a class.
3786
+ * isGroundedTerm itself stays exactly as narrow as before (it's exported and
3787
+ * pinned by test/services/chat-grounding.test.mjs) — every widening onto the
3788
+ * anchor band goes through this function instead. */
3780
3789
  async function isAnchorableTerm(term, lex, memoryDir, cache = null, graph = null) {
3781
3790
  if (await isGroundedTerm(term, lex, memoryDir, cache, graph)) return true;
3782
- return isCorpusAnchoredTerm(term, memoryDir, cache);
3791
+ return isAnchoredTerm(term, memoryDir, cache);
3783
3792
  }
3784
3793
  export { isAnchorableTerm };
3785
3794
 
@@ -4223,17 +4232,17 @@ async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon,
4223
4232
  && !/\s/.test(subjectRaw)
4224
4233
  && objectCarriesArticle(payload)
4225
4234
  && readsAsIndividualName(subjectRaw, lex);
4226
- // A PRIOR turn's minted term, a GENERIC_ANCHOR_NOUNS root, or a shipped
4227
- // corpus bundle's own isa row grounds Y just as legitimately as a static
4228
- // lexicon noun — all three are always treated as class-level (never
4235
+ // A PRIOR turn's minted term, a GENERIC_ANCHOR_NOUNS root, or an
4236
+ // anchor-band source's own isa row grounds Y just as legitimately as a
4237
+ // static lexicon noun — all three are always treated as class-level (never
4229
4238
  // property), consistent with unknownObjectFallback (below) always minting
4230
- // a CLASS. Without the corpus-anchored check, "p is a kind of consonant"
4231
- // (both sides corpus-anchored, neither in the static lexicon) refuses; with
4232
- // it, "florb is a kind of glyph" also stores, minting "florb" — one
4233
- // anchored side is enough, the same rule the taught tier already applies.
4239
+ // a CLASS. Without the anchor check, "p is a kind of consonant" (both sides
4240
+ // anchored, neither in the static lexicon) refuses; with it, "florb is a
4241
+ // kind of glyph" also stores, minting "florb" — one anchored side is
4242
+ // enough, the same rule the taught tier already applies.
4234
4243
  if (lookupNoun(lex, objectRaw) || GENERIC_ANCHOR_NOUNS.has(String(objectRaw).toLowerCase())
4235
4244
  || (await isGroundedByFact(objectRaw, memoryDir, cache))
4236
- || (await isCorpusAnchoredTerm(objectRaw, memoryDir, cache))) {
4245
+ || (await isAnchoredTerm(objectRaw, memoryDir, cache))) {
4237
4246
  return teachFact(memoryDir, sessionId, {
4238
4247
  subject,
4239
4248
  predicate: namedIndividual ? TYPE_PREDICATE : SUBCLASS_PREDICATE,
@@ -4338,9 +4347,9 @@ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon, c
4338
4347
  if (!/^(?:every|each|all|any)$/i.test((det || "").trim()) && !classIntent) return null; // class-level mint needs a universal quantifier or an explicit kind-of infix
4339
4348
  const { loadLexicon, lookupNoun } = await import("../domain/grammar/lexicon.mjs");
4340
4349
  const lex = lexicon || loadLexicon();
4341
- // Anchorable (not just lexicon-grounded): a corpus-anchored subject earns
4342
- // the same mint a lexicon-grounded one does ("p is a kind of alphabet
4343
- // letter" — "p" anchored via a corpus isa row, "alphabet letter" minted).
4350
+ // Anchorable (not just lexicon-grounded): an anchored subject earns the
4351
+ // same mint a lexicon-grounded one does ("p is a kind of alphabet letter"
4352
+ // — "p" anchored via a corpus isa row, "alphabet letter" minted).
4344
4353
  const subjectGrounded = await isAnchorableTerm(subjectRaw, lex, memoryDir, cache, graph);
4345
4354
  if (!subjectGrounded) return null; // ungrounded subject isn't this fallback's asymmetry — never a guessed mint
4346
4355
  // The OBJECT gate stays on isGroundedTerm, deliberately narrower than the
@@ -4449,15 +4458,15 @@ async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon
4449
4458
  if (/\s+(?:is|are)\s+an?\s+[\w-]+[.!?]*\s*$/i.test(String(payload).trim())) return null;
4450
4459
  const { loadLexicon, lookupNoun, lookupAdjective, classify } = await import("../domain/grammar/lexicon.mjs");
4451
4460
  const lex = lexicon || loadLexicon();
4452
- // Y already a known NOUN, a fact-grounded CLASS term, or a corpus-anchored
4453
- // CLASS term — a genuine class-membership sentence, unknownSubjectFallback/
4461
+ // Y already a known NOUN, a fact-grounded CLASS term, or an anchored CLASS
4462
+ // term — a genuine class-membership sentence, unknownSubjectFallback/
4454
4463
  // unknownObjectFallback's own territory (already had first refusal on it)
4455
- // — never misread as a property. The corpus-anchored check is required
4456
- // here, not optional: without it a corpus-anchored object could be
4457
- // misread as a brand-new adjective instead of the class term it is.
4464
+ // — never misread as a property. The anchor check is required here, not
4465
+ // optional: without it an anchored object could be misread as a brand-new
4466
+ // adjective instead of the class term it is.
4458
4467
  if (lookupNoun(lex, objectRaw) || GENERIC_ANCHOR_NOUNS.has(String(objectRaw).toLowerCase())
4459
4468
  || (await isGroundedByFact(objectRaw, memoryDir, cache))
4460
- || (await isCorpusAnchoredTerm(objectRaw, memoryDir, cache))) return null;
4469
+ || (await isAnchoredTerm(objectRaw, memoryDir, cache))) return null;
4461
4470
  // CLASS-LEVEL adjective predication — "every snake is venomous": a universal
4462
4471
  // quantifier over a grounded noun class, with an adjective complement. The
4463
4472
  // quantifier is the same deliberate-generalization signal the article/
@@ -4472,7 +4481,7 @@ async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon
4472
4481
  // itself carries the safety here, so leaving this narrow would make
4473
4482
  // "every p is a glyph" teach while "every p is venomous" refuses, an
4474
4483
  // unpredictable asymmetry between two universally-quantified sentences
4475
- // over the same corpus-anchored subject.
4484
+ // over the same anchored subject.
4476
4485
  // A bare GENERIC PLURAL subject with no determiner at all ("animals are
4477
4486
  // alive") carries the same implicit-universal reading matchBareHabitualTeach
4478
4487
  // already gives a bare plural verb subject ("dogs bark" mints the same
@@ -4502,13 +4511,13 @@ async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon
4502
4511
  const bareSubject = subjectRaw.replace(/^(?:the|an?)\s+/i, "").trim() || subjectRaw;
4503
4512
  const hadArticle = bareSubject !== subjectRaw;
4504
4513
  const capitalized = /^[A-Z]/.test(bareSubject);
4505
- // Deliberately isGroundedByFact (taught-only), NOT isCorpusAnchoredTerm or
4514
+ // Deliberately isGroundedByFact (taught-only), NOT isAnchoredTerm or
4506
4515
  // isAnchorableTerm: this is the bare, unquantified property claim, where
4507
4516
  // NOTHING else stands in for the article/capitalization/quantifier
4508
- // "deliberate entity" signal this function's own docblock requires. Corpus
4509
- // anchoring a bare subject here would reopen the pinned "module is banana"
4517
+ // "deliberate entity" signal this function's own docblock requires.
4518
+ // Anchoring a bare subject here would reopen the pinned "module is banana"
4510
4519
  // regression under a new spelling — "dog is banana" would silently mint
4511
- // dog mgx:hasProperty banana, since "dog" is corpus-anchored via ConceptNet.
4520
+ // dog mgx:hasProperty banana, since "dog" is anchored via ConceptNet.
4512
4521
  const factGrounded = await isGroundedByFact(bareSubject, memoryDir, cache);
4513
4522
  const genericAnchor = GENERIC_ANCHOR_NOUNS.has(bareSubject.toLowerCase());
4514
4523
  // A bare (no article, no capitalization) subject grounded ONLY via the
@@ -7147,8 +7156,8 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
7147
7156
  } catch { /* lexicon unavailable — fall through to the generic message */ }
7148
7157
  }
7149
7158
  // Residue is what the STATIC lexicon didn't recognize. A token the fact
7150
- // store anchors — taught, corpus, or a code-graph symbol — is a word tmct
7151
- // does know, so it must not be named here. Only a token unknown under
7159
+ // store anchors — taught, anchor-band, or a code-graph symbol — is a word
7160
+ // tmct does know, so it must not be named here. Only a token unknown under
7152
7161
  // EVERY tier is. Deliberately not isAnchorableTerm: its classify branch
7153
7162
  // would drop a token the lexicon knows under the WRONG part of speech, and
7154
7163
  // that token genuinely IS the problem — this loop stays word-anchor-only.
@@ -7157,7 +7166,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
7157
7166
  const survivors = [];
7158
7167
  for (const w of unknown) {
7159
7168
  if (await isGroundedByFact(w, memoryDir, cache)) continue;
7160
- if (await isCorpusAnchoredTerm(w, memoryDir, cache)) continue;
7169
+ if (await isAnchoredTerm(w, memoryDir, cache)) continue;
7161
7170
  if (graph && resolveSymbol(graph, w)?.match) continue;
7162
7171
  survivors.push(w);
7163
7172
  }
@@ -8147,9 +8156,10 @@ function citationProvenance(provenance) {
8147
8156
  }
8148
8157
 
8149
8158
  /** What every rendered fact line ends with: the extractor's caveat, when the
8150
- * row carries a finding worth declaring, then the source citation. The order
8151
- * is fixed a reader sees how the sentence was read, then who said it — and
8152
- * both halves are optional, so a clean untraced row ends with nothing at all.
8159
+ * row carries a finding worth declaring, then the speaker a report attributed
8160
+ * the claim to, then the source citation. The order is fixed a reader sees
8161
+ * how the sentence was read, then who said it, then where it came from — and
8162
+ * every part is optional, so a clean untraced row ends with nothing at all.
8153
8163
  *
8154
8164
  * A row whose extractor recorded a finding stays answerable (no lane declines
8155
8165
  * a stored fact); the caveat is how the answer says which reading it leans
@@ -8158,7 +8168,24 @@ function citationProvenance(provenance) {
8158
8168
  function factLineTail(f) {
8159
8169
  const caveat = findingCaveat(f);
8160
8170
  const cite = f.provenance ? ` (source: ${citationProvenance(f.provenance)})` : "";
8161
- return `${caveat ? ` ${caveat}` : ""}${cite}`;
8171
+ return `${caveat ? ` ${caveat}` : ""}${attributionClause(f)}${cite}`;
8172
+ }
8173
+
8174
+ /** "(president trump said)" — the speaker a report attributed this claim to,
8175
+ * off the `attributedTo` the fold hangs on an attributed row. A claim stays
8176
+ * grounded and answerable either way; naming the speaker is what keeps the
8177
+ * answer from reading as tmct's own assertion.
8178
+ *
8179
+ * Two speakers on one claim both get named: two outlets attributing one claim
8180
+ * to two people corroborate it, they do not disagree. Sorted and deduped, so
8181
+ * the line reads the same however the rows arrived. */
8182
+ function attributionClause(f) {
8183
+ const speakers = [...new Set((Array.isArray(f?.attributedTo) ? f.attributedTo : []).filter(Boolean))].sort();
8184
+ if (!speakers.length) return "";
8185
+ const named = speakers.length === 1
8186
+ ? speakers[0]
8187
+ : `${speakers.slice(0, -1).join(", ")} and ${speakers[speakers.length - 1]}`;
8188
+ return ` (${named} said)`;
8162
8189
  }
8163
8190
 
8164
8191
  /** Every extraction finding the rows behind ONE composed sentence carry. A
@@ -8168,6 +8195,14 @@ function composedExtraction(rows) {
8168
8195
  return rows.filter(Boolean).flatMap((r) => (Array.isArray(r.extraction) ? r.extraction : []));
8169
8196
  }
8170
8197
 
8198
+ /** Every speaker the rows behind ONE composed sentence were attributed to,
8199
+ * gathered the same way and for the same reason as the findings above: a
8200
+ * surface that cannot name the speaker must not print the claim, and a
8201
+ * composed sentence prints several rows at once. */
8202
+ function composedAttribution(rows) {
8203
+ return rows.filter(Boolean).flatMap((r) => (Array.isArray(r.attributedTo) ? r.attributedTo : []));
8204
+ }
8205
+
8171
8206
  /** One fact as an uncited-framing citation step: the phrase, its caveat and
8172
8207
  * its source. Chains, both-sides refusals and "via:" receipts all read a
8173
8208
  * premise this way — the same convention renderFactLine uses, without the
@@ -8215,14 +8250,20 @@ function renderFactLine(f) {
8215
8250
  * row-per-member dump: "every pet is a cat or a dog (source: …)". `node` is
8216
8251
  * the `<parent> rdfs:subClassOf <unionNode>` fact row (the parent and the
8217
8252
  * citation both come from it); `members` are the union node's own
8218
- * `owl:unionOf` rows, sorted here by member name so the same union always
8219
- * reads back the same way regardless of the order its rows arrived in.
8253
+ * `owl:unionOf` rows, sorted here by content so the same union always
8254
+ * reads back the same way regardless of the order its rows arrived in, and
8255
+ * regardless of the reader's locale. Every member shares the union node's
8256
+ * subject and predicate, so content order is member name then source.
8220
8257
  * Pure — takes the fact rows, returns one line. */
8221
8258
  function renderUnionLine(node, members) {
8222
- const sorted = [...members].sort((a, b) => a.object.localeCompare(b.object));
8259
+ const sorted = [...members].sort(compareFactsByContent);
8223
8260
  // One sentence states every arm, so its caveat covers every row it was
8224
8261
  // composed from, not just the one the citation came off.
8225
- const tail = factLineTail({ ...node, extraction: composedExtraction([node, ...sorted]) });
8262
+ const tail = factLineTail({
8263
+ ...node,
8264
+ extraction: composedExtraction([node, ...sorted]),
8265
+ attributedTo: composedAttribution([node, ...sorted]),
8266
+ });
8226
8267
  const arms = sorted.map((m) => `${indefiniteArticleFor(m.object)} ${m.object}`).join(" or ");
8227
8268
  return `every ${node.subject} is ${arms}${tail}`;
8228
8269
  }
@@ -8230,19 +8271,25 @@ function renderUnionLine(node, members) {
8230
8271
  /** An enumerated class's stored `owl:oneOf` triples read back as ONE sentence:
8231
8272
  * "the primary colours are exactly red, yellow and blue (source: …)". `cls`
8232
8273
  * is the enumerated class's own term; `members` are its `owl:oneOf` rows,
8233
- * sorted here by member name for the same order-independence renderUnionLine
8234
- * keeps. The class name pluralizes through the same naive "+s/+es/+ies" fold
8274
+ * sorted here by content for the same order- and locale-independence
8275
+ * renderUnionLine keeps; the source the line cites is read off the first
8276
+ * member in that order, so it settles the same way for every reader.
8277
+ * The class name pluralizes through the same naive "+s/+es/+ies" fold
8235
8278
  * thirdPersonSingularSurface already applies to a verb lemma — the same
8236
8279
  * accepted trade documented there. Pure — takes the fact rows, returns one
8237
8280
  * line. */
8238
8281
  function renderEnumerationLine(cls, members) {
8239
- const sorted = [...members].sort((a, b) => a.object.localeCompare(b.object));
8282
+ const sorted = [...members].sort(compareFactsByContent);
8240
8283
  const names = sorted.map((m) => m.object);
8241
8284
  const list = names.length > 1
8242
8285
  ? `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`
8243
8286
  : names.join("");
8244
8287
  const citeSource = sorted.find((m) => m.provenance)?.provenance;
8245
- const tail = factLineTail({ provenance: citeSource || "", extraction: composedExtraction(sorted) });
8288
+ const tail = factLineTail({
8289
+ provenance: citeSource || "",
8290
+ extraction: composedExtraction(sorted),
8291
+ attributedTo: composedAttribution(sorted),
8292
+ });
8246
8293
  const plural = thirdPersonSingularSurface(String(cls || "").replace(/-/g, " "));
8247
8294
  return `the ${plural} are exactly ${list}${tail}`;
8248
8295
  }
@@ -8258,12 +8305,57 @@ function renderEnumerationLine(cls, members) {
8258
8305
  function renderUniversalRestrictionLine(node, rows) {
8259
8306
  const onProperty = rows.find((r) => r.predicate === "owl:onProperty");
8260
8307
  const allValuesFrom = rows.find((r) => r.predicate === "owl:allValuesFrom");
8261
- const tail = factLineTail({ ...node, extraction: composedExtraction([node, onProperty, allValuesFrom]) });
8308
+ const tail = factLineTail({
8309
+ ...node,
8310
+ extraction: composedExtraction([node, onProperty, allValuesFrom]),
8311
+ attributedTo: composedAttribution([node, onProperty, allValuesFrom]),
8312
+ });
8262
8313
  const filler = thirdPersonSingularSurface(String(allValuesFrom?.object || "").replace(/-/g, " "));
8263
8314
  return `every ${node.subject} ${onProperty?.object || ""} only ${filler}${tail}`;
8264
8315
  }
8265
8316
  export { renderUnionLine, renderEnumerationLine, renderUniversalRestrictionLine };
8266
8317
 
8318
+ /** The read-time twin of sense-gate.mjs's screen on isa DERIVATION. The
8319
+ * WordNet-derived bands flatten every sense of a word onto one label —
8320
+ * "region" carries a geographic sense and an anatomical one, and both rows
8321
+ * store the same six characters — so a walk that climbs through the shared
8322
+ * label comes straight back down the other sense: russia ⊑ country ⊑
8323
+ * geographical area ⊑ region ⊑ body part. The offline closure already
8324
+ * refuses that step. The readers here walk the same edges live, one question
8325
+ * at a time, and so need the same screen.
8326
+ *
8327
+ * Built over the ASSERTED isa rows only. An entailed row is the closure's
8328
+ * own output, and feeding that back in is what the gate exists to stop. A
8329
+ * stated row is never screened either: nothing here constrains what a corpus
8330
+ * or a teacher recorded, only what a multi-hop walk concludes on top of it.
8331
+ *
8332
+ * Pure over the fact set — buildSenseGate sorts every frontier and memoizes
8333
+ * per term, so two ingestion orders of the same rows give the same verdicts.
8334
+ * Held on the caller's per-turn factRows cache and keyed on the row array
8335
+ * itself, so one turn builds one gate however many readers ask for it. */
8336
+ function readTimeSenseScreen(rows, cache = null) {
8337
+ if (cache?.senseScreen?.rows === rows) return cache.senseScreen.gate;
8338
+ const subClassEdges = [];
8339
+ const typeEdges = [];
8340
+ for (const f of rows || []) {
8341
+ if (String(f.provenance || "").includes("entailed:")) continue;
8342
+ if (f.predicate === SUBCLASS_PREDICATE) subClassEdges.push([f.subject, f.object]);
8343
+ else if (f.predicate === TYPE_PREDICATE) typeEdges.push([f.subject, f.object]);
8344
+ }
8345
+ const gate = buildSenseGate({ subClassEdges, typeEdges });
8346
+ if (cache) cache.senseScreen = { rows, gate };
8347
+ return gate;
8348
+ }
8349
+
8350
+ /** True when concluding `from ⊑ <the asked term>` would cross two senses the
8351
+ * screen holds apart. Every spelling variant of the asked term is tried: an
8352
+ * unplaced spelling resolves to no top and so blocks nothing, which is the
8353
+ * gate's own "one unresolved end lets it through" rule. */
8354
+ function senseScreenBlocksWalk(screen, from, toVariants) {
8355
+ for (const to of toVariants) if (screen.declines(from, to)) return true;
8356
+ return false;
8357
+ }
8358
+
8267
8359
  /** Append an is-a object's superclass chain to its rendered fact line, before
8268
8360
  * the caveat and the citation: "rover is a kind of dog" becomes "rover is a
8269
8361
  * kind of dog → canine → mammal → animal". Only the subject-side is-a lines
@@ -8271,11 +8363,20 @@ export { renderUnionLine, renderEnumerationLine, renderUniversalRestrictionLine
8271
8363
  *
8272
8364
  * `toward` steers the chain toward a specific ancestor (the class a "list …"
8273
8365
  * question actually asked about) when the is-a object has more than one
8274
- * taught parent. */
8275
- function renderFactLineWithChain(f, parents, subjectVariants, { toward = null } = {}) {
8366
+ * taught parent.
8367
+ *
8368
+ * `screen` (readTimeSenseScreen's gate) trims the chain where it would cross
8369
+ * a sense join, so the rendered line stops at the last ancestor the fact's
8370
+ * own object really sits under rather than reading "…→ region → body part"
8371
+ * off a geographic subject. */
8372
+ function renderFactLineWithChain(f, parents, subjectVariants, { toward = null, screen = null } = {}) {
8276
8373
  const base = renderFactLine(f);
8277
8374
  if (!ISA_PREDICATES.has(f.predicate) || !subjectVariants.has(f.subject)) return base;
8278
- const chain = ancestryChain(f.object, parents, { cap: 6, stopAt: ANSWER_STOP_SET, toward });
8375
+ let chain = ancestryChain(f.object, parents, { cap: 6, stopAt: ANSWER_STOP_SET, toward });
8376
+ if (screen) {
8377
+ const crossing = chain.findIndex((node, i) => i > 0 && screen.declines(f.object, node));
8378
+ if (crossing > 0) chain = chain.slice(0, crossing);
8379
+ }
8279
8380
  if (chain.length <= 1) return base;
8280
8381
  const suffix = ` → ${chain.slice(1).join(" → ")}`;
8281
8382
  const tail = factLineTail(f);
@@ -8292,10 +8393,11 @@ function renderFactLineWithChain(f, parents, subjectVariants, { toward = null }
8292
8393
  * Returns `{ lines, grouped }`. `lines` is the flat, chain-enhanced rendering
8293
8394
  * (indented by `indent`) the caller uses when senses do not split. `grouped`
8294
8395
  * is a ready `{ text, replace, pending? }` answer when they do, else null. */
8295
- function senseSplitFactList(hits, rows, subjectVariants, { indent = "" } = {}) {
8396
+ function senseSplitFactList(hits, rows, subjectVariants, { indent = "", cache = null } = {}) {
8296
8397
  const subClassEdges = rows.filter((f) => f.predicate === SUBCLASS_PREDICATE).map((f) => [f.subject, f.object]);
8297
8398
  const parents = subClassParents(subClassEdges);
8298
- const lines = hits.map((f) => `${indent}${renderFactLineWithChain(f, parents, subjectVariants)}`);
8399
+ const screen = readTimeSenseScreen(rows, cache);
8400
+ const lines = hits.map((f) => `${indent}${renderFactLineWithChain(f, parents, subjectVariants, { screen })}`);
8299
8401
 
8300
8402
  const isaSubjectFacts = hits.filter((f) => ISA_PREDICATES.has(f.predicate) && subjectVariants.has(f.subject));
8301
8403
  const isaObjects = [...new Set(isaSubjectFacts.map((f) => f.object))];
@@ -8313,7 +8415,7 @@ function senseSplitFactList(hits, rows, subjectVariants, { indent = "" } = {}) {
8313
8415
  const restItems = [];
8314
8416
  let shownCount = 0;
8315
8417
  const addLine = (f) => {
8316
- const rendered = renderFactLineWithChain(f, parents, subjectVariants);
8418
+ const rendered = renderFactLineWithChain(f, parents, subjectVariants, { screen });
8317
8419
  if (shownCount < FACT_ANSWER_CAP) { shownCount += 1; return `${indent}${rendered}`; }
8318
8420
  restItems.push(rendered);
8319
8421
  return null;
@@ -8786,6 +8888,14 @@ const RECURSIVE_LIST_ASK_RE = /^list\s+(?:the\s+|all\s+)?([a-z][\w-]*)\s+of\s+([
8786
8888
  const ISA_ASK_RE = /^(?:is|are)\s+(?:an?\s+)?(.+?)\s+(?:a\s+kind\s+of|a\s+type\s+of|an?)\s+(.+?)[?.!\s]*$/i;
8787
8889
  const ISA_PREDICATES = new Set(["rdfs:subClassOf", "rdf:type"]);
8788
8890
 
8891
+ /** An already-ranked hit list with the subject's type facts moved to the
8892
+ * front, each half keeping its ranked order. What a thing IS answers "what is
8893
+ * X" more directly than anything else the store holds about it. */
8894
+ const definitionFirst = (hits) => [
8895
+ ...hits.filter((f) => ISA_PREDICATES.has(f.predicate)),
8896
+ ...hits.filter((f) => !ISA_PREDICATES.has(f.predicate)),
8897
+ ];
8898
+
8789
8899
  /** How far the isa ladder's miss text probes for a chain it can name a
8790
8900
  * recovery for. Purely a REPORTING reach: the live chases answer within their
8791
8901
  * own hop bounds and this never widens them, it only tells the miss whether
@@ -9434,9 +9544,12 @@ async function restrictionExistentialHit(memoryDir, cache, subjectVariants, fill
9434
9544
  // the ones meant to answer, including a provable "no".
9435
9545
  const subjectRestrictions = rows.filter((r) => r.predicate === SUBCLASS_PREDICATE && subjectVariants.has(r.subject)
9436
9546
  && onPropertyOf.has(r.object) && someValuesFromOf.has(r.object));
9547
+ // Which restriction gets cited has to settle the same way for every reader,
9548
+ // so the pick runs on the restriction node's id in codepoint order and falls
9549
+ // through to the row's own content when two subject variants point at one node.
9437
9550
  const hit = subjectRestrictions
9438
9551
  .filter((r) => fillerVariants.has(someValuesFromOf.get(r.object)))
9439
- .sort((a, b) => a.object.localeCompare(b.object))[0];
9552
+ .sort((a, b) => byCodepoint(a.object, b.object) || compareFactsByContent(a, b))[0];
9440
9553
  if (hit) {
9441
9554
  const byId = new Map(rows.map((r) => [r.id, r]));
9442
9555
  const premises = (hit.justification || [])
@@ -9853,7 +9966,7 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
9853
9966
  const containerVariants = factTermVariants(normFactTerm, containerRaw.replace(/^(?:an?|the)\s+/i, "").trim());
9854
9967
  const hits = (await factRows(memoryDir, cache)).filter(
9855
9968
  (f) => predicates.includes(f.predicate) && containerVariants.has(f.object),
9856
- ).sort(orderKeyCompare); // content-addressed order, not arrival order — see answerCollectionContents' own note
9969
+ );
9857
9970
  if (hits.length) {
9858
9971
  const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
9859
9972
  const lines = ranked.map(renderFactLine);
@@ -10049,10 +10162,15 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
10049
10162
  }
10050
10163
  // Bias only REORDERS — every hit still renders and is cited (Part 6's
10051
10164
  // "disclosed, never dropped" contract). Unconfigured/tied bias degrades to
10052
- // trust-desc, byte-identical to before this feature existed.
10053
- hits = rankByBiasThenTrust(hits, biasByBundle);
10165
+ // trust-desc.
10166
+ //
10167
+ // A definition then leads with what the subject IS. Bias and trust say
10168
+ // nothing about that, and once they tie the isa row sorts wherever its
10169
+ // predicate spelling happens to fall, so "what is a dog" opens on "dog can
10170
+ // bark". Every other hit keeps its ranked place behind it.
10171
+ hits = definitionFirst(rankByBiasThenTrust(hits, biasByBundle));
10054
10172
  const allRows = await factRows(memoryDir, cache);
10055
- const { lines, grouped } = senseSplitFactList(hits, allRows, variants);
10173
+ const { lines, grouped } = senseSplitFactList(hits, allRows, variants, { cache });
10056
10174
  // A long undifferentiated "what is X" leads with the digest — a bounded
10057
10175
  // narrative over the same facts — and holds the full list behind the escape.
10058
10176
  // It wins over the sense-split grouping here: the digest's own selector
@@ -10657,12 +10775,21 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
10657
10775
  // framing on, just above.
10658
10776
  const isTaughtFact = (f) => !String(f.provenance || "").includes("corpus:") && !String(f.provenance || "").includes("web:");
10659
10777
  const isaRows = rows.filter((f) => ISA_PREDICATES.has(f.predicate) && isTaughtFact(f));
10778
+ // Each subject this walk collects is a claim that it is a subtype of the
10779
+ // ASKED term — a conclusion the walk derives, never a row anyone stated —
10780
+ // so it takes the same disjointness screen the offline closure applies to
10781
+ // the same conclusion. Without it a sense-mixed label part-way up the
10782
+ // chain carries the walk down the other sense, and "what do you know
10783
+ // about body part" answers with facts about Russia.
10784
+ const subtypeScreen = readTimeSenseScreen(rows, cache);
10660
10785
  const subtypeSubjects = new Set();
10661
10786
  let frontier = variants;
10662
10787
  for (let hop = 0; hop < 8 && frontier.size; hop += 1) {
10663
10788
  const nextSubjects = new Set();
10664
10789
  for (const f of isaRows) {
10665
- if (frontier.has(f.object) && !subtypeSubjects.has(f.subject)) nextSubjects.add(f.subject);
10790
+ if (!frontier.has(f.object) || subtypeSubjects.has(f.subject)) continue;
10791
+ if (senseScreenBlocksWalk(subtypeScreen, f.subject, variants)) continue;
10792
+ nextSubjects.add(f.subject);
10666
10793
  }
10667
10794
  if (!nextSubjects.size) break;
10668
10795
  for (const s of nextSubjects) subtypeSubjects.add(s);
@@ -10761,7 +10888,7 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
10761
10888
  hits = rankByBiasThenTrust(hits, biasByBundle);
10762
10889
  const header = `${hits.length} remembered fact${hits.length === 1 ? "" : "s"} about ${term}`
10763
10890
  + `${viaSubtype ? " (including its known subtypes)" : ""}:`;
10764
- const { lines, grouped } = senseSplitFactList(hits, rows, variants, { indent: " " });
10891
+ const { lines, grouped } = senseSplitFactList(hits, rows, variants, { indent: " ", cache });
10765
10892
  if (grouped) return { ...grouped, text: `${header}\n${grouped.text}`, ...(weakOnly ? { weakOnly: true } : {}) };
10766
10893
  const shown = lines.slice(0, FACT_ANSWER_CAP);
10767
10894
  const rest = lines.slice(FACT_ANSWER_CAP);
@@ -10845,7 +10972,7 @@ async function whatElseAnswer(memoryDir, query, last) {
10845
10972
  let normFactTerm;
10846
10973
  try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
10847
10974
  const variants = factTermVariants(normFactTerm, term);
10848
- const hits = (await memoryFacts(memoryDir)).filter((f) => variants.has(f.subject));
10975
+ const hits = (await memoryFacts(memoryDir)).filter((f) => variants.has(f.subject)).sort(compareFactsByContent);
10849
10976
  const picture = pickPhrase("full-picture", term.toLowerCase(), "the full picture");
10850
10977
  const nothingMore = {
10851
10978
  text: `That's everything I know about "${term}" — /memory to see ${picture}.`,
@@ -10888,7 +11015,7 @@ async function synonymFactAnswer(memoryDir, query, envelope) {
10888
11015
  const facts = await memoryFacts(memoryDir);
10889
11016
  for (const { variant, source } of await synonymsOf(term)) {
10890
11017
  const variants = factTermVariants(normFactTerm, variant);
10891
- const hits = facts.filter((f) => variants.has(f.subject));
11018
+ const hits = facts.filter((f) => variants.has(f.subject)).sort(compareFactsByContent);
10892
11019
  if (!hits.length) continue;
10893
11020
  const lines = hits.map(renderFactLine);
10894
11021
  const shown = lines.slice(0, FACT_ANSWER_CAP);
@@ -11263,7 +11390,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
11263
11390
  if (!((fallThroughIsa && !/^there\b/i.test(fallThroughIsa[1].trim())) || CONFIRM_TAG_RE.test(q))) return null;
11264
11391
  }
11265
11392
  const isa = rows.filter((f) => ISA_PREDICATES.has(f.predicate));
11266
- const byTrust = (a, b) => b.trust - a.trust;
11393
+ const byTrust = (a, b) => (b.trust - a.trust) || compareFactsByContent(a, b);
11267
11394
  const renderMany = (hits) => {
11268
11395
  const lines = hits.map(renderFactLine);
11269
11396
  const shown = lines.slice(0, FACT_ANSWER_CAP);
@@ -11812,9 +11939,17 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
11812
11939
  // technically-true-per-ConceptNet "yes" that has nothing to do with
11813
11940
  // what the OPERATOR taught; only operator/teach/entailed-sourced isa
11814
11941
  // facts are chased, matching "TAUGHT" in the gap's own name.
11942
+ // Every chase from here down concludes a subsumption nobody stated, so
11943
+ // each takes the same disjointness screen the offline closure applies to
11944
+ // the same conclusion: a subject whose own asserted chain places it under
11945
+ // a top disjoint from the asked class is not chased at all. A stated fact
11946
+ // is untouched — the direct-hit and polarity branches above have already
11947
+ // returned by the time this runs, so the screen only ever costs a walk.
11948
+ const chaseSenseScreen = readTimeSenseScreen(rows, cache);
11949
+ const chaseSubjects = [...subjCandidates].filter((s) => !senseScreenBlocksWalk(chaseSenseScreen, s, objVariants));
11815
11950
  const factForStep = (step) => (step.predicate === SC_PREDICATE ? chainSubClassRows : chainTypeRows)
11816
11951
  .find((f) => f.subject === step.subject && f.object === step.object);
11817
- for (const subj of subjCandidates) {
11952
+ for (const subj of chaseSubjects) {
11818
11953
  const chain = findIsaChain(subj, objVariants, chainTypeEdges, chainSubClassSucc, { maxHops: 2 });
11819
11954
  if (!chain) continue;
11820
11955
  const chainRefusal = disjointRefusalFor(subj);
@@ -11835,7 +11970,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
11835
11970
  // someValuesFrom chases keep their original, narrower discipline.
11836
11971
  const mixedFactForStep = (step) => (step.predicate === SC_PREDICATE ? mixedSubClassRows : mixedTypeRows)
11837
11972
  .find((f) => f.subject === step.subject && f.object === step.object);
11838
- for (const subj of subjCandidates) {
11973
+ for (const subj of chaseSubjects) {
11839
11974
  const chain = findIsaChain(subj, objVariants, mixedTypeEdges, mixedSubClassSucc, { maxHops: 2 });
11840
11975
  if (!chain) continue;
11841
11976
  const chainRefusal = disjointRefusalFor(subj);
@@ -12015,7 +12150,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
12015
12150
  }
12016
12151
  : undefined;
12017
12152
  };
12018
- for (const subj of subjCandidates) {
12153
+ for (const subj of chaseSubjects) {
12019
12154
  const chain = findIsaChain(subj, objVariants, chainTypeEdges, enlargedSubClassSucc, { maxHops: 3 });
12020
12155
  if (!chain) continue;
12021
12156
  const premises = chain.map(factForStepOrSvf);
@@ -12056,8 +12191,11 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
12056
12191
  // exists, and telling someone to teach a fact that already follows from
12057
12192
  // what they taught is the mirror lie. The probe reads the SAME taught
12058
12193
  // edge lists the chases use, and /syllogise closes over a superset of
12059
- // them, so a chain found here is one it can really materialize.
12060
- const deeperChainExists = [...subjCandidates].some(
12194
+ // them, so a chain found here is one it can really materialize. The
12195
+ // screened subject list is what the probe walks for that reason: a chain
12196
+ // across a sense join is one /syllogise's own gate would refuse, so
12197
+ // offering it here would send the reader after a fact that never lands.
12198
+ const deeperChainExists = chaseSubjects.some(
12061
12199
  (subj) => findIsaChain(subj, objVariants, chainTypeEdges, chainSubClassSucc, { maxHops: DEEP_CHAIN_PROBE_HOPS }),
12062
12200
  );
12063
12201
  // TRACK B1 — the automatic /prove fallback: every live chase above has
@@ -17174,7 +17312,7 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
17174
17312
  lines.push('taught actions: none yet — teach one ("you can move a disk onto a peg.") and /plan can use it.');
17175
17313
  } else {
17176
17314
  lines.push("taught actions (planned over, never dispatched):");
17177
- for (const [familyName, family] of [...families.entries()].sort(([a], [b]) => a.localeCompare(b))) {
17315
+ for (const [familyName, family] of [...families.entries()].sort(([a], [b]) => byCodepoint(a, b))) {
17178
17316
  const cap = capabilityFromActionRules(familyName, family);
17179
17317
  const sig = cap.parameters
17180
17318
  .map((p) => `${p.name}: ${p.classes.filter(Boolean).join("|") || "?"}`)
@@ -19129,7 +19267,9 @@ async function dispatchTurn(input, { config, source = defaultSource, graph = nul
19129
19267
  const separated = separateChoiceTie(grounded, constraintTerms, rowsAfterPull);
19130
19268
  if (separated) {
19131
19269
  const { winner, runnerUp, missedByRunnerUp } = separated;
19132
- const groundingFact = winner.facts.length > 0 ? winner.facts.slice().sort((a, b) => b.trust - a.trust)[0] : null;
19270
+ const groundingFact = winner.facts.length > 0
19271
+ ? winner.facts.slice().sort((a, b) => (b.trust - a.trust) || compareFactsByContent(a, b))[0]
19272
+ : null;
19133
19273
  const groundingClause = groundingFact
19134
19274
  ? `${factPhrase(groundingFact)} (source: ${citationProvenance(groundingFact.provenance)})`
19135
19275
  : renderIsaChain(winner.chain);
@@ -19170,7 +19310,7 @@ async function dispatchTurn(input, { config, source = defaultSource, graph = nul
19170
19310
  // edge exists for the winning option — probeChoiceOptions' own guard.
19171
19311
  let text; let matches; let hop; let traversalNote; let matchedBy;
19172
19312
  if (winner.facts.length > 0) {
19173
- const fact = winner.facts.slice().sort((a, b) => b.trust - a.trust)[0];
19313
+ const fact = winner.facts.slice().sort((a, b) => (b.trust - a.trust) || compareFactsByContent(a, b))[0];
19174
19314
  text = `${winner.text} — ${factPhrase(fact)} (source: ${citationProvenance(fact.provenance)}).`;
19175
19315
  matches = winner.facts;
19176
19316
  hop = 1;