@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.
- package/README.md +20 -23
- package/bin/tmct.mjs +16 -33
- package/corpus/LICENSES.json +0 -21
- package/corpus/README.md +10 -13
- package/corpus/reference/manifest.json +19 -19
- package/corpus/reference/shards/ref-01.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-04.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-08.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-10.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-11.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-17.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-20.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-25.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-2c.jsonl.gz +0 -0
- package/corpus/tier2/generate.mjs +6 -142
- package/corpus/tier2/manifest.json +0 -42
- package/package.json +6 -4
- package/src/adapters/corpus/child-seed.mjs +74 -0
- package/src/adapters/corpus/conceptnet.mjs +45 -26
- package/src/adapters/corpus/research-source.mjs +6 -2
- package/src/adapters/corpus/wikidata-live.mjs +92 -51
- package/src/adapters/memory/blocks.mjs +7 -1
- package/src/adapters/memory/core.mjs +505 -107
- package/src/adapters/memory/corpus-bands.mjs +27 -10
- package/src/adapters/memory/inspect.mjs +24 -5
- package/src/adapters/memory/rows.mjs +359 -30
- package/src/adapters/memory/shacl.mjs +10 -3
- package/src/domain/ask.mjs +27 -10
- package/src/domain/cli-verbs.mjs +3 -4
- package/src/domain/completions/group.mjs +8 -3
- package/src/domain/completions/infer.mjs +7 -2
- package/src/domain/completions/prune.mjs +5 -1
- package/src/domain/completions/rank.mjs +7 -2
- package/src/domain/digest/compose.mjs +5 -1
- package/src/domain/digest/select.mjs +12 -6
- package/src/domain/domain.mjs +15 -8
- package/src/domain/el-classify.mjs +11 -2
- package/src/domain/fact-phrase.mjs +86 -4
- package/src/domain/hash.mjs +9 -0
- package/src/domain/memory/bias.mjs +8 -4
- package/src/domain/memory/capability.mjs +12 -6
- package/src/domain/memory/fact-order.mjs +29 -0
- package/src/domain/memory/resolution.mjs +3 -0
- package/src/domain/news-feed.mjs +862 -92
- package/src/domain/reference-pack.mjs +5 -0
- package/src/domain/sense-gate.mjs +220 -0
- package/src/domain/sense-scope.mjs +116 -0
- package/src/domain/sense-split.mjs +1 -1
- package/src/domain/syllogise.mjs +60 -21
- package/src/domain/tableau.mjs +23 -14
- package/src/domain/term-ledger.mjs +16 -1
- package/src/domain/worlds-pack.mjs +5 -1
- package/src/services/adventure-autoplay.mjs +6 -1
- package/src/services/adventure-editor.mjs +43 -21
- package/src/services/adventure-viz.mjs +26 -9
- package/src/services/adventure.mjs +40 -10
- package/src/services/chat.mjs +270 -125
- package/src/services/extensions.mjs +51 -58
- package/src/services/extract-facts.mjs +906 -66
- package/src/services/init.mjs +4 -4
- package/src/services/ledger-viz.mjs +9 -4
- package/src/services/memory-panel-viz.mjs +4 -5
- package/src/services/mud-editor.mjs +40 -16
- package/src/services/mud-viz.mjs +8 -2
- package/src/services/mudiii-turn.mjs +5 -3
- package/src/services/mudiii-viz.mjs +8 -2
- package/src/services/news.mjs +306 -21
- package/src/services/research-viz.mjs +1 -1
- package/src/services/sprite-catalog-viz.mjs +10 -5
- package/src/surfaces/web/adventure-browser-entry.mjs +6 -12
- package/src/surfaces/web/memory-ask-browser.bundle.js +152 -151
- package/src/surfaces/web/mud-browser-entry.mjs +7 -11
- package/src/surfaces/web/research-browser-entry.mjs +5 -2
- package/corpus/tier2/aws.jsonl +0 -39
- package/corpus/tier2/java.jsonl +0 -31
- package/corpus/tier2/python.jsonl +0 -30
package/src/services/news.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
|
|
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}${
|
|
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
|
-
|
|
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
|
|
737
|
-
if (prose) {
|
|
738
|
-
ingested = await ingestText(prose, {
|
|
739
|
-
memoryDir, sourceTag: provenance, optimistic:
|
|
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, ...
|
|
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,
|
|
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
|
-
"
|
|
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
|
|
248
|
-
if (rest.length) clusters.push({ ancestor: null, entries: [...rest].sort((a, b) => a.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
|
|
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
|
|
471
|
-
apart.sort((a, b) => a.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
|
|
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
|
|
213
|
-
* every played turn writes one
|
|
214
|
-
*
|
|
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.
|
|
223
|
+
subject: f.subject,
|
|
230
224
|
predicate: f.predicate,
|
|
231
225
|
object: f.object,
|
|
232
226
|
provenance: f.kind === "other" ? tag : `${tag}:turn${editTurn}`,
|