@polycode-projects/the-mechanical-code-talker 2.10.3 → 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 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 six more pages each ground their
124
- own domain: a full chat seeded with 19,228 facts (the same nine bands as
125
- `npm run init:xl`, capped to a 22.8 MB download), the **memory ledger**
126
- (every fact as a readable sentence; drill by clicking the terms inside), a
127
- Towers-of-Hanoi plan replayed move by move, the spider-and-fly and
128
- text-adventure games, and a sprite gallery whose chat dock answers from
129
- 1,033 generated sprite facts.
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, and this never
575
- runs on the chat's hot path.
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 24 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.
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>.log.
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
- const text = await dispatchTool(sub, args, { config });
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",
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
 
@@ -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,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
- return { kind: "reference", pack: rest.slice(0, colon) || "unknown", article: rest.slice(colon + 1) };
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
- web: 0.4,
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 };