@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.
- package/README.md +131 -32
- package/bin/tmct.mjs +18 -91
- package/corpus/README.md +3 -3
- package/corpus/seon/README.md +1 -0
- package/corpus/tier2/generate.mjs +18 -18
- package/corpus/tier2/manifest.json +3 -3
- package/data/games/hanoi-3.txt +8 -2
- package/package.json +26 -8
- package/src/adapters/corpus-lanes.mjs +13 -0
- package/src/adapters/graph-build.mjs +5 -7
- package/src/adapters/import-closure.mjs +28 -0
- package/src/adapters/memory/blocks.mjs +5 -4
- package/src/adapters/memory/core.mjs +78 -5
- package/src/adapters/memory/shacl.mjs +12 -0
- package/src/adapters/providers/graph-service.mjs +12 -5
- package/src/adapters/tracked-files.mjs +17 -0
- package/src/domain/ask-vocab.mjs +2 -0
- package/src/domain/ask.mjs +225 -13
- package/src/domain/cli-verbs.mjs +201 -0
- package/src/domain/codegraph.mjs +142 -56
- package/src/domain/completions/graph-adapter.mjs +1 -1
- package/src/domain/completions/group.mjs +3 -17
- package/src/domain/completions/infer.mjs +4 -13
- package/src/domain/completions/rank.mjs +6 -19
- package/src/domain/grammar/lexicon-core.json +1 -1
- package/src/domain/hash.mjs +36 -13
- package/src/domain/interpret/fuzzy.mjs +7 -2
- package/src/domain/interpret/normalize.mjs +9 -0
- package/src/domain/interpret/strategies/keywords.mjs +19 -9
- package/src/domain/memory/capability.mjs +22 -3
- package/src/domain/memory/touched-facts.mjs +17 -0
- package/src/domain/module-paths.mjs +9 -0
- package/src/domain/persona/tiers.mjs +1 -1
- package/src/domain/planning.mjs +37 -0
- package/src/domain/prose.mjs +10 -2
- package/src/domain/relative-specifiers.mjs +12 -0
- package/src/domain/router/registry.mjs +3 -2
- package/src/domain/router/results.mjs +5 -18
- package/src/domain/seeded-random.mjs +33 -0
- package/src/domain/syllogise.mjs +10 -7
- package/src/domain/text-stats.mjs +31 -0
- package/src/services/chat.mjs +722 -184
- package/src/services/extract-facts.mjs +155 -0
- package/src/services/import-file.mjs +2 -2
- package/src/services/init.mjs +2 -2
- package/src/services/ledger-viz.mjs +6 -1
- package/src/services/sentences.mjs +26 -0
- package/src/surfaces/web/memory-ask-browser.bundle.js +11390 -360
- package/src/tools/graph-load.mjs +7 -1
- package/src/tools/readme-docs.mjs +113 -0
- package/src/tools/schema-docs.mjs +2 -2
- package/ROADMAP.md +0 -129
- package/corpus/namenet/generate.mjs +0 -309
- package/corpus/wordnet/generate.mjs +0 -332
- package/src/adapters/prose-tokens.mjs +0 -98
- package/src/adapters/wordnet-source.mjs +0 -70
- package/src/domain/corpus-matrix.mjs +0 -87
- package/src/domain/inflect.mjs +0 -67
- package/src/domain/licences.mjs +0 -68
- package/src/domain/markdown-links.mjs +0 -55
- package/src/domain/persona/codegen.mjs +0 -123
- package/src/domain/publish-gate.mjs +0 -41
- package/src/domain/schemaorg/turtle.mjs +0 -25
- package/src/domain/semcor/parse.mjs +0 -87
- package/src/domain/version-stamp.mjs +0 -36
- package/src/domain/wordnet/yaml.mjs +0 -133
package/src/domain/codegraph.mjs
CHANGED
|
@@ -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
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 && (
|
|
786
|
-
else if (
|
|
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
|
-
|
|
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
|
-
|
|
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` (
|
|
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
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
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
|
-
!
|
|
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
|
|
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 {
|
|
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
|
|
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 {
|
|
11
|
-
import {
|
|
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
|
|
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
|
|
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
|
-
//
|
|
5
|
+
// splitBlockSentences() is a simple regex splitter.
|
|
6
6
|
|
|
7
|
-
import {
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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": {},
|
package/src/domain/hash.mjs
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
|
-
// hash.mjs — the single home for tmct's content-address contract: the
|
|
2
|
-
//
|
|
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
|
-
//
|
|
5
|
-
// dependency-free,
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
-
*
|
|
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
|
|
138
|
-
* space.
|
|
139
|
-
|
|
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 —
|
|
19
|
-
//
|
|
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
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
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
|
|
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 =
|
|
200
|
-
|
|
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
|
-
/**
|
|
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
|
}
|