@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/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): parseQuery normalizes the
18
- // raw text (contractions, g-drop, filler-strip), rewrites recognized negative-
19
- // rhetorical constructions to their affirmative form, then runs TWO INDEPENDENT
20
- // parsing STRATEGIES against the same normalized text the original anchored-
21
- // template matcher (precise, fast, unweakened) and a keyword-spotting/
22
- // decomposition matcher (ELIZA's own mechanism: find the keyword, decompose
23
- // around it, tolerate reordering/casual phrasing) and MERGES their results:
24
- // one strategy hit -> use it; both hit and agree -> use it (high confidence);
25
- // both hit and DISAGREE -> a genuine parse-level ambiguity, surfaced honestly;
26
- // neither hits -> the honest grammar miss. STRATEGIES is a plain array so a
27
- // third strategy could join the same way, not a hardcoded two-branch special
28
- // case.
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
- CONTRACTIONS, MISSPELLINGS, WRONG_WORDS, G_DROP, FILLER_WORDS,
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
- function escapeRegex(s) {
114
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
115
- }
116
-
117
- // ---- §3.5 normalization runs before EITHER parsing strategy sees the text ----
118
-
119
- /** contraction/informal-spelling table -> word-boundary regex, longest phrase
120
- * first (so "there's" doesn't get shadowed by a shorter overlapping entry). */
121
- const tableRe = (table) => new RegExp(
122
- "\\b(" + Object.keys(table).sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b",
123
- "gi",
124
- );
125
- const CONTRACTION_RE = tableRe(CONTRACTIONS);
126
- // misspelling/wrong-word CORRECTIONS (ask-vocab.mjs) same mechanism, applied
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 NEITHER strategy fits — an honest grammar
578
- * miss (§6.3), never a best-effort guess. When both strategies parse and
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 both parse but DISAGREE (different shape/kind/term),
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. `opts.nlp` overrides the lemma/POS adapter
585
- * (pass null to force the adapter-less browser behavior in a Node test);
586
- * leaving it undefined picks the deterministic default (defaultNlp). Pure
587
- * given (query, adapter) the adapter itself is a fixed model, no sampling. */
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 two-strategy merge below — the
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 hits = STRATEGIES.map((s) => ({ name: s.name, parsed: s.parse(text, adapter) })).filter((r) => r.parsed);
605
- if (hits.length === 0) return null;
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 existing strategies on a FRAGMENT and return a single simple clause
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 (STRATEGIES[0]) — a fragment fed from the composer is already shape-
661
- * constrained, so the merge's "surface an ambiguity" behavior isn't wanted here. */
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
- const hits = STRATEGIES.map((s) => s.parse(text, nlp)).filter(Boolean);
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"}, or an unresolved term
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
  }