@polycode-projects/the-mechanical-code-talker 2.3.1 → 2.5.2

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 (66) hide show
  1. package/README.md +131 -32
  2. package/bin/tmct.mjs +18 -91
  3. package/corpus/README.md +3 -3
  4. package/corpus/seon/README.md +1 -0
  5. package/corpus/tier2/generate.mjs +18 -18
  6. package/corpus/tier2/manifest.json +3 -3
  7. package/data/games/hanoi-3.txt +8 -2
  8. package/package.json +26 -8
  9. package/src/adapters/corpus-lanes.mjs +13 -0
  10. package/src/adapters/graph-build.mjs +5 -7
  11. package/src/adapters/import-closure.mjs +28 -0
  12. package/src/adapters/memory/blocks.mjs +5 -4
  13. package/src/adapters/memory/core.mjs +78 -5
  14. package/src/adapters/memory/shacl.mjs +12 -0
  15. package/src/adapters/providers/graph-service.mjs +12 -5
  16. package/src/adapters/tracked-files.mjs +17 -0
  17. package/src/domain/ask-vocab.mjs +2 -0
  18. package/src/domain/ask.mjs +225 -13
  19. package/src/domain/cli-verbs.mjs +201 -0
  20. package/src/domain/codegraph.mjs +142 -56
  21. package/src/domain/completions/graph-adapter.mjs +1 -1
  22. package/src/domain/completions/group.mjs +3 -17
  23. package/src/domain/completions/infer.mjs +4 -13
  24. package/src/domain/completions/rank.mjs +6 -19
  25. package/src/domain/grammar/lexicon-core.json +1 -1
  26. package/src/domain/hash.mjs +36 -13
  27. package/src/domain/interpret/fuzzy.mjs +7 -2
  28. package/src/domain/interpret/normalize.mjs +9 -0
  29. package/src/domain/interpret/strategies/keywords.mjs +19 -9
  30. package/src/domain/memory/capability.mjs +22 -3
  31. package/src/domain/memory/touched-facts.mjs +17 -0
  32. package/src/domain/module-paths.mjs +9 -0
  33. package/src/domain/persona/tiers.mjs +1 -1
  34. package/src/domain/planning.mjs +37 -0
  35. package/src/domain/prose.mjs +10 -2
  36. package/src/domain/relative-specifiers.mjs +12 -0
  37. package/src/domain/router/registry.mjs +3 -2
  38. package/src/domain/router/results.mjs +5 -18
  39. package/src/domain/seeded-random.mjs +33 -0
  40. package/src/domain/syllogise.mjs +10 -7
  41. package/src/domain/text-stats.mjs +31 -0
  42. package/src/services/chat.mjs +722 -184
  43. package/src/services/extract-facts.mjs +155 -0
  44. package/src/services/import-file.mjs +2 -2
  45. package/src/services/init.mjs +2 -2
  46. package/src/services/ledger-viz.mjs +6 -1
  47. package/src/services/sentences.mjs +26 -0
  48. package/src/surfaces/web/memory-ask-browser.bundle.js +11390 -360
  49. package/src/tools/graph-load.mjs +7 -1
  50. package/src/tools/readme-docs.mjs +113 -0
  51. package/src/tools/schema-docs.mjs +2 -2
  52. package/ROADMAP.md +0 -129
  53. package/corpus/namenet/generate.mjs +0 -309
  54. package/corpus/wordnet/generate.mjs +0 -332
  55. package/src/adapters/prose-tokens.mjs +0 -98
  56. package/src/adapters/wordnet-source.mjs +0 -70
  57. package/src/domain/corpus-matrix.mjs +0 -87
  58. package/src/domain/inflect.mjs +0 -67
  59. package/src/domain/licences.mjs +0 -68
  60. package/src/domain/markdown-links.mjs +0 -55
  61. package/src/domain/persona/codegen.mjs +0 -123
  62. package/src/domain/publish-gate.mjs +0 -41
  63. package/src/domain/schemaorg/turtle.mjs +0 -25
  64. package/src/domain/semcor/parse.mjs +0 -87
  65. package/src/domain/version-stamp.mjs +0 -36
  66. package/src/domain/wordnet/yaml.mjs +0 -133
@@ -1,5 +1,8 @@
1
1
  import { lookupByProseTokens, proseLayerHits, splitIdentifierWords } from "./prose.mjs";
2
2
  import { CREATED_AT_PROP, UPDATED_AT_PROP } from "./memory/trust.mjs";
3
+ import { isTestPath } from "./module-paths.mjs";
4
+ import { idfWeight } from "./text-stats.mjs";
5
+ import { bfsLevels } from "./planning.mjs";
3
6
 
4
7
  // Pure (no-network, no-fs) query logic over the typed `entities` payload that the
5
8
  // deterministic indexer writes to <repo>/.tmct/graph.json (shape produced by
@@ -369,24 +372,12 @@ export function impactClosure(graph, ind, { maxDepth = 8 } = {}) {
369
372
  }
370
373
 
371
374
  const levels = []; // [[{id, label, via, tests[]}], …] indexed by depth-1
372
- const visited = new Set([ind.id]);
373
- let frontier = [ind.id];
374
- for (let depth = 1; depth <= maxDepth && frontier.length; depth += 1) {
375
- const next = [];
376
- const level = [];
377
- for (const id of frontier) {
378
- for (const dep of dependents.get(id) || []) {
379
- if (visited.has(dep.id)) continue;
380
- visited.add(dep.id);
381
- level.push({ ...dep, tests: coveredBy.get(dep.id) || [] });
382
- next.push(dep.id);
383
- }
384
- }
385
- if (level.length) {
386
- level.sort((a, b) => String(a.label).localeCompare(String(b.label)));
387
- levels.push(level);
388
- }
389
- frontier = next;
375
+ for (const level of bfsLevels(ind.id, (id) => dependents.get(id) || [], { maxDepth, keyOf: (dep) => dep.id })) {
376
+ if (!level.length) continue;
377
+ const withTests = level
378
+ .map((dep) => ({ ...dep, tests: coveredBy.get(dep.id) || [] }))
379
+ .sort((a, b) => String(a.label).localeCompare(String(b.label)));
380
+ levels.push(withTests);
390
381
  }
391
382
  return levels;
392
383
  }
