@polycode-projects/seonix 0.2.1 → 0.4.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
@@ -2,10 +2,13 @@
2
2
  // seonix graph. PLAN_MECHANICAL_CHAT.md (P0): a small, closed English grammar
3
3
  // compiles a free-text question into a graph traversal over the SAME classified
4
4
  // relation groups codegraph.mjs's other render functions read, then renders a
5
- // templated, citation-faithful answer. No embeddings, no fuzzy matching beyond
6
- // plain substring/component matching, no model calls a miss is a stated blank,
7
- // never a guess (the extraction pipeline's "no wrong edge" ethos, held at the
8
- // query layer too).
5
+ // templated, citation-faithful answer. No embeddings, no generative model calls —
6
+ // a miss is a stated blank, never a guess (the extraction pipeline's "no wrong
7
+ // edge" ethos, held at the query layer too). Term/keyword matching is TIERED
8
+ // (2026-07-02, two-level fuzzy): exact curated match always wins; a Node-only
9
+ // wink-nlp LEMMA/POS tier and a bounded Damerau-Levenshtein FUZZY tier fire only
10
+ // on a miss, a unique fuzzy hit is announced in the answer ("assuming you
11
+ // meant …"), and any tie surfaces as ambiguity — never a silently-broken guess.
9
12
  //
10
13
  // Four pure, independently-testable stages, orchestrated by ask():
11
14
  // parseQuery (grammar) -> resolveObject (mechanical term resolution) ->
@@ -35,10 +38,22 @@
35
38
  import { relationKind, impactClosure } from "./codegraph.mjs";
36
39
  import {
37
40
  VERB_TO_KIND, ENTITY_TO_TYPE, MODIFIER_TO_KIND,
38
- CONTRACTIONS, G_DROP, FILLER_WORDS, CONTEXT_PRONOUNS, NEGATION_FRAMES,
39
- META_MEANING_VERBS,
41
+ CONTRACTIONS, MISSPELLINGS, WRONG_WORDS, G_DROP, FILLER_WORDS,
42
+ CONTEXT_PRONOUNS, NEGATION_FRAMES, COMMIT_CONTENT_FRAMES, META_MEANING_VERBS,
43
+ WHERE_MARKERS, MENTION_MARKERS,
44
+ RELATIVE_PRONOUNS, PLACEHOLDER_NOUNS, BOOLEAN_CONNECTIVES, QUALIFIERS,
45
+ AGGREGATE_TRIGGERS, SUPERLATIVE_EXTREMES, EDGE_NOUN_TO_METRIC, ANAPHORA_TRIGGERS,
46
+ MEMBERSHIP_KINDS, CASCADE_NOISE, CASCADE_SYNONYMS, HELP_TRIGGERS,
40
47
  } from "./ask-vocab.mjs";
41
48
  import { lookupByProseTokens } from "./prose.mjs";
49
+ // The OPTIONAL Node-only wink-nlp adapter (lemma/POS tier). BOUNDARY: the inlined
50
+ // viewer bundle (viz.mjs askSource) strips this import line and never inlines
51
+ // ask-nlp.mjs, so in the browser `nlpAdapter` is simply an undeclared identifier —
52
+ // defaultNlp() below reads it through `typeof`, the one operator that touches an
53
+ // undeclared name without throwing, and the portable single-file HTML degrades to
54
+ // adapter-less parsing (curated tables + bounded fuzzy still on) instead of
55
+ // shipping a ~1MB language model inside the page.
56
+ import { nlpAdapter } from "./ask-nlp.mjs";
42
57
 
43
58
  /** All edges of a classified relation kind, flattened across relation groups —
44
59
  * a local copy of codegraph.mjs's private edgesOfKind (kept local rather than
@@ -60,6 +75,14 @@ function edgesOfKind(graph, kind) {
60
75
  const SYMBOL_GRAIN_SIBLING = { calls: "callsSymbol", touches: "touchesSymbol" };
61
76
  const FINE_ENTITY_TYPES = new Set(["Function", "Method", "Class", "Attribute", "GlobalVariable"]);
62
77
 
78
+ // Query-side UNION families (2026-07-02 query families): a parsed kind that is not
79
+ // itself a stored predicate but a curated union of stored kinds — "what uses X"
80
+ // honestly means the import graph AND the call graph together. Everything else
81
+ // maps to itself; grain selection (asked entity type) then narrows the union's
82
+ // subjects the same way it narrows a single kind's.
83
+ const KIND_UNIONS = { uses: ["imports", "calls", "callsSymbol"] };
84
+ const kindsFor = (kind) => KIND_UNIONS[kind] || [kind];
85
+
63
86
  const OVERFLOW_CAP = 12;
64
87
 
65
88
  const PLURAL_FORMS = {
@@ -67,6 +90,9 @@ const PLURAL_FORMS = {
67
90
  Class: ["class", "classes"], Module: ["module", "modules"],
68
91
  Attribute: ["attribute", "attributes"], GlobalVariable: ["variable", "variables"],
69
92
  Commit: ["commit", "commits"],
93
+ // "Change" is ask-vocab.mjs's pseudo-type (a wildcard over the touch traversal's
94
+ // results, never a node class) — it still needs noun forms for zero-hit templates.
95
+ Change: ["change", "changes"],
70
96
  };
71
97
  function nounFor(entityType, n) {
72
98
  const [s, p] = PLURAL_FORMS[entityType] || ["result", "results"];
@@ -92,10 +118,25 @@ function escapeRegex(s) {
92
118
 
93
119
  /** contraction/informal-spelling table -> word-boundary regex, longest phrase
94
120
  * first (so "there's" doesn't get shadowed by a shorter overlapping entry). */
95
- const CONTRACTION_RE = new RegExp(
96
- "\\b(" + Object.keys(CONTRACTIONS).sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b",
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])",
97
136
  "gi",
98
137
  );
138
+ const MISSPELLING_RE = correctionRe(MISSPELLINGS);
139
+ const WRONG_WORD_RE = correctionRe(WRONG_WORDS);
99
140
 
100
141
  /** Free-text -> normalized free-text: contractions expanded, g-dropped words
101
142
  * restored, filler/politeness words stripped. Idempotent and pure — the same
@@ -109,6 +150,8 @@ const CONTRACTION_RE = new RegExp(
109
150
  export function normalizeQuery(text) {
110
151
  let q = String(text || "");
111
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()]);
112
155
  q = q.replace(G_DROP, "$1ing");
113
156
  if (FILLER_WORDS.length) {
114
157
  const fillerRe = new RegExp(
@@ -120,12 +163,15 @@ export function normalizeQuery(text) {
120
163
  return q.replace(/\s+/g, " ").trim();
121
164
  }
122
165
 
123
- /** Recognized negative-rhetorical constructions, rewritten to the affirmative
124
- * form of the SAME question (§3.6) a small pattern set, not a general
125
- * negation-scope parser. First matching frame wins; unmatched text passes
126
- * through unchanged. */
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. */
127
173
  export function applyNegationFrames(text) {
128
- for (const frame of NEGATION_FRAMES) {
174
+ for (const frame of [...COMMIT_CONTENT_FRAMES, ...NEGATION_FRAMES]) {
129
175
  const m = text.match(frame.re);
130
176
  if (m) return frame.to(m).replace(/\s+/g, " ").trim();
131
177
  }
@@ -143,11 +189,12 @@ const META_ALT = META_MEANING_VERBS.slice().sort((a, b) => b.length - a.length).
143
189
 
144
190
  const TEMPLATES = [
145
191
  // T1 ASK: "does X import Y" / "is X a subclass of Y" -> Yes/No. Tried FIRST: it starts with
146
- // does/is/do, which the reverse/forward templates below never match (those start with
192
+ // does/is/do/did, which the reverse/forward templates below never match (those start with
147
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").
148
195
  {
149
196
  name: "ask",
150
- re: new RegExp(`^(?:does|do)\\s+(.+?)\\s+(${VERB_ALT})\\s+(.+?)\\??$`, "i"),
197
+ re: new RegExp(`^(?:does|do|did)\\s+(.+?)\\s+(${VERB_ALT})\\s+(.+?)\\??$`, "i"),
151
198
  build: (m) => ({
152
199
  shape: "ask", entityType: null, modifier: "direct",
153
200
  kind: VERB_TO_KIND[m[2].toLowerCase()], subject: m[1].trim(), object: m[3].trim(),
@@ -166,9 +213,10 @@ const TEMPLATES = [
166
213
  }),
167
214
  },
168
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").
169
217
  {
170
218
  name: "forward",
171
- re: new RegExp(`^what\\s+(?:does|do)\\s+(.+?)\\s+(${VERB_ALT})\\??$`, "i"),
219
+ re: new RegExp(`^what\\s+(?:does|do|did)\\s+(.+?)\\s+(${VERB_ALT})\\??$`, "i"),
172
220
  build: (m) => ({
173
221
  shape: "forward", entityType: null, modifier: "direct",
174
222
  kind: VERB_TO_KIND[m[2].toLowerCase()], object: m[1].trim(),
@@ -197,14 +245,48 @@ const TEMPLATES = [
197
245
  re: new RegExp(`^what\\s+(?:is|are)\\s+(?:an?)\\s+(.+?)\\??$`, "i"),
198
246
  build: (m) => ({ shape: "meta", entityType: null, modifier: "direct", kind: "meta", object: m[1].trim() }),
199
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
+ },
200
277
  ];
201
278
 
202
- /** Strategy 1: the original P0 anchored grammar, unmodified in behavior the
203
- * whole (normalized) string must match one of TEMPLATES start-to-end. Pure. */
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. */
204
283
  function parseAnchored(text) {
205
284
  for (const t of TEMPLATES) {
206
285
  const m = text.match(t.re);
207
- if (m) return t.build(m);
286
+ if (m) {
287
+ const parsed = t.build(m);
288
+ if (parsed) return parsed;
289
+ }
208
290
  }
209
291
  return null;
210
292
  }
@@ -217,26 +299,112 @@ function parseAnchored(text) {
217
299
 
218
300
  const STOPWORDS = new Set([
219
301
  "what", "who", "which", "where", "when", "why", "how",
220
- "does", "do", "is", "are", "the", "a", "an", "of", "to", "from", "at", "in", "on",
302
+ "does", "do", "did", "is", "are", "was", "were", "the", "a", "an", "of", "to", "from", "at", "in", "on",
221
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",
222
308
  ]);
223
309
 
224
310
  /** Find the longest phrase from `table`'s keys that appears as a contiguous
225
311
  * run of `words` (case already lowercased by the caller). Longest-match-first
226
312
  * (multi-word phrases before single words) so "co-changes with" isn't
227
- * shadowed by a shorter unrelated word. Returns {kind, start, end} (end
228
- * exclusive) or null. */
229
- function findPhrase(lcWords, table) {
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) {
230
319
  const phrases = Object.keys(table).sort((a, b) => b.split(" ").length - a.split(" ").length);
231
320
  for (const p of phrases) {
232
321
  const pWords = p.split(" ");
233
322
  for (let i = 0; i <= lcWords.length - pWords.length; i += 1) {
323
+ if (consumed && pWords.some((_, j) => consumed.has(i + j))) continue;
234
324
  if (pWords.every((w, j) => lcWords[i + j] === w)) return { kind: table[p], start: i, end: i + pWords.length };
235
325
  }
236
326
  }
237
327
  return null;
238
328
  }
239
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
+
240
408
  /** Strategy 2: scan (already-normalized) text for a verb keyword anywhere,
241
409
  * plus optional entity/modifier keywords anywhere, then split whatever's
242
410
  * left (after removing the matched spans + stopwords) into the words BEFORE
@@ -250,21 +418,95 @@ function findPhrase(lcWords, table) {
250
418
  * term is left as plain text — resolveTermOrContext (traverse-time)
251
419
  * recognizes it against an optional contextId, so no separate flag is
252
420
  * needed here. A misparse here costs nothing beyond an honest object-miss
253
- * downstream (resolveObject never guesses). Pure. */
254
- function parseKeywordSpot(text) {
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) {
255
441
  // Strip a trailing "?" (mirrors the anchored templates' own `\??$`) and turn commas into
256
442
  // pauses/spaces — but NEVER strip a mid-word ".": object terms are routinely dotted file/module
257
443
  // names ("a.py", "utils.mjs"), and the anchored strategy captures those raw, so keyword-spot
258
444
  // must too or the two strategies would "disagree" over a period that was never part of the intent.
259
445
  const words = text.replace(/\?+\s*$/, "").replace(/,/g, " ").split(/\s+/).filter(Boolean);
260
446
  const lcWords = words.map((w) => w.toLowerCase());
261
- const verbHit = findPhrase(lcWords, VERB_TO_KIND);
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
+ }
262
483
  if (!verbHit) return null;
263
- const entityHit = findPhrase(lcWords, ENTITY_TO_TYPE);
264
- const modifierHit = findPhrase(lcWords, MODIFIER_TO_KIND);
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
+ }
265
503
  const consumed = new Set();
266
504
  const mark = (hit) => { if (hit) for (let i = hit.start; i < hit.end; i += 1) consumed.add(i); };
267
- mark(verbHit); mark(entityHit); mark(modifierHit);
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);
268
510
  const sideText = (from, to) => words
269
511
  .slice(from, to)
270
512
  .filter((_, j) => !consumed.has(from + j) && !STOPWORDS.has(lcWords[from + j]))
@@ -273,12 +515,28 @@ function parseKeywordSpot(text) {
273
515
  const beforeText = sideText(0, verbHit.start);
274
516
  const afterText = sideText(verbHit.end, words.length);
275
517
  const kind = verbHit.kind;
276
- const entityType = entityHit ? ENTITY_TO_TYPE[lcWords.slice(entityHit.start, entityHit.end).join(" ")] : null;
277
- const modifier = modifierHit ? MODIFIER_TO_KIND[lcWords.slice(modifierHit.start, modifierHit.end).join(" ")] : "direct";
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
+ }
278
531
 
279
532
  if (beforeText && afterText) return { shape: "ask", entityType: null, modifier: "direct", kind, subject: beforeText, object: afterText };
280
533
  if (afterText) return { shape: "reverse", entityType, modifier, kind, object: afterText };
281
- if (beforeText) return { shape: "forward", entityType: null, modifier: "direct", kind, object: beforeText };
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 };
282
540
  return null;
283
541
  }
284
542
 
@@ -290,7 +548,20 @@ const STRATEGIES = [
290
548
  { name: "keyword-spot", parse: parseKeywordSpot },
291
549
  ];
292
550
 
293
- const cmpTerm = (s) => String(s || "").trim().toLowerCase().replace(/\s+/g, " ");
551
+ /** The default lemma/POS adapter: wink-nlp when this is a Node process with the
552
+ * optional deps installed, null otherwise. BOUNDARY (see the import comment):
553
+ * the inlined viewer bundle strips the ask-nlp.mjs import, so `nlpAdapter` is
554
+ * an UNDECLARED identifier there — `typeof` reads it without throwing and the
555
+ * browser path degrades to no adapter, same parse pipeline otherwise. */
556
+ function defaultNlp() {
557
+ return typeof nlpAdapter === "function" ? nlpAdapter() : null;
558
+ }
559
+
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}$)/, "");
294
565
 
