@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
@@ -23,10 +23,14 @@
23
23
  // ledger is built from state.ledger via ledgerFromPayload only
24
24
  // for the span of one call, then folded back with ledgerPayload.
25
25
  // providers { newsFetchers: Map<sourceId, { id, fetchItems }>,
26
- // getResearchProvider({ source }), preflightNewsUrl?(url) } —
26
+ // getResearchProvider({ source }), preflightNewsUrl?(url),
27
+ // queryBandTerm?({band, term, exclusiveStartKey, limit}) } —
27
28
  // every fetcher and provider this session may call, already
28
29
  // constructed (adapters own I/O construction, never this
29
- // module).
30
+ // module). `queryBandTerm` is subgraph-retrieval.mjs's
31
+ // `termQueryOverDocumentClient`, built once over the caller's own
32
+ // DynamoDB client; absent (the browser surface, tests with no
33
+ // band fixture) simply skips the band-grounding fallback below.
30
34
  // now a function returning the caller's clock reading (ISO string
31
35
  // or ms), or a fixed reading directly. Never read from here —
32
36
  // the wall clock enters only through this parameter.
@@ -41,7 +45,7 @@
41
45
  // section 3). Network lives in the adapters this module is handed, never
42
46
  // inside it.
43
47
 
44
- import { normFactTerm, normFactPredicate, factIdFor } from "../domain/hash.mjs";
48
+ import { normFactTerm, normFactPredicate, normText, factIdFor } from "../domain/hash.mjs";
45
49
  import {
46
50
  newsWindowRows, renderNewsParagraph, buildNewsItems, evictNewsFacts,
47
51
  conceptTerms, isQuantityTerm, newsItemKeys,
@@ -59,8 +63,11 @@ import {
59
63
  } from "../adapters/corpus/news-sources.mjs";
60
64
  import { DEFAULT_MIN_INTERVAL_MS } from "../adapters/corpus/courtesy.mjs";
61
65
  import { researchFacts } from "../adapters/corpus/research-source.mjs";
66
+ import { factFromBandRow } from "../adapters/memory/corpus-bands.mjs";
62
67
  import { throughSourceBreaker, sourceSkipStatusLine } from "../domain/source-breaker.mjs";
63
- import { ingestText, readsAsEntityTerm } from "./extract-facts.mjs";
68
+ import {
69
+ ingestText, readsAsEntityTerm, ungroundedTermOccurrences, termsUsedOnlyAsVerbs,
70
+ } from "./extract-facts.mjs";
64
71
 
65
72
  export { NEWS_SOURCE_RECORDS, DEFAULT_NEWS_SOURCE_IDS, DEFAULT_NEWS_KB_IDS };
66
73
 
@@ -77,6 +84,9 @@ export const NEWS_DEFAULTS = Object.freeze({
77
84
  enrichTermsPerCycle: 3,
78
85
  negativeCacheTtlHours: 24,
79
86
  syllogismsPerIngest: 12,
87
+ // Per source, not across the whole poll: each enabled source keeps its own
88
+ // window of up to this many snapshots, so one prolific source can never
89
+ // crowd another out of the window before either has been read.
80
90
  itemCap: 30,
81
91
  newsFactCap: 4000,
82
92
  feedTop: 3,
@@ -246,18 +256,19 @@ function abortSignalOf(ctx) {
246
256
  return typeof ctx?.shouldAbort === "function" ? ctx.shouldAbort : () => false;
247
257
  }
248
258
 
249
- // A feed title rarely carries its own terminal punctuation ("Scientists
250
- // Discover New Species"); a fixture built from a full sentence ("A module is
251
- // a component.") already does. Joining unconditionally with ". " doubles the
252
- // period in the second case and corrupts sentence splitting downstream so
253
- // the separator is a bare space when the title already ends the sentence.
254
- const SENTENCE_END_RE = /[.!?]["')\]]*$/;
259
+ // The headline and the item's own description are two separate texts, so they
260
+ // reach the ingest as two PARAGRAPHS. Run together on one line they merge into
261
+ // one sentence whenever the headline ends on something the sentence splitter
262
+ // will not break after an abbreviation ("… Arrives in the U.S." + "Russia
263
+ // released …" reads as one 26-word sentence, and the fact that falls out of it
264
+ // has "u.s. russia" for a subject) or a description that opens on a quotation.
265
+ // A blank line between them is the boundary ingestText already splits on.
255
266
  function joinTitleAndSummary(title, summary) {
256
267
  const t = String(title || "").trim();
257
268
  const s = String(summary || "").trim();
258
269
  if (!t) return s;
259
270
  if (!s) return t;
260
- return `${t}${SENTENCE_END_RE.test(t) ? " " : ". "}${s}`;
271
+ return `${t}\n\n${s}`;
261
272
  }
262
273
 
263
274
  function toMs(value) {
@@ -305,6 +316,31 @@ function buildSourcesByFactId(items) {
305
316
  return map;
306
317
  }
307
318
 
319
+ /** The entity names a card's own article text carries, read through the same
320
+ * capture the enrichment queue is fed from (`ungroundedTermOccurrences`), so a
321
+ * name reaches a card's background under exactly the key enrichment stored it
322
+ * under. The fact set handed in is empty on purpose: that call's own filter
323
+ * drops a term the graph already holds facts about, and a card wants precisely
324
+ * those. A single word the lexicon reads as an everyday noun drops out here —
325
+ * "developer" names nothing a lookup could define — while a name run keeps its
326
+ * whole spelling, "tim king" and "amigados" alike.
327
+ *
328
+ * A word the article only ever uses as a clause's verb names no entity either,
329
+ * however the tagger read it: "many say it is …" is the article saying
330
+ * something, and "the government moves to assert control" is the government
331
+ * moving. Either one reaching this list gives a card's background walk an
332
+ * anchor the article never named. */
333
+ export function articleEntityNames(texts, { lexicon } = {}) {
334
+ const lex = lexicon || loadLexicon();
335
+ const verbs = termsUsedOnlyAsVerbs(texts, { lexicon: lex });
336
+ const names = [];
337
+ for (const term of ungroundedTermOccurrences(texts, [], { lexicon: lex }).keys()) {
338
+ if (verbs.has(term)) continue;
339
+ if (term.includes(" ") || !isVocabGroundedTerm(lex, term)) names.push(term);
340
+ }
341
+ return names.sort();
342
+ }
343
+
308
344
  // ---------------------------------------------------------------------------
309
345
  // grounding definitions (10.2)
310
346
  // ---------------------------------------------------------------------------
@@ -639,10 +675,15 @@ export async function pollNewsSources(ctx) {
639
675
  recordSuccess(health, nowVal, "not-modified");
640
676
  } else {
641
677
  recordSuccess(health, nowVal, "ok");
642
- const merged = mergeSnapshots(state.items, result.items, {
678
+ // itemCap bounds THIS source's own window: mergeSnapshots sees only
679
+ // sourceId's existing snapshots, so one prolific source's window
680
+ // never crowds out another's before either has been read.
681
+ const ownItems = (state.items || []).filter((snap) => snap?.sourceId === sourceId);
682
+ const otherItems = (state.items || []).filter((snap) => snap?.sourceId !== sourceId);
683
+ const merged = mergeSnapshots(ownItems, result.items, {
643
684
  cap: config.itemCap, seen: state.seenItemKeys,
644
685
  });
645
- state.items = merged.items;
686
+ state.items = [...otherItems, ...merged.items].sort(byFetchedAtThenId);
646
687
  added = merged.added;
647
688
  newItemsTotal += added.length;
648
689
  }
@@ -715,6 +756,139 @@ const KB_SOURCE_TO_RESEARCH_CHOICE = Object.freeze({
715
756
  "dbpedia-lookup": "dbpedia",
716
757
  });
717
758
 
759
+ const ISA_PREDICATES = new Set(["rdfs:subClassOf", "rdf:type"]);
760
+
761
+ const WORD_CHARACTER_RE = /[a-z0-9]/;
762
+
763
+ /** True when `prose` names `term` as a whole word — the test for whether a
764
+ * looked-up body is a STATEMENT about the term or merely a LABEL for it.
765
+ *
766
+ * A reference article's summary opens on the word it defines ("AmigaDOS is
767
+ * the disk operating system of the AmigaOS"). An entity description field
768
+ * does not: it is a bare noun phrase naming the class instead ("disk
769
+ * operating system of the AmigaOS"), and a sentence recognizer reading one
770
+ * finds a subject, a verb and an object inside the phrase itself — "disk
771
+ * operates a system of the amigaos", a claim about a word the lookup was
772
+ * never about. The class such a phrase names is what the structured tier
773
+ * already carries, so there is nothing to lose by leaving it unread. */
774
+ function proseNamesTerm(prose, term) {
775
+ const haystack = normText(prose).toLowerCase();
776
+ const needle = normFactTerm(term);
777
+ if (!haystack || !needle) return false;
778
+ for (let from = 0; ; from += 1) {
779
+ const at = haystack.indexOf(needle, from);
780
+ if (at < 0) return false;
781
+ const before = at === 0 ? "" : haystack[at - 1];
782
+ const after = haystack[at + needle.length] ?? "";
783
+ if (!WORD_CHARACTER_RE.test(before) && !WORD_CHARACTER_RE.test(after)) return true;
784
+ from = at;
785
+ }
786
+ }
787
+
788
+ /** Every name the looked-up definition is ABOUT: the term that was asked and
789
+ * the title the source answered with ("solar eclipse" and "Solar eclipse",
790
+ * "u.s. virgin islands" and "United States Virgin Islands"), each with its
791
+ * plural, since a body says "Solar eclipses can only happen during a new
792
+ * moon" as readily as it says "a solar eclipse". */
793
+ function definedTermKeys(term, article) {
794
+ const keys = new Set();
795
+ for (const name of [term, article?.term, article?.title]) {
796
+ const key = normFactTerm(name ?? "");
797
+ if (!key) continue;
798
+ keys.add(key);
799
+ keys.add(normFactTerm(pluralOf(key)));
800
+ }
801
+ return keys;
802
+ }
803
+
804
+ const SENTENCE_WORD_RE = /[a-z0-9][a-z0-9'’-]*/g;
805
+
806
+ const COPULAS = new Set(["is", "are", "was", "were", "be", "been", "being"]);
807
+
808
+ // Words that mean the copula opened something other than the term's class:
809
+ // a clause ("an earthquake is WHEN tectonic plates shake"), a denial ("Puerto
810
+ // Rico is NOT an independent country"), a second phrase ("is one OF the...")
811
+ // or a list ("is a state AND a...").
812
+ const CLASS_PHRASE_BREAKERS = new Set([
813
+ "not", "no", "never", "n't", "nor",
814
+ "when", "where", "while", "why", "how", "who", "whom", "whose", "which", "that",
815
+ "what", "because", "if", "whether", "unless", "until", "since", "though", "although",
816
+ "of", "in", "on", "at", "from", "with", "by", "to", "for", "about", "into", "onto",
817
+ "over", "under", "between", "among", "during", "near", "across", "through", "against",
818
+ "and", "or", "but",
819
+ ]);
820
+
821
+ /** True when `sentence` states `object` as the class the copula introduces —
822
+ * the test for whether a class claim read out of a definition body is what
823
+ * the body actually says the term is.
824
+ *
825
+ * The optimistic tier answers with a class it found somewhere in the
826
+ * sentence, which is right when the sentence is a definition ("A tsunami is
827
+ * a natural disaster" -> disaster) and wrong the moment the copula opens
828
+ * anything else. "An earthquake is when Earth's tectonic plates shake"
829
+ * yields "an earthquake is an earth"; "Puerto Rico is not an independent
830
+ * country" yields the exact claim the sentence denies. So the class has to
831
+ * sit inside the noun phrase the copula introduces: only determiners,
832
+ * numbers and adjectives may stand between them, never a clause, a
833
+ * preposition, a conjunction or a negation. A body that states its class
834
+ * some other way keeps it to itself, which costs a fact and never a wrong
835
+ * one. */
836
+ function bodyStatesClass(sentence, object) {
837
+ const words = String(sentence ?? "").toLowerCase().match(SENTENCE_WORD_RE) || [];
838
+ const head = (normFactTerm(object).match(SENTENCE_WORD_RE) || [])[0];
839
+ if (!head || !words.length) return false;
840
+ const plural = pluralOf(head);
841
+ const namesHead = (word) => {
842
+ const bare = word.replace(/['’]s$/, "");
843
+ return bare === head || bare === plural || pluralOf(bare) === head;
844
+ };
845
+ for (let at = 0; at < words.length; at += 1) {
846
+ if (!namesHead(words[at])) continue;
847
+ for (let back = at - 1; back >= 0; back -= 1) {
848
+ if (CLASS_PHRASE_BREAKERS.has(words[back])) break;
849
+ if (COPULAS.has(words[back])) return true;
850
+ }
851
+ }
852
+ return false;
853
+ }
854
+
855
+ /** The rows a definition body is allowed to give up: those about the term it
856
+ * defines, and — for a class claim — those the body states as that term's
857
+ * class. A body is evidence about its own subject, so a row whose subject is
858
+ * some other term the prose merely mentions ("Every year there are about two
859
+ * solar eclipses" -> a year is a kind of eclipse; "The Calpine Corporation
860
+ * operates and owns 19 of the 22 facilities" -> a corporation owns a
861
+ * facility) is not a definition of anything and is dropped. */
862
+ function factsDefinitionBodyStates(rows, termKeys) {
863
+ return rows.filter((row) => {
864
+ if (!termKeys.has(normFactTerm(row.subject))) return false;
865
+ if (!ISA_PREDICATES.has(row.predicate)) return true;
866
+ return bodyStatesClass(row.sentence, row.object);
867
+ });
868
+ }
869
+
870
+ const provenanceHead = (tag) => String(tag ?? "").split(/[\s@]/)[0];
871
+
872
+ const tripleKeyOf = (row) => `${normFactTerm(row.subject)}\0${normFactPredicate(row.predicate)}\0${normFactTerm(row.object)}`;
873
+
874
+ /** Retracts rows this call's ingest wrote and the definition-body rule then
875
+ * turned down. ingestText has already stored them by the time their rows
876
+ * come back, so they are matched by triple under this ingest's own
877
+ * provenance and removed — the same retract-after-write path the recognizer
878
+ * takes for a fragment term. */
879
+ async function retractRejectedBodyFacts(ctx, rejected, provenance) {
880
+ const { memoryDir, store, now } = ctx;
881
+ if (!rejected.length) return;
882
+ const keys = new Set(rejected.map(tripleKeyOf));
883
+ const rows = store.readFactRows(await store.loadMemory(memoryDir));
884
+ const ids = rows
885
+ .filter((row) => keys.has(tripleKeyOf(row)) && provenanceHead(row.provenance).endsWith(provenanceHead(provenance)))
886
+ .map((row) => row.id);
887
+ if (!ids.length) return;
888
+ await store.removeFacts(memoryDir, ids, { retractedAt: resolveNow(now) });
889
+ invalidateCache(ctx.cache);
890
+ }
891
+
718
892
  /** Grounds `article` under `provider`'s own provenance tag, through the SAME
719
893
  * ingest seam a polled article takes: the structured or isa facts the
720
894
  * research-source seam licenses (researchFacts), then the article's own
@@ -722,7 +896,23 @@ const KB_SOURCE_TO_RESEARCH_CHOICE = Object.freeze({
722
896
  * looked-up article therefore reaches the graph with the density of
723
897
  * relations a polled one does, and the feed's paraphrase templates have the
724
898
  * same kind of material to write a card from — a bare isa edge left the
725
- * reader with one bald sentence where a polled item got a paragraph. */
899
+ * reader with one bald sentence where a polled item got a paragraph.
900
+ *
901
+ * Two things a definition body is not allowed to do, both of them ways of
902
+ * restating the lookup's own class claim worse than the lookup already
903
+ * stated it. It is not read at all unless it names the term (proseNamesTerm,
904
+ * above). And where the lookup already licensed a class for the term, the
905
+ * body is read by the strict recognizer only: the optimistic tier's guess at
906
+ * a class can then only agree with what the structured rows say or truncate
907
+ * it — "AmigaDOS is the disk operating system" mints "amigados is a disk"
908
+ * beside the structured "amigados is a disk operating system" — while the
909
+ * relations the body states ("Rottnest has a lighthouse") are the strict
910
+ * tier's own work and survive either way.
911
+ *
912
+ * What the body does give up is then held to what it says about the term it
913
+ * defines (factsDefinitionBodyStates): a row about some other term the prose
914
+ * mentions, or a class the sentence never states, is retracted before it can
915
+ * reach the feed or a syllogism. */
726
916
  async function ingestResearchArticle(ctx, term, provider, article) {
727
917
  const { memoryDir, store, config, lexicon, now } = ctx;
728
918
  const provenance = provider.provenanceTag(term);
@@ -731,19 +921,25 @@ async function ingestResearchArticle(ctx, term, provider, article) {
731
921
  await store.appendFacts(memoryDir, structured);
732
922
  invalidateCache(ctx.cache);
733
923
  }
924
+ const lookupStatedClass = structured.some(
925
+ (f) => ISA_PREDICATES.has(f.predicate) && normFactTerm(f.subject) === normFactTerm(term),
926
+ );
734
927
 
735
928
  const prose = String(article.summary || article.text || "").trim();
736
- let ingested = { extracted: [], optimistic: [] };
737
- if (prose) {
738
- ingested = await ingestText(prose, {
739
- memoryDir, sourceTag: provenance, optimistic: true,
929
+ let fromBody = [];
930
+ if (prose && proseNamesTerm(prose, term)) {
931
+ const ingested = await ingestText(prose, {
932
+ memoryDir, sourceTag: provenance, optimistic: !lookupStatedClass,
740
933
  lexicon: lexicon || loadLexicon(), observedAt: resolveNow(now), findings: true,
741
934
  attributeToSource: true,
742
935
  });
743
936
  invalidateCache(ctx.cache);
937
+ const read = [...ingested.extracted, ...ingested.optimistic];
938
+ fromBody = factsDefinitionBodyStates(read, definedTermKeys(term, article));
939
+ await retractRejectedBodyFacts(ctx, read.filter((row) => !fromBody.includes(row)), provenance);
744
940
  }
745
941
 
746
- const facts = [...structured, ...ingested.extracted, ...ingested.optimistic];
942
+ const facts = [...structured, ...fromBody];
747
943
  if (!facts.length) return { facts: 0, derived: 0 };
748
944
  const distinct = new Set(facts.map(
749
945
  (f) => `${normFactTerm(f.subject)}\0${normFactPredicate(f.predicate)}\0${normFactTerm(f.object)}`,
@@ -757,6 +953,68 @@ async function ingestResearchArticle(ctx, term, provider, article) {
757
953
  return { facts: distinct.size, derived };
758
954
  }
759
955
 
956
+ // The one band this fallback reads. WordNet is dictionary content — real
957
+ // words, not news entities — so it grounds the everyday nouns a headline
958
+ // mentions in passing ("harbor", "senator") without ever costing a KB round
959
+ // trip on those.
960
+ const BAND_TERM_LOOKUP_BAND = "wordnet-complete";
961
+ const BAND_TERM_LOOKUP_LIMIT = 50;
962
+ // A local DynamoDB Query has no courtesy throttle and nothing external to
963
+ // protect, but it still has to return: the enrich cycle shares the same wall
964
+ // budget every other phase does, and this runs once per candidate term. A
965
+ // slow or hung Query loses the race and reads as a miss — a timeout is a
966
+ // miss, never a guess — rather than stalling the whole cycle behind it.
967
+ const BAND_TERM_LOOKUP_TIMEOUT_MS = 750;
968
+
969
+ /** `term`'s rows from the wordnet-complete band, as `{subject, predicate,
970
+ * object, provenance}` triples ready for `appendFacts` — or `[]` when the
971
+ * band carries nothing for it, the query errors, or it doesn't return in
972
+ * time. `ctx.providers.queryBandTerm` is absent on the browser surface and
973
+ * in most tests, so this is a no-op there by construction, not a special
974
+ * case here. */
975
+ async function groundTermFromBand(ctx, term) {
976
+ const queryBandTerm = ctx.providers?.queryBandTerm;
977
+ if (typeof queryBandTerm !== "function") return [];
978
+ const timeout = new Promise((resolve) => { setTimeout(() => resolve(null), BAND_TERM_LOOKUP_TIMEOUT_MS); });
979
+ let response;
980
+ try {
981
+ response = await Promise.race([
982
+ queryBandTerm({ band: BAND_TERM_LOOKUP_BAND, term, limit: BAND_TERM_LOOKUP_LIMIT }),
983
+ timeout,
984
+ ]);
985
+ } catch {
986
+ return [];
987
+ }
988
+ if (!response) return [];
989
+ const facts = [];
990
+ for (const row of response.rows || []) {
991
+ const fact = factFromBandRow(row);
992
+ if (fact) facts.push(fact);
993
+ }
994
+ return facts;
995
+ }
996
+
997
+ /** Grounds `term` from `facts` — a band's own rows, already resolved
998
+ * `{subject, predicate, object, provenance}` triples with no article prose
999
+ * to walk, so this skips straight to append + syllogise rather than
1000
+ * repeating ingestResearchArticle's structured/prose split. Same shape of
1001
+ * return as ingestResearchArticle, so the caller folds it in identically. */
1002
+ async function ingestBandFacts(ctx, facts) {
1003
+ const { memoryDir, store, config } = ctx;
1004
+ await store.appendFacts(memoryDir, facts);
1005
+ invalidateCache(ctx.cache);
1006
+ const distinct = new Set(facts.map(
1007
+ (f) => `${normFactTerm(f.subject)}\0${normFactPredicate(f.predicate)}\0${normFactTerm(f.object)}`,
1008
+ ));
1009
+ let derived = 0;
1010
+ if (config.syllogismsPerIngest > 0) {
1011
+ const focus = [...termFocusOf(facts)];
1012
+ const res = await syllogise(memoryDir, { focus, expandFocus: true, budget: config.syllogismsPerIngest, store });
1013
+ derived = res?.count || 0;
1014
+ }
1015
+ return { facts: distinct.size, derived };
1016
+ }
1017
+
760
1018
  /** One KB lookup behind its source's circuit breaker. A source that has been
761
1019
  * timing out or throttling is skipped without a round trip, and the skip is
762
1020
  * reported so the cycle can tell "this source had nothing" from "this source
@@ -825,6 +1083,20 @@ export async function enrichTopTerms(ctx, { limit } = {}) {
825
1083
  // pending so the next cycle picks it up, never into the negative cache.
826
1084
  if (aborted) { markTerm(ledger, entry.term, "pending", nowVal); break; }
827
1085
  if (!hit) {
1086
+ // Every configured KB source came back empty (or none is configured) —
1087
+ // try the wordnet-complete band before giving up. It's another source
1088
+ // the resolver can reach, not a replacement for the KB walk above: a
1089
+ // headline's proper nouns still need a KB lookup, but its everyday
1090
+ // vocabulary is often already sitting in the band.
1091
+ const bandFacts = await groundTermFromBand(ctx, entry.term);
1092
+ if (bandFacts.length) {
1093
+ const res = await ingestBandFacts(ctx, bandFacts);
1094
+ factsTotal += res.facts;
1095
+ derivedTotal += res.derived;
1096
+ markTerm(ledger, entry.term, "grounded", nowVal);
1097
+ enriched.push(entry.term);
1098
+ continue;
1099
+ }
828
1100
  // No source was asked, so nothing learned this term is missing —
829
1101
  // the same posture a stop takes, for the same reason.
830
1102
  markTerm(ledger, entry.term, askedAnySource ? "missed" : "pending", nowVal);
@@ -875,7 +1147,15 @@ export async function enrichTopTerms(ctx, { limit } = {}) {
875
1147
  *
876
1148
  * `newName` is a display-only badge (never a gate): true when the lexicon
877
1149
  * has no everyday-noun reading for the hub, computed here rather than in
878
- * buildNewsItems because the domain layer carries no lexicon. */
1150
+ * buildNewsItems because the domain layer carries no lexicon.
1151
+ *
1152
+ * `articleEntityNames` is wired in for the same reason: reading the entity
1153
+ * names out of a card's own article text takes the lexicon and the wink
1154
+ * tagger, so the domain asks for them through a seam and this layer answers
1155
+ * with the capture the enrichment queue already uses. It is what puts a
1156
+ * definition and the card that needed it on the same page — a lookup on
1157
+ * "amigados" reaches a card whose only fact is that a site discussed the
1158
+ * headline the name sits inside. */
879
1159
  export async function buildFeed(ctx) {
880
1160
  const { memoryDir, store, config, state, now, lexicon } = ctx;
881
1161
  const nowVal = resolveNow(now);
@@ -886,7 +1166,12 @@ export async function buildFeed(ctx) {
886
1166
 
887
1167
  const lex = lexicon || loadLexicon();
888
1168
  const items = buildNewsItems(rows, {
889
- now: nowVal, windowMs, limit: config.itemCap, sourcesByFactId, readsAsEntityTerm,
1169
+ now: nowVal,
1170
+ windowMs,
1171
+ limit: config.itemCap,
1172
+ sourcesByFactId,
1173
+ readsAsEntityTerm,
1174
+ articleEntityNames: (texts) => articleEntityNames(texts, { lexicon: lex }),
890
1175
  }).map((item) => ({ ...item, newName: !isVocabGroundedTerm(lex, item.hub) }));
891
1176
  return { items, seedFallback: false, builtAt: nowVal };
892
1177
  }
@@ -54,7 +54,7 @@ export function sourceLabelFor(source) {
54
54
  const BANDS = {
55
55
  human: "human persona", "human-medium": "human persona", "human-large": "human persona",
56
56
  seon: "SEON ontology", conceptnet: "ConceptNet",
57
- "tier2-aws": "AWS", "tier2-python": "Python", "tier2-java": "Java", "wordnet-xl": "WordNet",
57
+ "wordnet-xl": "WordNet", namenet: "NameNet", child: "child vocabulary",
58
58
  };
59
59
  const key = (source && source.key) || "";
60
60
  if (key === "taught") return { label: "taught by telling", tone: "taught" };
@@ -64,6 +64,11 @@ import { splitSceneBackdrop } from "../domain/scene-compose.mjs";
64
64
  const DEFAULT_TITLE = "tmct — the sprite library";
65
65
  const MAX_CHAIN_DISPLAY = 6;
66
66
 
67
+ // Codepoint order, never localeCompare — an ancestor/class term walks the
68
+ // same rdfs:subClassOf fact rows the cards' ancestry pills print, and two
69
+ // readers must render the same ontology-tree order regardless of locale.
70
+ const byCodepoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
71
+
67
72
  /** A curated gap-fill for classes wordnet-xl's own prioritized subset
68
73
  * happens to carry NO rdfs:subClassOf row for at all (115 of 198 catalog
69
74
  * classes, checked directly against a build of this page) — every pair
@@ -244,8 +249,8 @@ export function clusterEntriesByAncestor(entries, spritedClasses) {
244
249
  if (key && list.length >= 2) clusters.push({ ancestor: key, entries: list });
245
250
  else rest.push(...list);
246
251
  }
247
- clusters.sort((a, b) => b.entries.length - a.entries.length || a.ancestor.localeCompare(b.ancestor));
248
- if (rest.length) clusters.push({ ancestor: null, entries: [...rest].sort((a, b) => a.className.localeCompare(b.className)) });
252
+ clusters.sort((a, b) => b.entries.length - a.entries.length || byCodepoint(a.ancestor, b.ancestor));
253
+ if (rest.length) clusters.push({ ancestor: null, entries: [...rest].sort((a, b) => byCodepoint(a.className, b.className)) });
249
254
  return clusters;
250
255
  }
251
256
 
@@ -463,12 +468,12 @@ export function buildOntologyTree(section, { index, spritedClasses, entriesByCla
463
468
  levels[level].push(nodeFor(term));
464
469
  }
465
470
  for (let i = 0; i < levels.length; i += 1) {
466
- levels[i] = (levels[i] || []).sort((a, b) => a.term.localeCompare(b.term));
471
+ levels[i] = (levels[i] || []).sort((a, b) => byCodepoint(a.term, b.term));
467
472
  }
468
473
  branches.push({ size: terms.length, key: terms.slice().sort()[0], levels });
469
474
  }
470
- branches.sort((a, b) => b.size - a.size || a.key.localeCompare(b.key));
471
- apart.sort((a, b) => a.term.localeCompare(b.term));
475
+ branches.sort((a, b) => b.size - a.size || byCodepoint(a.key, b.key));
476
+ apart.sort((a, b) => byCodepoint(a.term, b.term));
472
477
  return { branches, apart, termCount: allTerms.size, truncated };
473
478
  }
474
479
 
@@ -44,7 +44,7 @@ import { parseEntities } from "../../domain/codegraph.mjs";
44
44
  import { memoryFactGraphPayload } from "../../domain/memory-facts.mjs";
45
45
  import { loadLexicon } from "../../domain/grammar/lexicon.mjs";
46
46
  import { DEFAULT_GAME_CONFIG } from "../../domain/game-config.mjs";
47
- import { foldWorldState, worldDigestRows, roomAffordances, worldActionRows, snapshotSubject } from "../../services/adventure.mjs";
47
+ import { foldWorldState, worldDigestRows, roomAffordances, worldActionRows } from "../../services/adventure.mjs";
48
48
  import { runAdventureAutoplayTick, exposedFacts } from "../../services/adventure-autoplay.mjs";
49
49
  import { parseWorldEditorText, planWorldEditorSync } from "../../services/adventure-editor.mjs";
50
50
  import { resolveSpriteForClass, SPRITE_REGISTRY, classAncestorChain } from "../../domain/sprite-map.mjs";
@@ -209,24 +209,18 @@ export async function createAdventureSession(worldPayload, { restoredPayload = n
209
209
  * see adventure-editor.mjs's own header for why a typo must never be
210
210
  * read as "this fact is gone". Returns `{ unrecognized, added, removed }`.
211
211
  *
212
- * A placement or openness edit is written as a TURN SNAPSHOT, the way
213
- * every played turn writes one. foldWorldState ranks an unstamped row as
214
- * turn 0, so an edit to something already played the lamp taken on
215
- * turn 4, then moved back to the desk in the editor — would be outranked
216
- * by the very move it was meant to correct, and the edit would land in
217
- * the store and change nothing anyone can see. Type/exit/puzzle rows are
218
- * read raw and keep their bare subject: a stamped one names a subject no
219
- * verb resolves. */
212
+ * A placement or openness edit arrives from the planner already written
213
+ * as a TURN SNAPSHOT, the way every played turn writes one, so all this
214
+ * has to add is the matching provenance tag. */
220
215
  async applyEdit(text) {
221
216
  const allRows = readFactRows(await loadMemory(memoryDir));
222
217
  const worldRows = allRows.filter((r) => typeof r.provenance === "string" && r.provenance.indexOf(tag) === 0);
223
218
  const state = foldWorldState(worldRows);
224
219
  const { triples, unrecognized } = parseWorldEditorText(text, worldRows);
225
- const { toAppend, toRemoveIds } = planWorldEditorSync(worldRows, state, triples);
226
- const editTurn = state.turnCount + 1;
220
+ const { toAppend, toRemoveIds, editTurn } = planWorldEditorSync(worldRows, state, triples);
227
221
  if (toAppend.length) {
228
222
  await appendFacts(memoryDir, toAppend.map((f) => ({
229
- subject: f.kind === "other" ? f.subject : snapshotSubject(f.subject, editTurn, state.epoch),
223
+ subject: f.subject,
230
224
  predicate: f.predicate,
231
225
  object: f.object,
232
226
  provenance: f.kind === "other" ? tag : `${tag}:turn${editTurn}`,