@@ -470,7 +461,6 @@ const EXACT_W = 5; // token == a whole defined symbol name
470
461
  const SYM_MATCH_CAP = 4; // only the top-K highest-IDF symbol-component hits count
471
462
  const PROX_FRAC = 0.2; // import-adjacency bonus = this × the strongest matched neighbour
472
463
  const PROX_CAP_FRAC = 0.35; // capped at this × the module's own score
473
- const isTestLabel = (s) => /(^|\/)tests?\//.test(s) || /(^|\/)test_[^/]*\.py$/.test(s) || /\.tests(\.|$)/.test(s);
474
464
  // opt-in via demoteNonProd: demote (not exclude) example/fixture/sample/demo/test-*
475
465
  // paths, which share vocabulary with production modules and would otherwise shadow them
476
466
  const NONPROD_DEMOTE = 0.15;
@@ -758,14 +748,20 @@ function scoreModules(graph, tokens, opts = {}) {
758
748
  modules.push({ ind, label, labelLc, defines, symSet, symComps, dotted });
759
749
  }
760
750
  const N = modules.length || 1;
761
- // idf = log(1 + N/(1+df)): near-zero for ubiquitous tokens, large for rare ones
751
+ // idf = log(1 + N/(1+df)): near-zero for ubiquitous tokens, large for rare ones.
752
+ // Memoized so the literalMention pass below can price candidate tokens the query
753
+ // tokenizer never produced without recomputing document frequency for the rest.
762
754
  const idf = new Map();
763
- for (const t of tokens) {
764
- if (idf.has(t)) continue;
755
+ const documentFrequencyOf = (t) => {
765
756
  let df = 0;
766
757
  for (const m of modules) if (m.labelLc.includes(t) || m.symComps.has(t) || m.symSet.has(t)) df++;
767
- idf.set(t, Math.log(1 + N / (1 + df)));
768
- }
758
+ return df;
759
+ };
760
+ const idfOf = (t) => {
761
+ if (!idf.has(t)) idf.set(t, idfWeight(N, documentFrequencyOf(t)));
762
+ return idf.get(t);
763
+ };
764
+ for (const t of tokens) idfOf(t);
769
765
  const scored = [];
