@polycode-projects/the-mechanical-code-talker 2.10.2 → 2.10.5
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 +66 -10
- package/bin/tmct.mjs +5 -2
- package/package.json +3 -1
- package/src/adapters/memory/core.mjs +8 -1
- package/src/domain/cli-verbs.mjs +2 -0
- package/src/domain/domain.mjs +49 -21
- package/src/domain/memory/trust.mjs +21 -2
- package/src/domain/sense-split.mjs +203 -0
- package/src/services/adventure.mjs +73 -1
- package/src/services/chat-page-viz.mjs +87 -19
- package/src/services/chat-session.mjs +12 -7
- package/src/services/chat.mjs +384 -38
- package/src/services/code-explorer-viz.mjs +16 -2
- package/src/services/extract-facts.mjs +293 -81
- package/src/services/fold.mjs +1 -1
- package/src/services/ingest-viz.mjs +388 -0
- package/src/services/ledger-viz.mjs +110 -0
- package/src/services/session-log-format.mjs +64 -0
- package/src/services/sessions.mjs +56 -22
- package/src/services/spider-fly-turn.mjs +119 -1
- package/src/services/spider-fly-viz.mjs +21 -19
- package/src/surfaces/web/chat-browser-entry.mjs +10 -6
- package/src/surfaces/web/ingest-browser-entry.mjs +126 -0
- package/src/surfaces/web/ledger-browser-entry.mjs +15 -2
- package/src/surfaces/web/memory-ask-browser.bundle.js +128 -119
- 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
package/README.md
CHANGED
|
@@ -120,13 +120,18 @@ tmct> /exit
|
|
|
120
120
|
|
|
121
121
|
**[Try it live in your browser →](https://polycode-projects.gitlab.io/the-mechanical-code-talker/)**
|
|
122
122
|
runs the actual query engine client-side. No server, no install. The landing
|
|
123
|
-
page answers codebase questions live, and
|
|
124
|
-
own domain: a full chat seeded with
|
|
125
|
-
`npm run init:xl`, capped to a
|
|
126
|
-
(every fact as a readable sentence; drill by clicking the
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
123
|
+
page answers codebase questions live, and eight more pages each ground their
|
|
124
|
+
own domain: a full chat seeded with 32,646 facts (the same nine bands as
|
|
125
|
+
`npm run init:xl`, capped to a 38.7 MB download, 2.1 MB on the wire), the
|
|
126
|
+
**memory ledger** (every fact as a readable sentence; drill by clicking the
|
|
127
|
+
terms inside), the **code explorer** (the same ledger UI refocused on a code
|
|
128
|
+
graph, with a hint rail of suggested next questions), an **ingest page** that
|
|
129
|
+
turns pasted or dropped text into grounded facts and downloads them as
|
|
130
|
+
canonical JSONL, a Towers-of-Hanoi plan replayed move by move, the
|
|
131
|
+
spider-and-fly and text-adventure games, and a sprite gallery whose chat dock
|
|
132
|
+
answers from 1,033 generated sprite facts. The chat page and the ledger take
|
|
133
|
+
the same paste-or-drop text in place; every page that holds a fact store
|
|
134
|
+
exports it as JSONL.
|
|
130
135
|
The site hosts its own copy of wink-nlp, ships its assets precompressed,
|
|
131
136
|
and a service worker precaches the big ones, so a second visit works
|
|
132
137
|
offline. `tmct chat --render spider-fly|adventure|sprites [--output <path>]`
|
|
@@ -166,6 +171,11 @@ over the same graph. A hint rail suggests the next question from what the graph
|
|
|
166
171
|
actually holds — "what does X import", "which functions call Y", "list
|
|
167
172
|
functions" — so every suggestion resolves to a real answer.
|
|
168
173
|
|
|
174
|
+
The identical page also runs as a plain hosted page, over the demo code
|
|
175
|
+
graph — **[try it live →](https://polycode-projects.gitlab.io/the-mechanical-code-talker/code.html)**
|
|
176
|
+
— for a look with nothing to install. The desktop build below is for
|
|
177
|
+
exploring your own repo or graph, which the hosted page cannot reach.
|
|
178
|
+
|
|
169
179
|
Electron is a dev-only dependency and never ships in the npm package. Because
|
|
170
180
|
`.npmrc` sets `ignore-scripts=true`, installing it does not fetch the runtime
|
|
171
181
|
binary; fetch it once, then build and launch:
|
|
@@ -469,6 +479,11 @@ cited read-out, CC BY-SA, pinned to the revision it read (provenance
|
|
|
469
479
|
`reference:wikipedia-live:<Title>@<revid>`). A failed lookup (no matching
|
|
470
480
|
title, a timeout, a rate limit) leaves the honest miss byte-identical.
|
|
471
481
|
|
|
482
|
+
A live article carries its own source-type prior (`referenceLive`, 0.5) below
|
|
483
|
+
the curated revision-pinned pack (`reference`, 0.6). A fact fetched live never
|
|
484
|
+
outranks the shipped article on the same term, and a fact you teach outranks
|
|
485
|
+
both.
|
|
486
|
+
|
|
472
487
|
## How it remembers
|
|
473
488
|
|
|
474
489
|
tmct's memory has two layers, both fed by every parsed request and response and
|
|
@@ -479,6 +494,11 @@ by cleaned session logs:
|
|
|
479
494
|
- **text blocks under a PageRank-style index**, pulled into context on
|
|
480
495
|
relevance rather than loaded wholesale.
|
|
481
496
|
|
|
497
|
+
Every session also writes its own human-readable transcript,
|
|
498
|
+
`.tmct/session-<id>.md` — a glow-friendly Markdown file with one heading per
|
|
499
|
+
turn, the question as a blockquote, the reply in a fenced block. The
|
|
500
|
+
browser chat page's "export .md" button writes the same shape.
|
|
501
|
+
|
|
482
502
|
With no graph at all, tmct starts empty and remembers what you tell it. The
|
|
483
503
|
`.tmct/` graph is created from the conversation. On a first run it seeds the
|
|
484
504
|
committed vocabulary so it knows what it's talking about from turn one: an
|
|
@@ -531,6 +551,17 @@ rather than the code graph. A taught class answers both shapes too: after
|
|
|
531
551
|
"dog is a kind of animal", "how many animals are there" counts its members and
|
|
532
552
|
"list all animals" reads them back, each cited to where it came from.
|
|
533
553
|
|
|
554
|
+
When you ask about a term, the read-back shows each "is a kind of" object with
|
|
555
|
+
its own superclass chain: "what is rover" answers "rover is a kind of dog →
|
|
556
|
+
canine → mammal → animal". If one label carries two unrelated senses — you
|
|
557
|
+
taught "rover is a dog" and a corpus row says "rover is a scout" — the answer
|
|
558
|
+
groups by concept ("rover, the dog:" / "rover, the scout:") instead of listing
|
|
559
|
+
two unrelated lines as if they were one thing. The split is deterministic over
|
|
560
|
+
the stored hierarchy: two senses part when a stored disjointness separates
|
|
561
|
+
their ancestors, when their chains never meet, or when they meet only at the
|
|
562
|
+
very top. When the evidence is thin the answer stays a flat list — grouping is
|
|
563
|
+
presentation, and it never retracts or reranks a fact.
|
|
564
|
+
|
|
534
565
|
Teaching doesn't have to be typed, either. `tmct extract` runs a plain text
|
|
535
566
|
file through the same recognizer the chat's teach lane uses. Sentences the
|
|
536
567
|
recognizer grounds become fact rows; everything else is skipped and counted,
|
|
@@ -551,6 +582,20 @@ Pass `--repo <path>` instead to write the recognized facts straight into
|
|
|
551
582
|
that repo's memory, or `--out <file.jsonl>` to save the rows. Each one
|
|
552
583
|
carries an `extracted:<file>` provenance tag at its own trust tier.
|
|
553
584
|
|
|
585
|
+
`--optimistic` adds a second, lower-trust tier over the sentences the strict
|
|
586
|
+
recognizer skips: a copula or a known relation verb flanked by two nouns
|
|
587
|
+
becomes a candidate triple, stored under its own `optimistic-extract:<file>`
|
|
588
|
+
provenance (prior 0.35, below every curated pack) with no operator tag riding
|
|
589
|
+
alongside — so a fuzzy guess can never corroborate a curated fact. It is an
|
|
590
|
+
attempt, not full NLU: a sentence with no clean pair yields nothing.
|
|
591
|
+
`--canonical` prints each grounded fact as a triple, noting how each endpoint
|
|
592
|
+
already links into the store.
|
|
593
|
+
|
|
594
|
+
The same pipeline is one library seam, `ingestText(text, options)` (exported
|
|
595
|
+
as `@polycode-projects/the-mechanical-code-talker/ingest`), and one cold tool,
|
|
596
|
+
`tmct_ingest` — so a browser page, a script, or a tool-calling agent can ground
|
|
597
|
+
text without the CLI.
|
|
598
|
+
|
|
554
599
|
### Provenance and trust
|
|
555
600
|
|
|
556
601
|
Every fact and text block records **where it came from and when**. Sources are
|
|
@@ -571,8 +616,15 @@ provenance** rather than silently picking a winner.
|
|
|
571
616
|
batch: a forward-chaining materialisation over the memory's OWL 2 RL rule
|
|
572
617
|
kernels (the classical syllogism is one of them, and the verb keeps Aristotle's
|
|
573
618
|
broader sense; see the bibliography) that writes new **entailed** facts. They
|
|
574
|
-
are low-trust and retractable, never outranking a stated fact
|
|
575
|
-
|
|
619
|
+
are low-trust and retractable, never outranking a stated fact.
|
|
620
|
+
|
|
621
|
+
The full-store batch is a maintenance job, off the chat's hot path. One bounded
|
|
622
|
+
sibling does run inline: when a learn-on-miss load pulls new facts in (a child
|
|
623
|
+
pack, a reference or live-Wikipedia article), a small focus-scoped pass around
|
|
624
|
+
the loaded term connects those new facts to what's already remembered, so a load
|
|
625
|
+
becomes durable knowledge rather than an island. It shares the same kernels, the
|
|
626
|
+
same `entailed:*` provenance and the same low, retractable trust; only its scope
|
|
627
|
+
and budget shrink.
|
|
576
628
|
|
|
577
629
|
## Install & use
|
|
578
630
|
|
|
@@ -711,6 +763,9 @@ above — the same teach recognizer, reading a file instead of your typing:
|
|
|
711
763
|
[--repo <abs>] write the facts into that repo's own tmct memory; without
|
|
712
764
|
it nothing on disk is mutated and the facts print as JSONL
|
|
713
765
|
[--out <file.jsonl>] write that JSONL to a file instead of stdout
|
|
766
|
+
[--optimistic] also run a lower-trust fuzzy tier over the sentences the
|
|
767
|
+
strict recognizer skips; candidates rank below every curated pack
|
|
768
|
+
[--canonical] print each grounded fact as a triple linked into the store
|
|
714
769
|
```
|
|
715
770
|
|
|
716
771
|
`tmct extend --validate` checks a third-party extension pack's declared resources
|
|
@@ -1009,7 +1064,7 @@ loads a repo's graph into a `{ dispatch, resolve, graph }` context,
|
|
|
1009
1064
|
|
|
1010
1065
|
## The tool surface
|
|
1011
1066
|
|
|
1012
|
-
Everything above runs on the same
|
|
1067
|
+
Everything above runs on the same 25 tools. Each one is read-only, answers one question in a single call, and returns bounded output. None of them calls a model. A tool that cannot ground an answer says so — the same honest miss you get everywhere else in tmct.
|
|
1013
1068
|
|
|
1014
1069
|
Three of them are **hot**: their schemas stay resident, so an agent driving tmct sees them every turn and reaches for one call instead of a Read/Grep loop.
|
|
1015
1070
|
|
|
@@ -1082,6 +1137,7 @@ The remaining tools are **cold**: still served, but not billed to an agent every
|
|
|
1082
1137
|
| `tmct_cochanges` | Modules that historically change in the same commit as a symbol's module (git co-change). | `symbol` (required) |
|
|
1083
1138
|
| `tmct_context_more` | The bundle sections a lean tmct_context omitted (siblings / tests / cochange / class members / re-exports). | `symbol` (required) |
|
|
1084
1139
|
| `tmct_export` | Every stored memory fact as JSONL (subject/predicate/object/provenance) — the shape `tmct extract` emits, for backup or audit. | none |
|
|
1140
|
+
| `tmct_ingest` | Ground plain text into memory facts with the same deterministic recognizer the chat teach lane uses — the strict tier, plus an optional lower-trust fuzzy tier — and report the canonical triples. | `text` (required), `optimistic` |
|
|
1085
1141
|
|
|
1086
1142
|
Add `repo_path` to any of them to point at a repository other than the working directory. `tmct init` also writes this catalog, with a worked invocation per tool, to `.tmct/TOOLS.md` inside the repo it indexed.
|
|
1087
1143
|
|
package/bin/tmct.mjs
CHANGED
|
@@ -55,7 +55,7 @@ Usage:
|
|
|
55
55
|
${renderUsage()}
|
|
56
56
|
|
|
57
57
|
On a terminal, chat opens the full-screen TUI; piped input gets the plain shell.
|
|
58
|
-
In chat: /help lists slash-commands; /exit leaves. Session log → <repo>/.tmct/session-<id>.
|
|
58
|
+
In chat: /help lists slash-commands; /exit leaves. Session log → <repo>/.tmct/session-<id>.md.
|
|
59
59
|
|
|
60
60
|
Shared graph-path precedence (chat/serve/cli; see src/services/cli-args.mjs): --graph flag(s) >
|
|
61
61
|
TMCT_GRAPH_FILE env > tmct.toml graph_file/graph_files > --repo-derived
|
|
@@ -266,7 +266,10 @@ async function runCliMode() {
|
|
|
266
266
|
}
|
|
267
267
|
const config = await configFor(args.repo_path);
|
|
268
268
|
try {
|
|
269
|
-
|
|
269
|
+
// The recognizer seam a tool like tmct_ingest needs: injected here (bin
|
|
270
|
+
// sits above the service layer) rather than imported by the tool layer.
|
|
271
|
+
const { ingestText } = await import("../src/services/extract-facts.mjs");
|
|
272
|
+
const text = await dispatchTool(sub, args, { config, ingest: ingestText });
|
|
270
273
|
process.stdout.write(text + "\n");
|
|
271
274
|
} catch (e) {
|
|
272
275
|
process.stderr.write(`tmct: ${e?.message || e}\n`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polycode-projects/the-mechanical-code-talker",
|
|
3
|
-
"version": "2.10.
|
|
3
|
+
"version": "2.10.5",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; no codebase index of its own.",
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"./plan": "./src/domain/router/drive.mjs",
|
|
46
46
|
"./generateCompletion": "./src/services/completions.mjs",
|
|
47
47
|
"./createCompletionsGraphAdapter": "./src/services/completions.mjs",
|
|
48
|
+
"./ingest": "./src/services/extract-facts.mjs",
|
|
48
49
|
"./repository-interface": "./src/adapters/repository-interface.mjs",
|
|
49
50
|
"./conformance": "./src/tools/conformance.mjs"
|
|
50
51
|
},
|
|
@@ -139,6 +140,7 @@
|
|
|
139
140
|
"build:spider-fly-bundle": "node scripts/build-spider-fly-bundle.mjs",
|
|
140
141
|
"build:plan-bundle": "node scripts/build-plan-bundle.mjs",
|
|
141
142
|
"build:ledger-bundle": "node scripts/build-ledger-bundle.mjs",
|
|
143
|
+
"build:ingest-bundle": "node scripts/build-ingest-bundle.mjs",
|
|
142
144
|
"build:code-explorer-bundle": "node scripts/build-code-explorer-bundle.mjs",
|
|
143
145
|
"build:electron": "node scripts/build-electron-app.mjs",
|
|
144
146
|
"electron": "electron electron/main.mjs",
|
|
@@ -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
|
|
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
|
{
|
package/src/domain/domain.mjs
CHANGED
|
@@ -5,9 +5,18 @@
|
|
|
5
5
|
// arrives as data from the memory store's fact/Rule rows. Plugs into
|
|
6
6
|
// planning.mjs's findActionPath as its applyActions.
|
|
7
7
|
|
|
8
|
+
import { provenanceTagToSource } from "./memory/trust.mjs";
|
|
9
|
+
|
|
8
10
|
const MEMBER_EDGE_PREDICATES = new Set(["rdfs:subClassOf", "rdf:type"]);
|
|
9
11
|
const SNAPSHOT_RE = /^(.+)@step(\d+)$/;
|
|
10
12
|
|
|
13
|
+
/** The class-member list a grounding step ranges over. The default "all" is
|
|
14
|
+
* every typed member; "taught" restricts to instances a person taught, the
|
|
15
|
+
* scope the plan lane uses so a merged corpus never widens a quantified goal
|
|
16
|
+
* or the movable set. Falls back to nothing for an unknown scope. */
|
|
17
|
+
const membersInScope = (domain, cls, scope) =>
|
|
18
|
+
(scope === "taught" ? domain.taughtClassMembers : domain.classMembers)?.[cls] || [];
|
|
19
|
+
|
|
11
20
|
/** Trim a taught term defensively: some teach frames keep a sentence's
|
|
12
21
|
* trailing punctuation in the captured object. */
|
|
13
22
|
const normTerm = (value) => String(value ?? "").trim().replace(/[.!?]+$/, "");
|
|
@@ -110,18 +119,34 @@ export function compileDomain(factRows, ruleRows) {
|
|
|
110
119
|
|
|
111
120
|
// Class membership from typing edges. A member is a subject with a typing
|
|
112
121
|
// edge into the class and no typing edge pointing at itself (a leaf).
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
122
|
+
const membersFromEdges = (typingEdges) => {
|
|
123
|
+
const hasIncoming = new Set(typingEdges.map((r) => r.object));
|
|
124
|
+
const members = {};
|
|
125
|
+
for (const edge of typingEdges) {
|
|
126
|
+
if (hasIncoming.has(edge.subject)) continue;
|
|
127
|
+
(members[edge.object] ??= []).push(edge.subject);
|
|
128
|
+
}
|
|
129
|
+
for (const list of Object.values(members)) {
|
|
130
|
+
list.sort();
|
|
131
|
+
// de-dup while keeping order
|
|
132
|
+
for (let i = list.length - 1; i > 0; i -= 1) if (list[i] === list[i - 1]) list.splice(i, 1);
|
|
133
|
+
}
|
|
134
|
+
return members;
|
|
135
|
+
};
|
|
136
|
+
const typingRows = (factRows || []).filter((r) => MEMBER_EDGE_PREDICATES.has(normTerm(r.predicate)));
|
|
137
|
+
const edges = typingRows.map(normRow);
|
|
138
|
+
const classMembers = membersFromEdges(edges);
|
|
139
|
+
// The same membership, restricted to instances a person taught (an operator
|
|
140
|
+
// assert or a teach-lane frame). A planner that quantifies over a class —
|
|
141
|
+
// "every X" — ranges over the taught individuals only: a merged corpus can
|
|
142
|
+
// type dozens of unrelated things into that class, and sweeping those into
|
|
143
|
+
// the goal makes it unsatisfiable and the search fruitless. classMembers
|
|
144
|
+
// keeps every member for the readers that want them all.
|
|
145
|
+
const taughtEdges = edges.filter((_, i) => {
|
|
146
|
+
const kind = provenanceTagToSource(typingRows[i].provenance)?.kind;
|
|
147
|
+
return kind === "operator" || kind === "teach";
|
|
148
|
+
});
|
|
149
|
+
const taughtClassMembers = membersFromEdges(taughtEdges);
|
|
125
150
|
|
|
126
151
|
// A class-bound word (an effect role or constraint term that is neither
|
|
127
152
|
// "subject" nor "target") is substituted by its class's sole member at
|
|
@@ -158,7 +183,7 @@ export function compileDomain(factRows, ruleRows) {
|
|
|
158
183
|
.filter((r) => !dynamicPredicates.has(r.predicate) && !MEMBER_EDGE_PREDICATES.has(r.predicate))
|
|
159
184
|
.sort(rowSort);
|
|
160
185
|
|
|
161
|
-
return { actions, classMembers, dynamicPredicates, ordering };
|
|
186
|
+
return { actions, classMembers, taughtClassMembers, dynamicPredicates, ordering };
|
|
162
187
|
}
|
|
163
188
|
|
|
164
189
|
const domainIndividuals = (domain) => {
|
|
@@ -312,13 +337,16 @@ function constraintViolated(action, nextState, domain) {
|
|
|
312
337
|
}
|
|
313
338
|
|
|
314
339
|
/** Every legal grounded action from `state`, with its successor.
|
|
315
|
-
* Deterministic: actions, signatures, and members are walked sorted.
|
|
316
|
-
|
|
340
|
+
* Deterministic: actions, signatures, and members are walked sorted. `scope`
|
|
341
|
+
* ("all" | "taught") picks which class members ground the moves — the plan
|
|
342
|
+
* lane passes "taught" so corpus members of a taught class never enter the
|
|
343
|
+
* movable set. */
|
|
344
|
+
export function movesFromRules(state, domain, { budget = 5000, scope = "all" } = {}) {
|
|
317
345
|
let groundings = 0;
|
|
318
346
|
for (const action of domain.actions) {
|
|
319
347
|
for (const sig of action.signatures) {
|
|
320
|
-
groundings += (domain
|
|
321
|
-
(domain
|
|
348
|
+
groundings += membersInScope(domain, sig.subjectClass, scope).length *
|
|
349
|
+
membersInScope(domain, sig.targetClass, scope).length;
|
|
322
350
|
}
|
|
323
351
|
}
|
|
324
352
|
if (groundings > budget) throw new PlanBudgetError(groundings, budget);
|
|
@@ -327,8 +355,8 @@ export function movesFromRules(state, domain, { budget = 5000 } = {}) {
|
|
|
327
355
|
for (const action of domain.actions) {
|
|
328
356
|
const [verb, particle] = action.name.split(/\s+/);
|
|
329
357
|
for (const sig of action.signatures) {
|
|
330
|
-
for (const subject of domain
|
|
331
|
-
for (const target of domain
|
|
358
|
+
for (const subject of membersInScope(domain, sig.subjectClass, scope)) {
|
|
359
|
+
for (const target of membersInScope(domain, sig.targetClass, scope)) {
|
|
332
360
|
if (subject === target) continue;
|
|
333
361
|
let ok = true;
|
|
334
362
|
for (const precond of action.preconds) {
|
|
@@ -360,7 +388,7 @@ export function movesFromRules(state, domain, { budget = 5000 } = {}) {
|
|
|
360
388
|
* state predicate. Satisfaction is a transitive walk along the goal
|
|
361
389
|
* predicate: a stacked member reaches the goal object through its support
|
|
362
390
|
* chain, which a direct row lookup cannot see. */
|
|
363
|
-
export function compileGoal(goalSpecs, domain) {
|
|
391
|
+
export function compileGoal(goalSpecs, domain, { scope = "all" } = {}) {
|
|
364
392
|
const specs = (goalSpecs || []).map((g) => ({
|
|
365
393
|
universal: Boolean(g.universal),
|
|
366
394
|
term: normTerm(g.term),
|
|
@@ -369,7 +397,7 @@ export function compileGoal(goalSpecs, domain) {
|
|
|
369
397
|
}));
|
|
370
398
|
const checks = [];
|
|
371
399
|
for (const spec of specs) {
|
|
372
|
-
const members = spec.universal ? domain
|
|
400
|
+
const members = spec.universal ? membersInScope(domain, spec.term, scope) : [spec.term];
|
|
373
401
|
if (spec.universal && members.length === 0) {
|
|
374
402
|
throw new Error(`the goal names "${spec.term}" as a class, but it has no known members`);
|
|
375
403
|
}
|
|
@@ -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,8 @@ 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) };
|
|
63
79
|
}
|
|
64
80
|
const head = t.split(/\s+/)[0]; // drop trailing " /r/IsA" etc.
|
|
65
81
|
if (head.startsWith("corpus-weak:")) return { kind: "corpusWeak", name: head.slice("corpus-weak:".length) || "unknown" };
|
|
@@ -78,6 +94,7 @@ export function provenanceTagToSource(tag) {
|
|
|
78
94
|
}
|
|
79
95
|
if (head.startsWith("web:")) return { kind: "web", url: head.slice("web:".length) };
|
|
80
96
|
if (head.startsWith("url:")) return { kind: "web", url: head.slice("url:".length) };
|
|
97
|
+
if (head.startsWith("optimistic-extract:")) return { kind: "optimisticExtract", name: head.slice("optimistic-extract:".length) || "unknown" };
|
|
81
98
|
if (head.startsWith("extracted:")) return { kind: "extracted", name: head.slice("extracted:".length) || "unknown" };
|
|
82
99
|
if (head.startsWith("entailed:")) return { kind: "entailed", rule: head.slice("entailed:".length) };
|
|
83
100
|
if (head.startsWith("chat:") || head.startsWith("session:") || head.startsWith("operator")) return { kind: "operator" };
|
|
@@ -93,8 +110,10 @@ export const SOURCE_PRIOR = Object.freeze({
|
|
|
93
110
|
corpus: 0.7,
|
|
94
111
|
reference: 0.6,
|
|
95
112
|
corpusWeak: 0.55,
|
|
96
|
-
|
|
113
|
+
referenceLive: 0.5,
|
|
97
114
|
extracted: 0.45,
|
|
115
|
+
web: 0.4,
|
|
116
|
+
optimisticExtract: 0.35,
|
|
98
117
|
entailed: 0.3,
|
|
99
118
|
});
|
|
100
119
|
|
|
@@ -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 };
|