@polycode-projects/the-mechanical-code-talker 2.5.0 → 2.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +15 -11
  2. package/bin/tmct.mjs +8 -5
  3. package/corpus/LICENSES.json +7 -0
  4. package/corpus/README.md +3 -3
  5. package/corpus/reference/LICENSE-NOTICE +36 -0
  6. package/corpus/reference/README.md +19 -0
  7. package/corpus/reference/index.json.gz +0 -0
  8. package/corpus/reference/manifest.json +367 -0
  9. package/corpus/reference/shards/ref-00.jsonl.gz +0 -0
  10. package/corpus/reference/shards/ref-01.jsonl.gz +0 -0
  11. package/corpus/reference/shards/ref-02.jsonl.gz +0 -0
  12. package/corpus/reference/shards/ref-03.jsonl.gz +0 -0
  13. package/corpus/reference/shards/ref-04.jsonl.gz +0 -0
  14. package/corpus/reference/shards/ref-05.jsonl.gz +0 -0
  15. package/corpus/reference/shards/ref-06.jsonl.gz +0 -0
  16. package/corpus/reference/shards/ref-07.jsonl.gz +0 -0
  17. package/corpus/reference/shards/ref-08.jsonl.gz +0 -0
  18. package/corpus/reference/shards/ref-09.jsonl.gz +0 -0
  19. package/corpus/reference/shards/ref-0a.jsonl.gz +0 -0
  20. package/corpus/reference/shards/ref-0b.jsonl.gz +0 -0
  21. package/corpus/reference/shards/ref-0c.jsonl.gz +0 -0
  22. package/corpus/reference/shards/ref-0d.jsonl.gz +0 -0
  23. package/corpus/reference/shards/ref-0e.jsonl.gz +0 -0
  24. package/corpus/reference/shards/ref-0f.jsonl.gz +0 -0
  25. package/corpus/reference/shards/ref-10.jsonl.gz +0 -0
  26. package/corpus/reference/shards/ref-11.jsonl.gz +0 -0
  27. package/corpus/reference/shards/ref-12.jsonl.gz +0 -0
  28. package/corpus/reference/shards/ref-13.jsonl.gz +0 -0
  29. package/corpus/reference/shards/ref-14.jsonl.gz +0 -0
  30. package/corpus/reference/shards/ref-15.jsonl.gz +0 -0
  31. package/corpus/reference/shards/ref-16.jsonl.gz +0 -0
  32. package/corpus/reference/shards/ref-17.jsonl.gz +0 -0
  33. package/corpus/reference/shards/ref-18.jsonl.gz +0 -0
  34. package/corpus/reference/shards/ref-19.jsonl.gz +0 -0
  35. package/corpus/reference/shards/ref-1a.jsonl.gz +0 -0
  36. package/corpus/reference/shards/ref-1b.jsonl.gz +0 -0
  37. package/corpus/reference/shards/ref-1c.jsonl.gz +0 -0
  38. package/corpus/reference/shards/ref-1d.jsonl.gz +0 -0
  39. package/corpus/reference/shards/ref-1e.jsonl.gz +0 -0
  40. package/corpus/reference/shards/ref-1f.jsonl.gz +0 -0
  41. package/corpus/reference/shards/ref-20.jsonl.gz +0 -0
  42. package/corpus/reference/shards/ref-21.jsonl.gz +0 -0
  43. package/corpus/reference/shards/ref-22.jsonl.gz +0 -0
  44. package/corpus/reference/shards/ref-23.jsonl.gz +0 -0
  45. package/corpus/reference/shards/ref-24.jsonl.gz +0 -0
  46. package/corpus/reference/shards/ref-25.jsonl.gz +0 -0
  47. package/corpus/reference/shards/ref-26.jsonl.gz +0 -0
  48. package/corpus/reference/shards/ref-27.jsonl.gz +0 -0
  49. package/corpus/reference/shards/ref-28.jsonl.gz +0 -0
  50. package/corpus/reference/shards/ref-29.jsonl.gz +0 -0
  51. package/corpus/reference/shards/ref-2a.jsonl.gz +0 -0
  52. package/corpus/reference/shards/ref-2b.jsonl.gz +0 -0
  53. package/corpus/reference/shards/ref-2c.jsonl.gz +0 -0
  54. package/corpus/reference/shards/ref-2d.jsonl.gz +0 -0
  55. package/corpus/reference/shards/ref-2e.jsonl.gz +0 -0
  56. package/corpus/reference/shards/ref-2f.jsonl.gz +0 -0
  57. package/corpus/reference/shards/ref-30.jsonl.gz +0 -0
  58. package/corpus/reference/shards/ref-31.jsonl.gz +0 -0
  59. package/corpus/reference/shards/ref-32.jsonl.gz +0 -0
  60. package/corpus/reference/shards/ref-33.jsonl.gz +0 -0
  61. package/corpus/reference/shards/ref-34.jsonl.gz +0 -0
  62. package/corpus/reference/shards/ref-35.jsonl.gz +0 -0
  63. package/corpus/reference/shards/ref-36.jsonl.gz +0 -0
  64. package/corpus/reference/shards/ref-37.jsonl.gz +0 -0
  65. package/corpus/reference/shards/ref-38.jsonl.gz +0 -0
  66. package/corpus/reference/shards/ref-39.jsonl.gz +0 -0
  67. package/corpus/reference/shards/ref-3a.jsonl.gz +0 -0
  68. package/corpus/reference/shards/ref-3b.jsonl.gz +0 -0
  69. package/corpus/reference/shards/ref-3c.jsonl.gz +0 -0
  70. package/corpus/reference/shards/ref-3d.jsonl.gz +0 -0
  71. package/corpus/reference/shards/ref-3e.jsonl.gz +0 -0
  72. package/corpus/reference/shards/ref-3f.jsonl.gz +0 -0
  73. package/package.json +8 -4
  74. package/src/adapters/corpus/reference-pack.mjs +107 -0
  75. package/src/adapters/memory/core.mjs +104 -10
  76. package/src/domain/ask.mjs +66 -9
  77. package/src/domain/cli-verbs.mjs +3 -3
  78. package/src/domain/codegraph.mjs +6 -0
  79. package/src/domain/dialogue-acts.mjs +155 -0
  80. package/src/domain/grammar/lexicon-core.json +1 -1
  81. package/src/domain/interpret/normalize.mjs +14 -2
  82. package/src/domain/interpret/strategies/keywords.mjs +10 -1
  83. package/src/domain/memory/trust.mjs +12 -0
  84. package/src/domain/reference-pack.mjs +72 -0
  85. package/src/domain/skos-view.mjs +111 -0
  86. package/src/domain/syllogise.mjs +647 -56
  87. package/src/services/chat.mjs +499 -123
  88. package/src/services/init.mjs +2 -2
  89. package/src/surfaces/web/chat-browser-entry.mjs +87 -0
  90. package/src/surfaces/web/memory-ask-browser.bundle.js +865 -74
  91. package/src/tools/definitions.mjs +13 -0
  92. package/src/tools/handlers/index.mjs +2 -0
  93. package/src/tools/handlers/tmct-related.mjs +30 -0
  94. package/ROADMAP.md +0 -130
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "2.5.0",
3
+ "version": "2.5.3",
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.",
@@ -63,7 +63,6 @@
63
63
  "!src/domain/schemaorg/turtle.mjs",
