@polycode-projects/the-mechanical-code-talker 2.10.3 → 2.11.0
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 +68 -12
- package/bin/tmct.mjs +5 -2
- package/corpus/sprites/src/sprite-facts.jsonl +18 -0
- package/corpus/worlds/manifest.json +5 -5
- package/corpus/worlds/shards/ashcombe-hall.jsonl.gz +0 -0
- package/corpus/worlds/src/ashcombe-hall.jsonl +27 -0
- package/data/sprites/book-icon.toml +12 -0
- package/data/sprites/cellar-icon.toml +12 -0
- package/data/sprites/drawing-room-icon.toml +13 -0
- package/data/sprites/garden-icon.toml +12 -0
- package/data/sprites/kitchen-icon.toml +13 -0
- package/data/sprites/library-icon.toml +12 -0
- package/data/sprites/pan-icon.toml +11 -0
- package/data/sprites/study-icon.toml +12 -0
- package/package.json +7 -2
- package/src/adapters/corpus/wikipedia-live.mjs +182 -26
- package/src/adapters/corpus/worlds-pack.mjs +8 -2
- package/src/adapters/memory/core.mjs +8 -1
- package/src/adapters/toml-config.mjs +6 -0
- package/src/domain/cli-verbs.mjs +2 -0
- package/src/domain/memory/trust.mjs +32 -2
- package/src/domain/sense-split.mjs +203 -0
- package/src/domain/worlds-pack.mjs +50 -0
- package/src/services/adventure-autoplay.mjs +5 -2
- package/src/services/adventure-viz.mjs +301 -33
- package/src/services/adventure.mjs +162 -14
- package/src/services/chat-page-viz.mjs +341 -197
- package/src/services/chat-session.mjs +24 -9
- package/src/services/chat.mjs +580 -47
- package/src/services/code-explorer-viz.mjs +198 -76
- package/src/services/extract-facts.mjs +384 -82
- package/src/services/fold.mjs +1 -1
- package/src/services/ingest-viz.mjs +637 -0
- package/src/services/ledger-viz.mjs +209 -0
- package/src/services/memory-panel-viz.mjs +159 -0
- package/src/services/research.mjs +266 -0
- package/src/services/sentences.mjs +19 -0
- package/src/services/session-log-format.mjs +64 -0
- package/src/services/sessions.mjs +56 -22
- package/src/services/spider-fly-turn.mjs +54 -1
- package/src/services/spider-fly-viz.mjs +41 -23
- package/src/surfaces/web/adventure-browser-entry.mjs +9 -5
- package/src/surfaces/web/chat-browser-entry.mjs +32 -11
- package/src/surfaces/web/code-explorer-browser-entry.mjs +27 -11
- package/src/surfaces/web/ingest-browser-entry.mjs +208 -0
- package/src/surfaces/web/ledger-browser-entry.mjs +24 -5
- package/src/surfaces/web/memory-ask-browser.bundle.js +134 -125
- package/src/surfaces/web/memory-stats.mjs +53 -0
- package/src/tools/definitions.mjs +14 -0
- package/src/tools/handlers/index.mjs +2 -0
- package/src/tools/handlers/tmct-ingest.mjs +43 -0
- package/src/tools/server.mjs +5 -2
|
@@ -13,24 +13,55 @@
|
|
|
13
13
|
// The provider seam mirrors reference-pack.mjs's: registerLiveReferenceProvider
|
|
14
14
|
// swaps the whole lookup behind one async { lookup(normTerm) } contract (tests
|
|
15
15
|
// and the demo page stub it); null restores the default provider below.
|
|
16
|
+
//
|
|
17
|
+
// The research lane rides the same provider factory against
|
|
18
|
+
// simple.wikipedia.org (registerResearchProvider/getResearchProvider below),
|
|
19
|
+
// adding two fan-out reads: pageByTitle (an exact linked title costs ONE
|
|
20
|
+
// round trip, no opensearch) and linkedTitles (the lead section's
|
|
21
|
+
// namespace-0 links, document-ordered). Requests identify themselves per
|
|
22
|
+
// Wikimedia's robot etiquette (WIKIMEDIA_USER_AGENT) and carry maxlag so an
|
|
23
|
+
// overloaded replica set is backed off from, not hammered.
|
|
16
24
|
|
|
17
25
|
import { normFactTerm } from "../../domain/hash.mjs";
|
|
18
26
|
import { loadLexicon } from "../../domain/grammar/lexicon.mjs";
|
|
19
27
|
import { isReferenceArticleRow, sentencesUpTo, isaOf, SUMMARY_CHAR_CAP } from "../../domain/reference-pack.mjs";
|
|
20
28
|
|
|
21
29
|
export const WIKIPEDIA_LIVE_ORIGIN = "https://en.wikipedia.org";
|
|
30
|
+
export const SIMPLE_WIKIPEDIA_ORIGIN = "https://simple.wikipedia.org";
|
|
31
|
+
|
|
32
|
+
/** The identification string Wikimedia's robot policy asks API clients to
|
|
33
|
+
* carry, pointing at this project's public site as the contact. Browsers
|
|
34
|
+
* refuse to override the User-Agent request header, so the API-recognised
|
|
35
|
+
* Api-User-Agent header carries the same string there; under Node both are
|
|
36
|
+
* sent. */
|
|
37
|
+
export const WIKIMEDIA_USER_AGENT = "the-mechanical-code-talker (+https://polycode-projects.gitlab.io/the-mechanical-code-talker/)";
|
|
22
38
|
|
|
23
39
|
const DEFAULT_TIMEOUT_MS = 4000;
|
|
24
40
|
const DEFAULT_MIN_INTERVAL_MS = 2000;
|
|
25
41
|
const RETRY_AFTER_FLOOR_MS = 5000;
|
|
42
|
+
// Action-API requests carry maxlag so an overloaded replica set answers with
|
|
43
|
+
// an error we back off from instead of adding to its load (Wikimedia's own
|
|
44
|
+
// recommended default for non-interactive clients).
|
|
45
|
+
const MAXLAG_SECONDS = 5;
|
|
26
46
|
|
|
27
47
|
/**
|
|
28
|
-
* A live-lookup provider: { lookup(normTerm) -> article row | null }
|
|
48
|
+
* A live-lookup provider: { lookup(normTerm) -> article row | null }, plus
|
|
49
|
+
* the research fan-out surface: pageByTitle(title) fetches an exact title's
|
|
50
|
+
* summary in ONE round trip (no opensearch — a linked title is already
|
|
51
|
+
* exact), and linkedTitles(title) lists the namespace-0 articles the page's
|
|
52
|
+
* LEAD section links to, in document order.
|
|
29
53
|
*
|
|
30
54
|
* `fetchImpl` defaults to the global fetch; `origin` to en.wikipedia.org;
|
|
31
55
|
* `lexicon` (optional) feeds the isa extraction. The row shape is the shipped
|
|
32
56
|
* pack's own ({ term, title, text, summary, url, revid, isa? }), validated by
|
|
33
57
|
* isReferenceArticleRow before it is ever returned.
|
|
58
|
+
*
|
|
59
|
+
* Throttle posture: every public method takes one "slot" gated by the
|
|
60
|
+
* minimum interval, the single-flight guard and any open cool-off. By
|
|
61
|
+
* default a caller that asks too soon gets null (the chat's clean-miss hook
|
|
62
|
+
* must never block a turn); with `waitForSlot: true` the method WAITS for
|
|
63
|
+
* the slot instead — the research queue's posture, where a false miss would
|
|
64
|
+
* be dishonest and the caller is already paced turn-by-turn.
|
|
34
65
|
*/
|
|
35
66
|
export function createWikipediaLiveProvider({
|
|
36
67
|
fetchImpl,
|
|
@@ -38,6 +69,8 @@ export function createWikipediaLiveProvider({
|
|
|
38
69
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
39
70
|
minIntervalMs = DEFAULT_MIN_INTERVAL_MS,
|
|
40
71
|
lexicon = null,
|
|
72
|
+
userAgent = WIKIMEDIA_USER_AGENT,
|
|
73
|
+
waitForSlot = false,
|
|
41
74
|
} = {}) {
|
|
42
75
|
const doFetch = fetchImpl ?? ((...args) => globalThis.fetch(...args));
|
|
43
76
|
const cache = new Map(); // key -> row | null (hits AND settled misses)
|
|
@@ -45,18 +78,43 @@ export function createWikipediaLiveProvider({
|
|
|
45
78
|
let coolOffUntil = 0;
|
|
46
79
|
let inFlight = false;
|
|
47
80
|
|
|
81
|
+
const identifyingHeaders = () => {
|
|
82
|
+
if (!userAgent) return null;
|
|
83
|
+
const headers = { "Api-User-Agent": userAgent };
|
|
84
|
+
// A browser strips User-Agent as a forbidden header; only set it where
|
|
85
|
+
// no DOM says we are one (Node ships a global `navigator` these days, so
|
|
86
|
+
// `document` is the discriminating global).
|
|
87
|
+
if (typeof document === "undefined") headers["User-Agent"] = userAgent;
|
|
88
|
+
return headers;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
function openCoolOff(retryAfterSeconds) {
|
|
92
|
+
const retryAfterMs = Number(retryAfterSeconds) * 1000;
|
|
93
|
+
coolOffUntil = Date.now() + Math.max(retryAfterMs || 0, RETRY_AFTER_FLOOR_MS);
|
|
94
|
+
}
|
|
95
|
+
|
|
48
96
|
async function fetchJson(url) {
|
|
49
97
|
const controller = typeof AbortController === "function" ? new AbortController() : null;
|
|
50
98
|
const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
|
51
99
|
try {
|
|
52
|
-
const
|
|
100
|
+
const opts = {};
|
|
101
|
+
if (controller) opts.signal = controller.signal;
|
|
102
|
+
const headers = identifyingHeaders();
|
|
103
|
+
if (headers) opts.headers = headers;
|
|
104
|
+
const res = await doFetch(url, opts);
|
|
53
105
|
if (res.status === 429) {
|
|
54
|
-
|
|
55
|
-
coolOffUntil = Date.now() + Math.max(retryAfterMs || 0, RETRY_AFTER_FLOOR_MS);
|
|
106
|
+
openCoolOff(res.headers?.get?.("retry-after"));
|
|
56
107
|
return null;
|
|
57
108
|
}
|
|
58
109
|
if (!res.ok) return null;
|
|
59
|
-
|
|
110
|
+
const body = await res.json();
|
|
111
|
+
// A maxlag rejection arrives as HTTP 200 with an error body (and a
|
|
112
|
+
// Retry-After header) — back off exactly as a 429 asks.
|
|
113
|
+
if (body?.error?.code === "maxlag") {
|
|
114
|
+
openCoolOff(res.headers?.get?.("retry-after"));
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
return body;
|
|
60
118
|
} catch {
|
|
61
119
|
return null;
|
|
62
120
|
} finally {
|
|
@@ -64,6 +122,26 @@ export function createWikipediaLiveProvider({
|
|
|
64
122
|
}
|
|
65
123
|
}
|
|
66
124
|
|
|
125
|
+
/** When the next network slot opens: past the cool-off, past the minimum
|
|
126
|
+
* interval since the last taken slot. */
|
|
127
|
+
const slotOpensAt = () => Math.max(coolOffUntil, lastLookupAt + minIntervalMs);
|
|
128
|
+
|
|
129
|
+
/** Take the one network slot, or report it unavailable. Default posture
|
|
130
|
+
* returns false immediately (the clean-miss hook's "null, never block");
|
|
131
|
+
* `waitForSlot` sleeps until the slot opens instead. */
|
|
132
|
+
async function takeSlot() {
|
|
133
|
+
for (;;) {
|
|
134
|
+
const now = Date.now();
|
|
135
|
+
if (!inFlight && now >= slotOpensAt()) {
|
|
136
|
+
lastLookupAt = now;
|
|
137
|
+
inFlight = true;
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
if (!waitForSlot) return false;
|
|
141
|
+
await new Promise((resolve) => setTimeout(resolve, Math.max(slotOpensAt() - now, 25)));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
67
145
|
/** The opensearch title whose normFactTerm fold equals or extends the key —
|
|
68
146
|
* the topic-drift guard: "quasar" may resolve to "Quasar" or "Quasars",
|
|
69
147
|
* never to a first suggestion about something else. */
|
|
@@ -76,12 +154,7 @@ export function createWikipediaLiveProvider({
|
|
|
76
154
|
return null;
|
|
77
155
|
}
|
|
78
156
|
|
|
79
|
-
async function
|
|
80
|
-
const search = await fetchJson(
|
|
81
|
-
`${origin}/w/api.php?action=opensearch&format=json&origin=*&search=${encodeURIComponent(key)}&limit=3`,
|
|
82
|
-
);
|
|
83
|
-
const title = search ? matchingTitle(key, search) : null;
|
|
84
|
-
if (!title) return null;
|
|
157
|
+
async function summaryRow(key, title) {
|
|
85
158
|
const summary = await fetchJson(
|
|
86
159
|
`${origin}/api/rest_v1/page/summary/${encodeURIComponent(title.replace(/ /g, "_"))}`,
|
|
87
160
|
);
|
|
@@ -103,25 +176,77 @@ export function createWikipediaLiveProvider({
|
|
|
103
176
|
return isReferenceArticleRow(row) ? row : null;
|
|
104
177
|
}
|
|
105
178
|
|
|
179
|
+
async function roundTrips(key) {
|
|
180
|
+
const search = await fetchJson(
|
|
181
|
+
`${origin}/w/api.php?action=opensearch&format=json&origin=*&maxlag=${MAXLAG_SECONDS}&search=${encodeURIComponent(key)}&limit=3`,
|
|
182
|
+
);
|
|
183
|
+
const title = search ? matchingTitle(key, search) : null;
|
|
184
|
+
if (!title) return null;
|
|
185
|
+
return summaryRow(key, title);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** One slot-gated, cached operation: cache first (a settled hit or miss is
|
|
189
|
+
* never refetched), then the slot, then `work()`, with every failure
|
|
190
|
+
* cached as null so it never costs a second round trip. */
|
|
191
|
+
async function cachedFetch(cacheKey, work) {
|
|
192
|
+
if (cache.has(cacheKey)) return cache.get(cacheKey);
|
|
193
|
+
if (!(await takeSlot())) return null;
|
|
194
|
+
let value = null;
|
|
195
|
+
try {
|
|
196
|
+
value = await work();
|
|
197
|
+
} catch {
|
|
198
|
+
value = null;
|
|
199
|
+
} finally {
|
|
200
|
+
inFlight = false;
|
|
201
|
+
}
|
|
202
|
+
cache.set(cacheKey, value);
|
|
203
|
+
return value;
|
|
204
|
+
}
|
|
205
|
+
|
|
106
206
|
return {
|
|
107
207
|
async lookup(normTerm) {
|
|
108
208
|
const key = String(normTerm ?? "");
|
|
109
209
|
if (!key) return null;
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
210
|
+
return cachedFetch(key, () => roundTrips(key));
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
/** The summary for an EXACT title — one round trip, no opensearch. The
|
|
214
|
+
* research queue's depth-1 fetch: a linked title came from the wiki
|
|
215
|
+
* itself, so the fuzzy title match would only waste a request. */
|
|
216
|
+
async pageByTitle(title) {
|
|
217
|
+
const t = String(title ?? "").trim();
|
|
218
|
+
if (!t) return null;
|
|
219
|
+
return cachedFetch(`title\0${normFactTerm(t)}`, () => summaryRow(normFactTerm(t), t));
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
/** The namespace-0 articles the page's LEAD section links to, in document
|
|
223
|
+
* order, capped at `limit`. One action-API round trip
|
|
224
|
+
* (action=parse&prop=links§ion=0): the lead is the smallest payload
|
|
225
|
+
* that still orders links by how the article introduces its topic —
|
|
226
|
+
* a full-page prop=links listing is alphabetical, which would make the
|
|
227
|
+
* fan-out pick by spelling instead of relevance. Null on any failure. */
|
|
228
|
+
async linkedTitles(title, { limit = 25 } = {}) {
|
|
229
|
+
const t = String(title ?? "").trim();
|
|
230
|
+
if (!t) return null;
|
|
231
|
+
const listed = await cachedFetch(`links\0${normFactTerm(t)}`, async () => {
|
|
232
|
+
const parsed = await fetchJson(
|
|
233
|
+
`${origin}/w/api.php?action=parse&format=json&formatversion=2&origin=*&maxlag=${MAXLAG_SECONDS}&prop=links&redirects=1&page=${encodeURIComponent(t)}§ion=0`,
|
|
234
|
+
);
|
|
235
|
+
const links = parsed?.parse?.links;
|
|
236
|
+
if (!Array.isArray(links)) return null;
|
|
237
|
+
const seen = new Set();
|
|
238
|
+
const out = [];
|
|
239
|
+
for (const link of links) {
|
|
240
|
+
if (!link || link.ns !== 0 || link.exists === false) continue;
|
|
241
|
+
const linkTitle = String(link.title ?? link["*"] ?? "").trim();
|
|
242
|
+
const folded = normFactTerm(linkTitle);
|
|
243
|
+
if (!linkTitle || !folded || seen.has(folded)) continue;
|
|
244
|
+
seen.add(folded);
|
|
245
|
+
out.push(linkTitle);
|
|
246
|
+
}
|
|
247
|
+
return out;
|
|
248
|
+
});
|
|
249
|
+
return Array.isArray(listed) ? listed.slice(0, Math.max(0, limit)) : null;
|
|
125
250
|
},
|
|
126
251
|
};
|
|
127
252
|
}
|
|
@@ -143,3 +268,34 @@ export function getLiveReferenceProvider() {
|
|
|
143
268
|
if (!defaultProvider) defaultProvider = createWikipediaLiveProvider();
|
|
144
269
|
return defaultProvider;
|
|
145
270
|
}
|
|
271
|
+
|
|
272
|
+
// ---- the research lane's provider: Simple English Wikipedia, waiting slots --
|
|
273
|
+
|
|
274
|
+
let defaultResearchProvider = null;
|
|
275
|
+
let registeredResearchProvider = null;
|
|
276
|
+
|
|
277
|
+
/** Swap the research lane's lookup: provider = { lookup, pageByTitle,
|
|
278
|
+
* linkedTitles } (tests and the demo pages stub it, same seam shape as
|
|
279
|
+
* registerLiveReferenceProvider). Pass null to restore the default
|
|
280
|
+
* simple.wikipedia.org provider. */
|
|
281
|
+
export function registerResearchProvider(provider) {
|
|
282
|
+
registeredResearchProvider = provider && typeof provider.lookup === "function" ? provider : null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** The research lane's active provider — the registered one, else one lazily
|
|
286
|
+
* created singleton against simple.wikipedia.org with `waitForSlot` on: the
|
|
287
|
+
* queue is paced turn-by-turn, so a throttled step WAITS for its polite slot
|
|
288
|
+
* rather than reporting a false miss. `minIntervalMs` (first call only —
|
|
289
|
+
* the singleton keeps its throttle clock) can only ever RAISE the interval;
|
|
290
|
+
* the shipped minimum stays the floor. */
|
|
291
|
+
export function getResearchProvider({ minIntervalMs } = {}) {
|
|
292
|
+
if (registeredResearchProvider) return registeredResearchProvider;
|
|
293
|
+
if (!defaultResearchProvider) {
|
|
294
|
+
defaultResearchProvider = createWikipediaLiveProvider({
|
|
295
|
+
origin: SIMPLE_WIKIPEDIA_ORIGIN,
|
|
296
|
+
waitForSlot: true,
|
|
297
|
+
minIntervalMs: Math.max(DEFAULT_MIN_INTERVAL_MS, Number(minIntervalMs) || 0),
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
return defaultResearchProvider;
|
|
301
|
+
}
|
|
@@ -15,7 +15,7 @@ import { readFileSync } from "node:fs";
|
|
|
15
15
|
import { gunzipSync } from "node:zlib";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
17
|
import { dirname, join } from "node:path";
|
|
18
|
-
import { isWorldsIndexEntry, isWorldRow, isWorldFactRow, isWorldRuleRow, isWorldMetaRow } from "../../domain/worlds-pack.mjs";
|
|
18
|
+
import { isWorldsIndexEntry, isWorldRow, isWorldFactRow, isWorldRuleRow, isWorldMetaRow, expandWorldDefaultContents } from "../../domain/worlds-pack.mjs";
|
|
19
19
|
|
|
20
20
|
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
21
21
|
|
|
@@ -84,7 +84,13 @@ export function loadWorld(dir, worldName) {
|
|
|
84
84
|
else if (isWorldMetaRow(row) && !meta) meta = row;
|
|
85
85
|
} catch { /* tolerated: a bad line loses one row, not the world */ }
|
|
86
86
|
}
|
|
87
|
-
|
|
87
|
+
// Class-default contents (library -> a book, kitchen -> a pan) are
|
|
88
|
+
// materialized here, the one choke point every loader path runs through
|
|
89
|
+
// (openAdventure, the site build, the tests) — never baked into the
|
|
90
|
+
// shipped shard, which stays a byte-copy of its hand-authored source.
|
|
91
|
+
if (facts.length || rules.length || meta) {
|
|
92
|
+
payload = { name: worldName, facts: expandWorldDefaultContents(facts), rules, meta };
|
|
93
|
+
}
|
|
88
94
|
}
|
|
89
95
|
worldCacheByKey.set(key, payload);
|
|
90
96
|
return payload;
|
|
@@ -94,7 +94,7 @@ const MEMORY_VOCABULARY = [
|
|
|
94
94
|
{ prop: DERIVED_FROM_PROP, predicate: "derivedFrom", note: "umbrella: a Fact derived from a Source (or another Fact). ext ref prov:wasDerivedFrom (UNVERIFIED-pending-web-check)" },
|
|
95
95
|
{ prop: STATED_BY_PROP, predicate: "statedBy", note: "subPropertyOf derivedFrom: a Source directly asserts this Fact (one edge per independent source — replaces the factProvenance union)" },
|
|
96
96
|
{ prop: CANONICALISED_FROM_PROP, predicate: "canonicalisedFrom", note: "subPropertyOf derivedFrom: a canonical Fact cleaned from a raw Block/Source, never replacing it" },
|
|
97
|
-
{ prop: "mgx:sourceType", note: "a Source's kind: operator | teach | provider | corpus | corpusWeak | reference | extracted | web | entailed (the trust-prior key)" },
|
|
97
|
+
{ prop: "mgx:sourceType", note: "a Source's kind: operator | teach | provider | corpus | corpusWeak | reference | referenceLive | extracted | optimisticExtract | web | entailed (the trust-prior key)" },
|
|
98
98
|
{ prop: "mgx:sourceUrl", note: "a web Source's URL" },
|
|
99
99
|
{ prop: "mgx:sourceRule", note: "an entailed Source's rule id" },
|
|
100
100
|
{ prop: "mgx:sourceReliability", note: "actor-level (session-scoped) trust nudge in [0.5,1.5], neutral 1.0 when absent — materialised by recomputeSourceReliability from a session's asserted-vs-contradicted track record (memory/trust.mjs's sessionReliabilityFrom); folds into computeTrust's per-source prior" },
|
|
@@ -772,8 +772,13 @@ function sourceIdFor(desc) {
|
|
|
772
772
|
// One Source per pack article (the @revid stays in the article segment),
|
|
773
773
|
// so two facts from the same article corroborate nothing extra.
|
|
774
774
|
case "reference": return { id: `src:reference:${desc.pack}:${desc.article}`, type: "reference" };
|
|
775
|
+
// The live-Wikipedia pack: same per-article Source id, but a lower trust
|
|
776
|
+
// type so a live lookup ranks below the curated revision-pinned pack.
|
|
777
|
+
case "referenceLive": return { id: `src:reference:${desc.pack}:${desc.article}`, type: "referenceLive" };
|
|
775
778
|
// One Source per source-file basename, not per extraction run.
|
|
776
779
|
case "extracted": return { id: `src:extracted:${desc.name}`, type: "extracted" };
|
|
780
|
+
// The fuzzy tier's candidates: one low-trust Source per source label.
|
|
781
|
+
case "optimisticExtract": return { id: `src:optimistic-extract:${desc.name}`, type: "optimisticExtract" };
|
|
777
782
|
case "web": return { id: `src:learned:web:${fnv1aHex(String(desc.url || ""))}`, type: "web", url: String(desc.url || "") };
|
|
778
783
|
case "entailed": return { id: `src:entailed:${desc.rule}`, type: "entailed", rule: String(desc.rule || "") };
|
|
779
784
|
default: return null;
|
|
@@ -794,8 +799,10 @@ const PROV_CLASS_BY_SOURCE_TYPE = Object.freeze({
|
|
|
794
799
|
corpus: { subClass: "tmct:DocumentSource", prov: "prov:Entity" },
|
|
795
800
|
corpusWeak: { subClass: "tmct:DocumentSource", prov: "prov:Entity" },
|
|
796
801
|
reference: { subClass: "tmct:DocumentSource", prov: "prov:Entity" },
|
|
802
|
+
referenceLive: { subClass: "tmct:DocumentSource", prov: "prov:Entity" },
|
|
797
803
|
web: { subClass: "tmct:DocumentSource", prov: "prov:Entity" },
|
|
798
804
|
extracted: { subClass: "tmct:DocumentSource", prov: "prov:Entity" },
|
|
805
|
+
optimisticExtract: { subClass: "tmct:DocumentSource", prov: "prov:Entity" },
|
|
799
806
|
entailed: { subClass: "tmct:ActivitySource", prov: "prov:Activity" },
|
|
800
807
|
});
|
|
801
808
|
|
|
@@ -125,6 +125,12 @@ export async function normalizeConfig(raw, { configDir } = {}) {
|
|
|
125
125
|
if (src.games !== undefined) cfg.games = src.games;
|
|
126
126
|
if (src.planning !== undefined) cfg.planning = src.planning;
|
|
127
127
|
|
|
128
|
+
// Research-lane knobs (src/services/research.mjs): sparse PASS-THROUGH,
|
|
129
|
+
// same discipline as [games.*] — the raw `[research]` table
|
|
130
|
+
// (fanout_limit / depth_limit / min_interval_ms, snake_case) rides through
|
|
131
|
+
// unmodified; clamping and default-filling is resolveResearchConfig's job.
|
|
132
|
+
if (src.research !== undefined) cfg.research = src.research;
|
|
133
|
+
|
|
128
134
|
const idx = src.index || {};
|
|
129
135
|
const index = {};
|
|
130
136
|
if (idx.languages !== undefined) index.languages = idx.languages;
|
package/src/domain/cli-verbs.mjs
CHANGED
|
@@ -92,6 +92,8 @@ export const CLI_VERBS = [
|
|
|
92
92
|
{ flag: "[--file <text-file>]", prose: ["teach recognizer and keep the facts it grounds; every", "other sentence is skipped and counted, never paraphrased"] },
|
|
93
93
|
{ flag: "[--repo <abs>]", prose: ["write the facts into that repo's own tmct memory; without", "it nothing on disk is mutated and the facts print as JSONL"] },
|
|
94
94
|
{ flag: "[--out <file.jsonl>]", prose: ["write that JSONL to a file instead of stdout"] },
|
|
95
|
+
{ flag: "[--optimistic]", prose: ["also run a lower-trust fuzzy tier over the sentences the", "strict recognizer skips; candidates rank below every curated pack"] },
|
|
96
|
+
{ flag: "[--canonical]", prose: ["print each grounded fact as a triple linked into the store"] },
|
|
95
97
|
],
|
|
96
98
|
},
|
|
97
99
|
{
|
|
@@ -46,10 +46,25 @@ function parseChatTagRest(rest) {
|
|
|
46
46
|
* reference:<pack>:<article>[@revid] -> { kind:"reference", pack, article }
|
|
47
47
|
* (split on the first two colons only; the article keeps any @revid and
|
|
48
48
|
* any spaces — "reference:simplewiki:Polar bear@912" stays one article)
|
|
49
|
+
* EXCEPT the live-Wikipedia pack: reference:wikipedia-live:<article> parses
|
|
50
|
+
* as kind "referenceLive", which scores below the curated revision-pinned
|
|
51
|
+
* pack, so a live lookup never outranks the shipped article on the same term.
|
|
49
52
|
* extracted:<file-basename> -> { kind:"extracted", name:<file-basename> }
|
|
53
|
+
* optimistic-extract:<file-basename> -> { kind:"optimisticExtract", name }
|
|
54
|
+
* (the fuzzy tier of `tmct extract --optimistic`: a candidate the strict
|
|
55
|
+
* recognizer skipped, stored under its OWN low prior so it never
|
|
56
|
+
* corroborates a curated pack — no operator/teach tag rides alongside)
|
|
50
57
|
* entailed:<rule> -> { kind:"entailed", rule:<rule> }
|
|
51
58
|
* chat:/session: refs map to the operator; an unknown tag -> null (no Source).
|
|
52
59
|
*/
|
|
60
|
+
// The one reference pack whose content is fetched live at query time rather
|
|
61
|
+
// than shipped revision-pinned. Its facts score at the referenceLive prior
|
|
62
|
+
// (below curated `reference`), so a live lookup never outranks the shipped pack.
|
|
63
|
+
// Must equal reference-pack.mjs's LIVE_PACK_NAME; kept as a local literal so
|
|
64
|
+
// trust.mjs stays import-free of the lexicon-loading pack module.
|
|
65
|
+
const LIVE_REFERENCE_PACK = "wikipedia-live";
|
|
66
|
+
const referenceKindFor = (pack) => (pack === LIVE_REFERENCE_PACK ? "referenceLive" : "reference");
|
|
67
|
+
|
|
53
68
|
export function provenanceTagToSource(tag) {
|
|
54
69
|
const t = String(tag || "").trim();
|
|
55
70
|
if (!t) return null;
|
|
@@ -59,7 +74,19 @@ export function provenanceTagToSource(tag) {
|
|
|
59
74
|
const rest = t.slice("reference:".length);
|
|
60
75
|
const colon = rest.indexOf(":");
|
|
61
76
|
if (colon < 0) return { kind: "reference", pack: rest || "unknown", article: "" };
|
|
62
|
-
|
|
77
|
+
const pack = rest.slice(0, colon) || "unknown";
|
|
78
|
+
return { kind: referenceKindFor(pack), pack, article: rest.slice(colon + 1) };
|
|
79
|
+
}
|
|
80
|
+
// research:<topic>@<depth> — the research lane's Simple English Wikipedia
|
|
81
|
+
// loads. Live-fetched at query time like the wikipedia-live pack, so it
|
|
82
|
+
// scores at the same referenceLive prior, below every curated pack. Parsed
|
|
83
|
+
// from the FULL tag (a topic may contain spaces); the depth segment records
|
|
84
|
+
// how far the fan-out reached and is not part of the Source identity.
|
|
85
|
+
if (t.startsWith("research:")) {
|
|
86
|
+
const rest = t.slice("research:".length);
|
|
87
|
+
const at = rest.lastIndexOf("@");
|
|
88
|
+
const topic = (at >= 0 ? rest.slice(0, at) : rest).trim();
|
|
89
|
+
return { kind: "referenceLive", pack: "research", article: topic || "unknown" };
|
|
63
90
|
}
|
|
64
91
|
const head = t.split(/\s+/)[0]; // drop trailing " /r/IsA" etc.
|
|
65
92
|
if (head.startsWith("corpus-weak:")) return { kind: "corpusWeak", name: head.slice("corpus-weak:".length) || "unknown" };
|
|
@@ -78,6 +105,7 @@ export function provenanceTagToSource(tag) {
|
|
|
78
105
|
}
|
|
79
106
|
if (head.startsWith("web:")) return { kind: "web", url: head.slice("web:".length) };
|
|
80
107
|
if (head.startsWith("url:")) return { kind: "web", url: head.slice("url:".length) };
|
|
108
|
+
if (head.startsWith("optimistic-extract:")) return { kind: "optimisticExtract", name: head.slice("optimistic-extract:".length) || "unknown" };
|
|
81
109
|
if (head.startsWith("extracted:")) return { kind: "extracted", name: head.slice("extracted:".length) || "unknown" };
|
|
82
110
|
if (head.startsWith("entailed:")) return { kind: "entailed", rule: head.slice("entailed:".length) };
|
|
83
111
|
if (head.startsWith("chat:") || head.startsWith("session:") || head.startsWith("operator")) return { kind: "operator" };
|
|
@@ -93,8 +121,10 @@ export const SOURCE_PRIOR = Object.freeze({
|
|
|
93
121
|
corpus: 0.7,
|
|
94
122
|
reference: 0.6,
|
|
95
123
|
corpusWeak: 0.55,
|
|
96
|
-
|
|
124
|
+
referenceLive: 0.5,
|
|
97
125
|
extracted: 0.45,
|
|
126
|
+
web: 0.4,
|
|
127
|
+
optimisticExtract: 0.35,
|
|
98
128
|
entailed: 0.3,
|
|
99
129
|
});
|
|
100
130
|
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// sense-split.mjs — word-sense discrimination over a taught/stored class
|
|
2
|
+
// hierarchy. When one label carries two is-a facts ("rover is a kind of dog",
|
|
3
|
+
// "rover is a kind of scout"), their superclass ancestries decide whether the
|
|
4
|
+
// two end classes name the same concept or two different ones. The verdict is
|
|
5
|
+
// an implicit owl:differentFrom between the senses; the mechanism is pure and
|
|
6
|
+
// deterministic over the stored subClassOf closure and any stored
|
|
7
|
+
// owl:disjointWith pairs — never a model.
|
|
8
|
+
//
|
|
9
|
+
// The distinctness test, in order:
|
|
10
|
+
// 1. one class subsumes the other -> same lineage, not distinct.
|
|
11
|
+
// 2. any ancestor pair is disjoint -> distinct (the cax-dw ⊑-lift, read-only).
|
|
12
|
+
// 3. ancestries never meet below ⊤ -> distinct (wholly non-intersecting).
|
|
13
|
+
// 4. the least common subsumer is a root, or scores below threshold on a
|
|
14
|
+
// Wu-Palmer depth ratio (or a supplied Resnik information-content map)
|
|
15
|
+
// -> distinct.
|
|
16
|
+
// 5. otherwise -> not distinct.
|
|
17
|
+
|
|
18
|
+
import { SUBCLASS_PREDICATE } from "./syllogise.mjs";
|
|
19
|
+
|
|
20
|
+
/** child term -> Set of its direct superclasses, from `[child, parent]` edges.
|
|
21
|
+
* Self-loops and blank endpoints are dropped. */
|
|
22
|
+
export function subClassParents(subClassEdges) {
|
|
23
|
+
const parents = new Map();
|
|
24
|
+
for (const [child, parent] of subClassEdges || []) {
|
|
25
|
+
if (!child || !parent || child === parent) continue;
|
|
26
|
+
if (!parents.has(child)) parents.set(child, new Set());
|
|
27
|
+
parents.get(child).add(parent);
|
|
28
|
+
}
|
|
29
|
+
return parents;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The single rendering chain above `term` — `[term, parent, grandparent, …]`
|
|
33
|
+
* — following one deterministic (lowest-sorted) parent per hop, capped at
|
|
34
|
+
* `cap` nodes, cycle-safe. A branch point still yields one readable line;
|
|
35
|
+
* the full-ancestor-set comparisons below never rely on this single path. */
|
|
36
|
+
export function ancestryChain(term, parents, { cap = 6 } = {}) {
|
|
37
|
+
const chain = [term];
|
|
38
|
+
const seen = new Set([term]);
|
|
39
|
+
let node = term;
|
|
40
|
+
while (chain.length < cap) {
|
|
41
|
+
const ups = [...(parents.get(node) || [])].filter((p) => !seen.has(p)).sort();
|
|
42
|
+
if (!ups.length) break;
|
|
43
|
+
node = ups[0];
|
|
44
|
+
seen.add(node);
|
|
45
|
+
chain.push(node);
|
|
46
|
+
}
|
|
47
|
+
return chain;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Every ancestor of `term`, including `term` itself, as a Set. Cycle-safe,
|
|
51
|
+
* bounded by the reachable set. */
|
|
52
|
+
export function ancestorSet(term, parents, { cap = 64 } = {}) {
|
|
53
|
+
const seen = new Set([term]);
|
|
54
|
+
const queue = [term];
|
|
55
|
+
while (queue.length && seen.size < cap) {
|
|
56
|
+
const node = queue.shift();
|
|
57
|
+
for (const p of parents.get(node) || []) {
|
|
58
|
+
if (!seen.has(p)) { seen.add(p); queue.push(p); }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return seen;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Depth from a root, measured as the LONGEST upward chain length: a root
|
|
65
|
+
* (no parents) is depth 1, its children depth 2, and so on. Deterministic and
|
|
66
|
+
* cycle-safe; used for the Wu-Palmer ratio and to pick the deepest common
|
|
67
|
+
* subsumer as the least common subsumer. */
|
|
68
|
+
function depthFrom(term, parents, memo, stack) {
|
|
69
|
+
if (memo.has(term)) return memo.get(term);
|
|
70
|
+
if (stack.has(term)) return 1; // cycle guard — treat the re-entry as a root
|
|
71
|
+
stack.add(term);
|
|
72
|
+
const ups = parents.get(term);
|
|
73
|
+
let depth = 1;
|
|
74
|
+
if (ups && ups.size) {
|
|
75
|
+
let deepest = 0;
|
|
76
|
+
for (const p of ups) deepest = Math.max(deepest, depthFrom(p, parents, memo, stack));
|
|
77
|
+
depth = deepest + 1;
|
|
78
|
+
}
|
|
79
|
+
stack.delete(term);
|
|
80
|
+
memo.set(term, depth);
|
|
81
|
+
return depth;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A symmetric lookup "is A disjoint with B" over stored owl:disjointWith
|
|
85
|
+
* pairs. Both directions of every pair are indexed. */
|
|
86
|
+
function disjointIndex(disjointEdges) {
|
|
87
|
+
const of = new Map();
|
|
88
|
+
for (const [a, b] of disjointEdges || []) {
|
|
89
|
+
if (!a || !b) continue;
|
|
90
|
+
if (!of.has(a)) of.set(a, new Set());
|
|
91
|
+
of.get(a).add(b);
|
|
92
|
+
if (!of.has(b)) of.set(b, new Set());
|
|
93
|
+
of.get(b).add(a);
|
|
94
|
+
}
|
|
95
|
+
return of;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Decide whether classes `a` and `b` name distinct concepts, given the parent
|
|
99
|
+
* index, the disjointness index, a depth memo, and the scoring options.
|
|
100
|
+
* Returns `{ distinct, reason, lcs }`. */
|
|
101
|
+
function pairVerdict(a, b, parents, disjointOf, depthMemo, { threshold, rootDepth, icByTerm }) {
|
|
102
|
+
const ancA = ancestorSet(a, parents);
|
|
103
|
+
const ancB = ancestorSet(b, parents);
|
|
104
|
+
if (ancA.has(b) || ancB.has(a)) return { distinct: false, reason: "subsumes", lcs: ancA.has(b) ? b : a };
|
|
105
|
+
|
|
106
|
+
if (disjointOf.size) {
|
|
107
|
+
for (const x of ancA) {
|
|
108
|
+
const partners = disjointOf.get(x);
|
|
109
|
+
if (!partners) continue;
|
|
110
|
+
for (const y of ancB) if (partners.has(y)) return { distinct: true, reason: "disjoint", lcs: null };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const common = [...ancA].filter((x) => ancB.has(x));
|
|
115
|
+
if (!common.length) {
|
|
116
|
+
// Non-intersection only means "distinct" when both sides carry real
|
|
117
|
+
// ancestry evidence. A bare class with no recorded superclass tells us
|
|
118
|
+
// nothing about its sense — a sparse taxonomy that simply never linked
|
|
119
|
+
// two storage classes must not read as two concepts. Inconclusive keeps
|
|
120
|
+
// the flat list.
|
|
121
|
+
if (ancA.size < 2 || ancB.size < 2) return { distinct: false, reason: "inconclusive", lcs: null };
|
|
122
|
+
return { distinct: true, reason: "non-intersecting", lcs: null };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const depth = (t) => depthFrom(t, parents, depthMemo, new Set());
|
|
126
|
+
let lcs = common[0];
|
|
127
|
+
let lcsDepth = depth(lcs);
|
|
128
|
+
for (const c of common) {
|
|
129
|
+
const d = depth(c);
|
|
130
|
+
if (d > lcsDepth || (d === lcsDepth && c < lcs)) { lcs = c; lcsDepth = d; }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (lcsDepth <= rootDepth) return { distinct: true, reason: "root-subsumer", lcs };
|
|
134
|
+
|
|
135
|
+
if (icByTerm) {
|
|
136
|
+
const ic = icByTerm.get(lcs);
|
|
137
|
+
if (typeof ic === "number" && ic < threshold) return { distinct: true, reason: "low-ic", lcs };
|
|
138
|
+
return { distinct: false, reason: "shared-lineage", lcs };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const wuPalmer = (2 * lcsDepth) / (depth(a) + depth(b));
|
|
142
|
+
if (wuPalmer < threshold) return { distinct: true, reason: "shallow-subsumer", lcs };
|
|
143
|
+
return { distinct: false, reason: "shared-lineage", lcs };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Cluster same-predicate end classes into senses. Two classes join the same
|
|
147
|
+
* sense when they are NOT distinct (union-find over the pairwise verdicts);
|
|
148
|
+
* the answer splits when more than one cluster survives.
|
|
149
|
+
*
|
|
150
|
+
* Options:
|
|
151
|
+
* - subClassEdges / parents: the taxonomy (`parents` wins if both given).
|
|
152
|
+
* - disjointEdges: stored owl:disjointWith pairs.
|
|
153
|
+
* - threshold: Wu-Palmer ratio (or Resnik IC) below which a shared subsumer
|
|
154
|
+
* still reads as two senses. Default 0.5.
|
|
155
|
+
* - rootDepth: a subsumer at or above this shallow depth is treated as ⊤.
|
|
156
|
+
* Default 1 (only the very root).
|
|
157
|
+
* - icByTerm: an optional term->information-content map; when supplied the
|
|
158
|
+
* LCS is scored by Resnik IC instead of the Wu-Palmer depth ratio.
|
|
159
|
+
*
|
|
160
|
+
* Returns `{ split, clusters, pairs }`. Each cluster is
|
|
161
|
+
* `{ objects, label }` where `label` is the cluster's most specific class.
|
|
162
|
+
* `pairs` records every pairwise verdict for inspection/tests. */
|
|
163
|
+
export function clusterSenses(objects, {
|
|
164
|
+
subClassEdges = [], parents: parentsIn = null, disjointEdges = [],
|
|
165
|
+
threshold = 0.5, rootDepth = 1, icByTerm = null,
|
|
166
|
+
} = {}) {
|
|
167
|
+
const unique = [...new Set((objects || []).filter(Boolean))];
|
|
168
|
+
const parents = parentsIn || subClassParents(subClassEdges);
|
|
169
|
+
const disjointOf = disjointIndex(disjointEdges);
|
|
170
|
+
const depthMemo = new Map();
|
|
171
|
+
|
|
172
|
+
const parent = new Map(unique.map((o) => [o, o]));
|
|
173
|
+
const find = (x) => { while (parent.get(x) !== x) { parent.set(x, parent.get(parent.get(x))); x = parent.get(x); } return x; };
|
|
174
|
+
const union = (a, b) => { const ra = find(a), rb = find(b); if (ra !== rb) parent.set(ra < rb ? rb : ra, ra < rb ? ra : rb); };
|
|
175
|
+
|
|
176
|
+
const pairs = [];
|
|
177
|
+
for (let i = 0; i < unique.length; i += 1) {
|
|
178
|
+
for (let j = i + 1; j < unique.length; j += 1) {
|
|
179
|
+
const v = pairVerdict(unique[i], unique[j], parents, disjointOf, depthMemo, { threshold, rootDepth, icByTerm });
|
|
180
|
+
pairs.push({ a: unique[i], b: unique[j], ...v });
|
|
181
|
+
if (!v.distinct) union(unique[i], unique[j]);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const byRoot = new Map();
|
|
186
|
+
for (const o of unique) {
|
|
187
|
+
const r = find(o);
|
|
188
|
+
if (!byRoot.has(r)) byRoot.set(r, []);
|
|
189
|
+
byRoot.get(r).push(o);
|
|
190
|
+
}
|
|
191
|
+
const depth = (t) => depthFrom(t, parents, depthMemo, new Set());
|
|
192
|
+
const clusters = [...byRoot.values()].map((members) => {
|
|
193
|
+
const sorted = [...members].sort();
|
|
194
|
+
let label = sorted[0];
|
|
195
|
+
let best = depth(label);
|
|
196
|
+
for (const m of sorted) { const d = depth(m); if (d > best) { best = d; label = m; } }
|
|
197
|
+
return { objects: sorted, label };
|
|
198
|
+
}).sort((x, y) => x.label.localeCompare(y.label));
|
|
199
|
+
|
|
200
|
+
return { split: clusters.length > 1, clusters, pairs };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export { SUBCLASS_PREDICATE };
|