@polycode-projects/the-mechanical-code-talker 0.2.0 → 0.3.0
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/ROADMAP.md +5 -2
- package/bin/tmct.mjs +253 -12
- package/corpus/README.md +52 -0
- package/corpus/conceptnet/LICENSE-NOTICE +37 -0
- package/corpus/conceptnet/README.md +103 -0
- package/corpus/conceptnet/fetch-slice.mjs +136 -0
- package/corpus/conceptnet/filter-dump.mjs +89 -0
- package/corpus/conceptnet/slice.jsonl +14258 -0
- package/data/phrasebook/software-phrases.txt +231 -0
- package/data/templates/responses.jsonl +55 -0
- package/package.json +12 -3
- package/src/ask-nlp.mjs +14 -0
- package/src/ask-vocab.mjs +13 -1
- package/src/ask.mjs +92 -493
- package/src/chat.mjs +147 -45
- package/src/corpus/conceptnet-map.toml +251 -0
- package/src/corpus/conceptnet.mjs +155 -0
- package/src/corpus/templates.mjs +104 -0
- package/src/grammar/ace.mjs +341 -0
- package/src/grammar/assert.mjs +40 -0
- package/src/grammar/lexicon-core.json +287 -0
- package/src/grammar/lexicon.mjs +202 -0
- package/src/index.mjs +21 -5
- package/src/interpret/fuzzy.mjs +89 -0
- package/src/interpret/merge.mjs +148 -0
- package/src/interpret/normalize.mjs +117 -0
- package/src/interpret/pipeline.mjs +112 -0
- package/src/interpret/strategies/grammar.mjs +137 -0
- package/src/interpret/strategies/keywords.mjs +185 -0
- package/src/interpret/strategies/noise-strip.mjs +114 -0
- package/src/memory/blocks.mjs +201 -0
- package/src/memory/core.mjs +292 -0
- package/src/memory/fold.mjs +105 -0
- package/src/sessions.mjs +125 -3
- package/src/source.mjs +44 -5
- package/src/tui/app.mjs +173 -0
- package/bin/cli.mjs +0 -226
package/src/ask.mjs
CHANGED
|
@@ -14,18 +14,21 @@
|
|
|
14
14
|
// parseQuery (grammar) -> resolveObject (mechanical term resolution) ->
|
|
15
15
|
// traverse (graph lookup) -> render (templates).
|
|
16
16
|
//
|
|
17
|
-
// §3.5/3.6 (2026-07-02, ELIZA/PARRY-style breadth
|
|
18
|
-
// raw text (contractions,
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
17
|
+
// §3.5/3.6 (2026-07-02, ELIZA/PARRY-style breadth; split into src/interpret/ for
|
|
18
|
+
// ROADMAP items 8/10/13): parseQuery normalizes the raw text (contractions,
|
|
19
|
+
// g-drop, filler-strip — interpret/normalize.mjs), rewrites recognized negative-
|
|
20
|
+
// rhetorical constructions to their affirmative form, then runs the REGISTERED
|
|
21
|
+
// parsing STRATEGIES over the same normalized text (interpret/pipeline.mjs) —
|
|
22
|
+
// the original anchored-template matcher (interpret/strategies/grammar.mjs:
|
|
23
|
+
// precise, fast, unweakened) and a keyword-spotting/decomposition matcher
|
|
24
|
+
// (interpret/strategies/keywords.mjs — ELIZA's own mechanism: find the keyword,
|
|
25
|
+
// decompose around it, tolerate reordering/casual phrasing) — and MERGES their
|
|
26
|
+
// results (interpret/merge.mjs): one strategy hit -> use it; hits that agree ->
|
|
27
|
+
// use it (high confidence); same-class hits that DISAGREE -> a genuine
|
|
28
|
+
// parse-level ambiguity, surfaced honestly; no hits -> the honest grammar miss.
|
|
29
|
+
// STRATEGIES is a plain registration array (interpret/pipeline.mjs) so further
|
|
30
|
+
// strategies (Phase 2's ACE grammar) join the same way, not a hardcoded
|
|
31
|
+
// two-branch special case.
|
|
29
32
|
//
|
|
30
33
|
// Where a parsed intent is temporal/churn-shaped (touched/since/cochange as a
|
|
31
34
|
// FILTER over commits, not a structural edge), this engine does NOT re-implement
|
|
@@ -38,14 +41,26 @@
|
|
|
38
41
|
import { relationKind, impactClosure } from "./codegraph.mjs";
|
|
39
42
|
import {
|
|
40
43
|
VERB_TO_KIND, ENTITY_TO_TYPE, MODIFIER_TO_KIND,
|
|
41
|
-
|
|
42
|
-
CONTEXT_PRONOUNS, NEGATION_FRAMES, COMMIT_CONTENT_FRAMES, META_MEANING_VERBS,
|
|
44
|
+
CONTEXT_PRONOUNS, META_MEANING_VERBS,
|
|
43
45
|
WHERE_MARKERS, MENTION_MARKERS,
|
|
44
46
|
RELATIVE_PRONOUNS, PLACEHOLDER_NOUNS, BOOLEAN_CONNECTIVES, QUALIFIERS,
|
|
45
47
|
AGGREGATE_TRIGGERS, LIST_TRIGGERS, SUPERLATIVE_EXTREMES, EDGE_NOUN_TO_METRIC, ANAPHORA_TRIGGERS,
|
|
46
48
|
MEMBERSHIP_KINDS, CASCADE_NOISE, CASCADE_SYNONYMS, HELP_TRIGGERS,
|
|
47
49
|
} from "./ask-vocab.mjs";
|
|
50
|
+
// The interpretation layer (ROADMAP items 8/10/13) — the movable conversational
|
|
51
|
+
// grammar, split out of this file: normalization pre-pass, the two parsing
|
|
52
|
+
// strategies, and the bounded-fuzzy service. Re-exported below where existing
|
|
53
|
+
// callers/tests import them from here.
|
|
54
|
+
import { normalizeQuery, applyNegationFrames, STOPWORDS, splitWords, wordsOf } from "./interpret/normalize.mjs";
|
|
55
|
+
import { editDistance, fuzzyBound } from "./interpret/fuzzy.mjs";
|
|
56
|
+
import { parseAnchored } from "./interpret/strategies/grammar.mjs";
|
|
57
|
+
import { parseKeywordSpot, findPhrase } from "./interpret/strategies/keywords.mjs";
|
|
58
|
+
import { runStrategiesSync } from "./interpret/pipeline.mjs";
|
|
59
|
+
import { mergeStrategyResults } from "./interpret/merge.mjs";
|
|
48
60
|
import { lookupByProseTokens } from "./prose.mjs";
|
|
61
|
+
|
|
62
|
+
// Normalization stays importable from its original site (tests + chat surface).
|
|
63
|
+
export { normalizeQuery, applyNegationFrames };
|
|
49
64
|
// The OPTIONAL Node-only wink-nlp adapter (lemma/POS tier). BOUNDARY: the inlined
|
|
50
65
|
// viewer bundle (viz.mjs askSource) strips this import line and never inlines
|
|
51
66
|
// ask-nlp.mjs, so in the browser `nlpAdapter` is simply an undeclared identifier —
|
|
@@ -110,443 +125,20 @@ function verbFor(kind) {
|
|
|
110
125
|
return REVERSE_MISS_VERB[kind] || kind;
|
|
111
126
|
}
|
|
112
127
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
//
|
|
127
|
-
// after contractions: restore the intended spelling first, then map misused
|
|
128
|
-
// words to their canonical schema term. Deterministic and curated, so they run
|
|
129
|
-
// BEFORE either parse strategy and ahead of the bounded edit-distance fallback.
|
|
130
|
-
// The trailing lookahead refuses to rewrite a word glued to a dotted extension:
|
|
131
|
-
// WRONG_WORDS entries are real English words that plausibly NAME modules
|
|
132
|
-
// ("revision.mjs", "property.py"), and a correction that corrupts an object
|
|
133
|
-
// term would be a guess — the exact thing these tables exist to avoid.
|
|
134
|
-
const correctionRe = (table) => new RegExp(
|
|
135
|
-
"\\b(" + Object.keys(table).sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b(?!\\.[a-z0-9])",
|
|
136
|
-
"gi",
|
|
137
|
-
);
|
|
138
|
-
const MISSPELLING_RE = correctionRe(MISSPELLINGS);
|
|
139
|
-
const WRONG_WORD_RE = correctionRe(WRONG_WORDS);
|
|
140
|
-
|
|
141
|
-
/** Free-text -> normalized free-text: contractions expanded, g-dropped words
|
|
142
|
-
* restored, filler/politeness words stripped. Idempotent and pure — the same
|
|
143
|
-
* input always normalizes the same way, so both parsing strategies see
|
|
144
|
-
* identical text and their outputs are directly comparable. Deliberately
|
|
145
|
-
* does NOT force lowercase: object/subject terms (module names like
|
|
146
|
-
* "myFile", class names like "Base") are meaningfully cased, and every
|
|
147
|
-
* substitution below already matches case-insensitively (`i`/`gi` flags) —
|
|
148
|
-
* forcing the whole string to lowercase would silently corrupt every parsed
|
|
149
|
-
* term's case instead. */
|
|
150
|
-
export function normalizeQuery(text) {
|
|
151
|
-
let q = String(text || "");
|
|
152
|
-
q = q.replace(CONTRACTION_RE, (m) => CONTRACTIONS[m.toLowerCase()]);
|
|
153
|
-
q = q.replace(MISSPELLING_RE, (m) => MISSPELLINGS[m.toLowerCase()]);
|
|
154
|
-
q = q.replace(WRONG_WORD_RE, (m) => WRONG_WORDS[m.toLowerCase()]);
|
|
155
|
-
q = q.replace(G_DROP, "$1ing");
|
|
156
|
-
if (FILLER_WORDS.length) {
|
|
157
|
-
const fillerRe = new RegExp(
|
|
158
|
-
"\\b(" + [...FILLER_WORDS].sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b",
|
|
159
|
-
"gi",
|
|
160
|
-
);
|
|
161
|
-
q = q.replace(fillerRe, " ");
|
|
162
|
-
}
|
|
163
|
-
return q.replace(/\s+/g, " ").trim();
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/** Recognized rhetorical/idiomatic constructions rewritten to the canonical form
|
|
167
|
-
* of the SAME question before either parse strategy sees the text — a small
|
|
168
|
-
* closed pattern set, not a general rewriter. Two families, tried in order:
|
|
169
|
-
* COMMIT_CONTENT_FRAMES first ("what was in commit <sha>" -> "what did <sha>
|
|
170
|
-
* touch"; sha-anchored, so it can't swallow a containment question), then the
|
|
171
|
-
* §3.6 negative-rhetorical NEGATION_FRAMES. First matching frame across both wins
|
|
172
|
-
* and rewriting stops; unmatched text passes through unchanged. */
|
|
173
|
-
export function applyNegationFrames(text) {
|
|
174
|
-
for (const frame of [...COMMIT_CONTENT_FRAMES, ...NEGATION_FRAMES]) {
|
|
175
|
-
const m = text.match(frame.re);
|
|
176
|
-
if (m) return frame.to(m).replace(/\s+/g, " ").trim();
|
|
177
|
-
}
|
|
178
|
-
return text;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// ---- strategy 1: anchored templates — fixed precedence order; first fit wins,
|
|
182
|
-
// never ambiguous at the template level (a question matching two shapes is a
|
|
183
|
-
// design smell we test against). Unweakened from the original P0 grammar. ----
|
|
184
|
-
|
|
185
|
-
const VERB_ALT = Object.keys(VERB_TO_KIND).sort((a, b) => b.length - a.length).map(escapeRegex).join("|");
|
|
186
|
-
const ENTITY_ALT = Object.keys(ENTITY_TO_TYPE).sort((a, b) => b.length - a.length).map(escapeRegex).join("|");
|
|
187
|
-
const MODIFIER_ALT = Object.keys(MODIFIER_TO_KIND).sort((a, b) => b.length - a.length).map(escapeRegex).join("|");
|
|
188
|
-
const META_ALT = META_MEANING_VERBS.slice().sort((a, b) => b.length - a.length).map(escapeRegex).join("|");
|
|
189
|
-
|
|
190
|
-
const TEMPLATES = [
|
|
191
|
-
// T1 ASK: "does X import Y" / "is X a subclass of Y" -> Yes/No. Tried FIRST: it starts with
|
|
192
|
-
// does/is/do/did, which the reverse/forward templates below never match (those start with
|
|
193
|
-
// which/what), so precedence between T1 and the rest is structural, not a tie-break guess.
|
|
194
|
-
// "did" joins does/do for the past-tense commit forms ("did commit <sha> touch X").
|
|
195
|
-
{
|
|
196
|
-
name: "ask",
|
|
197
|
-
re: new RegExp(`^(?:does|do|did)\\s+(.+?)\\s+(${VERB_ALT})\\s+(.+?)\\??$`, "i"),
|
|
198
|
-
build: (m) => ({
|
|
199
|
-
shape: "ask", entityType: null, modifier: "direct",
|
|
200
|
-
kind: VERB_TO_KIND[m[2].toLowerCase()], subject: m[1].trim(), object: m[3].trim(),
|
|
201
|
-
}),
|
|
202
|
-
},
|
|
203
|
-
// T2 reverse: "which <entity> [<modifier>] <verb> <object>" — the operator's own example shape.
|
|
204
|
-
{
|
|
205
|
-
name: "reverse",
|
|
206
|
-
re: new RegExp(`^which\\s+(${ENTITY_ALT})\\s+(?:(${MODIFIER_ALT})\\s+)?(${VERB_ALT})\\s+(.+?)\\??$`, "i"),
|
|
207
|
-
build: (m) => ({
|
|
208
|
-
shape: "reverse",
|
|
209
|
-
entityType: ENTITY_TO_TYPE[m[1].toLowerCase()],
|
|
210
|
-
modifier: m[2] ? MODIFIER_TO_KIND[m[2].toLowerCase()] : "direct",
|
|
211
|
-
kind: VERB_TO_KIND[m[3].toLowerCase()],
|
|
212
|
-
object: m[4].trim(),
|
|
213
|
-
}),
|
|
214
|
-
},
|
|
215
|
-
// T3 forward: "what does <object> <verb>" — X is given, list its R-related things.
|
|
216
|
-
// "did" joins does/do for the past-tense commit forms ("what did commit <sha> touch").
|
|
217
|
-
{
|
|
218
|
-
name: "forward",
|
|
219
|
-
re: new RegExp(`^what\\s+(?:does|do|did)\\s+(.+?)\\s+(${VERB_ALT})\\??$`, "i"),
|
|
220
|
-
build: (m) => ({
|
|
221
|
-
shape: "forward", entityType: null, modifier: "direct",
|
|
222
|
-
kind: VERB_TO_KIND[m[2].toLowerCase()], object: m[1].trim(),
|
|
223
|
-
}),
|
|
224
|
-
},
|
|
225
|
-
// T4 meta: "what does <term> mean" — a question about the GRAPH'S OWN VOCABULARY
|
|
226
|
-
// (a SchemaClass/SchemaPredicate label, e.g. "cochange", or a raw prop token, e.g.
|
|
227
|
-
// "mgx:callsSymbol"), not a graph traversal over code edges. Tried after T3: T3 also
|
|
228
|
-
// starts "what does/do", but T3 only fires when the tail is a relation VERB_ALT
|
|
229
|
-
// phrase ("import"/"calls"/…), which "mean"/"means"/etc never are (disjoint tables —
|
|
230
|
-
// ask-vocab.mjs's file comment explains why they're kept separate), so the two never
|
|
231
|
-
// actually compete for the same input.
|
|
232
|
-
{
|
|
233
|
-
name: "meta-mean",
|
|
234
|
-
re: new RegExp(`^what\\s+(?:does|do|is|are)\\s+(.+?)\\s+(?:${META_ALT})\\??$`, "i"),
|
|
235
|
-
build: (m) => ({ shape: "meta", entityType: null, modifier: "direct", kind: "meta", object: m[1].trim() }),
|
|
236
|
-
},
|
|
237
|
-
// T5 meta: "what is a/an <term>" — the OTHER worked phrasing ("what is a Commit").
|
|
238
|
-
// The indefinite article is REQUIRED (not optional): a bare "what is <anything>"
|
|
239
|
-
// would also swallow "what is the meaning of this codebase" (an existing, deliberately
|
|
240
|
-
// honest grammar-miss regression case — ask.test.mjs/ask-dual-strategy.test.mjs both
|
|
241
|
-
// assert it stays null), which never mentions "a"/"an" before its tail. Requiring the
|
|
242
|
-
// article keeps this template's reach to the one worked shape without reopening that.
|
|
243
|
-
{
|
|
244
|
-
name: "meta-whatis",
|
|
245
|
-
re: new RegExp(`^what\\s+(?:is|are)\\s+(?:an?)\\s+(.+?)\\??$`, "i"),
|
|
246
|
-
build: (m) => ({ shape: "meta", entityType: null, modifier: "direct", kind: "meta", object: m[1].trim() }),
|
|
247
|
-
},
|
|
248
|
-
// T6 mention: "where is <term> mentioned/referenced" — the prose/mentions surface
|
|
249
|
-
// (2026-07-02 query families). Tried BEFORE T7: T7's trailing marker is optional,
|
|
250
|
-
// so without this ordering it would swallow the mention question and lose the
|
|
251
|
-
// marker that distinguishes "locate the definition" from "list the prose mentions".
|
|
252
|
-
{
|
|
253
|
-
name: "mention",
|
|
254
|
-
re: new RegExp(`^where\\s+(?:is|are|was|were)\\s+(.+?)\\s+(?:${MENTION_MARKERS.map(escapeRegex).join("|")})\\??$`, "i"),
|
|
255
|
-
build: (m) => ({ shape: "mentions", entityType: null, modifier: "direct", kind: "mentions", object: m[1].trim() }),
|
|
256
|
-
},
|
|
257
|
-
// T7 where: "where is <term> [defined|declared|located|implemented]" — definition
|
|
258
|
-
// location off the site attribute / defining module. "where" starts no other
|
|
259
|
-
// template, so precedence against T1-T5 is structural.
|
|
260
|
-
{
|
|
261
|
-
name: "where",
|
|
262
|
-
re: new RegExp(`^where\\s+(?:is|are|was|were)\\s+(.+?)(?:\\s+(?:${WHERE_MARKERS.map(escapeRegex).join("|")}))?\\??$`, "i"),
|
|
263
|
-
build: (m) => ({ shape: "where", entityType: null, modifier: "direct", kind: "where", object: m[1].trim() }),
|
|
264
|
-
},
|
|
265
|
-
// T8 when: "when did <term> [last] change/touched/updated…" — temporal shape over
|
|
266
|
-
// the touches edges + commit date attributes. The verb slot reuses VERB_ALT, but
|
|
267
|
-
// only the touches family carries dates to answer with, so build() rejects any
|
|
268
|
-
// other kind (returning null falls through — parseAnchored tolerates it) rather
|
|
269
|
-
// than pretending "when did X import Y" has a temporal answer.
|
|
270
|
-
{
|
|
271
|
-
name: "when",
|
|
272
|
-
re: new RegExp(`^when\\s+(?:did|does|do|was|were|is)\\s+(.+?)\\s+(?:last\\s+)?(${VERB_ALT})\\??$`, "i"),
|
|
273
|
-
build: (m) => (VERB_TO_KIND[m[2].toLowerCase()] === "touches"
|
|
274
|
-
? { shape: "when", entityType: null, modifier: "direct", kind: "touches", object: m[1].trim() }
|
|
275
|
-
: null),
|
|
276
|
-
},
|
|
277
|
-
];
|
|
278
|
-
|
|
279
|
-
/** Strategy 1: the original P0 anchored grammar — the whole (normalized) string
|
|
280
|
-
* must match one of TEMPLATES start-to-end. A build() may return null to reject
|
|
281
|
-
* a structural match on curated grounds (T8's non-temporal verbs); the scan then
|
|
282
|
-
* simply continues, exactly as if the regex had not matched. Pure. */
|
|
283
|
-
function parseAnchored(text) {
|
|
284
|
-
for (const t of TEMPLATES) {
|
|
285
|
-
const m = text.match(t.re);
|
|
286
|
-
if (m) {
|
|
287
|
-
const parsed = t.build(m);
|
|
288
|
-
if (parsed) return parsed;
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
return null;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// ---- strategy 2: keyword-spotting/decomposition — ELIZA's own mechanism: find
|
|
295
|
-
// the keyword(s) anywhere in the text, decompose around them, tolerate reordering
|
|
296
|
-
// and casual phrasing. Position-independent (no `^...$` anchor), so it tolerates
|
|
297
|
-
// "what calls this" / "who invokes this" / "something executes this, where from"
|
|
298
|
-
// — real phrasings the anchored grammar's fixed shapes don't cover. ----
|
|
299
|
-
|
|
300
|
-
const STOPWORDS = new Set([
|
|
301
|
-
"what", "who", "which", "where", "when", "why", "how",
|
|
302
|
-
"does", "do", "did", "is", "are", "was", "were", "the", "a", "an", "of", "to", "from", "at", "in", "on",
|
|
303
|
-
"there", "something", "anything", "nothing", "one", "any",
|
|
304
|
-
// temporal filler in when-questions ("when was X last touched") — a symbol
|
|
305
|
-
// literally named "last" would be the accepted residual cost, same trade as
|
|
306
|
-
// every other stopword.
|
|
307
|
-
"last",
|
|
308
|
-
]);
|
|
309
|
-
|
|
310
|
-
/** Find the longest phrase from `table`'s keys that appears as a contiguous
|
|
311
|
-
* run of `words` (case already lowercased by the caller). Longest-match-first
|
|
312
|
-
* (multi-word phrases before single words) so "co-changes with" isn't
|
|
313
|
-
* shadowed by a shorter unrelated word. A span overlapping `consumed` indices
|
|
314
|
-
* is skipped: the verb and entity tables now share a surface form ("change"
|
|
315
|
-
* is both a touches verb and the Change entity noun), and a word already
|
|
316
|
-
* claimed by the verb pass must not double as the entity keyword. Returns
|
|
317
|
-
* {kind, start, end} (end exclusive) or null. */
|
|
318
|
-
function findPhrase(lcWords, table, consumed = null) {
|
|
319
|
-
const phrases = Object.keys(table).sort((a, b) => b.split(" ").length - a.split(" ").length);
|
|
320
|
-
for (const p of phrases) {
|
|
321
|
-
const pWords = p.split(" ");
|
|
322
|
-
for (let i = 0; i <= lcWords.length - pWords.length; i += 1) {
|
|
323
|
-
if (consumed && pWords.some((_, j) => consumed.has(i + j))) continue;
|
|
324
|
-
if (pWords.every((w, j) => lcWords[i + j] === w)) return { kind: table[p], start: i, end: i + pWords.length };
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
return null;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// ---- bounded edit distance (two-level fuzzy, 2026-07-02) — hand-rolled
|
|
331
|
-
// Damerau-Levenshtein (optimal string alignment: substitution/insertion/deletion
|
|
332
|
-
// + adjacent transposition), bounded with an early row-minimum exit. Used by the
|
|
333
|
-
// keyword-spot FUZZY tier below and resolveObject's tier 5 — both fire only after
|
|
334
|
-
// every exact/curated tier missed, and a distance TIE is refused (keyword) or
|
|
335
|
-
// surfaced as ambiguity (object), never broken by a guess. Pure JS, no deps, so
|
|
336
|
-
// the inlined viewer bundle gets fuzzy matching for free. ----
|
|
337
|
-
|
|
338
|
-
/** Distance between a and b, or max+1 as soon as it provably exceeds `max`. */
|
|
339
|
-
function editDistance(a, b, max) {
|
|
340
|
-
if (a === b) return 0;
|
|
341
|
-
if (Math.abs(a.length - b.length) > max) return max + 1;
|
|
342
|
-
let prev2 = null;
|
|
343
|
-
let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
344
|
-
for (let i = 1; i <= a.length; i += 1) {
|
|
345
|
-
const cur = [i];
|
|
346
|
-
let rowMin = i;
|
|
347
|
-
for (let j = 1; j <= b.length; j += 1) {
|
|
348
|
-
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
349
|
-
let v = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
|
|
350
|
-
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) v = Math.min(v, prev2[j - 2] + cost);
|
|
351
|
-
cur[j] = v;
|
|
352
|
-
if (v < rowMin) rowMin = v;
|
|
353
|
-
}
|
|
354
|
-
if (rowMin > max) return max + 1;
|
|
355
|
-
prev2 = prev;
|
|
356
|
-
prev = cur;
|
|
357
|
-
}
|
|
358
|
-
return prev[b.length];
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
/** The curated distance budget: 1 edit for short tokens, 2 for longer ones. */
|
|
362
|
-
const fuzzyBound = (s) => (s.length <= 5 ? 1 : 2);
|
|
363
|
-
|
|
364
|
-
/** Every single word appearing in the three parse tables — the "is this word
|
|
365
|
-
* already vocabulary?" gate for the lemma/fuzzy canonicalization passes (an
|
|
366
|
-
* exact vocab word is NEVER rewritten: exact curated match always wins). */
|
|
367
|
-
const VOCAB_WORDS = new Set(
|
|
368
|
-
[...Object.keys(VERB_TO_KIND), ...Object.keys(ENTITY_TO_TYPE), ...Object.keys(MODIFIER_TO_KIND)]
|
|
369
|
-
.flatMap((p) => p.split(" ")),
|
|
370
|
-
);
|
|
371
|
-
|
|
372
|
-
/** Fuzzy-correction TARGETS: verb-phrase and modifier constituents only, length ≥4.
|
|
373
|
-
* Entity nouns are deliberately excluded — real identifiers collide with them at
|
|
374
|
-
* distance ≤2 far too easily ("myfile" is 2 edits from "file", "caller" 2 from
|
|
375
|
-
* "calls"-family words), and entity-noun typos are already owned by the curated
|
|
376
|
-
* MISSPELLINGS table where such calls are made deliberately. Short constituents
|
|
377
|
-
* ("of", "to", "in", "on") are excluded for the same reason: at bound 1 half of
|
|
378
|
-
* English is adjacent to them. */
|
|
379
|
-
const FUZZY_TARGET_WORDS = [...new Set(
|
|
380
|
-
[...Object.keys(VERB_TO_KIND), ...Object.keys(MODIFIER_TO_KIND)]
|
|
381
|
-
.flatMap((p) => p.split(" "))
|
|
382
|
-
.filter((w) => w.length >= 4),
|
|
383
|
-
)];
|
|
384
|
-
|
|
385
|
-
/** A query word may be canonicalized only if it is plain alphabetic, not a
|
|
386
|
-
* stopword, and not already vocabulary. Dotted/digit terms (file names, shas)
|
|
387
|
-
* are never touched. */
|
|
388
|
-
function eligibleForCanon(w) {
|
|
389
|
-
return /^[a-z]+$/.test(w) && !STOPWORDS.has(w) && !VOCAB_WORDS.has(w);
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
/** UNIQUE within-bound fuzzy vocab keyword for `w`, or null — a tie between two
|
|
393
|
-
* distinct target words at the same distance is refused outright (the honest-miss
|
|
394
|
-
* discipline at the vocabulary level; cf. MISSPELLINGS' curated "calss" decision). */
|
|
395
|
-
function fuzzyVocabWord(w) {
|
|
396
|
-
const bound = fuzzyBound(w);
|
|
397
|
-
let best = bound + 1;
|
|
398
|
-
let hit = null;
|
|
399
|
-
let tied = false;
|
|
400
|
-
for (const target of FUZZY_TARGET_WORDS) {
|
|
401
|
-
const d = editDistance(w, target, Math.min(best, bound));
|
|
402
|
-
if (d < best) { best = d; hit = target; tied = false; }
|
|
403
|
-
else if (d === best && d <= bound && target !== hit) tied = true;
|
|
404
|
-
}
|
|
405
|
-
return best <= bound && !tied ? hit : null;
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
/** Strategy 2: scan (already-normalized) text for a verb keyword anywhere,
|
|
409
|
-
* plus optional entity/modifier keywords anywhere, then split whatever's
|
|
410
|
-
* left (after removing the matched spans + stopwords) into the words BEFORE
|
|
411
|
-
* and AFTER the verb. Which side(s) are non-empty decides the shape —
|
|
412
|
-
* mirrors the three anchored shapes but by decomposition instead of a fixed
|
|
413
|
-
* template, so it tolerates reordering/casual phrasing the anchored regexes
|
|
414
|
-
* don't: text on BOTH sides ("does X import Y") -> ask{subject:before,
|
|
415
|
-
* object:after}; only AFTER the verb ("what calls this") -> reverse{object:
|
|
416
|
-
* after}; only BEFORE it ("what does X import") -> forward{object:before}.
|
|
417
|
-
* A lone context pronoun ("this"/"it"/"that"/"here") ending up as a resolved
|
|
418
|
-
* term is left as plain text — resolveTermOrContext (traverse-time)
|
|
419
|
-
* recognizes it against an optional contextId, so no separate flag is
|
|
420
|
-
* needed here. A misparse here costs nothing beyond an honest object-miss
|
|
421
|
-
* downstream (resolveObject never guesses).
|
|
422
|
-
*
|
|
423
|
-
* Keyword matching is TIERED (two-level fuzzy work, 2026-07-02) — each lower
|
|
424
|
-
* tier fires ONLY when every tier above found no verb phrase at all, so an
|
|
425
|
-
* exact curated match can never be displaced:
|
|
426
|
-
* 1. exact — the words as typed (post-normalization, which already applied
|
|
427
|
-
* the curated CONTRACTIONS/MISSPELLINGS/WRONG_WORDS corrections);
|
|
428
|
-
* 2. lemma (only with the optional Node-side `nlp` adapter) — each eligible
|
|
429
|
-
* word is replaced by its wink lemma IF that lemma is itself a vocab word
|
|
430
|
-
* ("imported"/"importing" -> "import"), so inflections hit the curated
|
|
431
|
-
* phrases without enumerating them. Every verb family already stores its
|
|
432
|
-
* lemma form ("import", "call", "touch", "use", …), so a direct
|
|
433
|
-
* lemma-in-vocab check is the whole lookup — no reverse index needed;
|
|
434
|
-
* 3. fuzzy (adapter-free; works in the inlined viewer too) — a word ≥4 chars
|
|
435
|
-
* matching nothing exactly may rewrite to a UNIQUE verb/modifier
|
|
436
|
-
* constituent within the bounded edit distance (see fuzzyVocabWord; ties
|
|
437
|
-
* are refused, entity nouns are never fuzzy targets).
|
|
438
|
-
* The canonicalized words drive PHRASE FINDING only — sideText always reads the
|
|
439
|
-
* ORIGINAL words, so a correction can never corrupt an object/subject term. */
|
|
440
|
-
function parseKeywordSpot(text, nlp = null) {
|
|
441
|
-
// Strip a trailing "?" (mirrors the anchored templates' own `\??$`) and turn commas into
|
|
442
|
-
// pauses/spaces — but NEVER strip a mid-word ".": object terms are routinely dotted file/module
|
|
443
|
-
// names ("a.py", "utils.mjs"), and the anchored strategy captures those raw, so keyword-spot
|
|
444
|
-
// must too or the two strategies would "disagree" over a period that was never part of the intent.
|
|
445
|
-
const words = text.replace(/\?+\s*$/, "").replace(/,/g, " ").split(/\s+/).filter(Boolean);
|
|
446
|
-
const lcWords = words.map((w) => w.toLowerCase());
|
|
447
|
-
// where/mentions shapes (2026-07-02 query families): "where is X [defined]" and
|
|
448
|
-
// "where is X mentioned" carry NO relation verb, so the verb-driven decomposition
|
|
449
|
-
// below can never reach them. Routed here by the "where" question word + marker —
|
|
450
|
-
// but ONLY when no relation verb exists anywhere in the sentence: "something
|
|
451
|
-
// executes this, where from" (an existing worked phrasing) has a verb, and its
|
|
452
|
-
// "where" is decorative, not a location question.
|
|
453
|
-
if (lcWords.includes("where") && !findPhrase(lcWords, VERB_TO_KIND)) {
|
|
454
|
-
const mention = lcWords.some((w) => MENTION_MARKERS.includes(w));
|
|
455
|
-
const markers = new Set([...WHERE_MARKERS, ...MENTION_MARKERS]);
|
|
456
|
-
const objText = words.filter((w, i) => !STOPWORDS.has(lcWords[i]) && !markers.has(lcWords[i])).join(" ").trim();
|
|
457
|
-
if (objText) {
|
|
458
|
-
const kind = mention ? "mentions" : "where";
|
|
459
|
-
return { shape: kind, entityType: null, modifier: "direct", kind, object: objText };
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
let canonWords = lcWords;
|
|
463
|
-
let verbHit = findPhrase(lcWords, VERB_TO_KIND);
|
|
464
|
-
if (!verbHit && nlp) {
|
|
465
|
-
// tier 2: lemma (see the tier doc above) — replace only when the lemma is
|
|
466
|
-
// itself vocabulary, so unknown words ("myfile") pass through untouched.
|
|
467
|
-
const lemmaWords = lcWords.map((w) => {
|
|
468
|
-
if (!eligibleForCanon(w)) return w;
|
|
469
|
-
const l = nlp.lemma(w);
|
|
470
|
-
return VOCAB_WORDS.has(l) ? l : w;
|
|
471
|
-
});
|
|
472
|
-
verbHit = findPhrase(lemmaWords, VERB_TO_KIND);
|
|
473
|
-
if (verbHit) canonWords = lemmaWords;
|
|
474
|
-
}
|
|
475
|
-
if (!verbHit) {
|
|
476
|
-
// tier 3: bounded-edit-distance rewrite toward verb/modifier keywords only
|
|
477
|
-
// ("impotr" -> "import"); ≥4-char words only — below that the bound covers
|
|
478
|
-
// half of English (and "and" is 1 edit from the "land in" constituent).
|
|
479
|
-
const fuzzyWords = lcWords.map((w) => (w.length >= 4 && eligibleForCanon(w) ? fuzzyVocabWord(w) || w : w));
|
|
480
|
-
verbHit = findPhrase(fuzzyWords, VERB_TO_KIND);
|
|
481
|
-
if (verbHit) canonWords = fuzzyWords;
|
|
482
|
-
}
|
|
483
|
-
if (!verbHit) return null;
|
|
484
|
-
// POS consumer (wink adapter, Node-side only): rescue the ONE decomposition this
|
|
485
|
-
// strategy provably mis-parses — a relation word used as a NOUN in a "the
|
|
486
|
-
// <imports> of <term>" nominal ("show the imports of walk.mjs" otherwise
|
|
487
|
-
// decomposes to ask{subject:"show"}; bare "the imports of walk.mjs" to the
|
|
488
|
-
// reverse shape, both wrong). The wink probe showed "import" is tagged NOUN even
|
|
489
|
-
// in genuine verb use ("which modules import walk.mjs"), so the POS signal is
|
|
490
|
-
// deliberately NOT a general verb veto — it only fires inside this exact
|
|
491
|
-
// det+NOUN+"of" frame, where the nominal reading is grammatically forced.
|
|
492
|
-
if (nlp && verbHit.end - verbHit.start === 1) {
|
|
493
|
-
const i = verbHit.start;
|
|
494
|
-
const det = lcWords[i - 1];
|
|
495
|
-
if ((det === "the" || det === "these" || det === "those") && lcWords[i + 1] === "of") {
|
|
496
|
-
const tags = nlp.posTags(words);
|
|
497
|
-
if (tags[i] === "NOUN") {
|
|
498
|
-
const objText = words.slice(i + 2).filter((w, j) => !STOPWORDS.has(lcWords[i + 2 + j])).join(" ").trim();
|
|
499
|
-
if (objText) return { shape: "forward", entityType: null, modifier: "direct", kind: verbHit.kind, object: objText };
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
const consumed = new Set();
|
|
504
|
-
const mark = (hit) => { if (hit) for (let i = hit.start; i < hit.end; i += 1) consumed.add(i); };
|
|
505
|
-
mark(verbHit);
|
|
506
|
-
const entityHit = findPhrase(canonWords, ENTITY_TO_TYPE, consumed);
|
|
507
|
-
mark(entityHit);
|
|
508
|
-
const modifierHit = findPhrase(canonWords, MODIFIER_TO_KIND, consumed);
|
|
509
|
-
mark(modifierHit);
|
|
510
|
-
const sideText = (from, to) => words
|
|
511
|
-
.slice(from, to)
|
|
512
|
-
.filter((_, j) => !consumed.has(from + j) && !STOPWORDS.has(lcWords[from + j]))
|
|
513
|
-
.join(" ")
|
|
514
|
-
.trim();
|
|
515
|
-
const beforeText = sideText(0, verbHit.start);
|
|
516
|
-
const afterText = sideText(verbHit.end, words.length);
|
|
517
|
-
const kind = verbHit.kind;
|
|
518
|
-
// slices read canonWords, not lcWords: the entity/modifier spans were matched
|
|
519
|
-
// against the canonicalized array, whose word IS the table key.
|
|
520
|
-
const entityType = entityHit ? ENTITY_TO_TYPE[canonWords.slice(entityHit.start, entityHit.end).join(" ")] : null;
|
|
521
|
-
const modifier = modifierHit ? MODIFIER_TO_KIND[canonWords.slice(modifierHit.start, modifierHit.end).join(" ")] : "direct";
|
|
522
|
-
|
|
523
|
-
// when shape (2026-07-02 query families): "when did X change" / "when was X last
|
|
524
|
-
// touched" — the "when" question word turns a touches decomposition temporal.
|
|
525
|
-
// Only touches carries commit dates to answer with; a "when" next to any other
|
|
526
|
-
// relation verb falls through to the ordinary shapes (and their honest answers).
|
|
527
|
-
if (kind === "touches" && lcWords.includes("when")) {
|
|
528
|
-
const objText = beforeText || afterText;
|
|
529
|
-
if (objText) return { shape: "when", entityType: null, modifier: "direct", kind: "touches", object: objText };
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
if (beforeText && afterText) return { shape: "ask", entityType: null, modifier: "direct", kind, subject: beforeText, object: afterText };
|
|
533
|
-
if (afterText) return { shape: "reverse", entityType, modifier, kind, object: afterText };
|
|
534
|
-
// forward keeps the spotted entityType ("which modules did commit <sha> touch" is a
|
|
535
|
-
// forward decomposition — subject before the verb — whose asked grain would otherwise
|
|
536
|
-
// be lost); traverse() only consults it for the commit-as-subject grain selection,
|
|
537
|
-
// so plain forwards behave exactly as before. Modifier stays hardcoded: no forward
|
|
538
|
-
// closure traversal exists (see modifierIsWired).
|
|
539
|
-
if (beforeText) return { shape: "forward", entityType, modifier: "direct", kind, object: beforeText };
|
|
540
|
-
return null;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
// ---- strategy merge — run both, agree/disagree/single/neither (§ above). A
|
|
544
|
-
// plain array + a merge step, so a third strategy plugs in the same way. ----
|
|
545
|
-
|
|
546
|
-
const STRATEGIES = [
|
|
547
|
-
{ name: "anchored", parse: parseAnchored },
|
|
548
|
-
{ name: "keyword-spot", parse: parseKeywordSpot },
|
|
549
|
-
];
|
|
128
|
+
// ---- the parsing strategies + normalization + fuzzy service formerly defined
|
|
129
|
+
// here now live in src/interpret/ (items 8/10/13): interpret/normalize.mjs
|
|
130
|
+
// (normalizeQuery, applyNegationFrames, STOPWORDS, splitWords), interpret/
|
|
131
|
+
// strategies/grammar.mjs (parseAnchored, the anchored TEMPLATES), interpret/
|
|
132
|
+
// strategies/keywords.mjs (parseKeywordSpot, findPhrase), interpret/fuzzy.mjs
|
|
133
|
+
// (editDistance, fuzzyBound — also resolveObject's tier-5 budget below). ----
|
|
134
|
+
|
|
135
|
+
// ---- strategy merge — now the interpret PIPELINE (item 8): the registered
|
|
136
|
+
// strategies (interpret/pipeline.mjs STRATEGIES — grammar, keyword-spot, …) run
|
|
137
|
+
// over the normalized text and interpret/merge.mjs merges them: same-class
|
|
138
|
+
// agreement dedupes to one parse, same-class disagreement is the honest
|
|
139
|
+
// {ambiguousParse, candidates} surface, and distinct-class alternates carry the
|
|
140
|
+
// "if you mean X then …" surround (unused on this synchronous path — parseQuery
|
|
141
|
+
// keeps the winning parse only, byte-identical to the original two-way merge). ----
|
|
550
142
|
|
|
551
143
|
/** The default lemma/POS adapter: wink-nlp when this is a Node process with the
|
|
552
144
|
* optional deps installed, null otherwise. BOUNDARY (see the import comment):
|
|
@@ -557,34 +149,21 @@ function defaultNlp() {
|
|
|
557
149
|
return typeof nlpAdapter === "function" ? nlpAdapter() : null;
|
|
558
150
|
}
|
|
559
151
|
|
|
560
|
-
// "commit abc1234" and bare "abc1234" are the SAME term once resolveObject's
|
|
561
|
-
// commit-sha tier strips the noun — the anchored strategy captures the noun inside
|
|
562
|
-
// its object span while keyword-spot consumes it as the entity keyword, so without
|
|
563
|
-
// this the two strategies would "disagree" over a word that names no different thing.
|
|
564
|
-
const cmpTerm = (s) => String(s || "").trim().toLowerCase().replace(/\s+/g, " ").replace(/^commit\s+(?=[0-9a-f]{7,40}$)/, "");
|
|
565
|
-
|
|
566
|
-
/** Do two independently-produced parses mean the same graph query? Same
|
|
567
|
-
* shape, same relation kind, and matching term(s) (both subject and object
|
|
568
|
-
* for "ask"; just object otherwise) — anything less is a genuine
|
|
569
|
-
* disagreement, not a near-miss to paper over. */
|
|
570
|
-
function sameParse(p, q) {
|
|
571
|
-
if (p.shape !== q.shape || p.kind !== q.kind) return false;
|
|
572
|
-
if (p.shape === "ask") return cmpTerm(p.subject) === cmpTerm(q.subject) && cmpTerm(p.object) === cmpTerm(q.object);
|
|
573
|
-
return cmpTerm(p.object) === cmpTerm(q.object);
|
|
574
|
-
}
|
|
575
|
-
|
|
576
152
|
/** Compile a free-text question into {shape, kind, entityType, modifier,
|
|
577
|
-
* object[, subject]}, or null if
|
|
578
|
-
* miss (§6.3), never a best-effort guess. When
|
|
153
|
+
* object[, subject]}, or null if NO strategy fits — an honest grammar
|
|
154
|
+
* miss (§6.3), never a best-effort guess. When strategies parse and
|
|
579
155
|
* AGREE, returns that parse unchanged (no fallback ordering — either
|
|
580
156
|
* strategy's own result is equally valid once they agree, per §above: "use
|
|
581
|
-
* either"). When
|
|
157
|
+
* either"). When they parse but DISAGREE (different shape/kind/term),
|
|
582
158
|
* returns {ambiguousParse: true, candidates: [...]} — a genuine "this could
|
|
583
159
|
* mean more than one thing" case, distinct from resolveObject's later
|
|
584
|
-
* object-resolution ambiguity.
|
|
585
|
-
* (
|
|
586
|
-
*
|
|
587
|
-
*
|
|
160
|
+
* object-resolution ambiguity. Routed through interpret/pipeline.mjs +
|
|
161
|
+
* interpret/merge.mjs (item 8) — the two legacy strategies at their existing
|
|
162
|
+
* precedence produce identical winners. `opts.nlp` overrides the lemma/POS
|
|
163
|
+
* adapter (pass null to force the adapter-less browser behavior in a Node
|
|
164
|
+
* test); leaving it undefined picks the deterministic default (defaultNlp).
|
|
165
|
+
* Pure given (query, adapter) — the adapter itself is a fixed model, no
|
|
166
|
+
* sampling. */
|
|
588
167
|
export function parseQuery(query, { nlp = undefined } = {}) {
|
|
589
168
|
const adapter = nlp === undefined ? defaultNlp() : nlp;
|
|
590
169
|
const raw = String(query || "").trim().replace(/\s+/g, " ");
|
|
@@ -595,18 +174,14 @@ export function parseQuery(query, { nlp = undefined } = {}) {
|
|
|
595
174
|
// descent over CLAUSES for the compositional shapes (nested/relative, boolean,
|
|
596
175
|
// qualifiers, aggregates, superlatives, anaphora). It fires ONLY when a
|
|
597
176
|
// compositional MARKER is present and returns null otherwise, so every plain
|
|
598
|
-
// clause falls straight through to the unchanged
|
|
177
|
+
// clause falls straight through to the unchanged strategy pipeline below — the
|
|
599
178
|
// whole existing grammar is preserved bit-for-bit. When a marker IS present but
|
|
600
179
|
// the phrase cannot be compiled, it returns an honest {node:"miss"} rather than
|
|
601
180
|
// letting keyword-spot guess at a composition it never expressed.
|
|
602
181
|
const composite = parseComposite(text, adapter);
|
|
603
182
|
if (composite) return composite;
|
|
604
|
-
const
|
|
605
|
-
|
|
606
|
-
if (hits.length === 1) return hits[0].parsed;
|
|
607
|
-
const [a, b] = hits;
|
|
608
|
-
if (sameParse(a.parsed, b.parsed)) return a.parsed;
|
|
609
|
-
return { ambiguousParse: true, candidates: hits.map((h) => h.parsed) };
|
|
183
|
+
const merged = mergeStrategyResults(runStrategiesSync(text, { nlp: adapter, raw }));
|
|
184
|
+
return merged ? merged.parsed : null;
|
|
610
185
|
}
|
|
611
186
|
|
|
612
187
|
// ============================================================================
|
|
@@ -650,20 +225,17 @@ const NEST_SENTINEL = "zzinnerset";
|
|
|
650
225
|
const PRED_LEAD_SKIP = new Set(["that", "which", "who", "are", "is", "was", "were", "do", "does", "also", "still", "both", "and"]);
|
|
651
226
|
const FRAME_WORDS = new Set(["which", "what", "who", "list", "show", "find", "give", "me", "us", "all"]);
|
|
652
227
|
|
|
653
|
-
const splitWords = (text) => String(text).replace(/\?+\s*$/, "").replace(/,/g, " ").split(/\s+/).filter(Boolean);
|
|
654
228
|
const entityNoun = (w) => (ENTITY_TO_TYPE[w] ? { entityType: ENTITY_TO_TYPE[w], placeholder: false }
|
|
655
229
|
: (PLACEHOLDER_NOUNS.includes(w) ? { entityType: null, placeholder: true } : null));
|
|
656
230
|
const isGerundVerb = (w) => !!VERB_TO_KIND[w] && w.endsWith("ing");
|
|
657
231
|
|
|
658
|
-
/** Run the two
|
|
232
|
+
/** Run the two legacy strategies on a FRAGMENT and return a single simple clause
|
|
659
233
|
* (or null). Deterministic tie-break: on strategy disagreement the anchored parse
|
|
660
|
-
* wins
|
|
661
|
-
*
|
|
234
|
+
* wins — a fragment fed from the composer is already shape-constrained, so the
|
|
235
|
+
* merge's "surface an ambiguity" behavior isn't wanted here. (Equivalent to the
|
|
236
|
+
* original two-strategy scan: anchored first, keyword-spot only on a miss.) */
|
|
662
237
|
function parseSimpleClause(text, nlp) {
|
|
663
|
-
|
|
664
|
-
if (!hits.length) return null;
|
|
665
|
-
if (hits.length === 1) return hits[0];
|
|
666
|
-
return sameParse(hits[0], hits[1]) ? hits[0] : hits[0];
|
|
238
|
+
return parseAnchored(text) || parseKeywordSpot(text, nlp);
|
|
667
239
|
}
|
|
668
240
|
|
|
669
241
|
/** Top compositional dispatcher — first marker-matching production wins; a
|
|
@@ -2061,7 +1633,6 @@ function renderCore(parsed, result) {
|
|
|
2061
1633
|
// tables + resolveObject/parseQuery, so it survives the viewer bundle's import strip.
|
|
2062
1634
|
// ============================================================================
|
|
2063
1635
|
|
|
2064
|
-
const wordsOf = (arr) => arr.flatMap((p) => String(p).toLowerCase().split(" "));
|
|
2065
1636
|
|
|
2066
1637
|
/** Every token the CLOSED grammar gives QUERY MEANING to — relation verbs, entity
|
|
2067
1638
|
* nouns, modifiers, qualifiers, aggregate/superlative triggers, edge-degree nouns,
|
|
@@ -2130,13 +1701,41 @@ function fuzzyCascadeWord(w) {
|
|
|
2130
1701
|
return best <= bound && !tied ? hit : null;
|
|
2131
1702
|
}
|
|
2132
1703
|
|
|
1704
|
+
/** The typo→schema-term trap guard (chatbench cycle 2, CHATBENCH_001 L3 — the
|
|
1705
|
+
* tf-modles hard fail). A term that resolves ONLY via the tier-5 bounded-fuzzy
|
|
1706
|
+
* pass onto one of the graph's OWN vocabulary individuals (a SchemaClass/
|
|
1707
|
+
* SchemaPredicate that ingestSchemaDocs merged in), while the same word ALSO
|
|
1708
|
+
* fuzzy-corrects to an entity KIND NOUN of the closed grammar ("modles" →
|
|
1709
|
+
* "modules"), is a typo'd kind noun, not a question about the schema term:
|
|
1710
|
+
* without this guard, "which modles import a.mjs" silently pivoted onto the
|
|
1711
|
+
* CLASS Module and confidently answered a question the visitor never asked
|
|
1712
|
+
* ("No — no imports edge found from Module to app/lib/a.mjs"). Reporting the
|
|
1713
|
+
* parse unanswerable sends it to the relaxation cascade, whose drop-unmatched
|
|
1714
|
+
* layer restores the kind noun (fuzzyCascadeWord) and whose winning re-parse is
|
|
1715
|
+
* ANNOUNCED as a repair receipt ('read as "which modules import a.mjs" — …').
|
|
1716
|
+
* If the cascade cannot produce a real answer, the original parse still stands
|
|
1717
|
+
* (ask() keeps the direct parse when relaxParse returns null), so a genuine
|
|
1718
|
+
* schema-adjacent question is never turned into a new kind of miss. Exact and
|
|
1719
|
+
* substring/prose matches are untouched — the guard reads matchedVia:"fuzzy"
|
|
1720
|
+
* only, and only when the kind-noun reading exists. */
|
|
1721
|
+
function schemaTypoTrap(resolution, term) {
|
|
1722
|
+
if (!resolution?.match || resolution.matchedVia !== "fuzzy" || resolution.ambiguous) return false;
|
|
1723
|
+
const cls = resolution.match.class;
|
|
1724
|
+
if (cls !== "SchemaClass" && cls !== "SchemaPredicate") return false;
|
|
1725
|
+
const lc = String(term || "").trim().toLowerCase();
|
|
1726
|
+
const kindNoun = fuzzyCascadeWord(lc);
|
|
1727
|
+
return !!kindNoun && kindNoun !== lc && !!ENTITY_TO_TYPE[kindNoun];
|
|
1728
|
+
}
|
|
1729
|
+
|
|
2133
1730
|
/** Is `parsed` a genuinely ANSWERABLE query — one that both parsed AND (for the simple
|
|
2134
1731
|
* clauses) resolves its named term(s) to a graph entity? A composite non-miss node,
|
|
2135
1732
|
* an ambiguous parse, and a meta/mentions surface all count; an unresolved-context
|
|
2136
1733
|
* pronoun is its OWN specific honest miss (kept, not relaxed). Returns:
|
|
2137
1734
|
* true — a real, executable answer (even if it later renders an empty set / "No")
|
|
2138
1735
|
* "ambiguous"/"pronoun" — a specific outcome to keep, distinct from relaxable
|
|
2139
|
-
* false — no parse at all, a compositional {node:"miss"},
|
|
1736
|
+
* false — no parse at all, a compositional {node:"miss"}, an unresolved term,
|
|
1737
|
+
* or a fuzzy-only schema-individual hit with a kind-noun reading
|
|
1738
|
+
* (schemaTypoTrap above — relaxable, so the cascade can re-read it)
|
|
2140
1739
|
* ask() starts the cascade ONLY on `false`, and accepts a relaxed attempt ONLY on the
|
|
2141
1740
|
* strict `true` (so the cascade can never "rescue" a query into another kind of miss). */
|
|
2142
1741
|
function answerable(graph, parsed, contextId) {
|
|
@@ -2146,11 +1745,11 @@ function answerable(graph, parsed, contextId) {
|
|
|
2146
1745
|
if (parsed.shape === "meta" || parsed.shape === "mentions") return true;
|
|
2147
1746
|
const o = resolveTermOrContext(graph, parsed.object, contextId);
|
|
2148
1747
|
if (o.unresolvedPronoun) return "pronoun";
|
|
2149
|
-
if (!o.match) return false;
|
|
1748
|
+
if (!o.match || schemaTypoTrap(o, parsed.object)) return false;
|
|
2150
1749
|
if (parsed.shape === "ask") {
|
|
2151
1750
|
const s = resolveTermOrContext(graph, parsed.subject, contextId);
|
|
2152
1751
|
if (s.unresolvedPronoun) return "pronoun";
|
|
2153
|
-
return s.match ? true : false;
|
|
1752
|
+
return s.match && !schemaTypoTrap(s, parsed.subject) ? true : false;
|
|
2154
1753
|
}
|
|
2155
1754
|
return true;
|
|
2156
1755
|
}
|