64
64
  "!src/domain/version-stamp.mjs",
65
65
  "README.md",
66
- "ROADMAP.md",
67
66
  "LICENSE",
68
67
  "corpus/",
69
68
  "!corpus/wordnet/generate.mjs",
@@ -133,19 +132,24 @@
133
132
  "audit": "npm audit --audit-level=high",
134
133
  "audit:fix": "npm audit fix",
135
134
  "demo:build": "node scripts/build-demo-site.mjs",
135
+ "roll": "node scripts/roll.mjs",
136
136
  "build:ask-bundle": "node scripts/build-ask-bundle.mjs",
137
+ "build:chat-bundle": "node scripts/build-chat-bundle.mjs",
138
+ "build:chat-seed": "node scripts/build-chat-seed.mjs",
137
139
  "build:demo-graph": "node scripts/build-demo-graph.mjs",
140
+ "build:demo-pack": "node scripts/build-demo-pack.mjs",
138
141
  "build:demo-memory": "node scripts/build-demo-memory.mjs",
139
142
  "gen:tool-docs": "node scripts/generate-tool-docs.mjs",
140
143
  "gen:collisions": "node scripts/generate-real-word-collisions.mjs",
141
144
  "gen:prose-corpus": "node scripts/fetch-prose-corpus.mjs",
145
+ "gen:reference-pack": "node scripts/fetch-reference-pack.mjs",
142
146
  "gen:variants": "node scripts/generate-template-variants.mjs",
143
147
  "extract": "node bin/tmct.mjs extract",
144
148
  "extract:facts": "node bin/tmct.mjs extract"
145
149
  },