770
766
  for (const m of modules) {
771
767
  let exactScore = 0, pathScore = 0, matchCount = 0;
@@ -782,8 +778,8 @@ function scoreModules(graph, tokens, opts = {}) {
782
778
  for (let i = 0; i < Math.min(compWeights.length, SYM_MATCH_CAP); i++) symScore += compWeights[i] * SYM_W;
783
779
  let score = exactScore + pathScore + symScore;
784
780
  if (!score) continue;
785
- if (demoteNonProd && (isTestLabel(m.labelLc) || isNonProdLabel(m.labelLc))) score *= NONPROD_DEMOTE;
786
- else if (isTestLabel(m.labelLc)) score *= 0.4; // source first; tests still discoverable
781
+ if (demoteNonProd && (isTestPath(m.labelLc) || isNonProdLabel(m.labelLc))) score *= NONPROD_DEMOTE;
782
+ else if (isTestPath(m.labelLc)) score *= 0.4; // source first; tests still discoverable
787
783
  const matching = m.defines.filter((d) => { const dl = d.toLowerCase(); const cs = identComponents(d); return tokens.some((t) => dl === t || cs.has(t)); });
788
784
  const density = m.defines.length ? matchCount / m.defines.length : 0;
789
785
  scored.push({ ind: m.ind, score, defineCount: m.defines.length, matching, density });
@@ -798,14 +794,6 @@ function scoreModules(graph, tokens, opts = {}) {
798
794
  }
799
795
  return false;
800
796
  };
801
- const idfOf = (t) => {
802
- if (!idf.has(t)) {
803
- let df = 0;
804
- for (const m of modules) if (m.labelLc.includes(t) || m.symComps.has(t) || m.symSet.has(t)) df++;
805
- idf.set(t, Math.log(1 + N / (1 + df)));
806
- }
807
- return idf.get(t);
808
- };
809
797
  const byModId = new Map(modules.map((m) => [m.ind.id, m]));
810
798
  let maxBase = 0;
811
799
  for (const s of scored) maxBase = Math.max(maxBase, s.score);
@@ -986,18 +974,131 @@ export function selectRankedModules(ranked, { top_k = 2, scoreGapK = null } = {}
986
974
  return picked;
987
975
  }
988
976
 
977
+ // ---- the `name` filter: caller text compiled to a regex, under a work bound ----
978
+
979
+ /* A backtracking engine can be made to run for minutes on one short label, and
980
+ `name` arrives from a tool call, so the pattern is compiled under bounds
981
+ rather than trusted. Two independent shapes cost real time, and measurement
982
+ is the only way to tell them apart:
983
+
984
+ (a+)+$ vs 30 characters — over 30s (a quantified group backtracks
985
+ exponentially in label length)
986
+ a*a*a*a*a*$ vs 128 characters — 101s (no group at all; every extra
987
+ quantifier raises the degree
988
+ of a polynomial in the same
989
+ length)
990
+
991
+ So the pattern gate refuses a quantified group and a back-reference, which is
992
+ what exponential backtracking needs, and caps the quantifier count, which
993
+ bounds the polynomial's degree. Capping the tested label length bounds its
994
+ base. The budget then backstops the pair: a static gate is exactly the kind of
995
+ thing that can miss a case, and the budget bounds the damage when it does. */
996
+ const NAME_PATTERN_MAX_LENGTH = 128;
997
+ const NAME_PATTERN_MAX_QUANTIFIERS = 4;
998
+ const NAME_MATCH_MAX_LABEL = 64; // the longest label measured across real graphs is 27
999
+ const NAME_MATCH_BUDGET_MS = 250;
1000
+
1001
+ class NameFilterBudgetExceeded extends Error {}
1002
+
1003
+ /** Classifies the backtracking hazards in `pattern`: how many quantifiers it
1004
+ * applies, whether any of them quantifies a group, and whether it back-refers.
1005
+ * Escapes and character-class interiors are inert, `(?` opens a group rather
1006
+ * than quantifying, and a `?` right after a quantifier only marks it lazy.
1007
+ * @returns {{quantifiers: number, quantifiedGroup: boolean, backreference: boolean}} */
1008
+ export function scanNamePattern(pattern) {
1009
+ let quantifiers = 0;
1010
+ let quantifiedGroup = false;
1011
+ let backreference = false;
1012
+ let inClass = false;
1013
+ let prev = "";
1014
+ let prevWasQuantifier = false;
1015
+ const countQuantifier = () => {
1016
+ quantifiers += 1;
1017
+ if (prev === ")") quantifiedGroup = true;
1018
+ prevWasQuantifier = true;
1019
+ };
1020
+ for (let i = 0; i < pattern.length; i++) {
1021
+ const ch = pattern[i];
1022
+ if (ch === "\\") {
1023
+ const next = pattern[i + 1] || "";
1024
+ if (!inClass && next >= "1" && next <= "9") backreference = true;
1025
+ i += 1;
1026
+ prev = "";
1027
+ prevWasQuantifier = false;
1028
+ continue;
1029
+ }
1030
+ if (inClass) {
1031
+ if (ch === "]") { inClass = false; prev = "]"; prevWasQuantifier = false; }
1032
+ continue;
1033
+ }
1034
+ if (ch === "[") { inClass = true; prev = "["; prevWasQuantifier = false; continue; }
1035
+ if (ch === "?" && (prev === "(" || prevWasQuantifier)) { prev = "?"; prevWasQuantifier = false; continue; }
1036
+ if (ch === "*" || ch === "+" || ch === "?") { countQuantifier(); prev = ch; continue; }
1037
+ if (ch === "{") {
1038
+ const close = pattern.indexOf("}", i);
1039
+ const body = close === -1 ? "" : pattern.slice(i + 1, close);
1040
+ if (close !== -1 && /^\d+(,\d*)?$/.test(body)) { countQuantifier(); i = close; prev = "}"; continue; }
1041
+ prev = "{";
1042
+ prevWasQuantifier = false;
1043
+ continue;
1044
+ }
1045
+ prev = ch;
1046
+ prevWasQuantifier = false;
1047
+ }
1048
+ return { quantifiers, quantifiedGroup, backreference };
1049
+ }
1050
+
1051
+ /** Compiles the `name` filter into a label matcher whose total work is bounded.
1052
+ * `now` is injectable so a test can drive the budget without waiting on it.
1053
+ * @returns {{test: (label: string) => boolean}|{error: string}} */
1054
+ export function compileNameFilter(name, { now = Date.now } = {}) {
1055
+ const pattern = String(name);
1056
+ if (pattern.length > NAME_PATTERN_MAX_LENGTH) {
1057
+ return { error: `name pattern is ${pattern.length} characters; the limit is ${NAME_PATTERN_MAX_LENGTH}.` };
1058
+ }
1059
+ const { quantifiers, quantifiedGroup, backreference } = scanNamePattern(pattern);
1060
+ if (quantifiedGroup) {
1061
+ return { error: `name pattern "${pattern}" quantifies a group, which can backtrack for minutes on one label. Quantify a character or a class instead.` };
1062
+ }
1063
+ if (backreference) {
1064
+ return { error: `name pattern "${pattern}" uses a back-reference, which can backtrack for minutes on one label.` };
1065
+ }
1066
+ if (quantifiers > NAME_PATTERN_MAX_QUANTIFIERS) {
1067
+ return { error: `name pattern "${pattern}" has ${quantifiers} quantifiers; the limit is ${NAME_PATTERN_MAX_QUANTIFIERS}.` };
1068
+ }
1069
+ let re;
1070
+ try { re = new RegExp(pattern, "i"); } catch { return { error: `invalid name pattern: ${pattern}` }; }
1071
+ let deadline = null;
1072
+ return {
1073
+ test(label) {
1074
+ if (deadline === null) deadline = now() + NAME_MATCH_BUDGET_MS;
1075
+ else if (now() > deadline) {
1076
+ throw new NameFilterBudgetExceeded(`name pattern "${pattern}" exceeded its ${NAME_MATCH_BUDGET_MS}ms matching budget.`);
1077
+ }
1078
+ return re.test(String(label).slice(0, NAME_MATCH_MAX_LABEL));
1079
+ },
1080
+ };
1081
+ }
1082
+
989
1083
  export function renderSearch(graph, query, { limit = SEARCH_LIMIT, kind = "", decorator = "", name = "" } = {}) {
990
1084
  const tokens = String(query || "").toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean);
991
1085
  const wantKind = String(kind || "").trim().toLowerCase();
992
1086
  const decFilter = String(decorator || "").trim().toLowerCase();
993
1087
  let nameRe = null;
994
1088
  if (name) {
995
- try { nameRe = new RegExp(name, "i"); } catch { return `invalid name pattern: ${name}`; }
1089
+ const compiled = compileNameFilter(name);
1090
+ if (compiled.error) return compiled.error;
1091
+ nameRe = compiled;
996
1092
  }
997
1093
  // kind= switches to symbol search (functions/classes/methods/attributes); the
998
1094
  // default (no kind) keeps the module "where does this live" search unchanged.
999
1095
  if (wantKind && wantKind !== "module") {
1000
- return searchSymbols(graph, tokens, { limit, kind: wantKind, decFilter, nameRe });
1096
+ try {
1097
+ return searchSymbols(graph, tokens, { limit, kind: wantKind, decFilter, nameRe });
1098
+ } catch (e) {
1099
+ if (e instanceof NameFilterBudgetExceeded) return e.message;
1100
+ throw e;
1101
+ }
1001
1102
  }
1002
1103
  if (!tokens.length && !nameRe && !decFilter) return "empty query";
1003
1104
  const scored = scoreModules(graph, tokens);
@@ -1137,7 +1238,7 @@ export function renderSignature(graph, ind) {
1137
1238
  }
1138
1239
 
1139
1240
  /** Forward bases + the transitive reverse inheritance closure (who extends this) —
1140
- * replaces grepping `class X(Base)` across the tree. Uses `inherits` (mgx:subclassOf). */
1241
+ * replaces grepping `class X(Base)` across the tree. Uses `inherits` (seon:hasSuperType). */
1141
1242
  export function renderSubclasses(graph, ind) {
1142
1243
  const inherits = edgesOfKind(graph, "inherits");
1143
1244
  const bases = inherits.filter((e) => e.subject === ind.id).map((e) => e.objectLabel || e.object);
@@ -1148,25 +1249,10 @@ export function renderSubclasses(graph, ind) {
1148
1249
  }
1149
1250
  const lines = [`${ind.label} — ${classHeading(ind.class)} (id: ${ind.id})`];
1150
1251
  lines.push(bases.length ? `extends: ${capJoin(bases, SUBCLASS_CAP)}` : "extends: (no internal/recorded base classes)");
1151
- const visited = new Set([ind.id]);
1152
1252
  const levels = [];
1153
- let frontier = [ind.id];
1154
- for (let depth = 1; depth <= 8 && frontier.length; depth += 1) {
1155
- const next = [];
1156
- const level = [];
1157
- for (const id of frontier) {
1158
- for (const c of childrenOf.get(id) || []) {
1159
- if (visited.has(c.id)) continue;
1160
- visited.add(c.id);
1161
- level.push(c.label);
1162
- next.push(c.id);
1163
- }
1164
- }
1165
- if (level.length) {
1166
- level.sort((a, b) => String(a).localeCompare(String(b)));
1167
- levels.push(level);
1168
- }
1169
- frontier = next;
1253
+ for (const level of bfsLevels(ind.id, (id) => childrenOf.get(id) || [], { maxDepth: 8, keyOf: (c) => c.id })) {
1254
+ if (!level.length) continue;
1255
+ levels.push(level.map((c) => c.label).sort((a, b) => String(a).localeCompare(String(b))));
1170
1256
  }
1171
1257
  const total = levels.reduce((n, l) => n + l.length, 0);
1172
1258
  if (!total) {
@@ -1252,7 +1338,7 @@ export function renderUntested(graph) {
1252
1338
  (i) =>
1253
1339
  (i.class || "") === "Module" &&
1254
1340
  !testModules.has(i.id) &&
1255
- !isTestLabel(String(i.label).toLowerCase()) &&
1341
+ !isTestPath(String(i.label).toLowerCase()) &&
1256
1342
  !covered.has(i.id),
1257
1343
  )
1258
1344
  .map((i) => i.label)
@@ -12,7 +12,7 @@ import { ask } from "../ask.mjs";
12
12
  import { requireInjected } from "./injected.mjs";
13
13
 
14
14
  /** renderDescribe() renders one line per fact with no terminal punctuation of its own;
15
- * rank.mjs's splitSentences() treats each line as its own candidate sentence, and
15
+ * rank.mjs's splitBlockSentences() treats each line as its own candidate sentence, and
16
16
  * complete.mjs joins kept sentences with a single space, so two adjacent lines without
17
17
  * a period between them would otherwise read as one run-on clause. */
18
18
  function withTerminalPunctuation(text) {
@@ -3,21 +3,12 @@
3
3
  // memory/blocks.mjs's buildNeighbours()/OVERLAP_MIN). Block granularity, no sub-block spans.
4
4
  // Each group's label is its top shared-IDF tokens (df/N over the hit set, not the corpus).
5
5
 
6
- import { STOPWORDS } from "../prose.mjs";
6
+ import { makeContentTokens } from "../prose.mjs";
7
+ import { idfOver } from "../text-stats.mjs";
7
8
  import { requireInjected } from "./injected.mjs";
8
9
 
9
10
  const LABEL_TOKEN_COUNT = 5;
10
11
 
11
- // tokenizeBlock's raw output re-admits stopwords, which would collapse almost any two
12
- // sentences into one giant component under unweighted overlap clustering; filter them here.
13
- const isContentToken = (t) => /^[a-z0-9]+$/.test(t) && !STOPWORDS.has(t);
14
-
15
- /** tokenizeBlock(text), narrowed to real content tokens (see isContentToken above) — the
16
- * token set this module actually clusters and labels on. */
17
- function makeContentTokens(tokenizeBlock) {
18
- return (text) => tokenizeBlock(text).filter(isContentToken);
19
- }
20
-
21
12
  /** Plain union-find (path halving, union-by-index) — small N here (a single broad search's
22
13
  * hit count). */
23
14
  function unionFind(n) {
@@ -86,12 +77,7 @@ export function groupHits(hits, { overlapMin, store } = {}) {
86
77
  }
87
78
 
88
79
  // IDF over THIS hit set (df/N), not the whole corpus.
89
- const N = ids.length;
90
- const df = new Map();
91
- for (const id of ids) {
92
- for (const t of new Set(tokensById[id])) df.set(t, (df.get(t) || 0) + 1);
93
- }
94
- const idf = (t) => Math.log(1 + N / (1 + (df.get(t) || 0)));
80
+ const { idf } = idfOver(tokensById);
95
81
 
96
82
  const groups = [];
97
83
  for (const memberIdx of componentIdx.values()) {
@@ -7,20 +7,11 @@
7
7
  // Entities are a group's content tokens narrowed to graph-known terms (normFactTerm-matched
8
8
  // against loaded facts) — sharing an English word alone never licenses a relation.
9
9
 
10
- import { splitSentences } from "./rank.mjs";
11
- import { STOPWORDS } from "../prose.mjs";
10
+ import { splitBlockSentences } from "./rank.mjs";
11
+ import { makeContentTokens } from "../prose.mjs";
12
12
  import { findActionPath, findReachableSet } from "../planning.mjs";
13
13
  import { requireInjected } from "./injected.mjs";
14
14
 
15
- // Same content-token filter group.mjs/rank.mjs apply to their own adjacency/ranking; not
16
- // exported from either, so replicated here rather than reached across files.
17
- const isContentToken = (t) => /^[a-z0-9]+$/.test(t) && !STOPWORDS.has(t);
18
-
19
- /** tokenizeBlock(text), narrowed to real content tokens — see isContentToken above. */
20
- function makeContentTokens(tokenizeBlock) {
21
- return (text) => tokenizeBlock(text).filter(isContentToken);
22
- }
23
-
24
15
  // Local copy of chat.mjs's private HAS_PROPERTY_PREDICATE constant — not exported, so callers
25
16
  // supply their own copy in the `helpers` bag resolveRelationChase expects.
26
17
  const HAS_PROPERTY_PREDICATE = "mgx:hasProperty";
@@ -54,13 +45,13 @@ function groupContentTokenSet(group, { contentTokens }) {
54
45
  return set;
55
46
  }
56
47
 
57
- /** Every sentence across a group's members, pre-split (rank.mjs's own splitSentences — reused
48
+ /** Every sentence across a group's members, pre-split (rank.mjs's own splitBlockSentences — reused
58
49
  * verbatim, no re-implementation), each carrying its own content-token set and negation flag
59
50
  * — the exact per-sentence facts the contradicts test needs. */
60
51
  function sentencesOf(group, { contentTokens }) {
61
52
  const out = [];
62
53
  for (const m of group?.members || []) {
63
- for (const sentence of splitSentences(m?.text || "")) {
54
+ for (const sentence of splitBlockSentences(m?.text || "")) {
64
55
  out.push({ sentence, tokens: new Set(contentTokens(sentence)), negated: sentenceIsNegated(sentence) });
65
56
  }
66
57
  }
@@ -2,19 +2,12 @@
2
2
  // within a group.mjs group. Reuses memory/blocks.mjs's rankBlocks() (PageRank) and degreeOf()
3
3
  // (hub dampening) verbatim at sentence granularity, combined with group-scoped IDF the same
4
4
  // way retrieveBlocks() fuses relevance/centrality/hub-dampening into one score.
5
- // splitSentences() is a simple regex splitter.
5
+ // splitBlockSentences() is a simple regex splitter.
6
6
 
7
- import { STOPWORDS } from "../prose.mjs";
7
+ import { makeContentTokens } from "../prose.mjs";
8
+ import { idfOver } from "../text-stats.mjs";
8
9
  import { requireInjected } from "./injected.mjs";
9
10
 
10
- // Same content-token filter group.mjs applies (not exported, so replicated here).
11
- const isContentToken = (t) => /^[a-z0-9]+$/.test(t) && !STOPWORDS.has(t);
12
-
13
- /** tokenizeBlock(text), narrowed to real content tokens — see isContentToken above. */
14
- function makeContentTokens(tokenizeBlock) {
15
- return (text) => tokenizeBlock(text).filter(isContentToken);
16
- }
17
-
18
11
  // Sentence boundary: a run of [.!?] followed by whitespace and an uppercase letter or digit
19
12
  // (no abbreviation dictionary).
20
13
  const SENTENCE_SPLIT_RE = /(?<=[.!?])\s+(?=[A-Z0-9])/;
@@ -25,7 +18,7 @@ const SENTENCE_SPLIT_RE = /(?<=[.!?])\s+(?=[A-Z0-9])/;
25
18
  * @param {string} text
26
19
  * @returns {string[]}
27
20
  */
28
- export function splitSentences(text) {
21
+ export function splitBlockSentences(text) {
29
22
  const out = [];
30
23
  for (const rawLine of String(text || "").split("\n")) {
31
24
  const line = rawLine.trim();
@@ -66,7 +59,7 @@ export function rankSentences(group, { overlapMin, query = null, store } = {}) {
66
59
  // One entry per sentence, with a stable id for PageRank/degree/IDF keying.
67
60
  const sentences = [];
68
61
  for (const m of members) {
69
- const parts = splitSentences(m?.text || "");
62
+ const parts = splitBlockSentences(m?.text || "");
70
63
  parts.forEach((sentence, i) => {
71
64
  sentences.push({ id: `${m.id}#${i}`, sentence, sourceBlockId: m.id });
72
65
  });
@@ -80,13 +73,7 @@ export function rankSentences(group, { overlapMin, query = null, store } = {}) {
80
73
  const degrees = degreeOf(tokensById, { overlapMin: edgeThreshold });
81
74
 
82
75
  // IDF scoped to THIS group's sentence set (df/N), not the whole corpus.
83
- const ids = Object.keys(tokensById);
84
- const N = ids.length;
85
- const df = new Map();
86
- for (const id of ids) {
87
- for (const t of new Set(tokensById[id])) df.set(t, (df.get(t) || 0) + 1);
88
- }
89
- const idf = (t) => Math.log(1 + N / (1 + (df.get(t) || 0)));
76
+ const { idf } = idfOver(tokensById);
90
77
 
91
78
  const queryTokens = query ? new Set(contentTokens(query)) : null;
92
79
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "comment": "tmct's starter lexicon (ROADMAP Phase 2, item 2), now covering BOTH the original software domain and (PLAN_SEED.md, the default 'human-world' persona) everyday people/places/objects/nature/time/body/mind vocabulary. Every word the ACE-OWL sub-fragment parser (src/domain/grammar/ace.mjs) is allowed to understand is DECLARED here — tmct never guesses a word's category. Nouns may declare a possessive property typing ('data' or 'object', pattern 7) or an irregular 'plural' (WordNet-sourced human nouns like 'man'/'child' need this — the morphology fold only handles regular -s/-ies/-es endings); adjectives MUST declare a type ('subclass' forms a class, 'data' asserts a boolean-ish datatype property, pattern 8); verbs may declare a preposition ('depend' + 'on' → dependsOn). The human-domain additions are flat and simple (breadth over depth — no gender/kinship taxonomy, no formal role hierarchy), matching corpus/tier2/generate.mjs's CORPUSES.human.lexicon sub-key, which --verify drift-checks against. Extend via loadLexicon(extra) with this same shape.",
2
+ "comment": "tmct's starter lexicon, now covering BOTH the original software domain and the default 'human-world' persona's everyday people/places/objects/nature/time/body/mind vocabulary. Every word the ACE-OWL sub-fragment parser (src/domain/grammar/ace.mjs) is allowed to understand is DECLARED here — tmct never guesses a word's category. Nouns may declare a possessive property typing ('data' or 'object', pattern 7) or an irregular 'plural' (WordNet-sourced human nouns like 'man'/'child' need this — the morphology fold only handles regular -s/-ies/-es endings); adjectives MUST declare a type ('subclass' forms a class, 'data' asserts a boolean-ish datatype property, pattern 8); verbs may declare a preposition ('depend' + 'on' → dependsOn). The human-domain additions are flat and simple (breadth over depth — no gender/kinship taxonomy, no formal role hierarchy), matching corpus/tier2/generate.mjs's CORPUSES.human.lexicon sub-key, which --verify drift-checks against. Extend via loadLexicon(extra) with this same shape.",
3
3
  "nouns": {
4
4
  "module": {},
5
5
  "class": {},
@@ -1,13 +1,17 @@
1
- // hash.mjs — the single home for tmct's content-address contract: the FNV-1a
2
- // hash, the fact-term/predicate normalization, and the fact-id derivation.
1
+ // hash.mjs — the single home for tmct's content-address contract: the hashes,
2
+ // the fact-term/predicate normalization, and the fact-id derivation.
3
3
  //
4
- // FNV-1a 32-bit is deliberately home-grown: it must be synchronous, browser-safe,
5
- // dependency-free, and — critically — CROSS-VERSION STABLE, because fact ids are
6
- // content-addressed by it and a fact's id is its identity across the whole memory
7
- // graph. Every library candidate fails at least one of those; this eight-line
8
- // function fails none. Normalization lives beside it because it is PART of that
9
- // identity: the same (s, p, o) must normalize and hash to the same id from every
10
- // writer, so the whole contract has exactly one definition.
4
+ // A fact's id is its identity across the whole memory graph, so its hash must be
5
+ // synchronous, browser-safe, dependency-free, CROSS-VERSION STABLE, and wide
6
+ // enough that two different facts do not land on one id. factIdFor derives the id
7
+ // from a 64-bit truncation of SHA-256 (sha256Bytes below) for the width; 32-bit
8
+ // FNV-1a is far too narrow for a graph of tens of thousands of facts. FNV-1a stays
9
+ // home-grown for the narrower content addresses that do not need that width (the
10
+ // paraphrase and answer-variant pools, per-URL web-Source ids, corpus dedupe).
11
+ // Every library candidate fails at least one of the stability constraints; these
12
+ // hand-rolled functions fail none. Normalization lives beside them because it is
13
+ // PART of that identity: the same (s, p, o) must normalize and hash to the same id
14
+ // from every writer, so the whole contract has exactly one definition.
11
15
 
12
16
  /** FNV-1a 32-bit. Returns the unsigned 32-bit integer (0 … 2^32−1). */
13
17
  export function fnv1a32(str) {
@@ -20,7 +24,8 @@ export function fnv1a32(str) {
20
24
  }
21
25
 
22
26
  /** FNV-1a 32-bit as a zero-padded 8-char hex string — the stable content-address
23
- * used for fact ids (`fact:<hex>`). Same (s,p,o) same id → upsert, never a dup. */
27
+ * for the narrow, non-fact-id pools (paraphrase, per-URL web Source, corpus
28
+ * dedupe). Fact ids use factIdFor's wider SHA-256 truncation, not this. */
24
29
  export function fnv1aHex(str) {
25
30
  return fnv1a32(str).toString(16).padStart(8, "0");
26
31
  }
@@ -82,6 +87,16 @@ export function sha256Bytes(str) {
82
87
  return out;
83
88
  }
84
89
 
90
+ /** SHA-256 of a string truncated to `nBytes` leading bytes, as lowercase hex.
91
+ * A fact id takes 8 bytes (64 bits): at 100,000 facts the chance of any two
92
+ * colliding is ~2.7e-10, against ~100% for 32-bit FNV at the same size. */
93
+ function sha256Hex(str, nBytes) {
94
+ const bytes = sha256Bytes(str);
95
+ let hex = "";
96
+ for (let i = 0; i < nBytes; i += 1) hex += bytes[i].toString(16).padStart(2, "0");
97
+ return hex;
98
+ }
99
+
85
100
  const TEXT_CAP = 2000; // an utterance's stored text (a whole answer fits; a pasted book doesn't)
86
101
 
87
102
  /** Whitespace-collapse + cap a stored text/predicate string. Every writer that
@@ -134,9 +149,17 @@ export function normFactPredicate(p) {
134
149
  }
135
150
 
136
151
  /** A Fact is content-addressed by its NUL-delimited (s, p, o) — NUL never
137
- * occurs in a normalized term/predicate, so it's collision-proof unlike a
138
- * space. Takes ALREADY-normalized parts; factIdForTriple normalizes first. */
139
- export const factIdFor = (s, p, o) => `fact:${fnv1aHex(`${s}\0${p}\0${o}`)}`;
152
+ * occurs in a normalized term/predicate, so the delimiter never collides
153
+ * unlike a space. The hash is a 64-bit SHA-256 truncation (16 hex chars),
154
+ * wide enough that distinct triples do not share an id at real corpus sizes.
155
+ * Takes ALREADY-normalized parts; factIdForTriple normalizes first. */
156
+ export const factIdFor = (s, p, o) => `fact:${sha256Hex(`${s}\0${p}\0${o}`, 8)}`;
157
+
158
+ /** The pre-widening 32-bit fact id (FNV-1a, 8 hex). A store written before
159
+ * factIdFor widened keys its Facts and their statedBy/derivedFrom edges by
160
+ * this; adapters/memory/core.mjs recognises the old shape and migrates each
161
+ * Fact onto its current factIdFor id on load. */
162
+ export const legacyFactIdFor = (s, p, o) => `fact:${fnv1aHex(`${s}\0${p}\0${o}`)}`;
140
163
 
141
164
  /** Content-address a fact's id from (subject, predicate, object) without
142
165
  * writing it — same contract as factIdFor. Lets a caller (e.g.
@@ -15,8 +15,13 @@ import collisionData from "../real-word-collisions.json" with { type: "json" };
15
15
  const NEVER_CANONICALIZE = ["used"];
16
16
  import { STOPWORDS } from "./normalize.mjs";
17
17
 
18
- // ---- bounded edit distance — hand-rolled Damerau-Levenshtein, bounded with an
19
- // early row-minimum exit ----
18
+ // ---- bounded edit distance — Optimal String Alignment (a restricted
19
+ // Damerau-Levenshtein: it allows adjacent transpositions but forbids editing any
20
+ // substring twice, so editDistance("CA","ABC")=3 where true Damerau-Levenshtein
21
+ // gives 2). The two-previous-rows recurrence (prev2) is OSA's; true DL needs a
22
+ // full last-seen-position table. OSA is NOT a metric — it can violate the
23
+ // triangle inequality. Bounded with an early row-minimum exit.
24
+ // Damerau 1964 (CACM 7(3)); Levenshtein 1966 (Soviet Physics Doklady 10(8)). ----
20
25
 
21
26
  /** Distance between a and b, or max+1 as soon as it provably exceeds `max`. */
22
27
  export function editDistance(a, b, max) {
@@ -154,6 +154,13 @@ const KNOW_WRAPPER_RE = /^do\s+you\s+know\s+(.+?)\??$/i;
154
154
  /** "i'd like to know <Q>" / "i want to know <Q>" -> "<Q>", same
155
155
  * interrogative-remainder gate as KNOW_WRAPPER_RE. */
156
156
  const WANT_KNOW_WRAPPER_RE = /^i(?:'d|\s+would)?\s+(?:like|want|need)\s+to\s+know\s+(.+?)\??$/i;
157
+ /** "i was wondering <Q>" / "i wondered <Q>" / "i'm curious <Q>" -> "<Q>", the
158
+ * same wrapper family and the same interrogative-remainder gate. The modal
159
+ * form ("could you tell me what a dog is") was already unwrapped; this one
160
+ * states the wish rather than asking, which is how it fell through every
161
+ * frame. The optional "if"/"whether"/"about" tail carries the same clause
162
+ * MODAL_WRAPPER_RE's own remainder does. */
163
+ const WONDERING_WRAPPER_RE = /^i(?:\s+was|\s+am|'m)?\s+(?:just\s+)?(?:wonder(?:ing|ed)|curious)(?:\s+(?:if|whether|about))?\s+(.+?)\??$/i;
157
164
  /** EMBEDDED-QUESTION DE-INVERSION: the wrappers above unwrap "could you
158
165
  * tell me what a dog is" down to the embedded clause "what a dog is",
159
166
  * which keeps declarative word order — nothing downstream parses it. Fold
@@ -214,6 +221,8 @@ export function applyPreambleFrames(text) {
214
221
  if (m && INTERROGATIVE_LEAD_RE.test(m[1].trim())) q = m[1].trim();
215
222
  m = q.match(WANT_KNOW_WRAPPER_RE);
216
223
  if (m && INTERROGATIVE_LEAD_RE.test(m[1].trim())) q = m[1].trim();
224
+ m = q.match(WONDERING_WRAPPER_RE);
225
+ if (m && INTERROGATIVE_LEAD_RE.test(m[1].trim())) q = m[1].trim();
217
226
  m = q.match(EMBEDDED_WHATIS_RE);
218
227
  if (m) q = `what ${m[2].toLowerCase()} ${m[1].trim()}`;
219
228
  m = q.match(EMBEDDED_MEANS_RE);
@@ -177,14 +177,23 @@ export function parseKeywordSpot(text, nlp = null) {
177
177
  if (objText) return stamp({ shape: "whoLast", entityType: null, modifier: "direct", kind: "touches", object: objText });
178
178
  }
179
179
 
180
- // Reversible passive ("PATIENT is VERBed BY AGENT"): a passive auxiliary plus a
181
- // standalone agent-marking "by" (not already swallowed into a multi-word verb
182
- // phrase) splits the sentence into a patient before "by" and an agent after it.
183
- // How many of the two the sentence actually names picks the shape: both named
184
- // is the yes/no ask, agent alone reads forward from the agent, patient alone
185
- // reads reverse over the patient.
180
+ // Reversible passive: a passive auxiliary plus a standalone agent-marking "by"
181
+ // (not already swallowed into a multi-word verb phrase) splits the sentence
182
+ // into a patient role and an agent role. How many of the two the sentence
183
+ // actually names picks the shape: both named is the yes/no ask, agent alone
184
+ // reads forward from the agent, patient alone reads reverse over the patient.
185
+ //
186
+ // The auxiliary's side of "by" says which role sits where. "X is imported BY
187
+ // Y" postposes the agent, so the aux comes first and the patient leads. "BY
188
+ // which modules is X imported" fronts it, so "by" comes first and the roles
189
+ // are mirrored: the agent runs from "by" up to the auxiliary, and the patient
190
+ // follows. Reading a fronted sentence on the postposed partition finds nothing
191
+ // before "by", takes the patient for the agent, and answers the reverse of the
192
+ // question asked.
186
193
  const byIdx = lcWords.indexOf("by");
187
- const hasPassiveAux = lcWords.slice(0, verbHit.start).some((w) => PASSIVE_AUX.has(w));
194
+ const passiveAuxIdx = lcWords.slice(0, verbHit.start).findIndex((w) => PASSIVE_AUX.has(w));
195
+ const hasPassiveAux = passiveAuxIdx >= 0;
196
+ const agentIsFronted = hasPassiveAux && passiveAuxIdx > byIdx;
188
197
  if (byIdx >= 0 && !consumed.has(byIdx) && hasPassiveAux) {
189
198
  const roleText = (from, to) => words
190
199
  .slice(from, to)
@@ -196,8 +205,9 @@ export function parseKeywordSpot(text, nlp = null) {
196
205
  })
197
206
  .join(" ")
198
207
  .trim();
199
- const patient = roleText(0, byIdx);
200
- const agent = roleText(byIdx + 1, words.length);
208
+ const [patient, agent] = agentIsFronted
209
+ ? [roleText(passiveAuxIdx + 1, words.length), roleText(byIdx + 1, passiveAuxIdx)]
210
+ : [roleText(0, byIdx), roleText(byIdx + 1, words.length)];
201
211
  if (patient && agent) return stamp({ shape: "ask", entityType: null, modifier: "direct", kind, subject: agent, object: patient });
202
212
  if (agent) return stamp({ shape: "forward", entityType, modifier, kind, object: agent });
203
213
  if (patient) return stamp({ shape: "reverse", entityType, modifier, kind, object: patient });
@@ -29,19 +29,38 @@ import { findIsaChain, SUBCLASS_PREDICATE, TYPE_PREDICATE } from "../syllogise.m
29
29
  export const NEG_PREDICATE_PREFIX = "mgxneg:";
30
30
  const POSITIVE_PREDICATE_PREFIX = "mgx:";
31
31
 
32
- /** Swap an mgx: predicate onto its negative twin. Applied AFTER the preposition
32
+ /** The negative twin of rdfs:subClassOf ("john is not a man"). The polarity
33
+ * prefix is tmct's; the term it negates is RDFS's, so this pair cannot be
34
+ * minted by the prefix swap below and is stated instead.
35
+ *
36
+ * Why a coined mgxneg: term and not owl:disjointWith: disjointness is a
37
+ * class-class axiom ("no man is a stone"), and the ask ladder already reads it
38
+ * that way. "john is not a man" denies ONE membership and says nothing about
39
+ * any other john, so storing it as disjointness would over-claim. */
40
+ export const NEG_SUBCLASS_PREDICATE = "mgxneg:subClassOf";
41
+ const EXPLICIT_NEGATIVE_TWINS = new Map([[SUBCLASS_PREDICATE, NEG_SUBCLASS_PREDICATE]]);
42
+ const EXPLICIT_POSITIVE_TWINS = new Map([...EXPLICIT_NEGATIVE_TWINS].map(([pos, neg]) => [neg, pos]));
43
+
44
+ /** Swap a predicate onto its negative twin. Applied AFTER the preposition
33
45
  * fold, so mgx:rest-on becomes mgxneg:rest-on and the object keeps nothing
34
- * meaning-bearing inside it. */
46
+ * meaning-bearing inside it. A predicate with no negative twin returns
47
+ * unchanged. */
35
48
  export function negatedPredicate(predicate) {
36
49
  const p = String(predicate || "");
50
+ const stated = EXPLICIT_NEGATIVE_TWINS.get(p);
51
+ if (stated) return stated;
37
52
  if (!p.startsWith(POSITIVE_PREDICATE_PREFIX)) return p;
38
53
  return NEG_PREDICATE_PREFIX + p.slice(POSITIVE_PREDICATE_PREFIX.length);
39
54
  }
40
55
 
41
56
  /** The inverse: mgxneg:capableOf -> mgx:capableOf, or null for any predicate
42
- * that isn't negative at all. */
57
+ * that isn't negative at all. The stated twins invert by lookup — a prefix
58
+ * swap would read mgxneg:subClassOf back as "mgx:subClassOf", a term that
59
+ * exists nowhere. */
43
60
  export function positivePredicate(predicate) {
44
61
  const p = String(predicate || "");
62
+ const stated = EXPLICIT_POSITIVE_TWINS.get(p);
63
+ if (stated) return stated;
45
64
  if (!p.startsWith(NEG_PREDICATE_PREFIX)) return null;
46
65
  return POSITIVE_PREDICATE_PREFIX + p.slice(NEG_PREDICATE_PREFIX.length);
47
66
  }