295
566
  /** Do two independently-produced parses mean the same graph query? Same
296
567
  * shape, same relation kind, and matching term(s) (both subject and object
@@ -310,13 +581,27 @@ function sameParse(p, q) {
310
581
  * either"). When both parse but DISAGREE (different shape/kind/term),
311
582
  * returns {ambiguousParse: true, candidates: [...]} — a genuine "this could
312
583
  * mean more than one thing" case, distinct from resolveObject's later
313
- * object-resolution ambiguity. Pure. */
314
- export function parseQuery(query) {
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. */
588
+ export function parseQuery(query, { nlp = undefined } = {}) {
589
+ const adapter = nlp === undefined ? defaultNlp() : nlp;
315
590
  const raw = String(query || "").trim().replace(/\s+/g, " ");
316
591
  if (!raw) return null;
317
592
  const text = applyNegationFrames(normalizeQuery(raw));
318
593
  if (!text) return null;
319
- const hits = STRATEGIES.map((s) => ({ name: s.name, parsed: s.parse(text) })).filter((r) => r.parsed);
594
+ // COMPOSITIONAL PARSE PATH (PLAN §5.16 P3) the new PRIMARY layer: a recursive
595
+ // descent over CLAUSES for the compositional shapes (nested/relative, boolean,
596
+ // qualifiers, aggregates, superlatives, anaphora). It fires ONLY when a
597
+ // compositional MARKER is present and returns null otherwise, so every plain
598
+ // clause falls straight through to the unchanged two-strategy merge below — the
599
+ // whole existing grammar is preserved bit-for-bit. When a marker IS present but
600
+ // the phrase cannot be compiled, it returns an honest {node:"miss"} rather than
601
+ // letting keyword-spot guess at a composition it never expressed.
602
+ const composite = parseComposite(text, adapter);
603
+ if (composite) return composite;
604
+ const hits = STRATEGIES.map((s) => ({ name: s.name, parsed: s.parse(text, adapter) })).filter((r) => r.parsed);
320
605
  if (hits.length === 0) return null;
321
606
  if (hits.length === 1) return hits[0].parsed;
322
607
  const [a, b] = hits;
@@ -324,10 +609,586 @@ export function parseQuery(query) {
324
609
  return { ambiguousParse: true, candidates: hits.map((h) => h.parsed) };
325
610
  }
326
611
 
612
+ // ============================================================================
613
+ // §compositional grammar (PLAN §5.16 P3) — the step up from ELIZA keyword-
614
+ // spotting to a real recursive-descent grammar. Tokenize -> recursive-descent
615
+ // parse to an AST of nodes -> compile to graph traversal. The AST node shapes
616
+ // (all carry a `node` tag so traverse()/render() can branch without touching the
617
+ // simple-clause path):
618
+ // {node:"clause", clause} — a wrapped simple parse (the leaf)
619
+ // {node:"allOfClass", entityType} — every individual of a class
620
+ // {node:"reverseSet"|"forwardSet", kind, entityType, inner} — nested/relative:
621
+ // the OBJECT (reverse) / SUBJECT (forward) of the outer edge is the id-set
622
+ // produced by evaluating `inner` (another AST) — two-stage traversal.
623
+ // {node:"membership", entityType, term} — "<entity> of/in <term>"
624
+ // {node:"qualifier", filters:[word…], inner} — adjective post-filters on a set
625
+ // {node:"boolean", entityType, atoms:[{op,kind,ast|filters}…]} — set algebra
626
+ // over the SAME subject (and/or/but-not); op ∈ seed/intersection/union/difference
627
+ // {node:"count", entityType, base} — aggregate: |eval(base)|
628
+ // {node:"superlative", entityType, metric, metricNoun, extreme} — rank by degree
629
+ // {node:"anaphora", mode, filter} — over ask()'s `prev` id array
630
+ // {node:"miss", reason} — a compositional marker was seen
631
+ // but could not compile: an honest stated miss, never a guess.
632
+ // The grammar COMPOSES the closed vocabulary (ask-vocab.mjs); it never opens it —
633
+ // every leaf still resolves through the existing curated clause parser + tiered
634
+ // resolveObject, so a term it can't resolve is still an honest object-miss.
635
+ // ============================================================================
636
+
637
+ // Depth cap on nesting (PLAN P3: "depth ≥2 nesting; guard against runaway with a
638
+ // sane hop cap and an honest 'too deep to resolve' if exceeded").
639
+ const MAX_COMPOSE_DEPTH = 4;
640
+ // A resolvable-later placeholder object term for the OUTER clause of a nested
641
+ // parse: the outer clause is parsed normally (so its verb/shape/grain classify),
642
+ // then its `object` is discarded and replaced at eval time by the inner set. Chosen
643
+ // to be plainly alphabetic (not a stopword, not vocabulary) so the clause parser
644
+ // treats it as an ordinary object term rather than dropping it.
645
+ const NEST_SENTINEL = "zzinnerset";
646
+ // Filler words dropped at the front of a relative predicate / anaphora filter.
647
+ const PRED_LEAD_SKIP = new Set(["that", "which", "who", "are", "is", "was", "were", "do", "does", "also", "still", "both", "and"]);
648
+ const FRAME_WORDS = new Set(["which", "what", "who", "list", "show", "find", "give", "me", "us", "all"]);
649
+
650
+ const splitWords = (text) => String(text).replace(/\?+\s*$/, "").replace(/,/g, " ").split(/\s+/).filter(Boolean);
651
+ const entityNoun = (w) => (ENTITY_TO_TYPE[w] ? { entityType: ENTITY_TO_TYPE[w], placeholder: false }
652
+ : (PLACEHOLDER_NOUNS.includes(w) ? { entityType: null, placeholder: true } : null));
653
+ const isGerundVerb = (w) => !!VERB_TO_KIND[w] && w.endsWith("ing");
654
+
655
+ /** Run the two existing strategies on a FRAGMENT and return a single simple clause
656
+ * (or null). Deterministic tie-break: on strategy disagreement the anchored parse
657
+ * wins (STRATEGIES[0]) — a fragment fed from the composer is already shape-
658
+ * constrained, so the merge's "surface an ambiguity" behavior isn't wanted here. */
659
+ function parseSimpleClause(text, nlp) {
660
+ const hits = STRATEGIES.map((s) => s.parse(text, nlp)).filter(Boolean);
661
+ if (!hits.length) return null;
662
+ if (hits.length === 1) return hits[0];
663
+ return sameParse(hits[0], hits[1]) ? hits[0] : hits[0];
664
+ }
665
+
666
+ /** Top compositional dispatcher — first marker-matching production wins; a
667
+ * production returns null (not this shape → fall through) or an AST node (which
668
+ * may itself be {node:"miss"} when the marker was present but uncompilable). */
669
+ function parseComposite(text, nlp) {
670
+ const w = splitWords(text);
671
+ const lc = w.map((x) => x.toLowerCase());
672
+ return parseAnaphora(w, lc, nlp)
673
+ || parseAggregate(w, lc, nlp)
674
+ || parseSuperlative(w, lc, nlp)
675
+ || parseNested(w, lc, nlp, 0)
676
+ || parseRelationalOrQualified(w, lc, nlp, 0);
677
+ }
678
+
679
+ /** A set-producing sub-expression (used for nested inner clauses, boolean branches,
680
+ * and count restrictors): nested first, then the relational/qualifier/boolean
681
+ * parser, then a bare simple clause. Carries `depth` for the nesting cap. */
682
+ function parseSetPhrase(text, nlp, depth) {
683
+ if (depth > MAX_COMPOSE_DEPTH) return { node: "miss", reason: "too deep to resolve" };
684
+ const w = splitWords(text);
685
+ const lc = w.map((x) => x.toLowerCase());
686
+ const nested = parseNested(w, lc, nlp, depth);
687
+ if (nested) return nested;
688
+ const rel = parseRelationalOrQualified(w, lc, nlp, depth);
689
+ if (rel) return rel;
690
+ const clause = parseSimpleClause(text, nlp);
691
+ if (clause) return { node: "clause", clause };
692
+ return null;
693
+ }
694
+
695
+ /** NESTED / RELATIVE (object-position relative clause): "<outer verb> <placeholder
696
+ * |entity> that <inner>" — the noun before "that" is the OBJECT of the outer edge,
697
+ * constrained by the inner clause. Distinguished from a subject-relative ("functions
698
+ * that call X", handled by parseRelationalOrQualified) by requiring a VERB before the
699
+ * relative noun (i.e. the noun is not the leading subject). Returns a reverse/forward
700
+ * Set node, an honest miss (marker present, uncompilable), or null (no object-relative
701
+ * marker → let another production try). */
702
+ function parseNested(w, lc, nlp, depth) {
703
+ for (let r = 1; r < lc.length; r += 1) {
704
+ if (!RELATIVE_PRONOUNS.includes(lc[r])) continue;
705
+ if (r + 1 >= lc.length) continue; // nothing after "that"
706
+ const noun = entityNoun(lc[r - 1]);
707
+ if (!noun) continue; // "that" not preceded by a noun
708
+ const head = w.slice(0, r - 1); // outer clause words, minus the placeholder noun
709
+ if (!head.length) continue; // noun is the leading subject → subject-relative, not this shape
710
+ const outer = parseSimpleClause([...head, NEST_SENTINEL].join(" "), nlp);
711
+ if (!outer || (outer.shape !== "reverse" && outer.shape !== "forward")) continue;
712
+ if (outer.modifier && outer.modifier !== "direct") continue; // no transitive-over-set closure primitive
713
+ // build the inner sub-query: "which <placeholder-noun> <inner-text>" — recurses,
714
+ // so the inner may itself be nested/boolean/qualified (depth ≥2).
715
+ const innerText = `which ${lc[r - 1]} ${w.slice(r + 1).join(" ")}`;
716
+ const inner = parseSetPhrase(innerText, nlp, depth + 1);
717
+ if (!inner || inner.node === "miss") return inner ? { node: "miss", reason: inner.reason || "inner clause didn't parse" } : { node: "miss", reason: "inner clause didn't parse" };
718
+ return { node: outer.shape === "reverse" ? "reverseSet" : "forwardSet", kind: outer.kind, entityType: outer.entityType, inner };
719
+ }
720
+ return null;
721
+ }
722
+
723
+ /** ANAPHORA over the previous result set: "which of those/them <filter>", "how many
724
+ * of those <filter>". Requires "of <pronoun>" (so a bare "those" in a term never
725
+ * fires). Returns a {node:"anaphora"} (mode count|list), a miss (filter present but
726
+ * uncompilable), or null. */
727
+ function parseAnaphora(w, lc, nlp) {
728
+ let p = -1;
729
+ for (let i = 1; i < lc.length; i += 1) {
730
+ if (ANAPHORA_TRIGGERS.includes(lc[i]) && lc[i - 1] === "of") { p = i; break; }
731
+ }
732
+ if (p < 0) return null;
733
+ const head = lc.slice(0, p - 1).join(" ");
734
+ const mode = /^(how many|how much|count)\b/.test(head) ? "count" : "list";
735
+ const filter = parsePredicateFilter(w.slice(p + 1), nlp);
736
+ if (filter === undefined) return { node: "miss", reason: "the follow-up filter didn't parse" };
737
+ return { node: "anaphora", mode, filter };
738
+ }
739
+
740
+ /** Parse a trailing filter (for anaphora, and any "of those that …" tail) into
741
+ * {type:"all"} | {type:"qual", filters} | {type:"clause", clause}. Returns
742
+ * undefined when a non-empty filter cannot be compiled (an honest miss upstream). */
743
+ function parsePredicateFilter(words, nlp) {
744
+ let i = 0;
745
+ const lc = words.map((x) => x.toLowerCase());
746
+ while (i < lc.length && PRED_LEAD_SKIP.has(lc[i])) i += 1;
747
+ const rest = words.slice(i);
748
+ const restLc = lc.slice(i);
749
+ if (!rest.length) return { type: "all" };
750
+ if (restLc.every((x) => QUALIFIERS[x])) return { type: "qual", filters: restLc };
751
+ const clause = parseSimpleClause(`what ${rest.join(" ")}`, nlp);
752
+ if (clause && (clause.shape === "reverse" || clause.shape === "forward") && clause.object) {
753
+ return { type: "clause", clause };
754
+ }
755
+ return undefined;
756
+ }
757
+
758
+ /** AGGREGATE / COUNT: "how many <entity> [<restrictor>]", "count <entity>",
759
+ * "number of <entity> that …". A bare "how many classes" counts the class of
760
+ * individuals; a restrictor tail counts a clause's result set. */
761
+ function parseAggregate(w, lc, nlp) {
762
+ const trig = AGGREGATE_TRIGGERS.find((t) => lc.slice(0, t.split(" ").length).join(" ") === t);
763
+ if (!trig) return null;
764
+ let i = trig.split(" ").length;
765
+ while (i < lc.length && (lc[i] === "the" || lc[i] === "a" || lc[i] === "all")) i += 1;
766
+ const quals = [];
767
+ while (i < lc.length && QUALIFIERS[lc[i]]) { quals.push(lc[i]); i += 1; }
768
+ const noun = i < lc.length ? entityNoun(lc[i]) : null;
769
+ if (!noun) return { node: "miss", reason: "count needs a known entity kind (functions, classes, modules, …)" };
770
+ const entWord = lc[i];
771
+ i += 1;
772
+ const tail = w.slice(i);
773
+ let base;
774
+ if (tail.length) {
775
+ const setAst = parseSetPhrase(`which ${entWord} ${tail.join(" ")}`, nlp, 1);
776
+ if (!setAst || setAst.node === "miss") return { node: "miss", reason: "the count restrictor didn't parse" };
777
+ base = setAst;
778
+ } else {
779
+ base = { node: "allOfClass", entityType: noun.entityType };
780
+ }
781
+ if (quals.length) base = { node: "qualifier", filters: quals, inner: base };
782
+ return { node: "count", entityType: noun.entityType, base };
783
+ }
784
+
785
+ /** SUPERLATIVE: "which <entity> has the most/fewest <edge-noun>", "the most-connected
786
+ * <entity>", "the largest <entity>". Ranks individuals of <entity> by a degree
787
+ * metric over the classified edge groups. An unrecognized edge noun is an honest
788
+ * miss naming the supported ones. */
789
+ function parseSuperlative(w, lc, nlp) {
790
+ // extreme (single word, or "most connected" two-word)
791
+ let ext = null; let extIdx = -1;
792
+ for (let i = 0; i < lc.length; i += 1) {
793
+ const two = lc.slice(i, i + 2).join(" ");
794
+ if (SUPERLATIVE_EXTREMES[two]) { ext = SUPERLATIVE_EXTREMES[two]; extIdx = i; break; }
795
+ if (SUPERLATIVE_EXTREMES[lc[i]]) { ext = SUPERLATIVE_EXTREMES[lc[i]]; extIdx = i; break; }
796
+ }
797
+ if (!ext) return null;
798
+ // entity noun anywhere (first match, deterministic)
799
+ let entityType; let entWord = null;
800
+ for (const x of lc) { const n = entityNoun(x); if (n && !n.placeholder) { entityType = n.entityType; entWord = x; break; } }
801
+ if (!entWord) return { node: "miss", reason: "a superlative needs an entity kind (module, class, function, …)" };
802
+ // edge noun after the extreme (imports/callers/methods/…)
803
+ let metric = null; let metricNoun = null;
804
+ for (let i = extIdx; i < lc.length; i += 1) {
805
+ if (EDGE_NOUN_TO_METRIC[lc[i]]) { metric = EDGE_NOUN_TO_METRIC[lc[i]]; metricNoun = lc[i]; break; }
806
+ }
807
+ const connectivity = lc.includes("connected") || lc.slice(extIdx, extIdx + 2).join(" ") === "most connected"
808
+ || ["largest", "biggest", "smallest"].includes(lc[extIdx]);
809
+ if (!metric) {
810
+ if (connectivity) { metric = EDGE_NOUN_TO_METRIC.connections; metricNoun = "connections"; }
811
+ else return { node: "miss", reason: "name what to rank by (imports, callers, methods, tests, or connections)" };
812
+ }
813
+ return { node: "superlative", entityType, metric, metricNoun, extreme: ext };
814
+ }
815
+
816
+ /** RELATIONAL / BOOLEAN / QUALIFIER (subject-first): "[which] [<qualifier>…] <entity>
817
+ * [that] <predicate>", where <predicate> is one or more relation clauses joined by
818
+ * and/or/but-not over the SAME subject, a "<of|in> <term>" membership, or empty (a
819
+ * bare qualified class). Fires ONLY on a compositional marker — a leading qualifier,
820
+ * a relative pronoun, a gerund-led predicate, or a membership "of/in" — so a plain
821
+ * reverse query ("which functions call helper") and the bare-template ambiguous case
822
+ * ("which classes extends Base and couples to logging", no marker) both fall through
823
+ * to the existing strategies untouched. Returns an AST node, a miss, or null. */
824
+ function parseRelationalOrQualified(w, lc, nlp, depth) {
825
+ let i = 0;
826
+ while (i < lc.length && FRAME_WORDS.has(lc[i])) i += 1;
827
+ const framed = i > 0;
828
+ const quals = [];
829
+ while (i < lc.length && QUALIFIERS[lc[i]]) { quals.push(lc[i]); i += 1; }
830
+ const noun = i < lc.length ? entityNoun(lc[i]) : null;
831
+ if (!noun) {
832
+ // an unknown adjective sitting in the qualifier slot, right before a known
833
+ // entity noun ("which shiny methods", "static frobnicated functions") — an
834
+ // honest miss that NAMES the supported qualifiers, never a guess (PLAN P3).
835
+ // STOPWORDS are excluded so a normal question auxiliary in that position ("what
836
+ // DID commit X touch", "what WAS in <sha>") is left for the existing parser, not
837
+ // mistaken for an unknown qualifier.
838
+ if ((framed || quals.length) && i + 1 < lc.length && /^[a-z]+$/.test(lc[i])
839
+ && !VERB_TO_KIND[lc[i]] && !STOPWORDS.has(lc[i]) && entityNoun(lc[i + 1])) {
840
+ return { node: "miss", reason: `unknown qualifier "${lc[i]}" — supported: ${Object.keys(QUALIFIERS).join(", ")}` };
841
+ }
842
+ return null; // no subject entity → not this shape
843
+ }
844
+ const entityType = noun.entityType;
845
+ const entWord = lc[i];
846
+ i += 1;
847
+ let predLc = lc.slice(i);
848
+ let predWords = w.slice(i);
849
+ let relFlag = false;
850
+ if (predLc.length && RELATIVE_PRONOUNS.includes(predLc[0])) { relFlag = true; predLc = predLc.slice(1); predWords = predWords.slice(1); }
851
+ const membershipLed = predLc[0] === "of" || predLc[0] === "in";
852
+ const gerundLed = predLc.length > 0 && isGerundVerb(predLc[0]);
853
+ // marker gate — the crux of backward-compat: without one of these, this is not a
854
+ // compositional query and we must NOT hijack it from the existing parser.
855
+ if (!(quals.length || relFlag || membershipLed || gerundLed)) return null;
856
+
857
+ // empty predicate → a bare qualified class ("public methods")
858
+ if (!predWords.length) {
859
+ let base = { node: "allOfClass", entityType };
860
+ if (!quals.length) return { node: "miss", reason: "nothing to filter or traverse" };
861
+ return { node: "qualifier", filters: quals, inner: base };
862
+ }
863
+
864
+ const subjPrefix = noun.placeholder ? "what" : `which ${entWord}`;
865
+ const { branches, ops } = splitBoolean(predLc, predWords);
866
+ // build one atom per branch, borrowing a leading verb phrase across bare branches
867
+ // ("importing X or Y" → the second branch inherits "importing").
868
+ let prevVerb = null;
869
+ const atoms = [];
870
+ for (let b = 0; b < branches.length; b += 1) {
871
+ const bw = branches[b];
872
+ const blc = bw.map((x) => x.toLowerCase());
873
+ const op = b === 0 ? "seed" : ops[b - 1];
874
+ if (bw.length && blc.every((x) => QUALIFIERS[x])) { atoms.push({ op, kind: "qual", filters: blc }); continue; }
875
+ if (blc[0] === "of" || blc[0] === "in") {
876
+ atoms.push({ op, kind: "set", ast: { node: "membership", entityType, term: bw.slice(1).join(" ") } });
877
+ continue;
878
+ }
879
+ let phrase = bw;
880
+ const vh = findPhrase(blc, VERB_TO_KIND);
881
+ if (vh) prevVerb = bw.slice(vh.start, vh.end);
882
+ else if (prevVerb) phrase = [...prevVerb, ...bw];
883
+ // a branch is a single predicate (top-level booleans are already split out), so
884
+ // parse it as nested-or-simple — NOT back through parseSetPhrase, which would
885
+ // re-detect the branch's own gerund/relative lead and recurse on identical text.
886
+ const ast = parseBranchAst(`${subjPrefix} ${phrase.join(" ")}`, nlp, depth + 1);
887
+ if (!ast || ast.node === "miss") return { node: "miss", reason: (ast && ast.reason) || "a clause in the combination didn't parse" };
888
+ atoms.push({ op, kind: "set", ast });
889
+ }
890
+ // the first atom must be a base set, not a bare qualifier (a qualifier needs
891
+ // something to filter). "public methods" already took the empty-predicate path above.
892
+ if (atoms[0].kind !== "set") return { node: "miss", reason: "start with a clause, then combine with and/or/but-not" };
893
+
894
+ let result;
895
+ if (atoms.length === 1) {
896
+ result = atoms[0].ast;
897
+ } else {
898
+ result = { node: "boolean", entityType, atoms };
899
+ }
900
+ if (quals.length) result = { node: "qualifier", filters: quals, inner: result };
901
+ return result;
902
+ }
903
+
904
+ /** Parse a single boolean branch (one predicate over the subject) into a set-AST:
905
+ * nested (the branch has its own object-relative "that") or a plain simple clause.
906
+ * Deliberately does NOT re-enter parseRelationalOrQualified — the branch has no
907
+ * top-level boolean of its own (it was just split off one), so descending there
908
+ * would only re-detect its gerund/relative lead and recurse on the same text. */
909
+ function parseBranchAst(text, nlp, depth) {
910
+ if (depth > MAX_COMPOSE_DEPTH) return { node: "miss", reason: "too deep to resolve" };
911
+ const w = splitWords(text);
912
+ const lc = w.map((x) => x.toLowerCase());
913
+ const nested = parseNested(w, lc, nlp, depth);
914
+ if (nested) return nested;
915
+ const clause = parseSimpleClause(text, nlp);
916
+ return clause ? { node: "clause", clause } : null;
917
+ }
918
+
919
+ /** Split a predicate word array on boolean connectives (longest key first, so
920
+ * "but not" beats a bare "not"). Returns {branches:[[word…]…], ops:[op…]} with
921
+ * branches.length === ops.length + 1. */
922
+ function splitBoolean(predLc, predWords) {
923
+ const conns = Object.keys(BOOLEAN_CONNECTIVES).sort((a, z) => z.split(" ").length - a.split(" ").length);
924
+ const branches = []; const ops = [];
925
+ let start = 0; let i = 0;
926
+ while (i < predLc.length) {
927
+ let hit = null;
928
+ for (const c of conns) {
929
+ const cw = c.split(" ");
930
+ if (predLc.slice(i, i + cw.length).join(" ") === c) { hit = { c, len: cw.length }; break; }
931
+ }
932
+ if (hit && i > start) { // a connective, and not at a branch start (avoid leading "and")
933
+ branches.push(predWords.slice(start, i));
934
+ ops.push(BOOLEAN_CONNECTIVES[hit.c]);
935
+ i += hit.len; start = i;
936
+ } else if (hit) { i += hit.len; start = i; } // connective at branch start — skip it
937
+ else i += 1;
938
+ }
939
+ branches.push(predWords.slice(start));
940
+ return { branches, ops };
941
+ }
942
+
943
+ // ---- compositional EVALUATION — compile an AST to a graph traversal, reusing the
944
+ // same primitives (edgesOfKind, resolveObject, refineToEntities, traverse) the
945
+ // simple path uses. Pure given (graph, ast, opts). ----
946
+
947
+ /** Reverse traversal over a SET of object ids (the nested "callers of {X…}" step) —
948
+ * mirrors traverse()'s reverse general case (symbol-grain sibling + defines-refine),
949
+ * but membership-tests e.object against a set instead of a single id. */
950
+ function reverseOverSet(graph, kind, entityType, objectIds) {
951
+ const symbolKind = SYMBOL_GRAIN_SIBLING[kind];
952
+ if (symbolKind && FINE_ENTITY_TYPES.has(entityType)) {
953
+ const edges = edgesOfKind(graph, symbolKind).filter((e) => objectIds.has(e.object));
954
+ return uniqueById(edges.map((e) => graph.byId.get(e.subject)).filter((s) => s && s.class === entityType));
955
+ }
956
+ const edges = kindsFor(kind).flatMap((k) => edgesOfKind(graph, k)).filter((e) => objectIds.has(e.object));
957
+ const subjects = uniqueById(edges.map((e) => graph.byId.get(e.subject)).filter(Boolean));
958
+ if (!entityType || entityType === "Change") return subjects;
959
+ const direct = subjects.filter((s) => s.class === entityType);
960
+ if (direct.length) return direct;
961
+ if (entityType !== "Module" && subjects.some((s) => s.class === "Module")) {
962
+ return refineToEntities(graph, new Set(subjects.filter((s) => s.class === "Module").map((s) => s.id)), entityType);
963
+ }
964
+ return [];
965
+ }
966
+
967
+ /** Forward traversal over a SET of subject ids (the "things {X…} call/define" step). */
968
+ function forwardOverSet(graph, kind, subjectIds) {
969
+ const edges = kindsFor(kind).flatMap((k) => edgesOfKind(graph, k)).filter((e) => subjectIds.has(e.subject));
970
+ return uniqueById(edges.map((e) => graph.byId.get(e.object)).filter(Boolean));
971
+ }
972
+
973
+ function uniqueById(inds) {
974
+ const seen = new Set(); const out = [];
975
+ for (const x of inds) if (x && !seen.has(x.id)) { seen.add(x.id); out.push(x); }
976
+ return out;
977
+ }
978
+
979
+ // Per-graph memo for the qualifier attribute/edge sets (exported symbols, tested
980
+ // modules, symbol→module map) — computed once, so a qualifier filter over a large
981
+ // result set stays cheap and deterministic.
982
+ const qualCache = new WeakMap();
983
+ function qualSets(graph) {
984
+ let c = qualCache.get(graph);
985
+ if (c) return c;
986
+ const exported = new Set();
987
+ for (const e of edgesOfKind(graph, "reexports")) {
988
+ exported.add(String(e.object).toLowerCase());
989
+ const ind = graph.byId.get(e.object);
990
+ if (ind) exported.add(String(ind.label).toLowerCase());
991
+ }
992
+ const testedModules = new Set(edgesOfKind(graph, "tests").map((e) => e.object));
993
+ const moduleOfSymbol = new Map();
994
+ for (const e of edgesOfKind(graph, "defines")) moduleOfSymbol.set(e.object, e.subject);
995
+ c = { exported, testedModules, moduleOfSymbol };
996
+ qualCache.set(graph, c);
997
+ return c;
998
+ }
999
+ function moduleIdOf(graph, ind) {
1000
+ if (!ind) return null;
1001
+ if (ind.class === "Module") return ind.id;
1002
+ return qualSets(graph).moduleOfSymbol.get(ind.id) || null;
1003
+ }
1004
+
1005
+ /** Does an individual satisfy one qualifier (spec from QUALIFIERS)? Reads only
1006
+ * attributes/edges the graph already carries — an unpopulated attribute (e.g.
1007
+ * isAbstract) simply yields false, an honest empty rather than an error. */
1008
+ function qualHolds(graph, ind, spec) {
1009
+ if (!spec) return false;
1010
+ switch (spec.via) {
1011
+ case "visibility": {
1012
+ const v = String((ind.attributes || []).find((a) => a.key === "visibility")?.value || "public").toLowerCase();
1013
+ return v === spec.value;
1014
+ }
1015
+ case "attr":
1016
+ return !!(ind.attributes || []).find((a) => a.key === spec.attr)?.value;
1017
+ case "exported": {
1018
+ const ex = qualSets(graph).exported;
1019
+ return ex.has(String(ind.label).toLowerCase()) || ex.has(String(ind.id).toLowerCase());
1020
+ }
1021
+ case "tested": {
1022
+ const mid = moduleIdOf(graph, ind);
1023
+ return (!!mid && qualSets(graph).testedModules.has(mid)) === spec.value;
1024
+ }
1025
+ default: return false;
1026
+ }
1027
+ }
1028
+
1029
+ /** Compile a set-producing AST into an array of individuals. */
1030
+ function evalSet(graph, ast, opts) {
1031
+ switch (ast.node) {
1032
+ case "clause": return traverse(graph, ast.clause, opts).matches || [];
1033
+ case "allOfClass": return graph.individuals.filter((i) => i.class === ast.entityType);
1034
+ case "reverseSet": {
1035
+ const ids = new Set(evalSet(graph, ast.inner, opts).map((i) => i.id));
1036
+ return reverseOverSet(graph, ast.kind, ast.entityType, ids);
1037
+ }
1038
+ case "forwardSet": {
1039
+ const ids = new Set(evalSet(graph, ast.inner, opts).map((i) => i.id));
1040
+ return forwardOverSet(graph, ast.kind, ids);
1041
+ }
1042
+ case "membership": {
1043
+ const r = resolveObject(graph, ast.term);
1044
+ if (!r.match) return [];
1045
+ const ids = new Set([r.match.id]);
1046
+ const objs = uniqueById(MEMBERSHIP_KINDS.flatMap((k) => forwardOverSet(graph, k, ids)));
1047
+ return ast.entityType ? objs.filter((o) => o.class === ast.entityType) : objs;
1048
+ }
1049
+ case "qualifier": {
1050
+ const base = evalSet(graph, ast.inner, opts);
1051
+ return base.filter((ind) => ast.filters.every((f) => qualHolds(graph, ind, QUALIFIERS[f])));
1052
+ }
1053
+ case "boolean": return evalBoolean(graph, ast, opts);
1054
+ case "anaphora": return evalAnaphora(graph, ast, opts).matches;
1055
+ default: return [];
1056
+ }
1057
+ }
1058
+
1059
+ /** Fold a boolean AST left-to-right into a result set. A qualifier atom acts as a
1060
+ * set filter on the accumulator (intersection keeps satisfiers, difference removes
1061
+ * them); a set atom contributes its own id-set for the op. */
1062
+ function evalBoolean(graph, ast, opts) {
1063
+ let acc = [];
1064
+ for (const atom of ast.atoms) {
1065
+ if (atom.op === "seed") { acc = evalSet(graph, atom.ast, opts); continue; }
1066
+ if (atom.kind === "qual") {
1067
+ const holds = (ind) => atom.filters.every((f) => qualHolds(graph, ind, QUALIFIERS[f]));
1068
+ acc = atom.op === "difference" ? acc.filter((i) => !holds(i)) : acc.filter((i) => holds(i));
1069
+ continue;
1070
+ }
1071
+ const oids = new Set(evalSet(graph, atom.ast, opts).map((i) => i.id));
1072
+ if (atom.op === "intersection") acc = acc.filter((i) => oids.has(i.id));
1073
+ else if (atom.op === "difference") acc = acc.filter((i) => !oids.has(i.id));
1074
+ else if (atom.op === "union") {
1075
+ const seen = new Set(acc.map((i) => i.id));
1076
+ for (const other of evalSet(graph, atom.ast, opts)) if (!seen.has(other.id)) { seen.add(other.id); acc.push(other); }
1077
+ }
1078
+ }
1079
+ return acc;
1080
+ }
1081
+
1082
+ /** Anaphora over ask()'s `prev` id array — filter/count the previous answer's ids.
1083
+ * No prev supplied → honest miss (never a guess), like an unresolved pronoun. */
1084
+ function evalAnaphora(graph, ast, opts) {
1085
+ const prev = opts && opts.prev;
1086
+ if (!Array.isArray(prev) || !prev.length) return { compositeMiss: true, reason: "no-prev", matches: [] };
1087
+ let items = prev.map((id) => graph.byId.get(id)).filter(Boolean);
1088
+ const f = ast.filter;
1089
+ if (f && f.type === "qual") {
1090
+ items = items.filter((ind) => f.filters.every((q) => qualHolds(graph, ind, QUALIFIERS[q])));
1091
+ } else if (f && f.type === "clause") {
1092
+ const r = resolveObject(graph, f.clause.object);
1093
+ if (!r.match) items = [];
1094
+ else {
1095
+ // include the symbol-grain sibling so a fn->fn "call" filter tests callsSymbol,
1096
+ // not just the module-coarse "calls" edge (mirrors traverse()'s reverse path).
1097
+ const sib = SYMBOL_GRAIN_SIBLING[f.clause.kind];
1098
+ const kinds = [...kindsFor(f.clause.kind), ...(sib ? [sib] : [])];
1099
+ const ok = new Set(kinds.flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.object === r.match.id).map((e) => e.subject));
1100
+ items = items.filter((ind) => ok.has(ind.id));
1101
+ }
1102
+ }
1103
+ // a count over a prior set names the entity kind when the survivors share a class.
1104
+ const common = items.length && items.every((x) => x.class === items[0].class) ? items[0].class : null;
1105
+ if (ast.mode === "count") return { compositeKind: "count", count: items.length, entityType: common, matches: [] };
1106
+ return { compositeKind: "set", matches: items, entityType: common };
1107
+ }
1108
+
1109
+ // Structural kinds counted for "most-connected" (total degree). Symbol-grain and
1110
+ // commit-history kinds are excluded so "connections" reads as the code-structure
1111
+ // degree a developer means, not every recorded touch.
1112
+ const DEGREE_KINDS = ["imports", "calls", "callsSymbol", "inherits", "contains", "tests"];
1113
+ /** Degree of an individual under a superlative metric ({kind, dir, sibling?, filter?}). */
1114
+ function degreeMetric(graph, ind, metric) {
1115
+ const kinds = metric.kind === "*" ? DEGREE_KINDS : [metric.kind, ...(metric.sibling ? [metric.sibling] : [])];
1116
+ let n = 0;
1117
+ for (const k of kinds) for (const e of edgesOfKind(graph, k)) {
1118
+ const out = e.subject === ind.id; const inc = e.object === ind.id;
1119
+ if (metric.dir === "out" && out) {
1120
+ if (metric.filter) { const o = graph.byId.get(e.object); if (!o || o.class !== metric.filter) continue; }
1121
+ n += 1;
1122
+ } else if (metric.dir === "in" && inc) n += 1;
1123
+ else if (metric.dir === "both" && (out || inc)) n += 1;
1124
+ }
1125
+ return n;
1126
+ }
1127
+ function evalSuperlative(graph, ast) {
1128
+ const pool = graph.individuals.filter((i) => i.class === ast.entityType);
1129
+ const scored = pool.map((ind) => ({ ind, score: degreeMetric(graph, ind, ast.metric) }))
1130
+ .sort((a, z) => (ast.extreme === "most" ? z.score - a.score : a.score - z.score));
1131
+ if (!scored.length) return { compositeKind: "superlative", entityType: ast.entityType, matches: [] };
1132
+ const best = scored[0].score;
1133
+ const winners = scored.filter((s) => s.score === best).map((s) => s.ind);
1134
+ return { compositeKind: "superlative", entityType: ast.entityType, metricNoun: ast.metricNoun, extreme: ast.extreme, score: best, matches: winners };
1135
+ }
1136
+
1137
+ /** Compile any compositional AST to a result object traverse() returns for the
1138
+ * simple path — {matches, …} plus compositeKind/compositeMiss flags render() reads. */
1139
+ export function evalComposite(graph, ast, opts = {}) {
1140
+ if (ast.node === "miss") return { compositeMiss: true, reason: ast.reason || null, matches: [] };
1141
+ if (ast.node === "count") return { compositeKind: "count", count: evalSet(graph, ast.base, opts).length, entityType: ast.entityType, matches: [] };
1142
+ if (ast.node === "superlative") return evalSuperlative(graph, ast);
1143
+ if (ast.node === "anaphora") return evalAnaphora(graph, ast, opts);
1144
+ return { compositeKind: "set", matches: evalSet(graph, ast, opts), entityType: ast.entityType || null };
1145
+ }
1146
+
1147
+ // ---- compositional RENDER — templated, same "honest miss vs cited hit" discipline
1148
+ // as renderCore. ----
1149
+
1150
+ const compositeList = (matches) => listJoin(matches.slice(0, OVERFLOW_CAP)
1151
+ .map((m) => (["Function", "Method"].includes(m.class) ? `${m.label}()` : m.label)))
1152
+ + (matches.length > OVERFLOW_CAP ? `, …and ${matches.length - OVERFLOW_CAP} more` : "");
1153
+
1154
+ /** A compositional worked example for the rephrase hint (§honest miss now shows a
1155
+ * compositional phrasing too). */
1156
+ export function compositionalHint() {
1157
+ return 'compositional queries also work: "which functions call X and call Y", "what calls something that imports X", "public methods of X", "how many classes", "which module has the most imports", or (after a listing) "which of those are tested"';
1158
+ }
1159
+
1160
+ function renderComposite(parsed, result) {
1161
+ if (result.compositeMiss) {
1162
+ if (result.reason === "no-prev") {
1163
+ return { content: `"those"/"them" needs a previous answer to refer to — ask a listing question first, then follow up.`, miss: true, ambiguous: false };
1164
+ }
1165
+ return { content: `couldn't compile this compositional question${result.reason ? ` (${result.reason})` : ""}. ${compositionalHint()}.`, miss: true, ambiguous: false };
1166
+ }
1167
+ if (result.compositeKind === "count") {
1168
+ const noun = result.entityType ? nounFor(result.entityType, result.count) : (result.count === 1 ? "result" : "results");
1169
+ return { content: `${result.count} ${noun}.`, miss: false, ambiguous: false, matches: [] };
1170
+ }
1171
+ if (result.compositeKind === "superlative") {
1172
+ if (!result.matches.length) return { content: `no ${nounFor(result.entityType, 2)} to rank in this index.`, miss: true, ambiguous: false };
1173
+ const lead = result.extreme === "most" ? "the most" : "the fewest";
1174
+ const tie = result.matches.length > 1 ? ` (${result.matches.length}-way tie)` : "";
1175
+ return {
1176
+ content: `${compositeList(result.matches)} — ${lead} ${result.metricNoun} (${result.score})${tie}.`,
1177
+ miss: false, ambiguous: false, matches: result.matches,
1178
+ };
1179
+ }
1180
+ // set-producing
1181
+ if (!result.matches.length) {
1182
+ return { content: `nothing in the index matches that${result.entityType ? ` (${nounFor(result.entityType, 2)})` : ""}.`, miss: true, ambiguous: false, matches: [] };
1183
+ }
1184
+ return { content: `${compositeList(result.matches)}.`, miss: false, ambiguous: false, matches: result.matches };
1185
+ }
1186
+
327
1187
  /** The rephrase hint shown on a grammar miss — generated from the SAME tables the parser
328
1188
  * uses, so it can never suggest a phrasing the grammar doesn't actually support (§6.3). */
329
1189
  export function rephraseHint() {
330
- return '"which <functions|classes|modules> <imports|calls|inherits from|tests|touched> <name>" or "what does <name> <import|call|inherit from>" or plainly "what calls this" (about a selected node) or "what does <term> mean"/"what is a <ClassName>" (about the graph\'s own vocabulary)';
1190
+ return '"which <functions|classes|modules> <imports|calls|uses|inherits from|tests|touched> <name>" or "what does <name> <import|call|export>" or "what uses <name>" or "where is <name> defined" / "where is <name> mentioned" or "when did <name> change" or "which changes touch commit <sha>"/"what did commit <sha> touch" (a commit\'s own changes) or plainly "what calls this" (about a selected node) or "what does <term> mean"/"what is a <ClassName>" (about the graph\'s own vocabulary). '
1191
+ + compositionalHint();
331
1192
  }
332
1193
 
333
1194
  // ---- §4 object-term resolution — mechanical, no embeddings, tiered, stop at first hit ----
@@ -339,7 +1200,9 @@ function componentSet(s) {
339
1200
  /** Resolve a free-text object/subject term against the graph's individuals, in priority
340
1201
  * order (§4, generalized beyond the module-coupling worked example to cover every verb
341
1202
  * family's object grain — `inherits`/`calls` resolve against Class/Function names, not
342
- * just modules): (1) exact label/id match, (2) an `ext:` unresolved-target match (today
1203
+ * just modules): a sha-shaped term ("[commit ]<hex≥7>") first resolves against Commit
1204
+ * individuals by unique id/label prefix (see the inline comment), then (1) exact
1205
+ * label/id match, (2) an `ext:` unresolved-target match (today
343
1206
  * ext: targets are edge-endpoint STRINGS with no individual of their own — e.g. an
344
1207
  * unimported inherits base, or any import target — so this tier returns a synthetic
345
1208
  * {id:"ext:<name>", label:<name>, class:null} match rather than an `individuals` lookup;
@@ -355,15 +1218,43 @@ function componentSet(s) {
355
1218
  * than the symbol's own name — the render layer does not currently read this (it treats
356
1219
  * a resolved match as a resolved match, same "honest miss vs genuine hit" binary tiers
357
1220
  * 1-3 already use), but the field is there for any caller that wants to surface it. (5)
358
- * no match at all an honest miss. Returns {match, candidates, tier, ambiguous
359
- * [, matchedVia]} — ambiguous on a true tier-3 score tie OR a true tier-4 overlap-count
360
- * tie. */
1221
+ * a bounded Damerau-Levenshtein pass against labels AND label components (two-level
1222
+ * fuzzy, 2026-07-02): a UNIQUE within-bound match resolves, tagged `matchedVia:
1223
+ * "fuzzy"` so render() can say "assuming you meant <label>" out loud; multiple
1224
+ * matches at the same best distance are an honest ambiguity listing the candidates.
1225
+ * Never applied to sha-shaped terms (the commit namespace is exact-or-ambiguous
1226
+ * only) nor to terms under 4 chars (the bound would cover half of everything).
1227
+ * (6) no match at all — an honest miss. Returns {match, candidates, tier, ambiguous
1228
+ * [, matchedVia]} — ambiguous on a true tier-3 score tie, a tier-4 overlap-count
1229
+ * tie, or a tier-5 distance tie. */
361
1230
  export function resolveObject(graph, term) {
362
1231
  const t = String(term || "").trim();
363
1232
  if (!t) return { match: null, candidates: [], tier: null, ambiguous: false };
364
1233
  const tLc = t.toLowerCase();
365
1234
  const pool = graph.individuals;
366
1235
 
1236
+ // commit-sha tier (checked first, only for sha-shaped terms): "ef74e44e25c8",
1237
+ // "commit ef74e44e25c8", "commit:ef74e44", or a full 40-char sha resolve against
1238
+ // Commit individuals by id/label prefix (ids are commit:<full-sha>, labels the
1239
+ // 12-char short sha), case-insensitive. A UNIQUE prefix is exact-grade over the
1240
+ // closed commit namespace (tier 1); a prefix shared by more than one commit is an
1241
+ // honest ambiguity listing the candidates — never "the first one"; a hex-looking
1242
+ // word matching NO commit falls through to the ordinary tiers unchanged (it may
1243
+ // be a real code identifier).
1244
+ const shaTerm = tLc.match(/^(commit[:\s])?([0-9a-f]{7,40})$/);
1245
+ if (shaTerm) {
1246
+ const sha = shaTerm[2];
1247
+ const hits = pool.filter((i) => i.class === "Commit"
1248
+ && (String(i.id).toLowerCase().startsWith(`commit:${sha}`) || String(i.label).toLowerCase().startsWith(sha)));
1249
+ if (hits.length === 1) return { match: hits[0], candidates: [], tier: 1, ambiguous: false };
1250
+ if (hits.length > 1) return { match: hits[0], candidates: hits.slice(1, 5), tier: 1, ambiguous: true };
1251
+ // the explicit "commit" noun declares intent — with no matching commit, falling
1252
+ // through would let the WORD "commit" component-match the Commit schema node (or
1253
+ // any identifier containing it): a guess, not a resolution. Bare hex still falls
1254
+ // through (it may be a real code identifier).
1255
+ if (shaTerm[1]) return { match: null, candidates: [], tier: null, ambiguous: false };
1256
+ }
1257
+
367
1258
  const exact = pool.find((i) => String(i.label).toLowerCase() === tLc || String(i.id).toLowerCase() === tLc);
368
1259
  if (exact) return { match: exact, candidates: [], tier: 1, ambiguous: false };
369
1260
 
@@ -379,16 +1270,42 @@ export function resolveObject(graph, term) {
379
1270
  }
380
1271
  if (extId) return { match: { id: extId, label: t, class: null }, candidates: [], tier: 2, ambiguous: false };
381
1272
 
382
- const termComps = componentSet(t);
1273
+ // tier 3 — two disjoint regimes (dotted-symbol fix, 2026-07-02, advisor-verified
1274
+ // bug): a DOTTED term with no slash ("res.json", "Widget.render", "walk.mjs") is
1275
+ // symbol-shaped (object.member / Class.method / a bare file name), and the old
1276
+ // any-substring-of-any-label pass let it land on a module whose PATH merely
1277
+ // contains the text ("res.json" -> test/res.json.js — the wrong grain presented
1278
+ // as if the term were that file). Such terms now match only (a) symbol labels
1279
+ // (whole-term containment, or the ".member" suffix when the owner alias differs:
1280
+ // "res.json" -> Response.json), and (b) module labels by EXACT basename equality
1281
+ // ("walk.mjs" -> src/walk.mjs — extension-stripped basename equality is
1282
+ // deliberately NOT used; that is precisely the phantom-path vector). Symbol
1283
+ // matches outrank module matches. Undotted/slashed terms keep the original pass.
383
1284
  const scored = [];
384
- for (const m of pool) {
385
- const label = String(m.label || "").toLowerCase();
386
- if (label.includes(tLc)) {
387
- scored.push({ ind: m, score: 1000 - Math.abs(label.length - tLc.length) });
388
- continue;
1285
+ const dotted = !tLc.includes("/") && /^[\w$]+(\.[\w$]+)+$/.test(tLc);
1286
+ if (dotted) {
1287
+ const lastSeg = tLc.split(".").pop();
1288
+ for (const m of pool) {
1289
+ const label = String(m.label || "").toLowerCase();
1290
+ if (m.class === "Module") {
1291
+ if (label.split("/").pop() === tLc) scored.push({ ind: m, score: 1000 - Math.abs(label.length - tLc.length) });
1292
+ } else if (label.includes(tLc)) {
1293
+ scored.push({ ind: m, score: 2000 - Math.abs(label.length - tLc.length) });
1294
+ } else if (label.endsWith(`.${lastSeg}`)) {
1295
+ scored.push({ ind: m, score: 1500 - Math.abs(label.length - tLc.length) });
1296
+ }
1297
+ }
1298
+ } else {
1299
+ const termComps = componentSet(t);
1300
+ for (const m of pool) {
1301
+ const label = String(m.label || "").toLowerCase();
1302
+ if (label.includes(tLc)) {
1303
+ scored.push({ ind: m, score: 1000 - Math.abs(label.length - tLc.length) });
1304
+ continue;
1305
+ }
1306
+ const overlap = [...termComps].filter((c) => componentSet(m.label).has(c)).length;
1307
+ if (overlap > 0) scored.push({ ind: m, score: overlap * 10 });
389
1308
  }
390
- const overlap = [...termComps].filter((c) => componentSet(m.label).has(c)).length;
391
- if (overlap > 0) scored.push({ ind: m, score: overlap * 10 });
392
1309
  }
393
1310
  scored.sort((a, b) => b.score - a.score);
394
1311
  if (scored.length) {
@@ -403,22 +1320,75 @@ export function resolveObject(graph, term) {
403
1320
  }
404
1321
 
405
1322
  // tier 4: prose-index fallback (PLAN_PROSE_INDEX.md §6) — see the function doc above.
406
- const proseHits = lookupByProseTokens(graph.proseIndex, t);
1323
+ // The typeof guard is the same viewer-bundle boundary as defaultNlp(): viz.mjs's
1324
+ // askSource strips the prose.mjs import but does not inline prose.mjs, so in the
1325
+ // browser `lookupByProseTokens` is an undeclared identifier — without the guard,
1326
+ // ANY term reaching this tier threw a ReferenceError in the page instead of
1327
+ // rendering the honest miss (a real, previously-untested viewer bug).
1328
+ // DOTTED terms never consult prose: "res.json" word-matches test/res.json.js's
1329
+ // own path tokens, which is the tier-3 phantom-path bug reappearing through a
1330
+ // side door — a dotted term names an identifier, and identifiers resolve by
1331
+ // label (tiers above) or the bounded fuzzy pass below, or they honestly miss.
1332
+ let proseResult = null;
1333
+ const proseHits = !dotted && typeof lookupByProseTokens === "function" ? lookupByProseTokens(graph.proseIndex, t) : [];
407
1334
  if (proseHits.length) {
408
1335
  const [best, ...rest] = proseHits;
409
1336
  const bestInd = graph.byId.get(best.id);
410
1337
  if (bestInd) {
411
1338
  const tied = rest.filter((h) => h.score === best.score);
412
- return {
1339
+ proseResult = {
413
1340
  match: bestInd,
414
1341
  candidates: rest.slice(0, 4).map((h) => graph.byId.get(h.id)).filter(Boolean),
415
1342
  tier: 4,
416
1343
  ambiguous: tied.length > 0,
417
1344
  matchedVia: "prose",
418
1345
  };
1346
+ // A UNIQUE SEMANTIC prose hit stands — fuzzy is never consulted (lower
1347
+ // tiers fire only on a miss). Two prose outcomes yield to tier 5 instead:
1348
+ // (a) an AMBIGUOUS tie — a typo'd identifier ("bulidContextBundle") often
1349
+ // word-overlaps several symbols' prose tokens at the same score while its
1350
+ // 1-edit NAME match is unique; (b) a hit that only exists because the
1351
+ // prose SPELL layer corrected the query token (via:"spell") — the same
1352
+ // typo tier 5 resolves with stronger (name) evidence and announces out
1353
+ // loud, exactly the division of labour prose.mjs's own spell-layer
1354
+ // comment specifies. If tier 5 cannot resolve uniquely either, the
1355
+ // stashed prose result is surfaced as-is.
1356
+ if (!proseResult.ambiguous && best.via !== "spell") return proseResult;
1357
+ }
1358
+ }
1359
+
1360
+ // tier 5: bounded fuzzy (see the function doc) — every exact tier above missed
1361
+ // (or tier 4 tied), so a typo'd identifier gets one honest chance against labels
1362
+ // and their components. sha-shaped terms never reach here (guard above);
1363
+ // sub-4-char terms are excluded because a 1-edit budget on 3 chars matches far
1364
+ // too much to ever be a unique intent.
1365
+ if (!shaTerm && tLc.length >= 4) {
1366
+ const bound = fuzzyBound(tLc);
1367
+ let best = bound + 1;
1368
+ let hits = [];
1369
+ for (const m of pool) {
1370
+ let d = editDistance(String(m.label || "").toLowerCase(), tLc, bound);
1371
+ if (d > 0) {
1372
+ for (const comp of componentSet(m.label)) {
1373
+ if (d <= 0) break;
1374
+ d = Math.min(d, editDistance(comp, tLc, bound));
1375
+ }
1376
+ }
1377
+ if (d < best) { best = d; hits = [m]; }
1378
+ else if (d === best && d <= bound) hits.push(m);
1379
+ }
1380
+ if (best <= bound && hits.length === 1) {
1381
+ return { match: hits[0], candidates: [], tier: 5, ambiguous: false, matchedVia: "fuzzy" };
1382
+ }
1383
+ if (best <= bound && hits.length > 1 && !proseResult) {
1384
+ // equidistant fuzzy tie with no prose evidence either — honest ambiguity.
1385
+ const [bestInd, ...rest] = hits;
1386
+ return { match: bestInd, candidates: rest.slice(0, 4), tier: 5, ambiguous: true, matchedVia: "fuzzy" };
419
1387
  }
420
1388
  }
421
- return { match: null, candidates: [], tier: null, ambiguous: false };
1389
+ // fuzzy couldn't resolve uniquely: surface the prose tie (when there was one)
1390
+ // exactly as before, else the honest miss.
1391
+ return proseResult || { match: null, candidates: [], tier: null, ambiguous: false };
422
1392
  }
423
1393
 
424
1394
  /** Resolve a term that may be a context pronoun ("this"/"it"/"that"/"here") —
@@ -452,6 +1422,35 @@ function refineToEntities(graph, moduleIds, entityType) {
452
1422
  return out;
453
1423
  }
454
1424
 
1425
+ /** Everything a commit touched, across BOTH stored grains — touches (Commit->Module)
1426
+ * and touchesSymbol (Commit->fn/method/class) — narrowed by the asked entity type:
1427
+ * "modules"/"files" keeps the coarse grain only, a fine type keeps its own symbol
1428
+ * class only, and null/"Change" ("which changes touch commit X") keeps the union.
1429
+ * The result carries `commitSubject` so render() cites the commit and groups the
1430
+ * touched entities by class instead of pretending the commit was a search target. */
1431
+ function commitTouches(graph, commit, entityType, extra = {}) {
1432
+ // "Commit" counts as wildcard here too: in "what was touched by commit X" the
1433
+ // keyword-spotter consumes "commit" as the entity keyword though it belongs to
1434
+ // the object noun phrase — and no commit ever touches another commit (no
1435
+ // Commit->Commit edges exist), so honoring it as a class filter could only
1436
+ // ever manufacture a false blank.
1437
+ const wildcard = !entityType || entityType === "Change" || entityType === "Commit";
1438
+ const wantCoarse = wildcard || entityType === "Module";
1439
+ const wantFine = wildcard || FINE_ENTITY_TYPES.has(entityType);
1440
+ const kinds = [...(wantCoarse ? ["touches"] : []), ...(wantFine ? ["touchesSymbol"] : [])];
1441
+ let matches = kinds
1442
+ .flatMap((k) => edgesOfKind(graph, k))
1443
+ .filter((e) => e.subject === commit.id)
1444
+ .map((e) => graph.byId.get(e.object))
1445
+ .filter(Boolean);
1446
+ if (entityType && FINE_ENTITY_TYPES.has(entityType)) matches = matches.filter((m) => m.class === entityType);
1447
+ return {
1448
+ matches, objMatch: commit, commitSubject: true, ambiguous: false, candidates: [],
1449
+ traversal: `${kinds.join("+")} edges where subject = commit ${commit.label}`,
1450
+ ...extra,
1451
+ };
1452
+ }
1453
+
455
1454
  /** Safety net (paired with the render-branch fix earlier in this file's history): a
456
1455
  * {shape, kind, entityType} combination must be explicitly listed here to receive
457
1456
  * real non-"direct" modifier behavior. Anything parsing to a non-"direct" modifier
@@ -478,8 +1477,13 @@ const TRANSITIVE_MAX_DEPTH = 8;
478
1477
  * needed one. Returns {matches, objMatch, candidates, traversal, ambiguous,
479
1478
  * answer?, unresolvedPronoun?} — `answer` only set for the "ask" shape.
480
1479
  * `matches` is always an array of individuals (or edge records for "ask"). */
481
- export function traverse(graph, parsed, { contextId = null } = {}) {
482
- if (!parsed || parsed.ambiguousParse) return { matches: [], objMatch: null, candidates: [], traversal: null, ambiguous: false };
1480
+ export function traverse(graph, parsed, { contextId = null, prev = null } = {}) {
1481
+ if (!parsed) return { matches: [], objMatch: null, candidates: [], traversal: null, ambiguous: false };
1482
+ // compositional AST (PLAN §5.16 P3) — the new grammar's nodes carry a `node` tag;
1483
+ // everything else (simple clauses, ambiguousParse) flows through the original path
1484
+ // below completely unchanged.
1485
+ if (parsed.node) return evalComposite(graph, parsed, { contextId, prev });
1486
+ if (parsed.ambiguousParse) return { matches: [], objMatch: null, candidates: [], traversal: null, ambiguous: false };
483
1487
  const { shape, kind, entityType } = parsed;
484
1488
 
485
1489
  // meta: a question about the graph's OWN vocabulary ("what does cochange mean", "what
@@ -504,6 +1508,23 @@ export function traverse(graph, parsed, { contextId = null } = {}) {
504
1508
  };
505
1509
  }
506
1510
 
1511
+ // mentions: "where is X mentioned" (2026-07-02 query families) — the prose
1512
+ // surface, not an edge traversal: list the individuals whose decomposed
1513
+ // identifier / doc-comment tokens contain the term's words (the same index
1514
+ // resolveObject's tier 4 consults, surfaced directly). The term itself is NOT
1515
+ // resolved to an entity first — the question is about mentions of the words,
1516
+ // which is exactly what the prose index stores. typeof guard: same viewer-
1517
+ // bundle boundary as tier 4 (prose.mjs is never inlined).
1518
+ if (shape === "mentions") {
1519
+ const term = String(parsed.object || "").trim();
1520
+ const hits = typeof lookupByProseTokens === "function" ? lookupByProseTokens(graph.proseIndex, term) : [];
1521
+ const matches = hits.map((h) => graph.byId.get(h.id)).filter(Boolean);
1522
+ return {
1523
+ matches, objMatch: null, candidates: [], ambiguous: false, mentionsShape: true,
1524
+ traversal: `proseIndex word lookup for "${term}"`,
1525
+ };
1526
+ }
1527
+
507
1528
  // §modifier support gate (safety net, see modifierIsWired's own doc above) — checked
508
1529
  // BEFORE object resolution, so an unsupported modifier+kind combination gets its own
509
1530
  // honest capability-gap message rather than masquerading as an object-miss, or worse,
@@ -525,10 +1546,21 @@ export function traverse(graph, parsed, { contextId = null } = {}) {
525
1546
  unresolvedPronoun: !!(subj.unresolvedPronoun || obj.unresolvedPronoun),
526
1547
  };
527
1548
  }
528
- const edges = edgesOfKind(graph, kind).filter((e) => e.subject === subj.match.id && e.object === obj.match.id);
1549
+ // touches edges are stored commit -> entity, so when the question names the
1550
+ // commit on the OBJECT side ("was walk.mjs touched by commit X"), orient the
1551
+ // edge test by where the commit actually is instead of failing on direction;
1552
+ // a commit subject is also checked at the symbol grain ("does commit X touch
1553
+ // <function>" lives on touchesSymbol, not the module-coarse kind).
1554
+ let [from, to] = [subj.match, obj.match];
1555
+ let kinds = kindsFor(kind); // "uses" checks the whole union ("does X use Y")
1556
+ if (kind === "touches") {
1557
+ if (to.class === "Commit" && from.class !== "Commit") [from, to] = [to, from];
1558
+ if (from.class === "Commit") kinds = ["touches", "touchesSymbol"];
1559
+ }
1560
+ const edges = kinds.flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.subject === from.id && e.object === to.id);
529
1561
  return {
530
1562
  matches: edges, answer: edges.length > 0, objMatch: obj.match, subjMatch: subj.match,
531
- candidates: [], traversal: `${kind} edge from ${subj.match.label} to ${obj.match.label}`, ambiguous: false,
1563
+ candidates: [], traversal: `${kinds.join("+")} edge from ${from.label} to ${to.label}`, ambiguous: false,
532
1564
  };
533
1565
  }
534
1566
 
@@ -537,10 +1569,61 @@ export function traverse(graph, parsed, { contextId = null } = {}) {
537
1569
  const { match: objMatch, candidates, ambiguous, unresolvedPronoun, matchedVia } = resolveTermOrContext(graph, parsed.object, contextId);
538
1570
  if (!objMatch) return { matches: [], objMatch: null, candidates, traversal: null, ambiguous: false, unresolvedPronoun };
539
1571
 
1572
+ // where: "where is X [defined]" (2026-07-02 query families) — the resolved
1573
+ // entity IS the answer; render() reads its class + site attribute ("path:
1574
+ // start[-end]", seon:startsAt) for the module/line citation.
1575
+ if (shape === "where") {
1576
+ const site = (objMatch.attributes || []).find((a) => a.key === "site")?.value || null;
1577
+ return {
1578
+ matches: [objMatch], objMatch, candidates, ambiguous, matchedVia, whereShape: true, site,
1579
+ traversal: site ? `site attribute of ${objMatch.label}` : `class + defining module of ${objMatch.label}`,
1580
+ };
1581
+ }
1582
+
1583
+ // when: "when did X change" / "when was X last touched" (2026-07-02 query
1584
+ // families) — the commits whose touch edges reach X, newest commit date first
1585
+ // (mgx:commitDate, ISO-8601, so a lexical sort IS the date sort; undated
1586
+ // commits sort last and render() says so honestly). Checked BEFORE the
1587
+ // commit-as-subject flip: "when did <sha> change" asks for the commit's own
1588
+ // date, not its touched files, so a Commit object answers with itself.
1589
+ if (shape === "when") {
1590
+ const dateOf = (c) => String((c.attributes || []).find((a) => a.key === "date")?.value || "");
1591
+ let commits;
1592
+ if (objMatch.class === "Commit") {
1593
+ commits = [objMatch];
1594
+ } else {
1595
+ const edges = ["touches", "touchesSymbol"].flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.object === objMatch.id);
1596
+ const seen = new Set();
1597
+ commits = [];
1598
+ for (const e of edges) {
1599
+ if (seen.has(e.subject)) continue;
1600
+ seen.add(e.subject);
1601
+ const c = graph.byId.get(e.subject);
1602
+ if (c && c.class === "Commit") commits.push(c);
1603
+ }
1604
+ commits.sort((a, b) => dateOf(b).localeCompare(dateOf(a)));
1605
+ }
1606
+ return {
1607
+ matches: commits, objMatch, candidates, ambiguous, matchedVia, whenShape: true,
1608
+ traversal: `touches+touchesSymbol edges where object = ${objMatch.label}, newest commit date first`,
1609
+ };
1610
+ }
1611
+
1612
+ // commit-as-subject flip: touches edges are stored commit -> entity, so when the
1613
+ // RESOLVED term of a touches question is itself a Commit — "which changes touch
1614
+ // commit ef74e44e25c8" (reverse), "what did commit abc1234 touch" (forward),
1615
+ // "what changed in abc1234" (casual reverse) — the honest reading is "what did
1616
+ // that commit touch": read the edges FROM the commit, grain-selected by the asked
1617
+ // entity type, instead of scanning for edges INTO it (a commit is never a touch
1618
+ // target, so the un-flipped scan would render a misleading blank).
1619
+ if (kind === "touches" && objMatch.class === "Commit") {
1620
+ return commitTouches(graph, objMatch, entityType, { candidates, ambiguous, matchedVia });
1621
+ }
1622
+
540
1623
  if (shape === "forward") {
541
- const edges = edgesOfKind(graph, kind).filter((e) => e.subject === objMatch.id);
1624
+ const edges = kindsFor(kind).flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.subject === objMatch.id);
542
1625
  const matches = edges.map((e) => graph.byId.get(e.object)).filter(Boolean);
543
- return { matches, objMatch, candidates, traversal: `${kind} edges where subject = ${objMatch.label}`, ambiguous, matchedVia };
1626
+ return { matches, objMatch, candidates, traversal: `${kindsFor(kind).join("+")} edges where subject = ${objMatch.label}`, ambiguous, matchedVia };
544
1627
  }
545
1628
 
546
1629
  // reverse + transitive (PLAN_MECHANICAL_CHAT.md P1): the gate above guarantees kind is
@@ -577,10 +1660,33 @@ export function traverse(graph, parsed, { contextId = null } = {}) {
577
1660
  // already match the requested entityType, use them directly (inherits); only when they're
578
1661
  // Module individuals and a FINER entityType was asked for do we refine via `defines`
579
1662
  // (imports) — never blindly treat an edge's subject id as if it were always a module id.
580
- const edges = edgesOfKind(graph, kind).filter((e) => e.object === objMatch.id);
581
- const subjects = edges.map((e) => graph.byId.get(e.subject)).filter(Boolean);
1663
+ let edges = kindsFor(kind).flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.object === objMatch.id);
1664
+ let extNote = "";
1665
+ if (!edges.length && objMatch.class) {
1666
+ // Unresolved ext:<Name> endpoints with the SAME name as the resolved entity:
1667
+ // the extractor declined to assert identity (e.g. commander's every "class X
1668
+ // extends Command" edge points at ext:Command, never the Class node), so a
1669
+ // strict id match renders a FALSE blank. Count them by NAME instead and say
1670
+ // so in the receipt — name-grade evidence, labeled as such, same standard as
1671
+ // resolveObject's own ext: tier.
1672
+ const extId = `ext:${String(objMatch.label).toLowerCase()}`;
1673
+ edges = kindsFor(kind).flatMap((k) => edgesOfKind(graph, k)).filter((e) => String(e.object).toLowerCase() === extId);
1674
+ if (edges.length) extNote = ` (by name, via unresolved ${extId} references)`;
1675
+ }
1676
+ // dedupe by id: a union kind ("uses") can reach the same subject through two
1677
+ // legs (a module that both imports AND calls X), and one answer must list it once.
1678
+ const subjects = [];
1679
+ const seenSubjects = new Set();
1680
+ for (const e of edges) {
1681
+ const s = graph.byId.get(e.subject);
1682
+ if (s && !seenSubjects.has(s.id)) { seenSubjects.add(s.id); subjects.push(s); }
1683
+ }
582
1684
  let matches, grainNote = "";
583
- if (!entityType) {
1685
+ // "Change" (ask-vocab.mjs's pseudo-type) is a wildcard here: "which changes touch
1686
+ // walk.mjs" means the touch edges' own subjects — the commits — not a node class
1687
+ // to filter by (no individual is ever class "Change", so filtering would always
1688
+ // produce a false blank).
1689
+ if (!entityType || entityType === "Change") {
584
1690
  matches = subjects;
585
1691
  } else {
586
1692
  const direct = subjects.filter((s) => s.class === entityType);
@@ -594,7 +1700,7 @@ export function traverse(graph, parsed, { contextId = null } = {}) {
594
1700
  matches = [];
595
1701
  }
596
1702
  }
597
- return { matches, objMatch, candidates, traversal: `${kind} edges where object = ${objMatch.label}${grainNote}`, ambiguous, matchedVia };
1703
+ return { matches, objMatch, candidates, traversal: `${kindsFor(kind).join("+")} edges where object = ${objMatch.label}${extNote}${grainNote}`, ambiguous, matchedVia };
598
1704
  }
599
1705
 
600
1706
  // ---- §5 templated renderer — string interpolation + grouping/pluralization/overflow rules,
@@ -613,6 +1719,10 @@ function symbolLabelOf(ind) {
613
1719
  return ["Function", "Method"].includes(ind.class) ? `function ${label}()` : label;
614
1720
  }
615
1721
 
1722
+ function listJoin(syms) {
1723
+ return syms.length > 1 ? `${syms.slice(0, -1).join(", ")} and ${syms[syms.length - 1]}` : syms[0];
1724
+ }
1725
+
616
1726
  /** One-line, honest rephrasing of a candidate parse — used to describe a
617
1727
  * parse-level disagreement between strategies without pretending to pick
618
1728
  * a winner. Template only, reads straight off the parsed fields. */
@@ -623,11 +1733,23 @@ function describeParse(p) {
623
1733
  }
624
1734
 
625
1735
  /** Render a compiled query result into {content, miss, ambiguous, matches?, candidates?}.
626
- * Every branch is a template, not generation — §5's grouping/pluralization/overflow rules. */
1736
+ * Every branch is a template, not generation — §5's grouping/pluralization/overflow rules.
1737
+ * A tier-5 fuzzy object resolution is ANNOUNCED, not silent: the answer is prefixed
1738
+ * "assuming you meant <label>:" so the correction is on the record next to the result
1739
+ * (an unannounced fuzzy hit would be indistinguishable from an exact one — a guess). */
627
1740
  export function render(parsed, result) {
1741
+ const r = renderCore(parsed, result);
1742
+ if (result && result.matchedVia === "fuzzy" && result.objMatch && !r.ambiguous) {
1743
+ r.content = `assuming you meant ${result.objMatch.label}: ${r.content}`;
1744
+ }
1745
+ return r;
1746
+ }
1747
+
1748
+ function renderCore(parsed, result) {
628
1749
  if (!parsed) {
629
1750
  return { content: `couldn't parse this as a graph question. Try: ${rephraseHint()}`, miss: true, ambiguous: false };
630
1751
  }
1752
+ if (parsed.node) return renderComposite(parsed, result);
631
1753
  if (parsed.ambiguousParse) {
632
1754
  const options = parsed.candidates.map((p, i) => `${i + 1}) ${describeParse(p)}`).join(" or ");
633
1755
  return {
@@ -658,18 +1780,113 @@ export function render(parsed, result) {
658
1780
  const kindWord = result.objMatch.class === "SchemaClass" ? "a class in the graph's schema" : "a predicate (relation) in the graph's schema";
659
1781
  return { content: `${result.objMatch.label} is ${kindWord}: ${doc}`, miss: false, ambiguous: false, matches: result.matches };
660
1782
  }
1783
+ // mentions: the prose surface — checked before the generic objMatch-null miss
1784
+ // below, because a mentions result deliberately carries no resolved object
1785
+ // (the question is about the term's words, not a graph entity).
1786
+ if (result.mentionsShape) {
1787
+ if (!result.matches.length) {
1788
+ return {
1789
+ content: `"${parsed.object}" is not mentioned in any indexed identifier or doc-comment prose. (traversal: ${result.traversal})`,
1790
+ miss: true, ambiguous: false,
1791
+ };
1792
+ }
1793
+ const shown = result.matches.slice(0, OVERFLOW_CAP).map((m) => `${m.label} (${nounFor(m.class, 1)})`);
1794
+ const extra = result.matches.length > OVERFLOW_CAP ? `, …and ${result.matches.length - OVERFLOW_CAP} more` : "";
1795
+ return {
1796
+ content: `"${parsed.object}" is mentioned in the prose tokens of ${listJoin(shown)}${extra}.`,
1797
+ miss: false, ambiguous: false, matches: result.matches,
1798
+ };
1799
+ }
661
1800
  if (!result.objMatch && (!result.candidates || result.candidates.length === 0) && parsed.shape !== "ask") {
1801
+ // name what kind of thing was looked for: a sha-shaped term was checked against
1802
+ // the commit namespace, a dotted slash-free term against symbol labels — a
1803
+ // generic "no module matching" would misreport both.
1804
+ const objText = String(parsed.object || "").trim();
1805
+ const what = /^(?:commit[:\s])?[0-9a-f]{7,40}$/i.test(objText) ? "commit"
1806
+ : (!objText.includes("/") && /^[\w$]+(\.[\w$]+)+$/.test(objText) ? "symbol" : "module");
662
1807
  return {
663
- content: `no module matching "${parsed.object}" found in the index.`,
1808
+ content: `no ${what} matching "${parsed.object}" found in the index.`,
664
1809
  miss: true, ambiguous: false, candidates: [],
665
1810
  };
666
1811
  }
667
1812
  if (result.ambiguous) {
1813
+ // the candidates say what KIND of thing is ambiguous — a shared commit-sha
1814
+ // prefix must read "more than one commit", not "module".
1815
+ const pool = [result.objMatch, ...(result.candidates || [])].filter(Boolean);
1816
+ const noun = pool.length && pool.every((i) => i.class === "Commit") ? "commit" : "module";
1817
+ return {
1818
+ content: `"${parsed.object}" matches more than one ${noun} ambiguously — please narrow the term.`,
1819
+ miss: false, ambiguous: true, candidates: pool.map((i) => i.label),
1820
+ };
1821
+ }
1822
+ // where: the resolved entity's own location, cited off the site attribute.
1823
+ if (result.whereShape) {
1824
+ const ind = result.objMatch;
1825
+ if (ind.class === "Module") {
1826
+ return { content: `${ind.label} is a module — the label is its repo path.`, miss: false, ambiguous: false, matches: result.matches };
1827
+ }
1828
+ if (ind.class === "Commit") {
1829
+ return { content: `${ind.label} is a commit, not a code location — try "what did commit ${ind.label} touch".`, miss: true, ambiguous: false };
1830
+ }
1831
+ const m = String(result.site || "").match(/^(.*):(\d+)(?:-(\d+))?$/);
1832
+ if (m) {
1833
+ const lines = m[3] && m[3] !== m[2] ? `lines ${m[2]}-${m[3]}` : `line ${m[2]}`;
1834
+ return { content: `${symbolLabelOf(ind)} is defined in ${m[1]} at ${lines}.`, miss: false, ambiguous: false, matches: result.matches };
1835
+ }
668
1836
  return {
669
- content: `"${parsed.object}" matches more than one module ambiguously please narrow the term.`,
670
- miss: false, ambiguous: true, candidates: [result.objMatch, ...(result.candidates || [])].map((i) => i.label),
1837
+ content: `${symbolLabelOf(ind)} is defined in ${moduleLabelOf(ind)} (no line span recorded in this index).`,
1838
+ miss: false, ambiguous: false, matches: result.matches,
671
1839
  };
672
1840
  }
1841
+ // when: newest touching commit + its date; undated commits are said out loud
1842
+ // (honest miss with the precise re-index hint), never silently skipped.
1843
+ if (result.whenShape) {
1844
+ const subject = result.objMatch.label;
1845
+ if (!result.matches.length) {
1846
+ return { content: `no recorded commit touches ${subject} in this index. (traversal: ${result.traversal})`, miss: true, ambiguous: false };
1847
+ }
1848
+ const newest = result.matches[0];
1849
+ const date = (newest.attributes || []).find((a) => a.key === "date")?.value || "";
1850
+ if (!date) {
1851
+ return {
1852
+ content: `commit ${newest.label} touched ${subject}, but this index records no commit dates — re-index with a current seonix to attach mgx:commitDate.`,
1853
+ miss: true, ambiguous: false,
1854
+ };
1855
+ }
1856
+ const msg = (newest.attributes || []).find((a) => a.key === "message")?.value || "";
1857
+ const day = String(date).slice(0, 10);
1858
+ if (newest.id === result.objMatch.id) {
1859
+ return { content: `commit ${newest.label} is dated ${day}${msg ? ` ("${msg}")` : ""}.`, miss: false, ambiguous: false, matches: result.matches };
1860
+ }
1861
+ const more = result.matches.length - 1;
1862
+ return {
1863
+ content: `${subject} was last touched by commit ${newest.label} on ${day}${msg ? ` ("${msg}")` : ""}${more ? `; ${more} earlier commit${more === 1 ? "" : "s"} recorded` : ""}.`,
1864
+ miss: false, ambiguous: false, matches: result.matches,
1865
+ };
1866
+ }
1867
+ // commit-as-subject answers ("which changes touch commit X", "what did commit X
1868
+ // touch"): cite the commit, group the touched entities by CLASS — modules and
1869
+ // symbols are different grains of the same answer, and flattening them into one
1870
+ // undifferentiated list would hide which is which. Same OVERFLOW_CAP as the
1871
+ // other list templates; zero hits is the standard honest blank, commit cited.
1872
+ if (result.commitSubject) {
1873
+ const cite = `commit ${result.objMatch.label}`;
1874
+ if (!result.matches.length) {
1875
+ return {
1876
+ content: `${cite} touched nothing recorded in the index. (traversal: ${result.traversal})`,
1877
+ miss: true, ambiguous: false,
1878
+ };
1879
+ }
1880
+ const byClass = new Map();
1881
+ for (const m of result.matches.slice(0, OVERFLOW_CAP)) {
1882
+ const cls = m.class || "Module";
1883
+ if (!byClass.has(cls)) byClass.set(cls, []);
1884
+ byClass.get(cls).push(["Function", "Method"].includes(cls) ? `${m.label}()` : m.label);
1885
+ }
1886
+ const clauses = [...byClass.entries()].map(([cls, labels]) => `${nounFor(cls, labels.length)} ${listJoin(labels)}`);
1887
+ const extra = result.matches.length > OVERFLOW_CAP ? `; …and ${result.matches.length - OVERFLOW_CAP} more` : "";
1888
+ return { content: `${cite} touched ${clauses.join("; ")}${extra}.`, miss: false, ambiguous: false, matches: result.matches };
1889
+ }
673
1890
  if (parsed.shape === "ask") {
674
1891
  if (!result.objMatch || !result.subjMatch) {
675
1892
  return { content: `couldn't resolve one of the terms in this question.`, miss: true, ambiguous: false };
@@ -701,8 +1918,10 @@ export function render(parsed, result) {
701
1918
  // still resolves to Module individuals for a module-level relation like "imports", and
702
1919
  // grouping those by-module (module label as its own "symbol" label) reads as nonsense
703
1920
  // ("in a.mjs there is a.mjs"). The fine-grained per-symbol grouping below is only
704
- // meaningful when the matches are sub-module entities (functions/classes/etc).
705
- if (parsed.shape === "forward" || parsed.entityType === "Module" || result.matches.every((m) => m.class === "Module")) {
1921
+ // meaningful when the matches are sub-module entities (functions/classes/etc) — a
1922
+ // Commit list ("which commits touched X") has no containing module to group by, so
1923
+ // anything that is not a fine entity takes the flat join.
1924
+ if (parsed.shape === "forward" || parsed.entityType === "Module" || result.matches.every((m) => !FINE_ENTITY_TYPES.has(m.class))) {
706
1925
  const shown = result.matches.slice(0, OVERFLOW_CAP).map((m) => m.label);
707
1926
  const extra = result.matches.length > OVERFLOW_CAP ? `, …and ${result.matches.length - OVERFLOW_CAP} more` : "";
708
1927
  return { content: shown.join(" and ") + extra + ".", miss: false, ambiguous: false, matches: result.matches };
@@ -716,8 +1935,6 @@ export function render(parsed, result) {
716
1935
  if (!byModule.has(mod)) byModule.set(mod, []);
717
1936
  byModule.get(mod).push(symbolLabelOf(m));
718
1937
  }
719
- const listJoin = (syms) =>
720
- syms.length > 1 ? `${syms.slice(0, -1).join(", ")} and ${syms[syms.length - 1]}` : syms[0];
721
1938
  const clauses = [...byModule.entries()].map(([mod, syms], i) => {
722
1939
  const list = listJoin(syms);
723
1940
  return i === 0 ? `in ${mod} there is ${list}` : `there is ${list} in ${mod}`;
@@ -726,20 +1943,239 @@ export function render(parsed, result) {
726
1943
  return { content: clauses.join(" and ") + extra + ".", miss: false, ambiguous: false, matches: result.matches };
727
1944
  }
728
1945
 
1946
+ // ============================================================================
1947
+ // §progressive-relaxation cascade (SHRDLU in a code graph, with a Zork parser's
1948
+ // forgiveness) — a controlled loop that wraps the WHOLE existing parse and runs
1949
+ // ONLY when the direct parse of the normalized query would MISS. A clean direct hit
1950
+ // never enters the cascade (it stays instant and exact); the cascade only ever DROPS
1951
+ // noise/unmatched words or NORMALISES a near-canonical word to the closed vocabulary,
1952
+ // re-attempting the full parse (compositional + templates + keyword-spot) after each
1953
+ // transform, and bottoms out in the SAME honest miss + rephrase hint the engine
1954
+ // already returned — never inventing a term or guessing an entity. Deterministic:
1955
+ // same input → same cascade path. All of it is plain JS over the already-imported
1956
+ // tables + resolveObject/parseQuery, so it survives the viewer bundle's import strip.
1957
+ // ============================================================================
1958
+
1959
+ const wordsOf = (arr) => arr.flatMap((p) => String(p).toLowerCase().split(" "));
1960
+
1961
+ /** Every token the CLOSED grammar gives QUERY MEANING to — relation verbs, entity
1962
+ * nouns, modifiers, qualifiers, aggregate/superlative triggers, edge-degree nouns,
1963
+ * boolean connectives, placeholder nouns, anaphora/meta/where/mention markers,
1964
+ * relative pronouns, and the small synonym keys. The noise-strip pass will NEVER
1965
+ * remove one of these, and the drop-unmatched pass always keeps them: they carry the
1966
+ * intent, only the packaging around them is negotiable. */
1967
+ const CONTENT_VOCAB = new Set([
1968
+ ...wordsOf(Object.keys(VERB_TO_KIND)), ...wordsOf(Object.keys(ENTITY_TO_TYPE)),
1969
+ ...wordsOf(Object.keys(MODIFIER_TO_KIND)), ...wordsOf(Object.keys(QUALIFIERS)),
1970
+ ...wordsOf(AGGREGATE_TRIGGERS), ...wordsOf(Object.keys(SUPERLATIVE_EXTREMES)),
1971
+ ...wordsOf(Object.keys(EDGE_NOUN_TO_METRIC)), ...wordsOf(Object.keys(BOOLEAN_CONNECTIVES)),
1972
+ ...wordsOf(PLACEHOLDER_NOUNS), ...wordsOf(ANAPHORA_TRIGGERS), ...wordsOf(META_MEANING_VERBS),
1973
+ ...wordsOf(WHERE_MARKERS), ...wordsOf(MENTION_MARKERS), ...wordsOf(RELATIVE_PRONOUNS),
1974
+ ...wordsOf(Object.keys(CASCADE_SYNONYMS)),
1975
+ ]);
1976
+
1977
+ /** Structural scaffolding words — question words, articles-in-questions, frame verbs,
1978
+ * and context pronouns. Not "content", but they hold a sentence together, so the
1979
+ * drop-unmatched pass keeps them (dropping "what"/"of" would corrupt the grammar);
1980
+ * the noise-strip pass may still remove the few of these that are ALSO curated noise
1981
+ * ("the"/"a"/"show"/"me") — the two sets overlap on purpose. */
1982
+ const STRUCTURAL_WORDS = new Set([...STOPWORDS, ...FRAME_WORDS, ...CONTEXT_PRONOUNS]);
1983
+ const CASCADE_NOISE_SET = new Set(wordsOf(CASCADE_NOISE));
1984
+
1985
+ /** Is `parsed` a genuinely ANSWERABLE query — one that both parsed AND (for the simple
1986
+ * clauses) resolves its named term(s) to a graph entity? A composite non-miss node,
1987
+ * an ambiguous parse, and a meta/mentions surface all count; an unresolved-context
1988
+ * pronoun is its OWN specific honest miss (kept, not relaxed). Returns:
1989
+ * true — a real, executable answer (even if it later renders an empty set / "No")
1990
+ * "ambiguous"/"pronoun" — a specific outcome to keep, distinct from relaxable
1991
+ * false — no parse at all, a compositional {node:"miss"}, or an unresolved term
1992
+ * ask() starts the cascade ONLY on `false`, and accepts a relaxed attempt ONLY on the
1993
+ * strict `true` (so the cascade can never "rescue" a query into another kind of miss). */
1994
+ function answerable(graph, parsed, contextId) {
1995
+ if (!parsed) return false;
1996
+ if (parsed.ambiguousParse) return "ambiguous";
1997
+ if (parsed.node) return parsed.node !== "miss";
1998
+ if (parsed.shape === "meta" || parsed.shape === "mentions") return true;
1999
+ const o = resolveTermOrContext(graph, parsed.object, contextId);
2000
+ if (o.unresolvedPronoun) return "pronoun";
2001
+ if (!o.match) return false;
2002
+ if (parsed.shape === "ask") {
2003
+ const s = resolveTermOrContext(graph, parsed.subject, contextId);
2004
+ if (s.unresolvedPronoun) return "pronoun";
2005
+ return s.match ? true : false;
2006
+ }
2007
+ return true;
2008
+ }
2009
+
2010
+ /** Whole-query help/orientation request → show the hint directly (never the relaxation
2011
+ * loop, never a pretend answer). Matches only when the ENTIRE normalized query is a
2012
+ * curated HELP_TRIGGER, so a symbol named "help" in a real question is untouched. */
2013
+ function isHelpRequest(query) {
2014
+ const q = String(query || "").trim().toLowerCase().replace(/[?.!\s]+$/, "");
2015
+ return HELP_TRIGGERS.includes(q);
2016
+ }
2017
+
2018
+ /** The relaxation cascade. Given a query whose direct parse MISSED, walk three
2019
+ * increasingly-permissive layers, re-attempting the full parse after each transform,
2020
+ * and return the FIRST attempt that yields an answerable parse (with a trace of what
2021
+ * it did) — or null if none does (caller then falls back to the original honest miss):
2022
+ * 1. NOISE-STRIP — remove one curated noise token (leftmost) at a time, never one
2023
+ * that is content vocab or resolves to an entity, re-parsing each
2024
+ * time, until a parse answers or no noise tokens remain.
2025
+ * 2. DROP-UNMATCHED — drop the plain-lowercase words that are NEITHER grammar NOR a
2026
+ * resolvable entity (an unknown "frobnicate"); identifier-shaped
2027
+ * tokens (dotted files, shas, CamelCase) are never dropped —
2028
+ * they are content that may honestly fail to resolve.
2029
+ * 3. SYNONYM — rewrite surviving near-canonical words to the closed vocab.
2030
+ * Bounded (one token removed per noise iteration; hard guard) and deterministic. */
2031
+ export function relaxParse(graph, query, { nlp = undefined, contextId = null, prev = null } = {}) {
2032
+ const from = applyNegationFrames(normalizeQuery(String(query || "")));
2033
+ let tokens = splitWords(from);
2034
+ if (!tokens.length) return null;
2035
+ const dropped = [];
2036
+ const steps = [];
2037
+
2038
+ // Two literal-resolution guards (never the fuzzy/prose tiers, whose loose near-
2039
+ // matches would let a noise word masquerade as an entity):
2040
+ // · resolvesExact — EXACT label/id or ext: match only (tier ≤ 2). Used to protect a
2041
+ // curated NOISE word from being stripped: only a token that literally NAMES an
2042
+ // entity ("a module called `the`") is safe-listed. A mere substring coincidence
2043
+ // ("me" ⊂ "Method", tier 3) must NOT block stripping a genuine filler word.
2044
+ // · resolvesLiteral — exact/ext/substring/component (tier ≤ 3). Used to KEEP an
2045
+ // identifier-shaped content token ("logging" ⊂ "src/logging.mjs") through the
2046
+ // drop-unmatched pass, and to hold a synonym rewrite off a real entity name.
2047
+ const resolvesExact = (t) => {
2048
+ const r = resolveObject(graph, t);
2049
+ return !!r.match && r.tier != null && r.tier <= 2;
2050
+ };
2051
+ const resolvesLiteral = (t) => {
2052
+ const r = resolveObject(graph, t);
2053
+ return !!r.match && r.tier != null && r.tier <= 3;
2054
+ };
2055
+ // Does a term string carry at least one REAL word — one the grammar doesn't already
2056
+ // own as vocabulary/scaffolding? Guards against a relaxation that drops the actual
2057
+ // asked term and lets a bare marker slide into its place ("where is [X] defined" →
2058
+ // "where is defined", "defined" is a WHERE_MARKER, never the thing being located).
2059
+ const hasRealTerm = (s) => splitWords(String(s || "")).some((w) => {
2060
+ const lc = w.toLowerCase();
2061
+ return !CONTENT_VOCAB.has(lc) && !STRUCTURAL_WORDS.has(lc);
2062
+ });
2063
+ // Accept a relaxed attempt ONLY if it is a genuinely answerable parse (terms resolve)
2064
+ // AND it renders a REAL positive answer — never another empty/miss (relaxation earns a
2065
+ // win only by turning a miss into an answer, never a differently-worded miss) — and
2066
+ // never by promoting a bare marker to the asked term.
2067
+ const TERM_SHAPES = new Set(["reverse", "forward", "where", "when", "ask"]);
2068
+ const attempt = (toks) => {
2069
+ const text = toks.join(" ");
2070
+ const p = parseQuery(text, { nlp });
2071
+ if (answerable(graph, p, contextId) !== true) return null;
2072
+ if (p && !p.node && TERM_SHAPES.has(p.shape)) {
2073
+ if (p.object != null && !hasRealTerm(p.object)) return null;
2074
+ if (p.shape === "ask" && p.subject != null && !hasRealTerm(p.subject)) return null;
2075
+ }
2076
+ const rendered = render(p, traverse(graph, p, { contextId, prev }));
2077
+ return rendered.miss ? null : { parsed: p, text };
2078
+ };
2079
+ const done = (hit) => ({ parsed: hit.parsed, from, to: hit.text, dropped: [...dropped], steps });
2080
+
2081
+ // Layer 1 — NOISE-STRIP (one lowest-value token at a time)
2082
+ let guard = 0;
2083
+ const hardCap = Math.max(tokens.length, 1) + 12;
2084
+ for (; guard < hardCap; guard += 1) {
2085
+ let idx = -1;
2086
+ for (let i = 0; i < tokens.length; i += 1) {
2087
+ const lc = tokens[i].toLowerCase();
2088
+ if (CASCADE_NOISE_SET.has(lc) && !CONTENT_VOCAB.has(lc) && !resolvesExact(tokens[i])) { idx = i; break; }
2089
+ }
2090
+ if (idx < 0) break;
2091
+ const removed = tokens[idx];
2092
+ tokens = tokens.filter((_, i) => i !== idx);
2093
+ dropped.push(removed);
2094
+ steps.push(`strip noise "${removed}" → "${tokens.join(" ")}"`);
2095
+ const hit = attempt(tokens);
2096
+ if (hit) return done(hit);
2097
+ }
2098
+
2099
+ // Layer 2 — DROP-UNMATCHED (plain-lowercase unknowns beside the real terms)
2100
+ const survivors = [];
2101
+ const nowDropped = [];
2102
+ for (const t of tokens) {
2103
+ const lc = t.toLowerCase();
2104
+ const plain = /^[a-z]+$/.test(lc);
2105
+ const keep = !plain || CONTENT_VOCAB.has(lc) || STRUCTURAL_WORDS.has(lc) || resolvesLiteral(t);
2106
+ if (keep) survivors.push(t); else nowDropped.push(t);
2107
+ }
2108
+ if (nowDropped.length && survivors.length) {
2109
+ tokens = survivors;
2110
+ dropped.push(...nowDropped);
2111
+ steps.push(`drop unmatched ${JSON.stringify(nowDropped)} → "${tokens.join(" ")}"`);
2112
+ const hit = attempt(tokens);
2113
+ if (hit) return done(hit);
2114
+ }
2115
+
2116
+ // Layer 3 — SYNONYM-NORMALISE the survivors onto the canonical vocabulary
2117
+ let changed = false;
2118
+ const normed = tokens.map((t) => {
2119
+ const lc = t.toLowerCase();
2120
+ if (CASCADE_SYNONYMS[lc] && !resolvesLiteral(t)) { changed = true; return CASCADE_SYNONYMS[lc]; }
2121
+ return t;
2122
+ });
2123
+ if (changed) {
2124
+ steps.push(`normalise synonyms → "${normed.join(" ")}"`);
2125
+ const hit = attempt(normed);
2126
+ if (hit) return done(hit);
2127
+ }
2128
+
2129
+ return null; // exhausted — the honest bottom of the cascade (caller keeps the original miss)
2130
+ }
2131
+
729
2132
  // ---- orchestration — the seonix_ask entry point (§6.3: parse -> resolve -> traverse -> render) ----
730
2133
 
731
2134
  /** Answer a free-text question over the graph, mechanically. `opts.contextId`
732
2135
  * resolves a context pronoun ("this"/"it"/…) — wired from a UI's currently-
733
2136
  * selected node when one exists; omit it in the bare CLI/MCP surface, where
734
- * a pronoun then produces an honest miss rather than a guess. Returns the
735
- * full {content, seonix_ask:{mechanical,parsed,matches,traversal,miss,
736
- * ambiguous,candidates?}} envelope §6.2 specifies. Zero model calls. */
737
- export function ask(graph, query, { contextId = null } = {}) {
738
- const parsed = parseQuery(query);
739
- const result = traverse(graph, parsed, { contextId });
2137
+ * a pronoun then produces an honest miss rather than a guess. `opts.nlp`
2138
+ * overrides the lemma/POS adapter (see parseQuery) — leave it undefined and
2139
+ * a Node process picks up wink automatically while the inlined viewer stays
2140
+ * adapter-less by construction. `opts.prev` is the id array of the LAST answer's
2141
+ * matches thread it from a chat loop so a follow-up anaphora question ("which of
2142
+ * those are tested", "how many of them call X") filters/counts the prior result
2143
+ * set; omit it and anaphora questions produce an honest "needs a previous answer"
2144
+ * miss. Returns the full {content, seonix_ask:
2145
+ * {mechanical,parsed,matches,traversal,miss,ambiguous,candidates?}} envelope
2146
+ * §6.2 specifies. Zero generative model calls. */
2147
+ export function ask(graph, query, { contextId = null, nlp = undefined, prev = null } = {}) {
2148
+ // Explicit help/orientation request → the rephrase hint directly (the honest bottom
2149
+ // of the cascade, reached on demand), never a pretend answer or a relaxation attempt.
2150
+ if (isHelpRequest(query)) {
2151
+ return {
2152
+ content: rephraseHint(),
2153
+ seonix_ask: {
2154
+ mechanical: true, parsed: null, matches: [], traversal: null,
2155
+ miss: true, ambiguous: false, matchedVia: null, help: true, relaxed: null,
2156
+ },
2157
+ };
2158
+ }
2159
+ const direct = parseQuery(query, { nlp });
2160
+ // The relaxation cascade fires ONLY when the DIRECT parse would miss (no parse, a
2161
+ // compositional {node:"miss"}, or an unresolved named term) — a clean hit, an
2162
+ // ambiguous parse, an unresolved-pronoun miss, and a real-but-empty answer all keep
2163
+ // the direct parse untouched (a hit stays instant and exact).
2164
+ let parsed = direct;
2165
+ let relaxed = null;
2166
+ if (answerable(graph, direct, contextId) === false) {
2167
+ const r = relaxParse(graph, query, { nlp, contextId, prev });
2168
+ if (r) { parsed = r.parsed; relaxed = { from: r.from, to: r.to, dropped: r.dropped, steps: r.steps }; }
2169
+ }
2170
+ const result = traverse(graph, parsed, { contextId, prev });
740
2171
  const rendered = render(parsed, result);
2172
+ // If relaxation materially rewrote the query and produced a real answer, note it
2173
+ // lightly (terse, honest) so the reader knows how the question was read.
2174
+ const content = (relaxed && !rendered.miss && relaxed.to !== relaxed.from)
2175
+ ? `read as "${relaxed.to}" — ${rendered.content}`
2176
+ : rendered.content;
741
2177
  return {
742
- content: rendered.content,
2178
+ content,
743
2179
  seonix_ask: {
744
2180
  mechanical: true,
745
2181
  parsed: (parsed && !parsed.ambiguousParse) ? parsed : null,
@@ -749,12 +2185,16 @@ export function ask(graph, query, { contextId = null } = {}) {
749
2185
  traversal: result.traversal || null,
750
2186
  miss: !!rendered.miss,
751
2187
  ambiguous: !!rendered.ambiguous,
752
- // Confidence provenance (PLAN_PROSE_INDEX.md §6): set only when resolveObject's
753
- // object-term resolution fell all the way through to the tier-4 prose-index
754
- // fallback (a word-level match against a decomposed identifier/doc-comment, not
755
- // the symbol's own literal name) null for every other (literal-identifier) tier,
756
- // so a caller can distinguish "matched the thing's own name" from "matched
757
- // something it merely talks about" without the rendered content wording changing.
2188
+ // The relaxation trace: null when the direct parse was used as-is (a clean hit or
2189
+ // an honest miss the cascade couldn't/shouldn't rescue), else what the cascade
2190
+ // dropped/normalised to reach an answer. A caller can assert relaxed===null to
2191
+ // prove the cascade never touched a direct hit.
2192
+ relaxed,
2193
+ // Confidence provenance: "prose" when resolveObject fell through to the tier-4
2194
+ // prose-index fallback (PLAN_PROSE_INDEX.md §6 — matched what the symbol talks
2195
+ // about, not its name); "fuzzy" when the tier-5 bounded-edit-distance pass
2196
+ // resolved a typo'd term (the rendered content also announces it: "assuming you
2197
+ // meant <label>"); null for every literal-identifier tier.
758
2198
  matchedVia: result.matchedVia || null,
759
2199
  ...(rendered.ambiguous ? { candidates: rendered.candidates } : {}),
760
2200
  },