146
150
  "devDependencies": {
147
- "esbuild": "^0.28.1",
148
- "ink-testing-library": "^4.0.0",
151
+ "esbuild": "0.28.1",
152
+ "ink-testing-library": "4.0.0",
149
153
  "playwright": "1.61.1",
150
154
  "publint": "0.3.21",
151
155
  "yaml": "2.9.0"
@@ -0,0 +1,107 @@
1
+ // corpus/reference-pack.mjs — lazy, failure-tolerated loader for the shipped
2
+ // reference pack (corpus/reference/): a gzipped term index consulted first,
3
+ // then exactly one gzipped JSONL shard per hit. Nothing here ever throws at a
4
+ // caller — an absent, truncated or corrupt pack reads as null, and a null is
5
+ // the ordinary honest miss.
6
+ //
7
+ // The provider seam: registerReferencePackProvider swaps the whole lookup
8
+ // behind one async `{ lookup(normTerm) }` contract. It exists because the
9
+ // browser demo cannot read this filesystem layout — the web surface registers
10
+ // a fetch-backed provider over public/reference-pack/ instead, and chat code
11
+ // never knows which one it is talking to. No provider registered = the fs
12
+ // loader below.
13
+
14
+ import { readFileSync } from "node:fs";
15
+ import { gunzipSync } from "node:zlib";
16
+ import { fileURLToPath } from "node:url";
17
+ import { dirname, join } from "node:path";
18
+ import { isReferenceArticleRow, isReferenceIndexEntry } from "../../domain/reference-pack.mjs";
19
+
20
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
21
+
22
+ /** The pack directory: TMCT_REFERENCE_PACK_DIR when set, else the package's
23
+ * own corpus/reference/. */
24
+ export function referencePackDir(env = process.env) {
25
+ return env?.TMCT_REFERENCE_PACK_DIR || join(PKG_ROOT, "corpus", "reference");
26
+ }
27
+
28
+ const indexCacheByDir = new Map(); // dir -> { term: {s, t, r} } | null
29
+ const shardCacheByKey = new Map(); // `${dir}\0${shard}` -> Map(term -> row) | null
30
+
31
+ /** Drop every cached index/shard — for tests that mutate a pack dir. */
32
+ export function clearReferencePackCache() {
33
+ indexCacheByDir.clear();
34
+ shardCacheByKey.clear();
35
+ }
36
+
37
+ function readGunzipped(file) {
38
+ try {
39
+ return gunzipSync(readFileSync(file));
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ /** The pack's term index, lazily read and cached per dir; null (cached) when
46
+ * the pack is absent or unreadable. Never throws. */
47
+ export function loadReferenceIndex(dir) {
48
+ if (indexCacheByDir.has(dir)) return indexCacheByDir.get(dir);
49
+ let index = null;
50
+ const body = readGunzipped(join(dir, "index.json.gz"));
51
+ if (body) {
52
+ try {
53
+ const parsed = JSON.parse(body.toString("utf8"));
54
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) index = parsed;
55
+ } catch { /* tolerated: a corrupt index is an absent pack */ }
56
+ }
57
+ indexCacheByDir.set(dir, index);
58
+ return index;
59
+ }
60
+
61
+ function loadShard(dir, shardName) {
62
+ const key = `${dir}\0${shardName}`;
63
+ if (shardCacheByKey.has(key)) return shardCacheByKey.get(key);
64
+ let rows = null;
65
+ const body = readGunzipped(join(dir, "shards", `${shardName}.jsonl.gz`));
66
+ if (body) {
67
+ rows = new Map();
68
+ for (const line of body.toString("utf8").split("\n")) {
69
+ if (!line.trim()) continue;
70
+ try {
71
+ const row = JSON.parse(line);
72
+ if (isReferenceArticleRow(row)) rows.set(row.term, row);
73
+ } catch { /* tolerated: a bad line loses one row, not the shard */ }
74
+ }
75
+ }
76
+ shardCacheByKey.set(key, rows);
77
+ return rows;
78
+ }
79
+
80
+ /** One article by its normalised term: index hit -> the one shard the index
81
+ * names (cached) -> the row. Null on an index miss, so an unknown term never
82
+ * costs a shard read. Never throws. */
83
+ export function loadReferenceArticle(dir, term) {
84
+ const index = loadReferenceIndex(dir);
85
+ if (!index) return null;
86
+ const entry = index[String(term ?? "")];
87
+ if (!isReferenceIndexEntry(entry)) return null;
88
+ const rows = loadShard(dir, entry.s);
89
+ return rows?.get(entry.t) ?? null;
90
+ }
91
+
92
+ const fsProvider = {
93
+ lookup: async (normTerm) => loadReferenceArticle(referencePackDir(), normTerm),
94
+ };
95
+
96
+ let registeredProvider = null;
97
+
98
+ /** Swap the pack lookup: provider = { lookup: async (normTerm) => row|null }.
99
+ * Pass null to restore the default fs loader. */
100
+ export function registerReferencePackProvider(provider) {
101
+ registeredProvider = provider && typeof provider.lookup === "function" ? provider : null;
102
+ }
103
+
104
+ /** The active provider — the registered one, else the lazy fs loader. */
105
+ export function getReferencePackProvider() {
106
+ return registeredProvider ?? fsProvider;
107
+ }
@@ -79,6 +79,7 @@ const MEMORY_VOCABULARY = [
79
79
  { prop: "rdf:object", note: "reified fact: the triple's object term" },
80
80
  { prop: "mgx:factProvenance", note: "LEGACY COMPAT SHIM: the ' | '-joined provenance tag string a fact came from; the source-of-truth is now the mgx:statedBy edges derived from it" },
81
81
  { prop: "mgx:factQuantifier", note: "OPTIONAL: the quantifier word a plural class-membership teach used ('every'/'some'/'a few'), for literal recall by 'how many Xs are Ys' — never real cardinality counting" },
82
+ { prop: "mgx:factJustification", note: "an entailed Fact's supporting premise fact ids: ' | '-separated environments, one space-separated premise-id list per independent derivation, capped by syllogise's maxEnvironments knob; a value with no ' | ' is a single environment" },
82
83
  { prop: "mgx:ruleName", note: "a taught Rule's own name (e.g. 'grandparent') — the query-dispatcher's lookup key, PLAN_TAUGHT_RELATIONS.md §2/§3" },
83
84
  { prop: "mgx:ruleKind", note: "a taught Rule's SHAPE tag — the closed vocabulary compose2 | filter | recursive (structural, like 'Fact'/'Rule' themselves, never a domain word)" },
84
85
  { prop: "mgx:ruleBase1", note: "compose2: the first hop's base relation name; filter: the base rule/relation being filtered (same 'base relation' role in both kinds, so the name is shared)" },
@@ -91,7 +92,7 @@ const MEMORY_VOCABULARY = [
91
92
  { 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)" },
92
93
  { prop: STATED_BY_PROP, predicate: "statedBy", note: "subPropertyOf derivedFrom: a Source directly asserts this Fact (one edge per independent source — replaces the factProvenance union)" },
93
94
  { prop: CANONICALISED_FROM_PROP, predicate: "canonicalisedFrom", note: "subPropertyOf derivedFrom: a canonical Fact cleaned from a raw Block/Source, never replacing it" },
94
- { prop: "mgx:sourceType", note: "a Source's kind: operator | teach | provider | corpus | corpusWeak | extracted | web | entailed (the trust-prior key)" },
95
+ { prop: "mgx:sourceType", note: "a Source's kind: operator | teach | provider | corpus | corpusWeak | reference | extracted | web | entailed (the trust-prior key)" },
95
96
  { prop: "mgx:sourceUrl", note: "a web Source's URL" },
96
97
  { prop: "mgx:sourceRule", note: "an entailed Source's rule id" },
97
98
  { 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" },
@@ -576,7 +577,14 @@ function migrateLegacyFactIds(payload) {
576
577
  for (const ind of payload.individuals) {
577
578
  if (Array.isArray(ind?.derived_from) && ind.derived_from.length) ind.derived_from = ind.derived_from.map(remapId);
578
579
  const just = (ind?.attributes || []).find((a) => a?.prop === "mgx:factJustification");
579
- if (just?.value) just.value = just.value.split(" ").filter(Boolean).map(remapId).join(" ");
580
+ if (just?.value) {
581
+ // Environment-aware: the value is ' | '-separated premise-id lists, one
582
+ // per independent derivation — remap the ids inside each, keep the shape.
583
+ just.value = just.value.split(" | ")
584
+ .map((env) => env.split(" ").filter(Boolean).map(remapId).join(" "))
585
+ .filter(Boolean)
586
+ .join(" | ");
587
+ }
580
588
  }
581
589
  return payload;
582
590
  }
@@ -591,6 +599,43 @@ async function persistMemory(dir, payload) {
591
599
  await atomicWriteJson(memoryGraphFile(dir), payload);
592
600
  }
593
601
 
602
+ // ---- Syllogise watermark state: a small backend-dispatched sidecar ---------
603
+ // { version, factIds, completedAt } — the fact ids at the end of the last
604
+ // COMPLETE syllogise pass. syllogise() diffs it against the live store to
605
+ // pick delta or full evaluation; a missing/removed-id state means full.
606
+
607
+ export const SYLLOGISE_STATE_REL = join(MEMORY_DIR_REL, "syllogise-state.json");
608
+ const SQLITE_SYLLOGISE_STATE_KEY = "syllogiseState";
609
+
610
+ /** Load the syllogise watermark for a repo dir OR a Backend B/C handle.
611
+ * Null when no complete pass has recorded one. */
612
+ export async function loadSyllogiseState(dir) {
613
+ if (isMemoryHandle(dir)) return dir.syllogiseState ? structuredClone(dir.syllogiseState) : null;
614
+ if (isSqliteHandle(dir)) {
615
+ const row = dir.db.prepare("SELECT v FROM meta WHERE k = ?").get(SQLITE_SYLLOGISE_STATE_KEY);
616
+ return row?.v ? JSON.parse(row.v) : null;
617
+ }
618
+ try {
619
+ return JSON.parse(await readFile(join(dir, SYLLOGISE_STATE_REL), "utf8"));
620
+ } catch (e) {
621
+ if (e?.code === "ENOENT") return null;
622
+ throw e;
623
+ }
624
+ }
625
+
626
+ /** Persist the syllogise watermark — atomic file write (Backend A), a cloned
627
+ * handle field (Backend B), or a meta-table row (Backend C). */
628
+ export async function saveSyllogiseState(dir, state) {
629
+ if (isMemoryHandle(dir)) { dir.syllogiseState = structuredClone(state); return; }
630
+ if (isSqliteHandle(dir)) {
631
+ dir.db.prepare("INSERT OR REPLACE INTO meta(k, v) VALUES (?, ?)").run(SQLITE_SYLLOGISE_STATE_KEY, JSON.stringify(state));
632
+ return;
633
+ }
634
+ const file = join(dir, SYLLOGISE_STATE_REL);
635
+ await mkdir(dirname(file), { recursive: true });
636
+ await atomicWriteJson(file, state);
637
+ }
638
+
594
639
  /** Fresh read -> mutate -> atomic write. Serialized per call; every public
595
640
  * append goes through here, including the lazy legacy-provenance migration
596
641
  * and actor-level Source reliability recompute. `fn` may be async (the
@@ -667,6 +712,9 @@ function sourceIdFor(desc) {
667
712
  case "teach": return { id: desc.sessionId ? `${TEACH_SOURCE_ID}:${desc.sessionId}` : TEACH_SOURCE_ID, type: "teach" };
668
713
  case "provider": return { id: `src:provider:${desc.name}`, type: "provider" };
669
714
  case "corpus": return { id: `src:corpus:${desc.name}`, type: "corpus" };
715
+ // One Source per pack article (the @revid stays in the article segment),
716
+ // so two facts from the same article corroborate nothing extra.
717
+ case "reference": return { id: `src:reference:${desc.pack}:${desc.article}`, type: "reference" };
670
718
  // One Source per source-file basename, not per extraction run.
671
719
  case "extracted": return { id: `src:extracted:${desc.name}`, type: "extracted" };
672
720
  case "web": return { id: `src:learned:web:${fnv1aHex(String(desc.url || ""))}`, type: "web", url: String(desc.url || "") };
@@ -688,6 +736,7 @@ const PROV_CLASS_BY_SOURCE_TYPE = Object.freeze({
688
736
  provider: { subClass: "tmct:AgentSource", prov: "prov:Agent" },
689
737
  corpus: { subClass: "tmct:DocumentSource", prov: "prov:Entity" },
690
738
  corpusWeak: { subClass: "tmct:DocumentSource", prov: "prov:Entity" },
739
+ reference: { subClass: "tmct:DocumentSource", prov: "prov:Entity" },
691
740
  web: { subClass: "tmct:DocumentSource", prov: "prov:Entity" },
692
741
  extracted: { subClass: "tmct:DocumentSource", prov: "prov:Entity" },
693
742
  entailed: { subClass: "tmct:ActivitySource", prov: "prov:Activity" },
@@ -1074,13 +1123,38 @@ export async function appendFact(dir, { subject, predicate, object, provenance =
1074
1123
  return { id };
1075
1124
  }
1076
1125
 
1126
+ /** Normalize appendFacts' `justification` input — either a flat premise-id
1127
+ * list (one derivation) or a list of premise-id lists (one per independent
1128
+ * derivation) — into the string[][] environment shape: empty/non-string ids
1129
+ * dropped, environments deduped by canonical key (sorted-id join) with
1130
+ * within-environment citation order preserved. Undefined when nothing
1131
+ * storable remains. */
1132
+ function normalizeJustificationEnvironments(justification) {
1133
+ if (!Array.isArray(justification)) return undefined;
1134
+ const rawEnvs = justification.some(Array.isArray)
1135
+ ? justification.filter(Array.isArray)
1136
+ : [justification];
1137
+ const envs = [];
1138
+ const seen = new Set();
1139
+ for (const raw of rawEnvs) {
1140
+ const env = raw.filter((id) => typeof id === "string" && id);
1141
+ if (!env.length) continue;
1142
+ const key = [...env].sort().join(" ");
1143
+ if (seen.has(key)) continue;
1144
+ seen.add(key);
1145
+ envs.push(env);
1146
+ }
1147
+ return envs.length ? envs : undefined;
1148
+ }
1149
+
1077
1150
  /** Batch append of grammar/corpus-derived triples — ONE read-modify-write for
1078
1151
  * a whole seed, collapsing looping appendFact's O(N²) I/O to a single
1079
1152
  * mutate (same resulting ids/provenance/trust). Malformed facts are skipped,
1080
1153
  * not thrown. Optional per-fact `premiseTrusts`/`ruleConfidence` (batched
1081
- * entailed-hook passthrough) and `justification` (premise fact ids, stored
1082
- * as mgx:factJustification, last-write-wins). Returns
1083
- * { ids, appended, skipped }. */
1154
+ * entailed-hook passthrough) and `justification` (premise fact ids — a flat
1155
+ * list, or a list of lists for multiple independent derivations — stored as
1156
+ * mgx:factJustification's ' | '-separated environments, last-write-wins).
1157
+ * Returns { ids, appended, skipped }. */
1084
1158
  export async function appendFacts(dir, facts) {
1085
1159
  const prepared = [];
1086
1160
  let skipped = 0;
@@ -1099,7 +1173,7 @@ export async function appendFacts(dir, facts) {
1099
1173
  quantifier: normText(f?.quantifier),
1100
1174
  premiseTrusts: Array.isArray(f?.premiseTrusts) ? f.premiseTrusts : undefined,
1101
1175
  ruleConfidence: typeof f?.ruleConfidence === "number" ? f.ruleConfidence : undefined,
1102
- justification: Array.isArray(f?.justification) ? f.justification.filter(Boolean) : undefined,
1176
+ environments: normalizeJustificationEnvironments(f?.justification),
1103
1177
  });
1104
1178
  }
1105
1179
  const ids = [];
@@ -1138,7 +1212,7 @@ export async function appendFacts(dir, facts) {
1138
1212
  ...(provs.length ? [{ prop: "mgx:factProvenance", key: "provenance", value: provs.join(" | ") }] : []),
1139
1213
  ...(f.tokens.length ? [{ prop: "mgx:hasProseTokens", key: "prose_tokens", value: f.tokens.join(" ") }] : []),
1140
1214
  ...(qVal ? [{ prop: "mgx:factQuantifier", key: "quantifier", value: qVal }] : []),
1141
- ...(f.justification && f.justification.length ? [{ prop: "mgx:factJustification", key: "justification", value: f.justification.join(" ") }] : []),
1215
+ ...(f.environments ? [{ prop: "mgx:factJustification", key: "justification", value: f.environments.map((e) => e.join(" ")).join(" | ") }] : []),
1142
1216
  ],
1143
1217
  };
1144
1218
  // Upsert via the shared helper — O(1) via the index (Object.assign in
@@ -1545,6 +1619,24 @@ export function readFactRows(memory) {
1545
1619
  .map((id) => (sourcesById.get(id)?.attributes || []).find((a) => a?.prop === "mgx:sourceType")?.value)
1546
1620
  .filter(Boolean);
1547
1621
  const justificationRaw = get("justification");
1622
+ // ' | '-separated environments, one premise-id list per independent
1623
+ // derivation; a legacy value with no ' | ' parses as one environment.
1624
+ const environments = [];
1625
+ if (justificationRaw) {
1626
+ for (const chunk of justificationRaw.split(" | ")) {
1627
+ const env = chunk.split(" ").filter(Boolean);
1628
+ if (env.length) environments.push(env);
1629
+ }
1630
+ }
1631
+ const justification = [];
1632
+ const seenPremise = new Set();
1633
+ for (const env of environments) {
1634
+ for (const id of env) {
1635
+ if (seenPremise.has(id)) continue;
1636
+ seenPremise.add(id);
1637
+ justification.push(id);
1638
+ }
1639
+ }
1548
1640
  rows.push({
1549
1641
  id: ind.id,
1550
1642
  subject: get("subject"), predicate: get("predicate"), object: get("object"),
@@ -1552,9 +1644,11 @@ export function readFactRows(memory) {
1552
1644
  quantifier: get("quantifier"), // "" unless a plural class-membership teach set one
1553
1645
  sourceIds, sourceTypes,
1554
1646
  trust: Number((ind.attributes || []).find((a) => a?.prop === TRUST_SCORE_PROP)?.value) || 0,
1555
- // [] unless a rule persisted its premise fact ids (justification-tracking,
1556
- // scm-sco only today; see syllogise.mjs).
1557
- justification: justificationRaw ? justificationRaw.split(" ").filter(Boolean) : [],
1647
+ // `environments`: every persisted premise set (empty unless entailed);
1648
+ // `justification`: their deduped union in first-occurrence order, for
1649
+ // readers that only need "which premises does this fact cite at all".
1650
+ environments,
1651
+ justification,
1558
1652
  });
1559
1653
  }
1560
1654
  return rows;
@@ -74,6 +74,7 @@ const FINE_CLASS_SIBLING = { Function: "Method", Method: "Function" };
74
74
  // + calls together).
75
75
  const KIND_UNIONS = { uses: ["imports", "calls", "callsSymbol"] };
76
76
  const kindsFor = (kind) => KIND_UNIONS[kind] || [kind];
77
+ const SYMBOL_GRAIN_KINDS = new Set(Object.values(SYMBOL_GRAIN_SIBLING));
77
78
 
78
79
  const OVERFLOW_CAP = 12;
79
80
 
@@ -377,9 +378,17 @@ function classesForKinds(graph, kinds) {
377
378
  /** The single OBJECT class a forward relation kind points at across the loaded graph
378
379
  * (imports → Module), or null when its objects span more than one class (an ambiguous
379
380
  * grain the complement's universe can't be pinned to). Used by the forwardComplement
380
- * evaluator to bound the universe it differences the positive forward set out of. */
381
+ * evaluator to bound the universe it differences the positive forward set out of.
382
+ *
383
+ * A union kind's symbol-grain member (callsSymbol) points at symbols by design —
384
+ * the fine view, served by its own reverse branch. The union's canonical object
385
+ * grain is defined by its coarse members, so the vote excludes the symbol-grain
386
+ * siblings; without this "uses" collapses to null and a Class-resolved object
387
+ * never up-refines to its containing module. */
381
388
  function kindObjectClass(graph, kind) {
382
- const classes = classesForKinds(graph, kindsFor(kind));
389
+ const kinds = kindsFor(kind);
390
+ const coarse = kinds.filter((k) => !SYMBOL_GRAIN_KINDS.has(k));
391
+ const classes = classesForKinds(graph, coarse.length ? coarse : kinds);
383
392
  return classes.size === 1 ? [...classes][0] : null;
384
393
  }
385
394
 
@@ -886,7 +895,14 @@ function parseSuperlative(w, lc, nlp) {
886
895
  if (!entityType) return { node: "miss", reason: "a superlative needs an entity kind (module, class, function, …)" };
887
896
  }
888
897
  if (!metric) return { node: "miss", reason: "name what to rank by (imports, callers, methods, tests, or connections)" };
889
- return { node: "superlative", entityType, metric, metricNoun, extreme: ext };
898
+ // A need/lack verb measures the ABSENCE of the metric, so it inverts the
899
+ // ranking direction: "what most needs a test" asks for the FEWEST tests,
900
+ // and answering the most-tested module is the exact inverse of the question.
901
+ const NEED_LACK_WORDS = ["needs", "need", "needing", "lacks", "lack", "lacking", "misses", "missing"];
902
+ const extreme = lc.some((x) => NEED_LACK_WORDS.includes(x))
903
+ ? (ext === "most" ? "fewest" : "most")
904
+ : ext;
905
+ return { node: "superlative", entityType, metric, metricNoun, extreme };
890
906
  }
891
907
 
892
908
  // Predicate-find: "find me the payment class" (trailing-type) or "find the
@@ -2536,9 +2552,27 @@ function unplacedTermWords(term, label) {
2536
2552
  *
2537
2553
  * Tier 3 only: the prose and fuzzy tiers resolve BY not matching the label
2538
2554
  * (doc-comment words, a typo'd spelling), and announce themselves in the
2539
- * answer where tier 3 says nothing. */
2555
+ * answer where tier 3 says nothing.
2556
+ *
2557
+ * The AMBIGUOUS case is the same failure with several candidates instead of
2558
+ * one: a tie spread over candidates that ALL leave the same words unread is
2559
+ * not a real ambiguity between readings of the question — it is the question
2560
+ * not matching, several ways at once, and enumerating every candidate would
2561
+ * answer each of them to a question none of them is. A word placed by ANY
2562
+ * candidate keeps the honest ambiguity (that candidate may be the one meant). */
2540
2563
  function declineOnUnplacedWords(result, term) {
2541
- if (!result?.match || result.ambiguous || result.tier !== 3 || result.matchedVia) return result;
2564
+ if (!result?.match || result.tier !== 3 || result.matchedVia) return result;
2565
+ if (result.ambiguous) {
2566
+ const pool = [result.match, ...(result.candidates || [])];
2567
+ const shared = pool
2568
+ .map((m) => new Set(unplacedTermWords(term, m.label)))
2569
+ .reduce((acc, s) => acc.filter((w) => s.has(w)), unplacedTermWords(term, pool[0].label));
2570
+ if (!shared.length) return result;
2571
+ return {
2572
+ match: null, candidates: [], tier: null, ambiguous: false,
2573
+ unplacedWords: shared, nearestLabel: listJoin(pool.slice(0, 4).map((m) => m.label)),
2574
+ };
2575
+ }
2542
2576
  const unplaced = unplacedTermWords(term, result.match.label);
2543
2577
  if (!unplaced.length) return result;
2544
2578
  return {
@@ -2998,8 +3032,11 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
2998
3032
  // symbol-grain sibling reads off the sibling when a fine subject grain was
2999
3033
  // asked for, or when the resolved object is itself a fine symbol — the
3000
3034
  // module-coarse edge can never point at a function/method, so a bare "what
3001
- // calls fnAlpha" would otherwise return a false empty.
3002
- const symbolKind = SYMBOL_GRAIN_SIBLING[kind];
3035
+ // calls fnAlpha" would otherwise return a false empty. A union kind's own
3036
+ // symbol-grain member ("uses" carries callsSymbol) plays the same sibling
3037
+ // role here, so "what uses <symbol>" walks the same grain ladder as "what
3038
+ // calls <symbol>" instead of skipping it.
3039
+ const symbolKind = SYMBOL_GRAIN_SIBLING[kind] || kindsFor(kind).find((k) => SYMBOL_GRAIN_KINDS.has(k)) || null;
3003
3040
  const objIsFineSymbol = !!(objMatch.class && FINE_ENTITY_TYPES.has(objMatch.class));
3004
3041
  if (symbolKind && (FINE_ENTITY_TYPES.has(entityType) || objIsFineSymbol)) {
3005
3042
  const edges = edgesOfKind(graph, symbolKind).filter((e) => e.object === objMatch.id);
@@ -3022,7 +3059,7 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3022
3059
  // rather than a confident-looking-but-wrong "nothing touched/calls it".
3023
3060
  // Not widened to Function/Method — symbol-level counting precision there
3024
3061
  // is a separate, deliberate guarantee this must not erode.
3025
- const upRefineEligible = (kind === "touches" || kind === "calls") && objMatch.class === "Class"
3062
+ const upRefineEligible = (kind === "touches" || kind === "calls" || kind === "uses") && objMatch.class === "Class"
3026
3063
  && !edgesOfKind(graph, "contains").some((e) => e.subject === objMatch.id);
3027
3064
  const upRefineModule = upRefineEligible ? graph.byId.get(moduleIdOf(graph, objMatch) || "") : null;
3028
3065
  if (matches.length || !(upRefineEligible && upRefineModule)) {
@@ -3538,6 +3575,20 @@ function renderCore(parsed, result, graph) {
3538
3575
  // Forward: parsed.object is the given subject, not a search target, so it
3539
3576
  // gets its own subject-first phrasing rather than reverse's template.
3540
3577
  if (parsed.shape === "forward") {
3578
+ // The index stores membership on either contains (Class -> member) or
3579
+ // defines (Module -> symbol), and MEMBERSHIP_KINDS declares the two
3580
+ // equivalent for a members-of question — so before reporting "no
3581
+ // contains edges" for a subject whose members live on defines, the
3582
+ // sibling kind is consulted over the SAME resolved subject. Adopted
3583
+ // only on a real match; anything else keeps the honest empty.
3584
+ if (MEMBERSHIP_KINDS.includes(parsed.kind) && graph && result.objMatch) {
3585
+ for (const alt of MEMBERSHIP_KINDS) {
3586
+ if (alt === parsed.kind) continue;
3587
+ const altParsed = { ...parsed, kind: alt };
3588
+ const altResult = traverse(graph, altParsed, { pinnedObjMatch: result.objMatch });
3589
+ if (altResult?.matches?.length && !altResult.ambiguous) return renderCore(altParsed, altResult, graph);
3590
+ }
3591
+ }
3541
3592
  return {
3542
3593
  content: `${result.objMatch.label} has no ${verbFor(parsed.kind)} edges in the index.`,
3543
3594
  miss: true, ambiguous: false,
@@ -3636,6 +3687,12 @@ const CASCADE_FUZZY_TARGETS = [...new Set([
3636
3687
  ...TRIGGER_FUZZY_WORDS,
3637
3688
  ])].filter((wd) => /^[a-z]+$/.test(wd) && wd.length >= 4 && !STOPWORDS.has(wd));
3638
3689
 
3690
+ /** Real words that carry their own intent and are never a typo of the closed
3691
+ * vocabulary: the corrector must not rewrite them at all. "impact" sits two
3692
+ * edits from "import", and rewriting it answers the reverse question in the
3693
+ * asker's own words — an unrecognised impact phrasing must decline instead. */
3694
+ const CASCADE_FUZZY_REAL_WORDS = new Set(["impact", "impacts", "impacted"]);
3695
+
3639
3696
  /** Unique within-bound fuzzy correction of `w` toward CASCADE_FUZZY_TARGETS,
3640
3697
  * or null — a distance tie between two distinct targets is refused. The
3641
3698
  * 4-char floor holds on the word being corrected as well as on the targets:
@@ -3643,7 +3700,7 @@ const CASCADE_FUZZY_TARGETS = [...new Set([
3643
3700
  * were never a typo of it ("old" -> "hold"), and the correction is announced
3644
3701
  * as though the asker had typed it. */
3645
3702
  function fuzzyCascadeWord(w) {
3646
- if (w.length < 4) return null;
3703
+ if (w.length < 4 || CASCADE_FUZZY_REAL_WORDS.has(w)) return null;
3647
3704
  const bound = fuzzyBound(w);
3648
3705
  let best = bound + 1; let hit = null; let tied = false;
3649
3706
  for (const target of CASCADE_FUZZY_TARGETS) {
@@ -104,10 +104,10 @@ export const CLI_VERBS = [
104
104
  mode: "syllogise",
105
105
  errorLabel: "syllogise",
106
106
  usage: "tmct syllogise [--repo <abs>]",
107
- prose: ["speculative inference (offline maintenance job): forward-"],
107
+ prose: ["speculative inference (offline maintenance job): a deterministic"],
108
108
  flags: [
109
- { flag: "[--depth <n>] [--budget <n>]", prose: ["chain the memory's rdfs:subClassOf closure, materialising"] },
110
- { flag: "[--config <path>]", prose: ["bounded, low-trust, retractable entailed facts (never on the chat path)"] },
109
+ { flag: "[--depth <n>] [--budget <n>]", prose: ["forward-chaining materialisation over OWL 2 RL rule kernels"] },
110
+ { flag: "[--config <path>]", prose: ["(the classical syllogism among them), writing bounded, low-trust,", "retractable entailed facts (never on the chat path)"] },
111
111
  ],
112
112
  },
113
113
  {
@@ -362,6 +362,12 @@ export function impactClosure(graph, ind, { maxDepth = 8 } = {}) {
362
362
  if (!subjModId || !objModId) continue;
363
363
  const subjLabel = graph.byId.get(subjModId)?.label || subjModId;
364
364
  addDependent(objModId, subjModId, subjLabel, g.predicate);
365
+ // Keyed under the callee's SYMBOL id too: an impact query resolving a
366
+ // function name seeds the BFS from the symbol, and the module-coarse
367
+ // key above is invisible from there — the caller's module was a real
368
+ // dependent that read back as "no dependents found". Same-module
369
+ // calls stay skipped, mirroring the self-skip on the coarse key.
370
+ if (subjModId !== objModId) addDependent(e.object, subjModId, subjLabel, g.predicate);
365
371
  }
366
372
  } else if (kind === "tests") {
367
373
  for (const e of g.edges) {