@polycode-projects/the-mechanical-code-talker 1.9.2 → 1.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +441 -202
  2. package/bin/tmct.mjs +126 -1
  3. package/package.json +4 -2
  4. package/src/answer-variants.mjs +8 -36
  5. package/src/ask-browser-entry.mjs +5 -23
  6. package/src/ask-browser.bundle.js +1 -2
  7. package/src/ask-nlp.mjs +9 -23
  8. package/src/ask-vocab.mjs +139 -589
  9. package/src/ask.mjs +627 -1729
  10. package/src/chat.mjs +1684 -2872
  11. package/src/cli-args.mjs +14 -28
  12. package/src/codegraph.mjs +236 -644
  13. package/src/completions/complete.mjs +18 -62
  14. package/src/completions/graph-adapter.mjs +14 -60
  15. package/src/completions/group.mjs +12 -68
  16. package/src/completions/infer.mjs +38 -126
  17. package/src/completions/prune.mjs +17 -70
  18. package/src/completions/rank.mjs +16 -69
  19. package/src/completions/search.mjs +8 -31
  20. package/src/concept.mjs +32 -88
  21. package/src/conformance.mjs +11 -15
  22. package/src/corpus/conceptnet.mjs +31 -89
  23. package/src/corpus/templates.mjs +19 -45
  24. package/src/corpus/unknown-ingest.mjs +31 -92
  25. package/src/embed.mjs +10 -22
  26. package/src/extensions.mjs +50 -154
  27. package/src/finish.mjs +35 -91
  28. package/src/grammar/ace.mjs +16 -40
  29. package/src/grammar/assert.mjs +1 -1
  30. package/src/grammar/lexicon-core.json +1 -1
  31. package/src/grammar/lexicon.mjs +9 -27
  32. package/src/graph-merge.mjs +2 -3
  33. package/src/hash.mjs +6 -14
  34. package/src/index.mjs +6 -10
  35. package/src/init.mjs +38 -125
  36. package/src/interpret/fuzzy.mjs +10 -29
  37. package/src/interpret/merge.mjs +9 -27
  38. package/src/interpret/normalize.mjs +137 -585
  39. package/src/interpret/pipeline.mjs +23 -71
  40. package/src/interpret/strategies/ace.mjs +7 -31
  41. package/src/interpret/strategies/constructions.mjs +14 -41
  42. package/src/interpret/strategies/grammar.mjs +21 -60
  43. package/src/interpret/strategies/keywords.mjs +42 -131
  44. package/src/interpret/strategies/noise-strip.mjs +18 -89
  45. package/src/memory/bias.mjs +11 -54
  46. package/src/memory/blocks.mjs +18 -69
  47. package/src/memory/core.mjs +171 -591
  48. package/src/memory/fold.mjs +0 -0
  49. package/src/memory/inspect.mjs +7 -25
  50. package/src/memory/shacl.mjs +10 -39
  51. package/src/memory/trust.mjs +26 -127
  52. package/src/memory-ask-browser-entry.mjs +7 -30
  53. package/src/memory-ask-browser.bundle.js +1 -1
  54. package/src/paraphrase.mjs +20 -53
  55. package/src/planning.mjs +15 -157
  56. package/src/prose-nlp.mjs +4 -17
  57. package/src/prose.mjs +19 -67
  58. package/src/providers/bootstrap.mjs +1 -2
  59. package/src/providers/fixture.mjs +1 -2
  60. package/src/providers/graph-service.mjs +28 -59
  61. package/src/repository-interface.mjs +6 -8
  62. package/src/router/drive.mjs +183 -0
  63. package/src/router/goal-reasoner.mjs +66 -231
  64. package/src/router/guardrail.mjs +20 -58
  65. package/src/router/planner.mjs +15 -46
  66. package/src/router/registry.mjs +13 -43
  67. package/src/router/resolver.mjs +46 -131
  68. package/src/router/results.mjs +231 -0
  69. package/src/schema-docs.mjs +10 -27
  70. package/src/server-http.mjs +10 -19
  71. package/src/server.mjs +22 -28
  72. package/src/sessions.mjs +15 -30
  73. package/src/source-slice.mjs +5 -7
  74. package/src/source.mjs +10 -20
  75. package/src/syllogise.mjs +187 -575
  76. package/src/telemetry.mjs +3 -3
  77. package/src/toml-config.mjs +4 -4
  78. package/src/tui/app.mjs +9 -19
  79. package/src/viz.mjs +66 -123
  80. package/src/wink-model.mjs +10 -24
package/src/ask.mjs CHANGED
@@ -1,42 +1,20 @@
1
- // ask.mjs — a mechanical (zero-model-call) natural-language query engine over the
2
- // tmct graph. PLAN_MECHANICAL_CHAT.md (P0): a small, closed English grammar
3
- // compiles a free-text question into a graph traversal over the SAME classified
4
- // relation groups codegraph.mjs's other render functions read, then renders a
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.
1
+ // ask.mjs — a mechanical (zero-model-call) natural-language query engine over
2
+ // the tmct graph. A small, closed English grammar compiles a free-text
3
+ // question into a graph traversal, then renders a templated, citation-faithful
4
+ // answer. A miss is a stated blank, never a guess.
12
5
  //
13
6
  // Four pure, independently-testable stages, orchestrated by ask():
14
7
  // parseQuery (grammar) -> resolveObject (mechanical term resolution) ->
15
8
  // traverse (graph lookup) -> render (templates).
16
9
  //
17
- // §3.5/3.6 (2026-07-02, ELIZA/PARRY-style breadth; split into src/interpret/ for
18
- // ROADMAP items 8/10/13): parseQuery normalizes the raw text (contractions,
19
- // g-drop, filler-strip interpret/normalize.mjs), rewrites recognized negative-
20
- // rhetorical constructions to their affirmative form, then runs the REGISTERED
21
- // parsing STRATEGIES over the same normalized text (interpret/pipeline.mjs) —
22
- // the original anchored-template matcher (interpret/strategies/grammar.mjs:
23
- // precise, fast, unweakened) and a keyword-spotting/decomposition matcher
24
- // (interpret/strategies/keywords.mjs — ELIZA's own mechanism: find the keyword,
25
- // decompose around it, tolerate reordering/casual phrasing) — and MERGES their
26
- // results (interpret/merge.mjs): one strategy hit -> use it; hits that agree ->
27
- // use it (high confidence); same-class hits that DISAGREE -> a genuine
28
- // parse-level ambiguity, surfaced honestly; no hits -> the honest grammar miss.
29
- // STRATEGIES is a plain registration array (interpret/pipeline.mjs) so further
30
- // strategies (Phase 2's ACE grammar) join the same way, not a hardcoded
31
- // two-branch special case.
10
+ // Term/keyword matching is tiered: exact curated match always wins; a
11
+ // Node-only wink-nlp lemma/POS tier and a bounded Damerau-Levenshtein fuzzy
12
+ // tier fire only on a miss, a unique fuzzy hit is announced in the answer,
13
+ // and any tie surfaces as ambiguity.
32
14
  //
33
- // Where a parsed intent is temporal/churn-shaped (touched/since/cochange as a
34
- // FILTER over commits, not a structural edge), this engine does NOT re-implement
35
- // that see PLAN_MECHANICAL_CHAT.md §2: matchQuery/nlToQuery (temporal.mjs) already
36
- // own that surface for the Chronograph browser; ask.mjs's own `touches`/`cochange`
37
- // verbs here answer "which modules touch/co-change with X" as ONE-HOP structural
38
- // edges (mgx:touchedByCommit / mgx:changeCoupledWith), which is a different (and
39
- // simpler) question than the browser's time-scrubbing view.
15
+ // ask.mjs's own `touches`/`cochange` verbs answer one-hop structural edges
16
+ // (mgx:touchedByCommit / mgx:changeCoupledWith) a different, simpler
17
+ // question than temporal.mjs's time-scrubbing Chronograph surface.
40
18
 
41
19
  import { relationKind, impactClosure, normPath, HISTORY_CAP } from "./codegraph.mjs";
42
20
  import {
@@ -47,10 +25,6 @@ import {
47
25
  AGGREGATE_TRIGGERS, LIST_TRIGGERS, SUPERLATIVE_EXTREMES, EDGE_NOUN_TO_METRIC, METRIC_IMPLIES_ENTITY, ANAPHORA_TRIGGERS,
48
26
  MEMBERSHIP_KINDS, CASCADE_NOISE, CASCADE_SYNONYMS, HELP_TRIGGERS,
49
27
  } from "./ask-vocab.mjs";
50
- // The interpretation layer (ROADMAP items 8/10/13) — the movable conversational
51
- // grammar, split out of this file: normalization pre-pass, the two parsing
52
- // strategies, and the bounded-fuzzy service. Re-exported below where existing
53
- // callers/tests import them from here.
54
28
  import { normalizeQuery, applyNegationFrames, applyPhrasingFrames, matchNegationSet, STOPWORDS, splitWords, wordsOf } from "./interpret/normalize.mjs";
55
29
  import { editDistance, fuzzyBound } from "./interpret/fuzzy.mjs";
56
30
  import { parseAnchored } from "./interpret/strategies/grammar.mjs";
@@ -64,31 +38,19 @@ import { pickPhrase } from "./answer-variants.mjs";
64
38
  export { normalizeQuery, applyNegationFrames };
65
39
  import { nlpAdapter } from "./ask-nlp.mjs";
66
40
 
67
- /** Per-graph, per-kind memo for THIS file's own edgesOfKind copy same WeakMap<graph,
68
- * Map<kind, edge[]>> shape as codegraph.mjs's twin (and this file's own qualCache,
69
- * below), kept as an independent cache rather than sharing codegraph.mjs's (same
70
- * commit-boundary reasoning as the function copy itself: both derive the identical
71
- * result from the identical relationKind classification, so two caches can never
72
- * disagree, only duplicate a little memory). Correctness rests on the same
73
- * invariant qualCache already relies on: a loaded graph's `relations` are never
74
- * mutated in place (a refresh always builds a NEW graph object via parseEntities).
75
- * Deliberately NAMED DIFFERENTLY from codegraph.mjs's `edgesOfKindCache` — the
76
- * inlined viewer bundle (viz.mjs's askSource) literally CONCATENATES a stripped
77
- * codegraph.mjs + this file into one classic script (test/ask-nlp.test.mjs pins
78
- * this), so two `const`s with the same name would be a real SyntaxError there. */
41
+ // Per-graph, per-kind memo; a local copy of codegraph.mjs's private
42
+ // edgesOfKind. Named differently from codegraph.mjs's own cache: the inlined
43
+ // viewer bundle concatenates a stripped codegraph.mjs + this file into one
44
+ // script, so two same-named consts there would be a SyntaxError.
79
45
  const askEdgesOfKindCache = new WeakMap();
80
46
 
81
- /** All edges of a classified relation kind, flattened across relation groups —
82
- * a local copy of codegraph.mjs's private edgesOfKind (kept local rather than
83
- * exported+imported to avoid coupling this file's commit. */
84
47
  function edgesOfKind(graph, kind) {
85
48
  let byKind = askEdgesOfKindCache.get(graph);
86
49
  if (!byKind) { byKind = new Map(); askEdgesOfKindCache.set(graph, byKind); }
87
50
  const cached = byKind.get(kind);
88
51
  if (cached) return cached;
89
52
  const out = [];
90
- // Plain-loop append, NOT out.push(...g.edges): argument spread overflows the call
91
- // stack past ~100k edges on graph-scale relation groups (see codegraph.mjs twin).
53
+ // Plain-loop append: argument spread overflows the call stack past ~100k edges.
92
54
  for (const g of graph.relations) {
93
55
  if (relationKind(g) !== kind) continue;
94
56
  for (const e of g.edges) out.push(e);
@@ -97,26 +59,16 @@ function edgesOfKind(graph, kind) {
97
59
  return out;
98
60
  }
99
61
 
100
- // ---- §3 vocabulary single-sourced in ./ask-vocab.mjs; the grammar, the
101
- // rephrase-hint text, and the renderer's noun forms all derive from those
102
- // three tables, so they cannot drift. ----
103
-
104
- // Predicate kinds carrying a finer, symbol-grain sibling (module-coarse -> fn/method-precise).
105
- // "which functions call X" should read off callsSymbol (fn->fn), not the module-coarse "calls".
62
+ // Predicate kinds carrying a finer, symbol-grain sibling: "which functions
63
+ // call X" should read off callsSymbol (fn->fn), not module-coarse "calls".
106
64
  const SYMBOL_GRAIN_SIBLING = { calls: "callsSymbol", touches: "touchesSymbol" };
107
65
  const FINE_ENTITY_TYPES = new Set(["Function", "Method", "Class", "Attribute", "GlobalVariable"]);
108
- // The fn/method FAMILY (0.8.2 WS1): callers of a symbol are recorded at whichever
109
- // grain the extractor saw (a method Widget.render is class "Method"), but a person
110
- // asking "which functions call X" means the callable family, not the storage class.
111
- // Used ONLY as an empty-result fallback (see traverse's reverse symbol-grain path):
112
- // an exact-class answer is never widened, so every non-empty answer is byte-stable.
66
+ // Empty-result fallback only: an exact-class answer is never widened.
113
67
  const FINE_CLASS_SIBLING = { Function: "Method", Method: "Function" };
114
68
 
115
- // Query-side UNION families (2026-07-02 query families): a parsed kind that is not
116
- // itself a stored predicate but a curated union of stored kinds "what uses X"
117
- // honestly means the import graph AND the call graph together. Everything else
118
- // maps to itself; grain selection (asked entity type) then narrows the union's
119
- // subjects the same way it narrows a single kind's.
69
+ // Query-side union families: a parsed kind that isn't itself a stored
70
+ // predicate but a curated union of stored kinds ("what uses X" means imports
71
+ // + calls together).
120
72
  const KIND_UNIONS = { uses: ["imports", "calls", "callsSymbol"] };
121
73
  const kindsFor = (kind) => KIND_UNIONS[kind] || [kind];
122
74
 
@@ -127,13 +79,7 @@ const PLURAL_FORMS = {
127
79
  Class: ["class", "classes"], Module: ["module", "modules"],
128
80
  Attribute: ["attribute", "attributes"], GlobalVariable: ["variable", "variables"],
129
81
  Commit: ["commit", "commits"],
130
- // "Change" is ask-vocab.mjs's pseudo-type (a wildcard over the touch traversal's
131
- // results, never a node class) — it still needs noun forms for zero-hit templates.
132
82
  Change: ["change", "changes"],
133
- // Memory-graph classes (memory/core.mjs) — real noun forms for the dynamic
134
- // class count/list fallback (PLAN_BREADTH_FIRST_NLU.md (d), see
135
- // dynamicClassQuery below) so "2 facts." reads naturally instead of falling
136
- // back to the generic "2 results.".
137
83
  Fact: ["fact", "facts"], Utterance: ["utterance", "utterances"],
138
84
  Session: ["session", "sessions"], Source: ["source", "sources"], Rule: ["rule", "rules"],
139
85
  };
@@ -142,31 +88,16 @@ function nounFor(entityType, n) {
142
88
  return n === 1 ? s : p;
143
89
  }
144
90
 
145
- // Every relation KIND token is already the correct 3rd-person-singular verb form
146
- // ("X imports Y", "X calls Y", "X touches Y") EXCEPT "cochange", the one kind whose
147
- // name is a bare noun/verb stem ("X cochange Y" is wrong; "X cochanges Y" is right) —
148
- // so the reverse-shape zero-hit template below reads off this table instead of
149
- // unconditionally appending "s" (which used to double-pluralize every other kind:
150
- // "callss", "importss", "touchess"). "reexports" -> "export" (Bug B3, HANDOVER
151
- // follow-up #2): the raw internal kind identifier "reexports" leaked straight into
152
- // the forward-miss prose ("X has no reexports edges in the index") — the human
153
- // word for this relation is "export" ("X has no export edges in the index"),
154
- // matching every other kind's already-natural phrasing.
91
+ // Every relation kind is already the correct 3rd-person-singular verb form
92
+ // except "cochange" ("X cochanges Y") and "reexports" (human word "export").
155
93
  const REVERSE_MISS_VERB = { cochange: "cochanges", reexports: "export" };
156
94
  function verbFor(kind) {
157
95
  return REVERSE_MISS_VERB[kind] || kind;
158
96
  }
159
97
 
160
- // Leading-relation-verb strip for the tests-kind honest empty (0.8.2 WS1): the
161
- // keyword strategy can match the "tests" NOUN as the relation verb and leave the
162
- // user's OWN verb at the head of the object term ("do any tests touch f.mjs"
163
- // object "touch f.mjs"), which the old ^cover-only strip missed ("No tests cover
164
- // touch app/lib/f.mjs."). The closed list is read from ask-vocab.mjs's exported
165
- // VERB_TO_KIND (derived from the RELATIONS verb table — the source of truth,
166
- // including the `tests` kind's own verbs: cover/check/verify/exercise/…), longest
167
- // phrase first so multi-word verbs strip whole; a bare optional s/ing/ed tail keeps
168
- // the previously-stripped inflections ("covering") without enumerating them. Only
169
- // ever applied to the tests-kind zero-hit template's object — never to resolution.
98
+ // Strips a leading relation verb from the tests-kind honest-empty object
99
+ // ("do any tests touch f.mjs" -> object "touch f.mjs"), so the miss template
100
+ // doesn't render "No tests cover touch f.mjs." Longest phrase first.
170
101
  const LEADING_RELATION_VERB_RE = new RegExp(
171
102
  `^(?:${Object.keys(VERB_TO_KIND)
172
103
  .sort((a, b) => b.length - a.length)
@@ -175,70 +106,23 @@ const LEADING_RELATION_VERB_RE = new RegExp(
175
106
  "i",
176
107
  );
177
108
 
178
- // ---- the parsing strategies + normalization + fuzzy service formerly defined
179
- // here now live in src/interpret/ (items 8/10/13): interpret/normalize.mjs
180
- // (normalizeQuery, applyNegationFrames, STOPWORDS, splitWords), interpret/
181
- // strategies/grammar.mjs (parseAnchored, the anchored TEMPLATES), interpret/
182
- // strategies/keywords.mjs (parseKeywordSpot, findPhrase), interpret/fuzzy.mjs
183
- // (editDistance, fuzzyBound — also resolveObject's tier-5 budget below). ----
184
-
185
- // ---- strategy merge — now the interpret PIPELINE (item 8): the registered
186
- // strategies (interpret/pipeline.mjs STRATEGIES — grammar, keyword-spot, …) run
187
- // over the normalized text and interpret/merge.mjs merges them: same-class
188
- // agreement dedupes to one parse, same-class disagreement is the honest
189
- // {ambiguousParse, candidates} surface, and distinct-class alternates carry the
190
- // "if you mean X then …" surround (unused on this synchronous path — parseQuery
191
- // keeps the winning parse only, byte-identical to the original two-way merge). ----
192
-
193
- /** The default lemma/POS adapter: wink-nlp when this is a Node process with the
194
- * optional deps installed, null otherwise.. */
109
+ /** The default lemma/POS adapter: wink-nlp when Node has the optional deps
110
+ * installed, null otherwise. */
195
111
  function defaultNlp() {
196
112
  return typeof nlpAdapter === "function" ? nlpAdapter() : null;
197
113
  }
198
114
 
199
115
  /** Compile a free-text question into {shape, kind, entityType, modifier,
200
- * object[, subject]}, or null if NO strategy fits an honest grammar
201
- * miss (§6.3), never a best-effort guess. When strategies parse and
202
- * AGREE, returns that parse unchanged (no fallback ordering either
203
- * strategy's own result is equally valid once they agree, per §above: "use
204
- * either"). When they parse but DISAGREE (different shape/kind/term),
205
- * returns {ambiguousParse: true, candidates: [...]} a genuine "this could
206
- * mean more than one thing" case, distinct from resolveObject's later
207
- * object-resolution ambiguity. Routed through interpret/pipeline.mjs +
208
- * interpret/merge.mjs (item 8) the two legacy strategies at their existing
209
- * precedence produce identical winners. `opts.nlp` overrides the lemma/POS
210
- * adapter (pass null to force the adapter-less browser behavior in a Node
211
- * test); leaving it undefined picks the deterministic default (defaultNlp).
212
- * Pure given (query, adapter) — the adapter itself is a fixed model, no
213
- * sampling. */
214
- // SCHEMA-TERM / COMMON-WORD "WHAT DOES X MEAN" DISAMBIGUATION (CHATBENCH decision-log
215
- // item 1, g-a1-naming-8: "what does tests mean"). When the object term X is ITSELF a
216
- // real RELATIONS/VERB_TO_KIND keyword ("tests", "imports", …), keyword-spot
217
- // independently reads the sentence as a "reverse"-shaped query — kind:X, object:"mean"
218
- // — because "mean"/"means" (META_MEANING_VERBS, ask-vocab.mjs §7) is deliberately kept
219
- // OUT of the relation tables (see that table's own comment): keyword-spot has no idea
220
- // it just consumed the meta question's own verb as if it were an object noun. That
221
- // reading can never resolve to anything real ("mean" is never a graph entity) — it's a
222
- // spurious parse, not a genuine second reading — yet it collides with the grammar
223
- // strategy's own clean "meta" parse of the SAME sentence to manufacture the legacy
224
- // {ambiguousParse} surface ("this could mean more than one thing: 1) meta X or 2) X
225
- // 'mean' — try rephrasing"). Pruned here whenever the winning class's candidates are
226
- // EXACTLY [a meta-shape parse, a same-precedence parse whose object is a
227
- // META_MEANING_VERB] — collapsing back to the meta parse alone, so the term's own
228
- // schema-predicate definition answers directly instead of the unhelpful two-way punt.
229
- //
230
- // EXCEPT "imports": am-meta-imports (chatbench/graded-pool.jsonl) and
231
- // quickwins.test.mjs's "fix1: the frozen am-meta-imports ambiguity is NOT admitted"
232
- // both lock this EXACT ambiguity in as the intended, honest answer for that one term —
233
- // "what does imports mean" is byte-identical input to both am-meta-imports (which
234
- // requires the ambiguous answer) and g-a1-naming-9 (which wants the plain definition).
235
- // A deterministic function cannot satisfy both on the same input; widening the prune to
236
- // "imports" would silently flip am-meta-imports from passing to failing, a real
237
- // regression this project's own decision rule (SKILL_BENCHMARK_CEFR_ENGLISH.md §1)
238
- // forbids. So "imports" keeps its existing ambiguous answer — the same judgment call
239
- // chat.mjs's relationTermOf already makes for it (see that function's own docblock) —
240
- // while every OTHER relation term this collision can hit ("tests" included) is fixed
241
- // generally, not as a one-off patch.
116
+ * object[, subject]}, or null if no strategy fits. When strategies parse and
117
+ * disagree, returns {ambiguousParse: true, candidates: [...]}. `opts.nlp`
118
+ * overrides the lemma/POS adapter (pass null to force adapter-less browser
119
+ * behavior in a Node test). */
120
+ // Collapses a spurious ambiguity: when an object term is itself a
121
+ // RELATIONS/VERB_TO_KIND keyword, keyword-spot can misread "what does X mean"
122
+ // as a reverse query with object "mean", colliding with the grammar
123
+ // strategy's clean "meta" parse. That reading never resolves to anything
124
+ // real, so it's pruned back to the meta parse alone. "imports" is excluded —
125
+ // its ambiguous answer is the intended one (matches chat.mjs's relationTermOf).
242
126
  const FROZEN_META_AMBIGUOUS_TERMS = new Set(["imports"]);
243
127
  function pruneSpuriousMeaningAmbiguity(parsed) {
244
128
  if (!parsed?.ambiguousParse || !Array.isArray(parsed.candidates) || parsed.candidates.length !== 2) return parsed;
@@ -256,29 +140,17 @@ export function parseQuery(query, { nlp = undefined } = {}) {
256
140
  return parseQueryFull(query, { nlp }).parsed;
257
141
  }
258
142
 
259
- /** Sibling of `parseQuery` that also surfaces what `parseQuery` has always
260
- * discarded: `merge.mjs`'s `class`/`alternates` (PLAN_BREADTH_FIRST_NLU.md §3)
261
- * a genuine, distinct-class alternate reading a different strategy produced,
262
- * silently dropped on every hit until now. `parseQuery`'s own contract stays
263
- * byte-identical (it's defined in terms of this function's `.parsed` field,
264
- * above) — 142 existing call sites are untouched. Returns
265
- * `{parsed, alternates, class}`; `alternates`/`class` are `[]`/`null` on the
266
- * compositional-parse path (a structurally separate grammar layer that never
267
- * reaches `mergeStrategyResults`) or on a total miss. */
143
+ /** Sibling of `parseQuery` that also surfaces `merge.mjs`'s `class`/`alternates`.
144
+ * Returns `{parsed, alternates, class}`; `alternates`/`class` are `[]`/`null`
145
+ * on the compositional-parse path or a total miss. */
268
146
  export function parseQueryFull(query, { nlp = undefined } = {}) {
269
147
  const adapter = nlp === undefined ? defaultNlp() : nlp;
270
148
  const raw = String(query || "").trim().replace(/\s+/g, " ");
271
149
  if (!raw) return { parsed: null, alternates: [], class: null };
272
150
  const text = applyPhrasingFrames(applyNegationFrames(normalizeQuery(raw)));
273
151
  if (!text) return { parsed: null, alternates: [], class: null };
274
- // COMPOSITIONAL PARSE PATH (PLAN §5.16 P3) the new PRIMARY layer: a recursive
275
- // descent over CLAUSES for the compositional shapes (nested/relative, boolean,
276
- // qualifiers, aggregates, superlatives, anaphora). It fires ONLY when a
277
- // compositional MARKER is present and returns null otherwise, so every plain
278
- // clause falls straight through to the unchanged strategy pipeline below — the
279
- // whole existing grammar is preserved bit-for-bit. When a marker IS present but
280
- // the phrase cannot be compiled, it returns an honest {node:"miss"} rather than
281
- // letting keyword-spot guess at a composition it never expressed.
152
+ // Fires only when a compositional marker is present; every plain clause
153
+ // falls through to the strategy pipeline below.
282
154
  const composite = parseComposite(text, adapter);
283
155
  if (composite) return { parsed: composite, alternates: [], class: null };
284
156
  const merged = mergeStrategyResults(runStrategiesSync(text, { nlp: adapter, raw }));
@@ -291,64 +163,23 @@ export function parseQueryFull(query, { nlp = undefined } = {}) {
291
163
  }
292
164
 
293
165
  // ============================================================================
294
- // §compositional grammar (PLAN §5.16 P3) the step up from ELIZA keyword-
295
- // spotting to a real recursive-descent grammar. Tokenize -> recursive-descent
296
- // parse to an AST of nodes -> compile to graph traversal. The AST node shapes
297
- // (all carry a `node` tag so traverse()/render() can branch without touching the
298
- // simple-clause path):
299
- // {node:"clause", clause} — a wrapped simple parse (the leaf)
300
- // {node:"allOfClass", entityType} — every individual of a class
301
- // {node:"reverseSet"|"forwardSet", kind, entityType, inner} — nested/relative:
302
- // the OBJECT (reverse) / SUBJECT (forward) of the outer edge is the id-set
303
- // produced by evaluating `inner` (another AST) — two-stage traversal. `inner`
304
- // is usually a nested relative clause, but {node:"prevSet"} (below) is a
305
- // second, discourse-shaped leaf composing with the same union logic.
306
- // {node:"prevSet"} — the FULL id set of ask()'s own
307
- // `prev` (the immediately-preceding list-shaped answer) — a plural pronoun's
308
- // ("those"/"them") antecedent, only ever used as reverseSet/forwardSet's
309
- // `inner` (see parsePluralAnaphoraObject).
310
- // {node:"membership", entityType, term} — "<entity> of/in <term>"
311
- // {node:"qualifier", filters:[word…], inner} — adjective post-filters on a set
312
- // {node:"boolean", entityType, atoms:[{op,kind,ast|filters}…]} — set algebra
313
- // over the SAME subject (and/or/but-not); op ∈ seed/intersection/union/difference
314
- // {node:"count", entityType, base} — aggregate: |eval(base)|
315
- // {node:"list", entityType, base, scoped} — list the individuals of eval(base),
316
- // capped at OVERFLOW_CAP; `scoped` suppresses the "narrow with …" hint when the
317
- // list was already restricted (a module scope or predicate tail)
318
- // {node:"superlative", entityType, metric, metricNoun, extreme} — rank by degree
319
- // {node:"anaphora", mode, filter} — over ask()'s `prev` id array
320
- // {node:"miss", reason} — a compositional marker was seen
321
- // but could not compile: an honest stated miss, never a guess.
322
- // The grammar COMPOSES the closed vocabulary (ask-vocab.mjs); it never opens it —
323
- // every leaf still resolves through the existing curated clause parser + tiered
324
- // resolveObject, so a term it can't resolve is still an honest object-miss.
166
+ // compositional grammar recursive-descent tokenize -> AST -> compile to
167
+ // graph traversal, composing the closed vocabulary (ask-vocab.mjs) for
168
+ // multi-hop / set-algebra shapes (nested/relative, boolean, qualifiers,
169
+ // aggregates, superlatives, anaphora). Every AST node carries a `node` tag;
170
+ // {node:"miss"} means a compositional marker was seen but couldn't compile —
171
+ // an honest stated miss, never a guess. Every leaf still resolves through the
172
+ // same curated clause parser + tiered resolveObject.
325
173
  // ============================================================================
326
174
 
327
- // Depth cap on nesting (PLAN P3: "depth ≥2 nesting; guard against runaway with a
328
- // sane hop cap and an honest 'too deep to resolve' if exceeded").
329
175
  const MAX_COMPOSE_DEPTH = 4;
330
- // A resolvable-later placeholder object term for the OUTER clause of a nested
331
- // parse: the outer clause is parsed normally (so its verb/shape/grain classify),
332
- // then its `object` is discarded and replaced at eval time by the inner set. Chosen
333
- // to be plainly alphabetic (not a stopword, not vocabulary) so the clause parser
334
- // treats it as an ordinary object term rather than dropping it.
176
+ // Placeholder object term for a nested clause's outer parse; its `object` is
177
+ // discarded and replaced at eval time by the inner set.
335
178
  const NEST_SENTINEL = "zzinnerset";
336
- // Filler words dropped at the front of a relative predicate / anaphora filter.
337
- // "then"/"though" (Tier-2 playtest, 5th pass): a trailing discourse tag on an
338
- // otherwise-bare anaphora follow-up — "how many of those THEN", "which of
339
- // them THOUGH" — used to be read as an (uncompilable) filter clause instead
340
- // of being dropped as filler, so the follow-up MISSED at PARSE time with a
341
- // generic "the follow-up filter didn't parse" instead of reaching the
342
- // friendly eval-time "needs a previous answer" nudge when there was truly no
343
- // prior set (or the correct count/list when there was one) — the exact same
344
- // discourse-tag tolerance WHAT_ABOUT_RE already carries for "what about X
345
- // then"/"what about X though".
346
179
  const PRED_LEAD_SKIP = new Set(["that", "which", "who", "are", "is", "was", "were", "do", "does", "also", "still", "both", "and", "then", "though"]);
347
180
  const FRAME_WORDS = new Set(["which", "what", "who", "list", "show", "find", "give", "me", "us", "all"]);
348
- // A bare copula leading a boolean branch ("...and ARE untested") is discourse glue,
349
- // not part of the qualifier — dropped before a branch is tested/used as a
350
- // qualifier-only atom (both the marker-gate probe below and the two atom-building
351
- // folds — buildPredicateAtoms, parseRelationalOrQualified's own fold — apply it).
181
+ // A bare copula leading a boolean branch ("...and ARE untested") is discourse
182
+ // glue, not part of the qualifier.
352
183
  const COPULA_WORDS = new Set(["are", "is", "was", "were"]);
353
184
  function dropLeadCopula(bw, blc) {
354
185
  return blc.length && COPULA_WORDS.has(blc[0]) ? { bw: bw.slice(1), blc: blc.slice(1) } : { bw, blc };
@@ -358,18 +189,13 @@ const entityNoun = (w) => (ENTITY_TO_TYPE[w] ? { entityType: ENTITY_TO_TYPE[w],
358
189
  : (PLACEHOLDER_NOUNS.includes(w) ? { entityType: null, placeholder: true } : null));
359
190
  const isGerundVerb = (w) => !!VERB_TO_KIND[w] && w.endsWith("ing");
360
191
 
361
- /** Run the two legacy strategies on a FRAGMENT and return a single simple clause
362
- * (or null). Deterministic tie-break: on strategy disagreement the anchored parse
363
- * wins — a fragment fed from the composer is already shape-constrained, so the
364
- * merge's "surface an ambiguity" behavior isn't wanted here. (Equivalent to the
365
- * original two-strategy scan: anchored first, keyword-spot only on a miss.) */
192
+ /** Run the two legacy strategies on a fragment, anchored parse taking priority
193
+ * on disagreement (a composer-fed fragment is already shape-constrained). */
366
194
  function parseSimpleClause(text, nlp) {
367
195
  return parseAnchored(text) || parseKeywordSpot(text, nlp);
368
196
  }
369
197
 
370
- /** Top compositional dispatcher — first marker-matching production wins; a
371
- * production returns null (not this shape → fall through) or an AST node (which
372
- * may itself be {node:"miss"} when the marker was present but uncompilable). */
198
+ /** Top compositional dispatcher — first marker-matching production wins. */
373
199
  function parseComposite(text, nlp) {
374
200
  const w = splitWords(text);
375
201
  const lc = w.map((x) => x.toLowerCase());
@@ -389,23 +215,10 @@ function parseComposite(text, nlp) {
389
215
  || parseRelationalOrQualified(w, lc, nlp, 0);
390
216
  }
391
217
 
392
- // B1 NEGATION (Cycle 5, archive/PLAN_CYCLE_4.md) — the SET COMPLEMENT. "which X do not <verb>
393
- // Y" / "X that don't <verb> Y" / "modules not importing Y" / "which X are not
394
- // <qualifier>" compiles to allOfClass(kind) DIFFERENCE (the positive result set),
395
- // reusing the EXISTING machinery: evalBoolean already folds a "difference" atom, and
396
- // the allOfClass node is a ready-made bounded universe of a kind. The only new work is
397
- // recognizing the negation marker (matchNegationSet, normalize.mjs) and assembling the
398
- // boolean-difference AST — no new traversal primitive. Regression guards, all tested:
399
- // (1) honest-empty stays honest — an EMPTY complement ("which functions are not
400
- // exported", where the only function is exported) renders the standard honest
401
- // "nothing matches" miss, never invents a member and never re-trips the literal-
402
- // 'not' trap (the "not" is consumed here, so it can't leak into an object term);
403
- // (2) BOUNDED UNIVERSE only — the universe is the queried kind within the loaded
404
- // graph; the "Change" pseudo-type (ask-vocab.mjs) is a wildcard, not a stored
405
- // enumerable class, so a complement over "changes" is REFUSED honestly rather
406
- // than answered over an empty universe;
407
- // (3) active-voice/positive queries are untouched — parseNegation returns null unless
408
- // matchNegationSet finds an explicit set-negation marker.
218
+ // Negation as set complement: "which X do not <verb> Y" compiles to
219
+ // allOfClass(kind) DIFFERENCE (the positive result set). The "Change"
220
+ // pseudo-type has no bounded enumerable universe, so a complement over
221
+ // "changes" is refused honestly rather than answered over an empty universe.
409
222
  function complementAst(entityType, diffAtom) {
410
223
  return {
411
224
  node: "boolean",
@@ -454,7 +267,7 @@ function parseNegation(text, nlp, depth = 0) {
454
267
  return complementAst(entityType, { op: "difference", kind: "set", ast: positive });
455
268
  }
456
269
 
457
- // B1 FORWARD NEGATION (Cycle 5, pron+neg) — the SUBJECT-side complement's mirror: "what
270
+ // FORWARD NEGATION — the subject-side complement's mirror: "what
458
271
  // does[n't] <subj> <verb>" ("what doesn't it import", "what does app/lib/e.mjs not import")
459
272
  // is every individual of the verb's OBJECT grain that <subj> does NOT reach via that verb.
460
273
  // Distinct from parseNegation (which negates a queried KIND — "which modules do not import
@@ -542,14 +355,10 @@ function parseSetPhrase(text, nlp, depth) {
542
355
  *
543
356
  * The marker is either an explicit relative pronoun ("the module THAT imports X") or a
544
357
  * REDUCED relative clause with the pronoun dropped ("the module IMPORTING X" — a gerund
545
- * verb right where the pronoun+verb would go means the same thing). Root-cause fix for
546
- * g-c1-temp-8 (2026-07-12): "who touched the module importing X" has no "that", so this
547
- * loop used to find no marker at all, return null, and let the query fall through to the
548
- * legacy (non-compositional) strategy pipeline which then misread the leading verb
549
- * "touched" itself as the flat ASK shape's subject TERM ("does 'touched' import X"),
550
- * never resolving to a real entity. "the module THAT imports X" (explicit pronoun)
551
- * already routed correctly through this same function; the gerund form is the identical
552
- * nested-set shape and is now recognized the same way. */
358
+ * verb right where the pronoun+verb would go means the same thing): without recognizing
359
+ * the gerund form, "who touched the module importing X" falls through to the legacy
360
+ * strategy pipeline, which misreads the leading verb "touched" as the flat ASK shape's
361
+ * subject term instead of resolving to a real entity. */
553
362
  function parseNested(w, lc, nlp, depth) {
554
363
  for (let r = 1; r < lc.length; r += 1) {
555
364
  const isPronoun = RELATIVE_PRONOUNS.includes(lc[r]);
@@ -575,25 +384,10 @@ function parseNested(w, lc, nlp, depth) {
575
384
  return null;
576
385
  }
577
386
 
578
- // PLURAL ANAPHORA OBJECT (HANDOVER.md 2026-07-12 finding: CONTEXT_WORDS/resolveTermOrContext
579
- // only ever bound a SINGULAR pronoun to ask()'s contextId — "what tests cover those", after a
580
- // listing turn, fell to an honest miss with the literal word "those" treated as an unresolvable
581
- // module name). "those"/"them" standing as a BARE pronoun in an ordinary reverse/forward clause
582
- // ("what tests cover those" — trailing, the reverse OBJECT; "what do those import" — leading, the
583
- // forward SUBJECT) refer to the FULL id set of the immediately-preceding list-shaped answer —
584
- // exactly the id array ask()'s own `prev` already threads for parseAnaphora's "of those"/"count
585
- // them" shapes. Reuses reverseSet/forwardSet's existing multi-object UNION traversal (parseNested,
586
- // just above) verbatim: the only new thing is a second kind of `inner` leaf, {node:"prevSet"},
587
- // that reads `prev` instead of evaluating a nested clause.
588
- //
589
- // Two positions are recognized, both requiring the pronoun to stand ALONE (never a determiner —
590
- // "list those functions" is untouched): the sentence's FINAL word (mirrors parseAnaphora's own
591
- // "count them"/"list them" terminal pinning — the reverse-clause object trails its verb), or
592
- // immediately followed by a known relation verb (the forward-clause subject leads its verb: "those
593
- // IMPORT" is unambiguously a pronoun-then-verb, never "those <noun>"). An "of those"/"of them"
594
- // tail is parseAnaphora's own territory (checked earlier in parseComposite) and is skipped here.
595
- // After the substitution, `outer.object` is verified to BE the sentinel itself — guards against a
596
- // keyword-spot retry silently resolving the object to some other word in the sentence instead.
387
+ // Plural anaphora object: "those"/"them" standing alone as a reverse-clause
388
+ // object ("what tests cover those") or forward-clause subject ("what do
389
+ // those import") refer to the full id set of ask()'s `prev`. Reuses
390
+ // reverseSet/forwardSet's traversal with a {node:"prevSet"} inner leaf.
597
391
  const PLURAL_ANAPHORA_OBJECT = new Set(["those", "them"]);
598
392
  function parsePluralAnaphoraObject(w, lc, nlp) {
599
393
  for (let i = 0; i < lc.length; i += 1) {
@@ -612,13 +406,10 @@ function parsePluralAnaphoraObject(w, lc, nlp) {
612
406
  return null;
613
407
  }
614
408
 
615
- // TEMPORAL-OVER-RELATIVE (Phase 11 Track 1, lever 3) — "when did <relative set> [last]
616
- // change". The flat when-shape (traverse) dates the commits touching ONE resolved term;
617
- // this composes that same touches→commit→date-sort machinery as an OUTER operator over a
618
- // NESTED inner set ("when did the modules that import X last change", "when were the
619
- // functions that call Y last touched"). Fires only for a RELATIVE subject (a "that/which"
620
- // marker) so the single-entity "when did X change" stays on the flat path untouched; a
621
- // marker present but uncompilable inner is an honest miss, never a guess.
409
+ // Temporal-over-relative: "when did <relative set> [last] change" composes
410
+ // the flat when-shape's touches->commit->date-sort machinery over a nested
411
+ // inner set. Fires only for a relative subject; single-entity "when did X
412
+ // change" stays on the flat path.
622
413
  const TEMPORAL_AUX = new Set(["did", "was", "were", "do", "does", "has", "have", "had"]);
623
414
  const TEMPORAL_TAIL = new Set([
624
415
  "change", "changed", "changes", "update", "updated", "updates",
@@ -650,17 +441,11 @@ function parseTemporal(w, lc, nlp, depth = 0) {
650
441
  return { node: "temporal", inner, entityType: (noun && noun.entityType) || null };
651
442
  }
652
443
 
653
- // COMMIT FILTER (Track 1 temporal lever, PLAN_CHAT_FEEL item 6 remainder) — "what
654
- // changed since/before/after/on <date-or-commit>": a date-qualified SURVEY of every
655
- // recorded commit (distinct from the flat when-shape above, which dates ONE named
656
- // entity's touch history). The pivot is either a literal ISO-8601 date (yyyy-mm-dd,
657
- // mgx:commitDate's own format, so a lexical compare is a chronological one) or a
658
- // named commit — resolved at EVAL time (graph-dependent), whose own recorded date
659
- // becomes the pivot and who is excluded from its own before/after comparison (never
660
- // "before/after itself"). "in"/"during" are deliberately NOT among the qualifiers:
661
- // "what changed in <commit>" already means something else (that commit's own touch-
662
- // set — the commit-as-subject flip elsewhere in this file), and this recognizer must
663
- // never shadow it.
444
+ // Commit filter: "what changed since/before/after/on <date-or-commit>" is a
445
+ // date-qualified survey of every recorded commit, distinct from the flat
446
+ // when-shape above (which dates one named entity's touch history). "in"/
447
+ // "during" are deliberately excluded "what changed in <commit>" means that
448
+ // commit's own touch-set instead.
664
449
  const COMMIT_FILTER_OPS = new Set(["since", "before", "after", "on"]);
665
450
  function parseCommitFilter(w, lc) {
666
451
  if (lc[0] !== "what" || lc[1] !== "changed") return null;
@@ -673,25 +458,14 @@ function parseCommitFilter(w, lc) {
673
458
  return { node: "commitFilter", op, pivotRaw };
674
459
  }
675
460
 
676
- // Minimal code-identifier token shape dotted paths ("app/lib/e.mjs"), Capitalized
677
- // symbols ("Store"), or lowerCamelCase symbols ("fnAlpha"). Intentionally DUPLICATED
678
- // from chat.mjs's own NAME_TOKEN_RE (chat.mjs ~line 4998, used there for exactly this
679
- // kind of code-identifier detection in discourseRewrite) rather than imported:
680
- // chat.mjs only ever imports ask.mjs LAZILY (dynamic `await import("./ask.mjs")`,
681
- // per chat.mjs's own top-of-file comment), so a static ask.mjs -> chat.mjs import
682
- // would invert that layering for the sake of one regex. Keep the two in sync by
683
- // hand if either changes.
461
+ // Minimal code-identifier token shape: dotted paths, Capitalized symbols, or
462
+ // lowerCamelCase. Duplicated from chat.mjs's NAME_TOKEN_RE rather than
463
+ // imported, since chat.mjs only imports ask.mjs lazily.
684
464
  const ANAPHORA_NAME_TOKEN_RE = /\b[\w-]+(?:[/.][\w-]+)+\b|\b[A-Z][A-Za-z0-9_]*\b|\b[a-z][a-z0-9]*[A-Z][A-Za-z0-9]*\b/;
685
465
 
686
- /** Distinct code-identifier-shaped tokens in `words` (original case preserved —
687
- * the regex cares about case), in first-occurrence order, de-duplicated
688
- * case-insensitively. Feeds parseAnaphora's in-sentence candidate-set fix
689
- * (HANDOVER.md item 1, C2 pronoun-binding): "which of them <filter>" doesn't
690
- * ALWAYS mean "the previous turn's answer set" — when the SAME sentence already
691
- * named 2+ real candidates before the trigger ("app/lib/e.mjs ... app/lib/f.mjs
692
- * ... which of them ...", a single turn, no prior turn at all), THAT'S the
693
- * referent. Ordinary English words (even repeated nouns) never match this
694
- * regex, so this never widens beyond genuine code identifiers. */
466
+ /** Distinct code-identifier-shaped tokens in `words`, first-occurrence order.
467
+ * Feeds parseAnaphora's in-sentence candidate set: if the same sentence
468
+ * already names 2+ real candidates, they're the referent instead of `prev`. */
695
469
  function inSentenceNameTokens(words) {
696
470
  const seen = new Set();
697
471
  const out = [];
@@ -710,16 +484,9 @@ function inSentenceNameTokens(words) {
710
484
  * fires). Returns a {node:"anaphora"} (mode count|list), a miss (filter present but
711
485
  * uncompilable), or null. */
712
486
  function parseAnaphora(w, lc, nlp) {
713
- // "which ones"/"which one" a bare anaphoric re-LIST of the previous result set,
714
- // phrased as a QUESTION rather than the imperative ("list them") or pronoun-tail
715
- // ("count them") shapes the loop below already covers. Found live (0.9.14 Tier-2
716
- // playtest, third pass, numeric/quantifier relation touches): after "how many
717
- // modules import app/lib/a.mjs" / "which modules import app/lib/a.mjs", the
718
- // completely natural follow-up "which ones" fell straight through to the generic
719
- // orientation card — "ones" isn't an ANAPHORA_TRIGGERS pronoun and bare "which"
720
- // isn't a LIST_TRIGGERS head, so neither existing branch below ever fires for it.
721
- // Pinned to the WHOLE query (exactly two words) so it never shadows an ordinary
722
- // "which one of these two functions …" clause, which has more words after "one".
487
+ // Bare "which ones"/"which one": an anaphoric re-list phrased as a
488
+ // question. Pinned to the whole two-word query so it never shadows
489
+ // "which one of these two functions …".
723
490
  if (lc.length === 2 && lc[0] === "which" && (lc[1] === "ones" || lc[1] === "one")) {
724
491
  return { node: "anaphora", mode: "list", filter: { type: "all" } };
725
492
  }
@@ -728,11 +495,7 @@ function parseAnaphora(w, lc, nlp) {
728
495
  for (let i = 1; i < lc.length; i += 1) {
729
496
  if (!ANAPHORA_TRIGGERS.includes(lc[i])) continue;
730
497
  if (lc[i - 1] === "of") { p = i; viaOf = true; break; } // "how many of those", "which of them"
731
- // BARE anaphoric pronoun as the FINAL word, directly after a count/list trigger
732
- // ("count them", "count those", "list them") — the discourse-reference count/list over
733
- // the previous answer with no "of" (Cycle 5, disc+count). Pinned to the terminal
734
- // position so a mid-sentence "these"/"those" used as a determiner ("list these
735
- // functions") is left for the ordinary list/clause path, not seized as an anaphor.
498
+ // bare pronoun as the final word after a count/list trigger ("count them")
736
499
  const headSoFar = lc.slice(0, i).join(" ");
737
500
  if (i === lc.length - 1 && (AGGREGATE_TRIGGERS.includes(headSoFar) || LIST_TRIGGERS.includes(headSoFar))) { p = i; break; }
738
501
  }
@@ -741,14 +504,8 @@ function parseAnaphora(w, lc, nlp) {
741
504
  const mode = AGGREGATE_TRIGGERS.includes(head) || /^(how many|how much|count|number|quantity|total)\b/.test(head) ? "count" : "list";
742
505
  const filter = parsePredicateFilter(w.slice(p + 1), nlp);
743
506
  if (filter === undefined) return { node: "miss", reason: "the follow-up filter didn't parse" };
744
- // in-sentence candidate set (HANDOVER.md item 1): if the SAME utterance already
745
- // named 2+ real code identifiers BEFORE the "of them"/"of those" trigger — e.g.
746
- // "app/lib/e.mjs ... because it imports app/lib/f.mjs — which of them imports
747
- // app/lib/f.mjs" — those are the referent, not a previous turn's cached result
748
- // set. evalAnaphora tries this FIRST and only falls back to opts.prev when it's
749
- // absent or resolves to fewer than 2 real graph entities, so an ordinary
750
- // multi-turn follow-up (no named entities in the current utterance at all) is
751
- // completely unaffected.
507
+ // In-sentence candidate set: if this utterance already names 2+ real code
508
+ // identifiers before the trigger, those are the referent instead of `prev`.
752
509
  const cutIdx = viaOf ? p - 1 : p;
753
510
  const candidateTerms = inSentenceNameTokens(w.slice(0, cutIdx));
754
511
  const ast = { node: "anaphora", mode, filter };
@@ -774,23 +531,12 @@ function parsePredicateFilter(words, nlp) {
774
531
  return undefined;
775
532
  }
776
533
 
777
- /** EXISTENCE: "is there a/an <kind> [called|named <term>] [in <module>] [anywhere]"
778
- * and "are there any <kind>(s) [called|named <term>] [in <module>]" — a genuine
779
- * existence question ("does this kind/name exist at all", optionally scoped to a
780
- * module), answered directly against class/kind membership rather than routed
781
- * through the relation-verb machinery. Triage bug (2026-07-09, seonix dogfooding):
782
- * with no dedicated recognizer, "is there a class called Store anywhere" fell
783
- * through to the legacy keyword-spot strategy, whose lemma tier canonicalizes
784
- * "called" -> "call" (a `calls` verb — ask-vocab.mjs) and silently answered a
785
- * DIFFERENT question ("which classes call Store") with a confidently-wrong-shaped
786
- * negative, even though a class named Store genuinely exists. "is there a class in
787
- * <module>" walled out the same way — no marker in this grammar recognized it at
788
- * all. Scoped to a tight closed shape: a leading "is there a/an" or "are there any"
789
- * immediately followed by a recognized entity-kind noun, then ONLY "called"/"named
790
- * <term>", "in <module>", the two combined, or an empty/"anywhere"/"at all" tail —
791
- * anything else (a relative clause, a verb phrase: "is there a class THAT CALLS
792
- * Store") is a genuine relationship question and is left untouched for the
793
- * relation parsers below, never swallowed here. */
534
+ /** Existence: "is there a/an <kind> [called|named <term>] [in <module>] [anywhere]".
535
+ * Answered directly against class/kind membership rather than the relation-
536
+ * verb machinery — otherwise "is there a class called Store" would misparse
537
+ * "called" as a `calls` verb and answer a different question. A relative
538
+ * clause or verb phrase ("is there a class THAT CALLS Store") is left
539
+ * untouched for the relation parsers below. */
794
540
  function parseExistence(w, lc) {
795
541
  let i;
796
542
  if (lc[0] === "is" && lc[1] === "there") i = 2;
@@ -840,25 +586,10 @@ function parseExistence(w, lc) {
840
586
  // different (relationship) question; leave it for the parsers below.
841
587
  }
842
588
 
843
- /** QUALIFIER-CHECK: "is <term> [a/an] <qualifier> [<kind>]?", "is <term> not
844
- * <qualifier> …" — a single-ENTITY Yes/No property check ("is Task.title
845
- * public", "is it exported", "is that class abstract"), reusing the SAME
846
- * closed QUALIFIERS vocabulary and qualHolds() evaluator the attributive/
847
- * predicative-survey filters already fold over a SET ("public methods",
848
- * "which methods are public") — this is the missing single-entity sibling
849
- * (0.9.15 Tier-1 single-touch playtest: "is it a public attribute?", a
850
- * natural follow-up to a concept-force touch, had no recognizer at all and
851
- * hit the bare grammar wall — even "is Task.title public", a concretely
852
- * NAMED entity with no anaphora involved, walled the same way). Scoped
853
- * tight: a leading "is"/"are", then TERM tokens up to the FIRST recognized
854
- * qualifier word — a leading "the" and a trailing "a"/"an" article around
855
- * the boundary are dropped, and a trailing decorative kind noun ("… public
856
- * ATTRIBUTE") is simply never consumed, never required to agree with the
857
- * resolved entity's real class. Guarded off "is/are THERE …" (parseExistence
858
- * above owns that shape) and off any text with no qualifier word at all, so
859
- * it can never swallow a genuine relationship/existence question. The term
860
- * is resolved at EVAL time (a pronoun binds through the standing contextId,
861
- * exactly like every other object term), never here. */
589
+ /** Qualifier-check: "is <term> [a/an] <qualifier> [<kind>]?" a single-entity
590
+ * yes/no property check ("is Task.title public"), the missing single-entity
591
+ * sibling of the qualifier post-filter over a set. Guarded off "is/are
592
+ * there …" (parseExistence's shape). */
862
593
  function parseQualifierCheck(w, lc) {
863
594
  if (lc[0] !== "is" && lc[0] !== "are") return null;
864
595
  if (lc[1] === "there") return null; // parseExistence's own shape
@@ -877,18 +608,9 @@ function parseQualifierCheck(w, lc) {
877
608
  return { node: "qualCheck", term, qualifier: lc[qualIdx], negated };
878
609
  }
879
610
 
880
- /** Trailing "and that's the whole question" filler an aggregate/list tail can carry
881
- * ("how many classes are there", "list functions in total", "which classes exist in
882
- * the index") a count/list over a bare kind is frequently phrased with such a tail,
883
- * and it must NOT be mistaken for a restrictor (that's the exact bug behind "how many
884
- * classes are there" → the count-restrictor miss). Combined with STOPWORDS (which
885
- * already carries are/there/is/in/the/…) at the call site, so only the non-stopword
886
- * extras live here. A tail with ANY word outside this ∪ STOPWORDS is a real restrictor.
887
- * "this"/"that" (0.9.14 Tier-2 playtest): "which classes exist IN THIS codebase"/
888
- * "which methods exist in this codebase" used to miss — "codebase" alone was already
889
- * filler, but the demonstrative right before it ("this"/"that") is not a STOPWORDS
890
- * entry, so the tail read as non-filler and the whole thing fell through to the
891
- * grammar wall instead of the bare list. */
611
+ /** Trailing filler an aggregate/list tail can carry ("how many classes are
612
+ * there", "list functions in total") that must not be mistaken for a
613
+ * restrictor. Combined with STOPWORDS at the call site. */
892
614
  const AGG_TAIL_FILLER = new Set([
893
615
  "total", "altogether", "overall", "exist", "exists", "existing", "present",
894
616
  "here", "now", "currently", "graph", "index", "codebase", "repo", "repository",
@@ -924,37 +646,20 @@ function parseAggregate(w, lc, nlp) {
924
646
  return { node: "count", entityType: noun.entityType, base };
925
647
  }
926
648
 
927
- // Determiners/objects skipped after a LIST trigger verb ("show me THE classes") — a
928
- // superset of the aggregate skip so "give me all the modules" reaches the kind noun.
929
649
  const LIST_SKIP = new Set(["the", "a", "an", "all", "me", "us"]);
930
650
  const LIST_TRIGGERS_SORTED = [...LIST_TRIGGERS].sort((a, b) => b.split(" ").length - a.split(" ").length);
931
- // The listable node classes, named in the honest miss and the empty-index message.
932
651
  const LISTABLE_KINDS = "functions, classes, methods, modules, attributes, variables, or commits";
933
- // SCOPE PREPOSITIONS (HANDOVER item 12.2/11.1): a leading "in"/"inside"/"under" right
934
- // after the entity noun (past an optional copula) is an unambiguous LOCATION-SCOPE
935
- // tail, never a reverse-clause predicate object — "which modules import X" has no
936
- // preposition there at all. Narrowly scoped to just these three words so the
937
- // interrogative exception below can't be mistaken for a general tail-acceptance.
652
+ // A leading "in"/"inside"/"under" right after the entity noun is an
653
+ // unambiguous location-scope tail, never a reverse-clause predicate object.
938
654
  const SCOPE_PREPOSITIONS = new Set(["in", "inside", "under"]);
939
655
 
940
- /** LIST: "list <kind>", "show me the <kind>s", "what are the <kind>", "list <kind> in
941
- * <module>". A sibling of the count node — it enumerates the individuals of a class
942
- * (rendered under OVERFLOW_CAP) instead of counting them. Fires on a LIST_TRIGGERS
943
- * verb, OR the bare interrogative "what/which <kind>" but the interrogative form is
944
- * gated to a filler-only tail so an ordinary reverse query ("which functions call X")
945
- * is NOT hijacked into a list (it must stay a simple clause; the compat tests pin it).
946
- * A scope/predicate tail ("in walk.mjs", "that call X") is delegated to parseSetPhrase
947
- * (reusing membership/relational/boolean), and its `scoped` flag suppresses the
948
- * "narrow with …" hint. An unknown kind after a clear imperative trigger ("list
949
- * bananas") is an honest miss naming the listable kinds; anything less certain falls
950
- * through (null) to the existing parser/cascade rather than guessing.
951
- *
952
- * A single narrowly-scoped EXCEPTION to the interrogative gate (HANDOVER item
953
- * 12.2/11.1): "what/which <kind> [is|are] in|inside|under <scope>" — a leading scope
954
- * preposition, past an optional copula, straight after the entity noun — is routed the
955
- * SAME way the imperative "list <kind> in <scope>" already is. This does NOT widen the
956
- * general decline: "which modules import X"/"which functions call X" have a VERB there,
957
- * not a scope preposition, so they still fall through untouched. */
656
+ /** List: "list <kind>", "show me the <kind>s", "what are the <kind>". A
657
+ * sibling of the count node — enumerates individuals (capped at
658
+ * OVERFLOW_CAP) instead of counting them. Fires on a LIST_TRIGGERS verb or
659
+ * the bare interrogative "what/which <kind>", gated to a filler-only tail so
660
+ * "which functions call X" isn't hijacked into a list. One exception:
661
+ * "what/which <kind> [is|are] in|inside|under <scope>" routes the same way
662
+ * the imperative "list <kind> in <scope>" does. */
958
663
  function parseList(w, lc, nlp, depth) {
959
664
  let i = 0;
960
665
  let interrogative = false;
@@ -988,7 +693,7 @@ function parseList(w, lc, nlp, depth) {
988
693
  i += 1;
989
694
  const tail = w.slice(i);
990
695
  const tailMeaningful = lc.slice(i).some((t) => !STOPWORDS.has(t) && !AGG_TAIL_FILLER.has(t));
991
- // SCOPE-PREPOSITION exception (HANDOVER 12.2/11.1): past an optional copula
696
+ // SCOPE-PREPOSITION exception: past an optional copula
992
697
  // ("is"/"are"), does the tail open with "in"/"inside"/"under"? If so this is a
993
698
  // location-scope tail ("what modules ARE IN app/lib"), not a reverse-clause
994
699
  // predicate — strip only the copula (the imperative form never has one: "list
@@ -1074,29 +779,14 @@ function parseSuperlative(w, lc, nlp) {
1074
779
  return { node: "superlative", entityType, metric, metricNoun, extreme: ext };
1075
780
  }
1076
781
 
1077
- // PREDICATE-FIND (Workstream 2 new product feature): "find [me/us] [the/a] <term>
1078
- // <entityType>" (trailing-type — "find me the payment class") or "find [me/us]
1079
- // [the/a] <entityType> <linker> <term>" (leading-type-with-linker "find the class
1080
- // named Foo"). A TYPE FILTER ∧ FUZZY PROPERTY-SURFACE MATCH, not a literal name
1081
- // lookup (contrast parseList's plain class enumeration) — reuses the same closed
1082
- // compositional grammar (evalSet's new "find" case, renderComposite's new branch)
1083
- // so it composes for free with qualifiers/booleans later (§6 generalization below).
782
+ // Predicate-find: "find me the payment class" (trailing-type) or "find the
783
+ // class named Foo" (leading-type-with-linker)a type filter + fuzzy
784
+ // property-surface match, not a literal name lookup.
1084
785
  const FIND_LINKERS = new Set(["called", "named", "about", "like", "containing", "matching", "with"]);
1085
786
 
1086
- /** PREDICATE-FIND: see the file comment above. Triggered ONLY by a leading "find"
1087
- * (parseNegation, earlier in parseComposite's chain, already claims "find" as an
1088
- * optional lead before an EXPLICIT set-negation marker — "find modules that don't
1089
- * import X" reaches that production first and never reaches here). Reuses
1090
- * LIST_SKIP and entityNoun/ENTITY_TO_TYPE exactly as parseList does. A clear
1091
- * imperative "find <one unknown plain word>" (mirroring parseList's own single-
1092
- * trailing-word discipline) is an honest miss naming LISTABLE_KINDS — a
1093
- * PARSE-TIME miss, structurally distinct from evalSet("find")'s zero-hit SEARCH
1094
- * miss; anything less certain — a longer uncertain remainder, or ANY relative-
1095
- * clause marker present anywhere (that/which/who) — defers (null) to the existing
1096
- * parser/cascade, so "find the file that imports store" keeps parsing via the
1097
- * established parseNested/parseRelationalOrQualified relative-clause path (the
1098
- * §6 generalization below is the ONE place a term-bearing find-with-predicate
1099
- * shape is recognized, and it is a structurally separate production). */
787
+ /** A relative-clause marker anywhere defers to the existing relative-clause
788
+ * path ("find the file that imports store"); this only handles the
789
+ * term-bearing find-with-predicate shape. */
1100
790
  function parseFind(w, lc, nlp, depth) {
1101
791
  if (lc[0] !== "find") return null;
1102
792
  let i = 1;
@@ -1120,7 +810,7 @@ function parseFind(w, lc, nlp, depth) {
1120
810
  }
1121
811
 
1122
812
  // A relative-clause marker anywhere means a DIFFERENT production owns this text
1123
- // (either the plain relative-clause path, or the §6 find-with-predicate
813
+ // (either the plain relative-clause path, or the find-with-predicate
1124
814
  // generalization inside parseRelationalOrQualified) — never claimed here.
1125
815
  if (lc.some((t) => RELATIVE_PRONOUNS.includes(t))) return null;
1126
816
  // A clear imperative single unknown trailing word (mirrors parseList's own rule).
@@ -1130,23 +820,14 @@ function parseFind(w, lc, nlp, depth) {
1130
820
  return null;
1131
821
  }
1132
822
 
1133
- /** RELATIONAL / BOOLEAN / QUALIFIER (subject-first): "[which] [<qualifier>…] <entity>
1134
- * [that] <predicate>", where <predicate> is one or more relation clauses joined by
1135
- * and/or/but-not over the SAME subject, a "<of|in> <term>" membership, or empty (a
1136
- * bare qualified class). Fires ONLY on a compositional marker a leading qualifier,
1137
- * a relative pronoun, a gerund-led predicate, or a membership "of/in" — so a plain
1138
- * reverse query ("which functions call helper") and the bare-template ambiguous case
1139
- * ("which classes extends Base and couples to logging", no marker) both fall through
1140
- * to the existing strategies untouched. Returns an AST node, a miss, or null. */
1141
- /** §6 generalization (predicate-find, Workstream 2 follow-up) — detect the HEAD of
1142
- * "find [me/us] [the/a] <term…> <entityType> that|which|who <predicate>": mirrors
1143
- * parseFind's own trailing-type recognition (LIST_SKIP, entityNoun), but requires
1144
- * a relative-clause marker directly after the entity noun and a NON-EMPTY term
1145
- * before it. An EMPTY term ("find classes that…") is deliberately NOT this shape
1146
- * — it already reaches parseRelationalOrQualified's normal head-parsing below
1147
- * once "find" is skipped as a FRAME_WORD, with no find-seed needed. Returns
1148
- * {entityType, term, relIdx} (relIdx = the relative pronoun's token index) or
1149
- * null — null on anything less than an exact match (never a guess). */
823
+ /** Relational/boolean/qualifier (subject-first): "[which] [<qualifier>…]
824
+ * <entity> [that] <predicate>" where predicate is relation clauses joined by
825
+ * and/or/but-not, a membership "<of|in> <term>", or empty (bare qualified
826
+ * class). Fires only on a compositional marker, so a plain reverse query
827
+ * falls through to the existing strategies untouched. */
828
+ /** Detects the head of "find [me/us] [the/a] <term…> <entityType>
829
+ * that|which|who <predicate>". Returns {entityType, term, relIdx} or null
830
+ * an empty term ("find classes that…") is deliberately not this shape. */
1150
831
  function parseFindPredicateHead(w, lc) {
1151
832
  if (lc[0] !== "find") return null;
1152
833
  let i = 1;
@@ -1161,15 +842,10 @@ function parseFindPredicateHead(w, lc) {
1161
842
  return { entityType: noun.entityType, term, relIdx: r };
1162
843
  }
1163
844
 
1164
- /** Build boolean/qualifier atoms for a predicate whose FIRST (seed) atom the
1165
- * caller already determined externally (the §6 generalization's find-seed,
1166
- * above) `predLc`/`predWords` are the tokens AFTER the leading relative
1167
- * pronoun has already been consumed. Every atom's op defaults to
1168
- * "intersection" (a relative clause always RESTRICTS the seed) except where an
1169
- * explicit and/or/but-not connective says otherwise. Mirrors
1170
- * parseRelationalOrQualified's own branch-classification (qualifier-only /
1171
- * membership / verb-phrase clause) in miniature, duplicated rather than
1172
- * shared, so neither path risks regressing the other. */
845
+ /** Build boolean/qualifier atoms for a predicate whose first (seed) atom the
846
+ * caller already determined externally. Every atom's op defaults to
847
+ * "intersection" except where an explicit and/or/but-not connective says
848
+ * otherwise. */
1173
849
  function buildPredicateAtoms(entityType, subjPrefix, predLc, predWords, nlp, depth) {
1174
850
  const { branches, ops } = splitBoolean(predLc, predWords);
1175
851
  let prevVerb = null;
@@ -1195,12 +871,12 @@ function buildPredicateAtoms(entityType, subjPrefix, predLc, predWords, nlp, dep
1195
871
  return { atoms };
1196
872
  }
1197
873
 
1198
- // Seonix Batch 3 (3b): the closed set of temporal-lead words a bare "<lead> commits"
874
+ // The closed set of temporal-lead words a bare "<lead> commits"
1199
875
  // query can use — see the dedicated recentCommits AST node this feeds, below.
1200
876
  const RECENT_COMMIT_LEAD = new Set(["recent", "latest", "newest"]);
1201
877
 
1202
878
  function parseRelationalOrQualified(w, lc, nlp, depth) {
1203
- // §6 generalization (predicate-find): seeds the SAME boolean/qualifier fold
879
+ // predicate-find generalization: seeds the SAME boolean/qualifier fold
1204
880
  // below with a {node:"find",…} atom instead of the plain {node:"allOfClass"}
1205
881
  // a bare qualified class gets — see parseFindPredicateHead's own doc above.
1206
882
  const findHead = parseFindPredicateHead(w, lc);
@@ -1222,35 +898,16 @@ function parseRelationalOrQualified(w, lc, nlp, depth) {
1222
898
  while (i < lc.length && QUALIFIERS[lc[i]]) { quals.push(lc[i]); i += 1; }
1223
899
  const noun = i < lc.length ? entityNoun(lc[i]) : null;
1224
900
  if (!noun) {
1225
- // An unknown adjective sitting in the qualifier slot, right before a known entity
1226
- // noun ("list payment modules", "which shiny methods") try it as a predicate-find
1227
- // fuzzy term FIRST ("payment" filtering Module labels/attributes) before declaring
1228
- // it an unrecognized qualifier: a real, honest answer beats an error message, and a
1229
- // genuine zero-hit still renders find's own honest "no <noun> found matching <term>"
1230
- // miss (never a confident-wrong guess either way — same discipline as everywhere
1231
- // else this AST node is produced). STOPWORDS are excluded so a normal question
1232
- // auxiliary in that position ("what DID commit X touch") is left for the existing
1233
- // parser, not mistaken for a term.
901
+ // An unknown adjective before a known entity noun ("list payment
902
+ // modules") tries as a predicate-find fuzzy term before declaring an
903
+ // unrecognized qualifier.
1234
904
  const nextNoun = i + 1 < lc.length ? entityNoun(lc[i + 1]) : null;
1235
- // Seonix Batch 3 (3b) a bare temporal-qualifier lead on a Commit noun with no
1236
- // further term ("recent commits", "latest commits", "newest commits") used to
1237
- // fall into the generic find-fallback just below with the qualifier WORD ITSELF
1238
- // as the search term ("no Commit found matching 'recent'") — a false miss, since
1239
- // "recent"/"latest"/"newest" were never meant as a name to search for, just a
1240
- // sort direction the graph already has (mgx:commitDate). Checked BEFORE the
1241
- // generic fallback, and only when nothing follows the noun (a real filter tail,
1242
- // e.g. "recent commits touching a.py", is left to the ordinary parser).
905
+ // "recent/latest/newest commits" with nothing further is a sort
906
+ // direction, not a search term for the word itself.
1243
907
  if (RECENT_COMMIT_LEAD.has(lc[i]) && nextNoun && nextNoun.entityType === "Commit" && i + 2 === lc.length) {
1244
908
  return { node: "recentCommits" };
1245
909
  }
1246
- // Track 1 temporal lever (remainder) the SAME bare lead, past an optional
1247
- // copula + determiner ("what IS THE newest commit", "what WAS THE latest
1248
- // commit"): FRAME_WORDS only strips "what"/"which"/…, so "is the"/"was the"
1249
- // left `i` sitting on the copula, never reaching the check above at all. A
1250
- // narrow lookahead (never mutating `i`, so every OTHER branch here is
1251
- // byte-identical) that re-tries the exact same closed RECENT_COMMIT_LEAD
1252
- // check past those two filler words only — an honest decline (falls through)
1253
- // the instant either word doesn't match, never a guess.
910
+ // Same lead past an optional copula + determiner ("what IS THE newest commit").
1254
911
  if (COPULA_WORDS.has(lc[i])) {
1255
912
  let j = i + 1;
1256
913
  if (j < lc.length && (lc[j] === "the" || lc[j] === "a" || lc[j] === "an")) j += 1;
@@ -1259,17 +916,8 @@ function parseRelationalOrQualified(w, lc, nlp, depth) {
1259
916
  return { node: "recentCommits" };
1260
917
  }
1261
918
  }
1262
- // CASCADE_NOISE_SET excluded alongside STOPWORDS (Tier-2 playtest, cycle 8):
1263
- // "what about classes"/"how about the modules" used to reach here with
1264
- // "about" sitting right where a real qualifying adjective would ("payment"
1265
- // in "list payment modules") — framed (past "what") and immediately before
1266
- // a known noun ("classes") — and get misread as a fuzzy find TERM ("no
1267
- // classes found matching 'about'") instead of the topic-lead-in filler it
1268
- // is. CASCADE_NOISE already curates "about" for exactly this reading (see
1269
- // its own docblock, ask-vocab.mjs) — checking it here too lets a real
1270
- // unknown qualifier ("shiny"/"payment") still reach the find fallback while
1271
- // a known no-graph-meaning filler word falls through to the ordinary bare-
1272
- // kind-noun path instead (the cascade's own noise-strip terminal rule).
919
+ // CASCADE_NOISE_SET excluded alongside STOPWORDS: "what about classes"
920
+ // would otherwise misread "about" as a fuzzy find term.
1273
921
  if ((framed || quals.length) && nextNoun && /^[a-z]+$/.test(lc[i])
1274
922
  && !VERB_TO_KIND[lc[i]] && !STOPWORDS.has(lc[i]) && !CASCADE_NOISE_SET.has(lc[i])) {
1275
923
  return { node: "find", entityType: nextNoun.entityType, term: w[i] };
@@ -1285,31 +933,17 @@ function parseRelationalOrQualified(w, lc, nlp, depth) {
1285
933
  if (predLc.length && RELATIVE_PRONOUNS.includes(predLc[0])) { relFlag = true; predLc = predLc.slice(1); predWords = predWords.slice(1); }
1286
934
  const membershipLed = predLc[0] === "of" || predLc[0] === "in";
1287
935
  const gerundLed = predLc.length > 0 && isGerundVerb(predLc[0]);
1288
- // A boolean branch whose OWN content past an optional leading copula ("and ARE
1289
- // untested") — collapses to qualifier words alone is the compositional shape too
1290
- // ("functions that call X and are untested": verb clause AND qualifier, same
1291
- // subject). Probe with the same splitBoolean+QUALIFIERS fold the atoms loop below
1292
- // uses, so a bare verb+verb boolean chain with NO qualifier signal anywhere
1293
- // ("which classes extends Base and couples to logging") still has no marker here
1294
- // and correctly stays on the legacy ambiguous-parse path, untouched.
936
+ // A boolean branch that collapses to qualifier words alone (past an
937
+ // optional copula) is the compositional shape too ("functions that call X
938
+ // and are untested").
1295
939
  const boolQualLed = predWords.length > 0 && splitBoolean(predLc, predWords).branches.some((bw) => {
1296
940
  const { blc } = dropLeadCopula(bw, bw.map((x) => x.toLowerCase()));
1297
941
  return blc.length && blc.every((x) => QUALIFIERS[x]);
1298
942
  });
1299
- // A bare "call X and call Y" / "call X but not Y" chain — verb+verb (or
1300
- // verb+bare-object), no qualifier, no "that" is ALSO the compositional shape
1301
- // when branch0 leads with an explicit verb and every OTHER branch is either (a)
1302
- // an explicit repeat of that SAME mapped verb kind ("call loadStore and call
1303
- // saveStore" == calls(loadStore) ∩ calls(saveStore)) or (b) a bare object with NO
1304
- // verb of its own at all ("call saveStore but not loadStore" — "loadStore" alone
1305
- // inherits "call" via the SAME ellipsis-borrowing buildPredicateAtoms/the atoms
1306
- // loop below already do for OR-chains like "importing X or Y"; this only widens
1307
- // the GATE that lets that borrowing fire for and/but-not too). Deliberately
1308
- // narrower than "any verb+verb and": the compat-guarded bare case ("which classes
1309
- // extends Base and couples to logging") gives its SECOND branch its own DIFFERENT
1310
- // explicit verb (coupled-to, not inherits) and so fails case (a) and isn't bare
1311
- // for case (b) either — it still has no marker and correctly stays on the legacy
1312
- // ambiguous-parse path, untouched.
943
+ // A bare "call X and call Y" chain is also compositional when every branch
944
+ // after the first either repeats the same verb kind or is a bare object
945
+ // that inherits it narrower than "any verb+verb and" so the legacy
946
+ // ambiguous-parse case (different explicit verbs per branch) stays untouched.
1313
947
  const sameVerbBranches = predWords.length > 0 ? splitBoolean(predLc, predWords).branches : [];
1314
948
  let sameVerbLed = false;
1315
949
  if (sameVerbBranches.length > 1) {
@@ -1323,31 +957,10 @@ function parseRelationalOrQualified(w, lc, nlp, depth) {
1323
957
  });
1324
958
  }
1325
959
  }
1326
- // Batch 4/5 (HANDOVER item 4, compositional-AND): a later AND-branch with its OWN
1327
- // DIFFERENT, but still RECOGNIZED, verb ("which functions call X and test Y" —
1328
- // "test" maps to "tests", a different kind from the lead "call"/"calls") is ALSO
1329
- // the compositional shape — additive to sameVerbLed above, not a replacement:
1330
- // sameVerbLed already covers a same-kind repeat or a bare ellipsis-borrowed
1331
- // object; this covers the one case it deliberately left closed (a branch with
1332
- // its own explicit, different verb). The atom-building loop below needs no
1333
- // change: it already builds one independent {kind:"set", ast} per verb-led
1334
- // branch regardless of kind, and evalBoolean intersects them the same way a
1335
- // qualifier atom is intersected (610915a) — this only widens the GATE that lets
1336
- // that existing machinery fire for a mixed-kind "and" chain too.
1337
- // NARROWED to a single-WORD later verb (`vh.end - vh.start === 1`) — same
1338
- // "narrow, low-risk signal" discipline 610915a itself used (it deliberately did
1339
- // NOT widen to "any boolean connective"): a bare single content word ("call",
1340
- // "test", "tests") reads unambiguously as its own relation no matter what
1341
- // follows it, whereas a multi-word verb PHRASE ("couples to", "is a subclass
1342
- // of", "depends on") is exactly the shape the pre-existing compat guard pins
1343
- // OFF (ask-compositional.test.mjs:67 and :144, both asserting
1344
- // `ambiguousParse:true` for "which classes extends Base and couples to
1345
- // logging" STRICTLY) — "couples to" IS recognized (VERB_TO_KIND maps it to
1346
- // "imports"; the two-different-recognized-verbs shape is structurally
1347
- // identical to the target case), so accepting ANY recognized different verb
1348
- // here regressed both pinned tests when tried; the single-word restriction is
1349
- // the narrowest rule that admits the required target case ("test") while
1350
- // leaving the multi-word compat case exactly as closed as it always was.
960
+ // A later AND-branch with its own different but recognized verb ("which
961
+ // functions call X and test Y") is also compositional narrowed to a
962
+ // single-word later verb so a multi-word verb phrase ("couples to") stays
963
+ // on the legacy ambiguous-parse path.
1351
964
  let differentVerbLed = false;
1352
965
  if (sameVerbBranches.length > 1) {
1353
966
  const firstBlc = sameVerbBranches[0].map((x) => x.toLowerCase());
@@ -1360,20 +973,13 @@ function parseRelationalOrQualified(w, lc, nlp, depth) {
1360
973
  });
1361
974
  }
1362
975
  }
1363
- // marker gate — the crux of backward-compat: without one of these, this is not a
1364
- // compositional query and we must NOT hijack it from the existing parser.
976
+ // Marker gate: without one of these, this isn't a compositional query.
1365
977
  if (!(quals.length || relFlag || membershipLed || gerundLed || boolQualLed || sameVerbLed || differentVerbLed)) return null;
1366
978
 
1367
979
  // empty predicate → a bare qualified class ("public methods")
1368
980
  if (!predWords.length) {
1369
981
  let base = { node: "allOfClass", entityType };
1370
982
  if (!quals.length) return { node: "miss", reason: "nothing to filter or traverse" };
1371
- // entityType carried on the qualifier node itself too (not just `inner`) — a
1372
- // top-level "qualifier" AST has no dedicated evalComposite case, so it falls to
1373
- // the generic {compositeKind:"set", entityType: ast.entityType||null} catch-all;
1374
- // without this, a zero-match qualifier query ("public methods of X" with no
1375
- // public methods) rendered a bare "nothing in the index matches that." with no
1376
- // entity-kind receipt, same wall-shaped miss as a genuinely unrecognized query.
1377
983
  return { node: "qualifier", filters: quals, inner: base, entityType };
1378
984
  }
1379
985
 
@@ -1397,15 +1003,12 @@ function parseRelationalOrQualified(w, lc, nlp, depth) {
1397
1003
  const vh = findPhrase(blc, VERB_TO_KIND);
1398
1004
  if (vh) prevVerb = bw.slice(vh.start, vh.end);
1399
1005
  else if (prevVerb) phrase = [...prevVerb, ...bw];
1400
- // a branch is a single predicate (top-level booleans are already split out), so
1401
- // parse it as nested-or-simple NOT back through parseSetPhrase, which would
1402
- // re-detect the branch's own gerund/relative lead and recurse on identical text.
1006
+ // Parse as nested-or-simple, not back through parseSetPhrase (which would
1007
+ // re-detect this branch's own gerund/relative lead and recurse).
1403
1008
  const ast = parseBranchAst(`${subjPrefix} ${phrase.join(" ")}`, nlp, depth + 1);
1404
1009
  if (!ast || ast.node === "miss") return { node: "miss", reason: (ast && ast.reason) || "a clause in the combination didn't parse" };
1405
1010
  atoms.push({ op, kind: "set", ast });
1406
1011
  }
1407
- // the first atom must be a base set, not a bare qualifier (a qualifier needs
1408
- // something to filter). "public methods" already took the empty-predicate path above.
1409
1012
  if (atoms[0].kind !== "set") return { node: "miss", reason: "start with a clause, then combine with and/or/but-not" };
1410
1013
 
1411
1014
  let result;
@@ -1414,8 +1017,6 @@ function parseRelationalOrQualified(w, lc, nlp, depth) {
1414
1017
  } else {
1415
1018
  result = { node: "boolean", entityType, atoms };
1416
1019
  }
1417
- // entityType carried on the qualifier wrapper too — see the identical comment on
1418
- // the empty-predicate qualifier node above; same catch-all-miss-receipt fix.
1419
1020
  if (quals.length) result = { node: "qualifier", filters: quals, inner: result, entityType };
1420
1021
  return result;
1421
1022
  }
@@ -1472,7 +1073,7 @@ function reverseOverSet(graph, kind, entityType, objectIds) {
1472
1073
  const edges = edgesOfKind(graph, symbolKind).filter((e) => objectIds.has(e.object));
1473
1074
  return uniqueById(edges.map((e) => graph.byId.get(e.subject)).filter((s) => s && s.class === entityType));
1474
1075
  }
1475
- // GRAIN-AWARE OBJECT SET (Phase 11 Track 1, lever 3): when the inner set resolved to
1076
+ // GRAIN-AWARE OBJECT SET: when the inner set resolved to
1476
1077
  // FINE symbols (functions/methods/…), also scan the symbol-grain sibling — the coarse
1477
1078
  // edge (touches Commit→Module, calls Module→Module) can never point AT a symbol, so a
1478
1079
  // two-hop whose inner clause produced symbols ("which commits touched the functions
@@ -1503,12 +1104,9 @@ function uniqueById(inds) {
1503
1104
  return out;
1504
1105
  }
1505
1106
 
1506
- /** The Module individuals whose path lives strictly UNDER the directory named by
1507
- * `term` — a proper path-segment prefix match (normPath(label).startsWith(dir +
1508
- * "/")), never a bare substring, so "src/lib" cannot spuriously catch
1509
- * "src/libfoo/x.mjs". Mirrors renderArchitecture's own pkg-prefix scoping
1510
- * (codegraph.mjs) but returns individuals rather than a summary string — this is
1511
- * the "membership" AST node's directory-scope branch (see its call site above). */
1107
+ /** Module individuals whose path lives strictly under the directory named by
1108
+ * `term` — a proper path-segment prefix match, never a bare substring, so
1109
+ * "src/lib" cannot spuriously catch "src/libfoo/x.mjs". */
1512
1110
  function directoryScopeModules(graph, term) {
1513
1111
  const norm = normPath(term);
1514
1112
  if (!norm) return [];
@@ -1536,34 +1134,23 @@ function qualSets(graph) {
1536
1134
  qualCache.set(graph, c);
1537
1135
  return c;
1538
1136
  }
1539
- // KNOWN DIVERGENCE (not a bug, do not merge): this moduleIdOf is DEFINES-EDGE-keyed
1540
- // (walks the `defines` edge Module->symbol, built once in qualSets above), while
1541
- // codegraph.mjs's own moduleIdOf (codegraph.mjs:1198) is SITE-ATTRIBUTE-keyed (reads
1542
- // the individual's `site` attribute / a `fn:<path>#name` id shape) the two can
1543
- // disagree for a symbol whose site attribute and defines edge point at different
1544
- // modules (a genuine, currently-untested cross-file edge case), so this file
1545
- // deliberately keeps its own copy rather than importing codegraph.mjs's.
1137
+ // Known divergence (not a bug, do not merge): this moduleIdOf is
1138
+ // defines-edge-keyed, while codegraph.mjs's own (codegraph.mjs:1198) is
1139
+ // site-attribute-keyed; the two can disagree for a symbol whose site
1140
+ // attribute and defines edge point at different modules, so this file keeps
1141
+ // its own copy rather than importing codegraph.mjs's.
1546
1142
  function moduleIdOf(graph, ind) {
1547
1143
  if (!ind) return null;
1548
1144
  if (ind.class === "Module") return ind.id;
1549
1145
  return qualSets(graph).moduleOfSymbol.get(ind.id) || null;
1550
1146
  }
1551
1147
 
1552
- /** META FALLBACK TO REAL ENTITIES (0.8.2 WS1; widened + extracted HANDOVER.md
1553
- * 2026-07-10 item 6): "what is a Record" used to say "'Record' isn't a term in
1554
- * this graph's own vocabulary" even when Record is a real code-graph entity —
1555
- * after a SchemaClass/SchemaPredicate miss, an exact case-insensitive UNIQUE
1556
- * label match against a small set of real code-entity classes (Class/Function/
1557
- * Method/GlobalVariable/Attribute not just Class; CHATBENCH g-a2-naming-6:
1558
- * "what does fnAlpha mean", a Function, hit this same false vocabulary-miss
1559
- * wall). Uniqueness is GLOBAL across all these classes together, not per-class:
1560
- * a name colliding across two different classes stays an honest miss, never a
1561
- * guess at which one was meant. Extracted (not just inlined in traverse()'s own
1562
- * meta branch below) so chat.mjs's BARE "what is X" last-resort lane — no
1563
- * article, so T5's structural parse never even produces a meta shape to reach
1564
- * traverse() at all (CHATBENCH g-a2-naming-2: "what is Widget") — can reuse the
1565
- * exact same lookup + wording, rather than risk the two silently drifting
1566
- * apart. Returns null on anything less than a unique exact hit. */
1148
+ /** Meta fallback to real entities: after a SchemaClass/SchemaPredicate miss,
1149
+ * an exact case-insensitive unique label match against real code-entity
1150
+ * classes, so "what is a Record" answers even though Record isn't a graph
1151
+ * vocabulary term. Uniqueness is global across all these classes together —
1152
+ * a name colliding across two classes stays an honest miss. Returns null on
1153
+ * anything less than a unique exact hit. */
1567
1154
  const META_FALLBACK_CLASSES = new Set(["Class", "Function", "Method", "GlobalVariable", "Attribute"]);
1568
1155
  export function metaFallbackEntityAnswer(graph, term) {
1569
1156
  const termLc = String(term || "").trim().toLowerCase();
@@ -1585,39 +1172,22 @@ export function metaFallbackEntityAnswer(graph, term) {
1585
1172
  };
1586
1173
  }
1587
1174
 
1588
- // ---- MEMBERSHIP inheritance cascade (HANDOVER item 6) — "<kind> of <owner>" walks
1589
- // UP `inherits` when the owner's own surface has nothing, exactly the way
1590
- // computeFind's narrow-then-broaden pass does for predicate-find, below. ----
1175
+ // ---- membership inheritance cascade: "<kind> of <owner>" walks up `inherits`
1176
+ // when the owner's own surface has nothing, mirroring computeFind's
1177
+ // narrow-then-broaden pass below. ----
1591
1178
 
1592
- /** OWN-ONLY membership hits for exactly ONE owner id every MEMBERSHIP_KINDS
1593
- * forward-hit from `id` alone (never an ancestor), filtered to `entityType` when
1594
- * given. This IS the un-broadened lookup the "membership" case always ran before
1595
- * item 6 — extracted so both the plain evalSet case and the inheritance-aware
1596
- * cascade below (computeMembership) share the exact same one-node lookup. */
1179
+ /** Own-only membership hits for exactly one owner id (never an ancestor),
1180
+ * filtered to `entityType` when given. */
1597
1181
  function membershipOwnSet(graph, id, entityType) {
1598
1182
  const objs = uniqueById(MEMBERSHIP_KINDS.flatMap((k) => forwardOverSet(graph, k, new Set([id]))));
1599
1183
  return entityType ? objs.filter((o) => o.class === entityType) : objs;
1600
1184
  }
1601
1185
 
1602
- /** Resolve a "<kind> of/in <term>" owner term to either a DIRECTORY scope (a bare
1603
- * path prefix with no exact node of its own see directoryScopeModules's own
1604
- * doc) or a single container individual, exactly like the "membership" case's own
1605
- * pre-item-6 resolution extracted unchanged so evalSet's plain path and the
1606
- * composite/disclosure path (evalMembershipComposite) can never drift.
1607
- *
1608
- * `contextId` (HANDOVER.md 2026-07-12 finding, fast-loop round 4): this used to
1609
- * call bare `resolveObject(graph, term)` with no context-pronoun notion at all —
1610
- * "methods of that"/"attributes of it" reached resolveObjectCore's ordinary
1611
- * mechanical tiers with the raw pronoun string, an honest miss at best and, at
1612
- * worst, a false-positive substring hit (a 2-4 letter pronoun is a near-certain
1613
- * accidental substring of SOME real label — the exact
1614
- * STACCATO_LEAKED_CONNECTIVES trap chat.mjs documents for "it"/"and"). Routed
1615
- * through resolveTermOrContext instead — the SAME contextId-aware resolution
1616
- * evalQualCheck and traverse()'s reverse/forward shapes already use for
1617
- * subject-/object-position pronouns — so a pronoun binds to the standing focus
1618
- * and a non-pronoun term resolves byte-identically to before (resolveTermOrContext
1619
- * falls through to the same bare `resolveObject` call for anything that isn't a
1620
- * CONTEXT_PRONOUNS member). */
1186
+ /** Resolve a "<kind> of/in <term>" owner term to either a directory scope (a
1187
+ * bare path prefix with no exact node) or a single container individual.
1188
+ * Routed through resolveTermOrContext (not a bare resolveObject) so a
1189
+ * context pronoun ("methods of that") binds to the standing focus instead of
1190
+ * risking a false-positive substring hit. */
1621
1191
  function resolveMembershipOwner(graph, term, contextId = null) {
1622
1192
  const r = resolveTermOrContext(graph, term, contextId);
1623
1193
  if (!(r.match && r.tier === 1)) {
@@ -1628,23 +1198,12 @@ function resolveMembershipOwner(graph, term, contextId = null) {
1628
1198
  return { kind: "single", id: r.match.id, entityClass: r.match.class, label: r.match.label };
1629
1199
  }
1630
1200
 
1631
- /** The narrow-then-walk inheritance cascade behind a "<kind> of <owner>" membership
1632
- * query (HANDOVER item 6): the owner's OWN members — optionally `filterFn`-
1633
- * filtered (a qualifier, e.g. "public") win outright whenever non-empty; only
1634
- * when that (possibly filtered) own result is EMPTY, and the owner's class
1635
- * actually participates in `inherits` today (inheritsApplicable), do we walk
1636
- * `ancestorsOf` NEAREST-FIRST, stopping at the first ancestor whose own
1637
- * (identically filtered) member set is non-empty. Applying `filterFn` INSIDE the
1638
- * walk — not once after it returns — is what makes a QUALIFIED query ("public
1639
- * methods of TaskController") correctly walk up when the owner has own members
1640
- * but none satisfy the qualifier, rather than stopping early on an unfiltered
1641
- * non-empty own-set that the qualifier alone would have emptied (see the two
1642
- * call sites: an omitted/identity `filterFn` is the plain unqualified case).
1643
- * Returns {own, inherited, viaId, viaLabel} — `inherited`/`viaId`/`viaLabel` are
1644
- * populated ONLY when the walk actually found something on an ancestor, so a
1645
- * caller can disclose "inherited from <viaLabel>" rather than silently
1646
- * presenting an ancestor's members as the owner's own (never-fabricate — the
1647
- * same discipline computeFind's "related, not exact" broad pass documents). */
1201
+ /** The narrow-then-walk inheritance cascade behind a "<kind> of <owner>"
1202
+ * query: the owner's own (optionally `filterFn`-filtered) members win
1203
+ * outright when non-empty; only when empty does it walk `ancestorsOf`
1204
+ * nearest-first. `filterFn` is applied inside the walk, not after, so a
1205
+ * qualified query ("public methods of TaskController") correctly walks up
1206
+ * when the owner has members but none satisfy the qualifier. */
1648
1207
  function computeMembership(graph, ownerId, ownerClass, entityType, filterFn) {
1649
1208
  const pass = filterFn || (() => true);
1650
1209
  const own = membershipOwnSet(graph, ownerId, entityType).filter(pass);
@@ -1684,10 +1243,9 @@ function qualHolds(graph, ind, spec) {
1684
1243
  }
1685
1244
  }
1686
1245
 
1687
- // ---- predicate-find (Workstream 2) — the narrow-then-broaden inheritance cascade
1688
- // over `inherits` edges (Class->Class today; ANY entityType that later gains such
1689
- // edges between individuals of the SAME class extends automatically — detected
1690
- // dynamically via inheritsApplicable, never hardcoded to "Class"). ----
1246
+ // ---- predicate-find: the narrow-then-broaden inheritance cascade over
1247
+ // `inherits` edges, detected dynamically via inheritsApplicable rather than
1248
+ // hardcoded to "Class". ----
1691
1249
 
1692
1250
  /** All `inherits` edges (subject inherits FROM object — the derived class is the
1693
1251
  * subject, the base class is the object; RELATIONS.inherits' own comment). */
@@ -1726,13 +1284,9 @@ function ancestorsOf(graph, id) {
1726
1284
  }
1727
1285
  return out;
1728
1286
  }
1729
- /** Does `entityType` participate in an `inherits`-style subsumption relation TODAY —
1730
- * at least one inherits edge whose subject AND object are both individuals of this
1731
- * class? Detected dynamically (never hardcoded to "Class") so the cascade below
1732
- * extends automatically to any future type that gains such edges; when false, the
1733
- * broad (ancestor/sibling) pass is simply a no-op and predicate-find degrades to a
1734
- * flat own-label+attributes match — the common case for Module/Function today.
1735
- * Memoized per graph (a WeakMap so it never leaks/needs manual invalidation). */
1287
+ /** Does `entityType` participate in an `inherits`-style subsumption relation
1288
+ * today? When false, the broad (ancestor/sibling) pass is a no-op and
1289
+ * predicate-find degrades to a flat own-label+attributes match. */
1736
1290
  const inheritsApplicableCache = new WeakMap();
1737
1291
  function inheritsApplicable(graph, entityType) {
1738
1292
  let byType = inheritsApplicableCache.get(graph);
@@ -1746,10 +1300,8 @@ function inheritsApplicable(graph, entityType) {
1746
1300
  return ok;
1747
1301
  }
1748
1302
 
1749
- /** Does `ind`'s OWN property surface (label, or an attribute value) contain EVERY
1750
- * token of the fuzzy term (AND across tokens, same tokenizer resolveObject's own
1751
- * tier-3 uses)? Returns "label" | "attr" | null — the provenance tag findSortHits
1752
- * scores label hits above attribute-only hits (per the match-scope design). */
1303
+ /** Does `ind`'s own property surface (label, or an attribute value) contain
1304
+ * every token of the fuzzy term? Returns "label" | "attr" | null. */
1753
1305
  function ownSurfaceHit(ind, termTokens) {
1754
1306
  const labelLc = String(ind.label || "").toLowerCase();
1755
1307
  if (termTokens.every((tok) => labelLc.includes(tok))) return "label";
@@ -1757,9 +1309,8 @@ function ownSurfaceHit(ind, termTokens) {
1757
1309
  if (termTokens.every((tok) => attrs.some((v) => v.includes(tok)))) return "attr";
1758
1310
  return null;
1759
1311
  }
1760
- // own-label hits rank above inheritance-chain hits above attribute-only hits (the
1761
- // match-scope design's stated scoring); tie-break by shorter label (the same
1762
- // convention resolveObject's own tiers use for a scored tie).
1312
+ // Own-label hits rank above inheritance-chain hits above attribute-only hits;
1313
+ // tie-break by shorter label.
1763
1314
  const FIND_TIER = { label: 3, chain: 2, attr: 1 };
1764
1315
  function sortFindHits(hits) {
1765
1316
  return hits.slice()
@@ -1767,17 +1318,9 @@ function sortFindHits(hits) {
1767
1318
  .map((h) => h.ind);
1768
1319
  }
1769
1320
 
1770
- /** A BOUNDED-FUZZY (Damerau-Levenshtein, same budget resolveObject's own tier-5
1771
- * uses) near-match of the WHOLE term against `ind`'s label or any of its
1772
- * components. Used ONLY by the broad pass below never the narrow pass, whose
1773
- * exact-substring `ownSurfaceHit` test already runs against EVERY individual of
1774
- * the type (ancestors and siblings included, being ordinary pool members too),
1775
- * so an exact-substring re-test in the broad pass would be logically vacuous: if
1776
- * narrow found nothing, no individual's own surface can contain the term as a
1777
- * substring, full stop. Fuzzy near-matching is what makes the broad pass find
1778
- * something narrow genuinely couldn't (a typo'd or partial name on a relative),
1779
- * which is also why a broad-pass hit is always rendered "related, not exact" —
1780
- * it is a near-miss by construction, not a confident equal. */
1321
+ /** A bounded-fuzzy (Damerau-Levenshtein) near-match of the whole term against
1322
+ * `ind`'s label or any of its components. Used only by the broad pass —
1323
+ * always rendered "related, not exact", since it's a near-miss by construction. */
1781
1324
  function fuzzyFindHit(ind, term) {
1782
1325
  const tLc = String(term || "").trim().toLowerCase();
1783
1326
  if (tLc.length < 4) return false; // same floor resolveObject's tier-5 uses
@@ -1789,28 +1332,13 @@ function fuzzyFindHit(ind, term) {
1789
1332
  return false;
1790
1333
  }
1791
1334
 
1792
- /** The narrow-then-broaden search behind evalSet's "find" case and evalComposite's
1793
- * dedicated "find" handling (predicate-find, Workstream 2):
1794
- * 1. NARROW for each `entityType` individual, a hit if its OWN surface matches
1795
- * every term token, OR (when the type participates in `inherits` today) any of
1796
- * its DESCENDANTS' own surface does — a subclass genuinely IS a kind of its
1797
- * superclass, so a hit anywhere in the subtree counts as the candidate itself
1798
- * matching. If this pass finds ≥1 hit anywhere in the pool, it is the WHOLE
1799
- * answer — never silently widened when a specific answer exists (the same
1800
- * discipline Bug C's grain-aware resolution establishes). Every individual of
1801
- * the type is tested here, ancestors and siblings included (they are ordinary
1802
- * pool members too) — so an EMPTY narrow pass means no individual's own
1803
- * surface anywhere in the pool contains the term as a substring.
1804
- * 2. BROAD — only when the narrow pass is EMPTY across the WHOLE pool AND the
1805
- * type participates in `inherits`: for each candidate, walk UP to its
1806
- * superclass(es); a bounded-FUZZY near-match (fuzzyFindHit, above — never a
1807
- * repeat of narrow's exact test, which the previous point shows would find
1808
- * nothing new) on a superclass's own surface counts, and so does one on that
1809
- * superclass's OTHER direct children (siblings) — always rendered as
1810
- * "related, not exact" (renderComposite), never an unqualified match.
1811
- * Returns {narrow, broad} — `broad` is only ever non-empty when `narrow` is empty.
1812
- * When the type has no inherits edges at all, the broad pass is a no-op and this
1813
- * degrades to a flat own-label+attributes match (Module/Function today). */
1335
+ /** The narrow-then-broaden search behind "find": narrow tests every
1336
+ * `entityType` individual's own surface (plus descendants' surfaces, when
1337
+ * the type participates in `inherits`) for every term token; a non-empty
1338
+ * narrow pass is the whole answer. Only when narrow is empty does broad walk
1339
+ * up to superclasses/siblings with a bounded-fuzzy near-match, always
1340
+ * rendered "related, not exact". Returns {narrow, broad} broad is only
1341
+ * ever non-empty when narrow is empty. */
1814
1342
  function computeFind(graph, entityType, term) {
1815
1343
  const pool = graph.individuals.filter((i) => i.class === entityType);
1816
1344
  const termTokens = [...componentSet(term)];
@@ -1854,24 +1382,20 @@ function evalSet(graph, ast, opts) {
1854
1382
  switch (ast.node) {
1855
1383
  case "clause": return traverse(graph, ast.clause, opts).matches || [];
1856
1384
  case "allOfClass": return graph.individuals.filter((i) => i.class === ast.entityType);
1857
- // predicate-find (Workstream 2), embedded as a set atom (§6 generalization
1858
- // a find-seed inside a boolean/qualifier fold): the narrow-then-broaden
1859
- // cascade's result, transparently flattened (the "related, not exact" framing
1860
- // is a top-level RENDER concern — evalComposite's dedicated "find" handling
1861
- // below, not this generic embedding).
1385
+ // Predicate-find as a set atom: the narrow-then-broaden cascade's result,
1386
+ // transparently flattened ("related, not exact" is a render concern).
1862
1387
  case "find": {
1863
1388
  const { narrow, broad } = computeFind(graph, ast.entityType, ast.term);
1864
1389
  return narrow.length ? narrow : broad;
1865
1390
  }
1866
- // the SUBJECTS that have ANY edge of a kind (the existential "modules that import
1867
- // anything") the positive set an existential negation ("do not import anything")
1868
- // differences off allOfClass to yield "modules that import nothing".
1391
+ // Subjects with any edge of a kind; an existential negation differences
1392
+ // this off allOfClass to yield "modules that import nothing".
1869
1393
  case "existsEdge": {
1870
1394
  const subs = new Set(kindsFor(ast.kind).flatMap((k) => edgesOfKind(graph, k)).map((e) => e.subject));
1871
1395
  return graph.individuals.filter((i) => subs.has(i.id) && (!ast.entityType || i.class === ast.entityType));
1872
1396
  }
1873
- // forward complement: the verb's object-grain universe MINUS what the (late-resolved,
1874
- // focus-bindable) subject reaches via that verb "what doesn't it import".
1397
+ // Forward complement: the verb's object-grain universe minus what the
1398
+ // subject reaches via that verb ("what doesn't it import").
1875
1399
  case "forwardComplement": {
1876
1400
  const r = resolveTermOrContext(graph, ast.subjectTerm, opts && opts.contextId);
1877
1401
  if (!r.match) return []; // unresolved subject / focus-less pronoun → honest empty
@@ -1888,11 +1412,9 @@ function evalSet(graph, ast, opts) {
1888
1412
  const ids = new Set(evalSet(graph, ast.inner, opts).map((i) => i.id));
1889
1413
  return forwardOverSet(graph, ast.kind, ids);
1890
1414
  }
1891
- // the previous list-shaped answer's own id set (parsePluralAnaphoraObject's
1892
- // "those"/"them" leaf)evalComposite's reverseSet/forwardSet dispatch already
1893
- // intercepts the genuinely-empty (no `prev` at all) case as an honest "needs a
1894
- // previous answer" miss, same as evalAnaphora's own no-prev branch; this is only
1895
- // reached with a real, non-empty `prev` in hand.
1415
+ // The previous list-shaped answer's own id set. Only reached with a real,
1416
+ // non-empty `prev`the no-`prev` case is intercepted earlier as an
1417
+ // honest "needs a previous answer" miss.
1896
1418
  case "prevSet": {
1897
1419
  const prev = opts && opts.prev;
1898
1420
  return Array.isArray(prev) ? prev.map((id) => graph.byId.get(id)).filter(Boolean) : [];
@@ -1917,10 +1439,10 @@ function evalSet(graph, ast, opts) {
1917
1439
  return objs.filter((o) => o.class === ast.entityType);
1918
1440
  }
1919
1441
  if (owner.kind === "miss") return [];
1920
- // item 6 (HANDOVER): the owner's own members win outright when non-empty;
1442
+ // The owner's own members win outright when non-empty;
1921
1443
  // only an EMPTY own set walks up `inherits` (computeMembership) — see its
1922
1444
  // own doc above. evalSet's flat-array embedding (a find-seed inside a
1923
- // boolean/qualifier fold, §6-style) transparently flattens own vs. inherited,
1445
+ // boolean/qualifier fold) transparently flattens own vs. inherited,
1924
1446
  // same as evalSet's "find" case does for computeFind's narrow/broad split;
1925
1447
  // the DISCLOSED (never-silent) version lives in evalMembershipComposite,
1926
1448
  // below, for the top-level (and qualifier-wrapped) membership query shapes.
@@ -1929,8 +1451,8 @@ function evalSet(graph, ast, opts) {
1929
1451
  }
1930
1452
  case "qualifier": {
1931
1453
  // a qualifier wrapping a MEMBERSHIP inner needs the filter applied INSIDE
1932
- // the inheritance walk, at each level, not once after a flat resolve (item
1933
- // 6, Fix 1's per-level requirement — see computeMembership's own doc: a
1454
+ // the inheritance walk, at each level, not once after a flat resolve
1455
+ // see computeMembership's own doc: a
1934
1456
  // class can own a non-empty member set that the qualifier alone empties
1935
1457
  // out, which must still walk up rather than stopping on the unfiltered own
1936
1458
  // set). evalMembershipComposite is the single source of truth for this;
@@ -1969,12 +1491,9 @@ function evalBoolean(graph, ast, opts) {
1969
1491
  return acc;
1970
1492
  }
1971
1493
 
1972
- /** Resolve parseAnaphora's in-sentence candidateTerms (HANDOVER.md item 1) to real
1973
- * graph entities, de-duplicated by resolved id. Returns null (not just []) when
1974
- * fewer than 2 resolve, so the caller can tell "no in-sentence candidates" apart
1975
- * from "named candidates that happen to fail resolution" and fall back to
1976
- * opts.prev either way — never a guess, same discipline as everywhere else in
1977
- * this function. */
1494
+ /** Resolve parseAnaphora's in-sentence candidateTerms to real graph entities,
1495
+ * de-duplicated by resolved id. Returns null (not []) when fewer than 2
1496
+ * resolve, so the caller falls back to opts.prev either way. */
1978
1497
  function resolveInSentenceCandidates(graph, terms) {
1979
1498
  if (!Array.isArray(terms) || terms.length < 2) return null;
1980
1499
  const seen = new Set();
@@ -1986,11 +1505,9 @@ function resolveInSentenceCandidates(graph, terms) {
1986
1505
  return resolved.length >= 2 ? resolved : null;
1987
1506
  }
1988
1507
 
1989
- /** Anaphora over the candidate set: EITHER the current utterance's own in-sentence
1990
- * named entities (parseAnaphora's candidateTerms a single turn, no prior turn
1991
- * needed at all, HANDOVER.md item 1), tried first, OR ask()'s `prev` id array (a
1992
- * genuine previous-turn follow-up), filtered/counted the same way either way. No
1993
- * candidate set at all → honest miss (never a guess), like an unresolved pronoun. */
1508
+ /** Anaphora over the candidate set: the current utterance's in-sentence named
1509
+ * entities, tried first, or ask()'s `prev` id array otherwise. No candidate
1510
+ * set at all is an honest miss. */
1994
1511
  function evalAnaphora(graph, ast, opts) {
1995
1512
  const inSentence = resolveInSentenceCandidates(graph, ast.candidateTerms);
1996
1513
  let baseItems = inSentence;
@@ -2007,21 +1524,17 @@ function evalAnaphora(graph, ast, opts) {
2007
1524
  const r = resolveObject(graph, f.clause.object);
2008
1525
  if (!r.match) items = [];
2009
1526
  else {
2010
- // include the symbol-grain sibling so a fn->fn "call" filter tests callsSymbol,
2011
- // not just the module-coarse "calls" edge (mirrors traverse()'s reverse path).
1527
+ // Include the symbol-grain sibling so a fn->fn "call" filter tests
1528
+ // callsSymbol, not just module-coarse "calls".
2012
1529
  const sib = SYMBOL_GRAIN_SIBLING[f.clause.kind];
2013
1530
  const kinds = [...kindsFor(f.clause.kind), ...(sib ? [sib] : [])];
2014
1531
  const ok = new Set(kinds.flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.object === r.match.id).map((e) => e.subject));
2015
1532
  items = items.filter((ind) => ok.has(ind.id));
2016
1533
  }
2017
1534
  }
2018
- // a count over a prior set names the entity kind when the survivors share a class.
2019
- // When the filter narrows a real prior set down to ZERO, fall back to the PRIOR
2020
- // set's own class (still shared, pre-filter) so the honest-empty render still
2021
- // names what was checked ("nothing in the index matches that (methods)."
2022
- // instead of a bare, kind-less "nothing in the index matches that.") — the
2023
- // filter genuinely found no survivors, but the entity kind it filtered is not
2024
- // itself unknown, so the miss shouldn't read as if it were.
1535
+ // A count over a prior set names the entity kind when survivors share a
1536
+ // class; fall back to the prior set's own class when the filter empties it,
1537
+ // so the honest-empty render still names what was checked.
2025
1538
  const sameClass = (list) => (list.length && list.every((x) => x.class === list[0].class) ? list[0].class : null);
2026
1539
  const common = items.length ? sameClass(items) : sameClass(baseItems);
2027
1540
  if (ast.mode === "count") return { compositeKind: "count", count: items.length, entityType: common, matches: [] };
@@ -2032,13 +1545,9 @@ function evalAnaphora(graph, ast, opts) {
2032
1545
  // commit-history kinds are excluded so "connections" reads as the code-structure
2033
1546
  // degree a developer means, not every recorded touch.
2034
1547
  const DEGREE_KINDS = ["imports", "calls", "callsSymbol", "inherits", "contains", "tests"];
2035
- /** Degree of an individual under a superlative metric ({kind, dir, sibling?, filter?}).
2036
- * Exported (2026-07-12, HANDOVER "bare 'how many X' fails for edge-nominalized
2037
- * nouns" fix) so chat.mjs's answerEdgeCount can compute the SAME per-entity
2038
- * degree for a single named entity ("how many callers does X have") that this
2039
- * file's own evalSuperlative already uses to rank every entity of a class
2040
- * ("which module has the most callers") — one metric definition
2041
- * (EDGE_NOUN_TO_METRIC), one degree computation, two call sites. */
1548
+ /** Degree of an individual under a superlative metric ({kind, dir, sibling?,
1549
+ * filter?}). Exported so chat.mjs's answerEdgeCount shares this computation
1550
+ * for a single-entity query. */
2042
1551
  export function degreeMetric(graph, ind, metric) {
2043
1552
  const kinds = metric.kind === "*" ? DEGREE_KINDS : [metric.kind, ...(metric.sibling ? [metric.sibling] : [])];
2044
1553
  let n = 0;
@@ -2052,12 +1561,8 @@ export function degreeMetric(graph, ind, metric) {
2052
1561
  }
2053
1562
  return n;
2054
1563
  }
2055
- /** Seonix Batch 3 (3b): every Commit individual, newest date first the eval side of
2056
- * the bare "recent commits"/"latest commits"/"newest commits" AST node (see
2057
- * RECENT_COMMIT_LEAD/parseRelationalOrQualified above). Reuses the SAME
2058
- * dateOf/localeCompare sort every other Commit-date reader in this file already
2059
- * uses (ISO-8601 sorts correctly lexically). An empty graph is an honest empty,
2060
- * never a guess. */
1564
+ /** Every Commit individual, newest date first (ISO-8601 sorts correctly
1565
+ * lexically). */
2061
1566
  function evalRecentCommits(graph) {
2062
1567
  const commits = graph.individuals.filter((i) => i.class === "Commit");
2063
1568
  const dateOf = (c) => String((c.attributes || []).find((a) => a.key === "date")?.value || "");
@@ -2066,12 +1571,9 @@ function evalRecentCommits(graph) {
2066
1571
  }
2067
1572
 
2068
1573
  const COMMIT_FILTER_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
2069
- /** COMMIT FILTER eval — resolves the pivot (a literal ISO date, or a NAMED commit
2070
- * whose own recorded date becomes the pivot resolved here, graph-dependent, per
2071
- * the parser's own doc), then filters every Commit individual's date against it.
2072
- * A pivot that IS itself a commit is excluded from its own comparison. An
2073
- * unresolvable pivot (neither a date nor a known commit) declines honestly
2074
- * (pivotResolved:false) rather than guessing an empty result. */
1574
+ /** Resolves the pivot (a literal ISO date, or a named commit whose own date
1575
+ * becomes the pivot), then filters every Commit's date against it. An
1576
+ * unresolvable pivot declines honestly (pivotResolved:false). */
2075
1577
  function evalCommitFilter(graph, ast) {
2076
1578
  const { op, pivotRaw } = ast;
2077
1579
  const dateOf = (c) => String((c.attributes || []).find((a) => a.key === "date")?.value || "").slice(0, 10);
@@ -2100,18 +1602,13 @@ function evalCommitFilter(graph, ast) {
2100
1602
  return { compositeKind: "commitFilter", op, pivotRaw, pivotDate, pivotResolved: true, matches };
2101
1603
  }
2102
1604
 
2103
- /** TEMPORAL over a nested set (lever 3) — the commits that touched ANY member of the
2104
- * inner set, newest commit date first. Reuses the SAME touches→commit→date-sort the
2105
- * flat when-shape runs (mgx:commitDate is ISO-8601, so a lexical sort IS a date sort;
2106
- * undated commits sort last and render says so). `entityType` is the inner noun, only
2107
- * for phrasing. An empty inner set (nothing resolved) or no touching commit is an
2108
- * honest empty — never a guess. */
1605
+ /** Temporal over a nested set: the commits that touched any member of the
1606
+ * inner set, newest date first. `entityType` is the inner noun, for phrasing only. */
2109
1607
  function evalTemporal(graph, ast, opts) {
2110
1608
  const inner = evalSet(graph, ast.inner, opts);
2111
1609
  const ids = new Set(inner.map((i) => i.id));
2112
1610
  if (!ids.size) return { compositeKind: "temporal", matches: [], entityType: ast.entityType, innerCount: 0 };
2113
- // reverseOverSet(touches) collects the touching commits across BOTH grains (its
2114
- // grain-aware object-set branch reads touchesSymbol when the inner set is symbols).
1611
+ // reverseOverSet(touches) collects touching commits across both grains.
2115
1612
  const commits = reverseOverSet(graph, "touches", "Commit", ids);
2116
1613
  const dateOf = (c) => String((c.attributes || []).find((a) => a.key === "date")?.value || "");
2117
1614
  commits.sort((a, b) => dateOf(b).localeCompare(dateOf(a)));
@@ -2128,20 +1625,11 @@ function evalSuperlative(graph, ast) {
2128
1625
  return { compositeKind: "superlative", entityType: ast.entityType, metricNoun: ast.metricNoun, extreme: ast.extreme, score: best, matches: winners };
2129
1626
  }
2130
1627
 
2131
- /** TOP-LEVEL "<kind> of/in <owner>" membership eval (HANDOVER item 6), covering
2132
- * both a bare membership node and a QUALIFIER node wrapping one ("public methods
2133
- * of TaskController") — the two share this one function so the qualifier's
2134
- * filter is threaded INSIDE computeMembership's inheritance walk (item 6, Fix 1's
2135
- * per-level requirement) rather than applied once, after a plain flat resolve
2136
- * (which would wrongly stop on a non-empty-but-unfiltered own set that the
2137
- * qualifier alone would empty out — see computeMembership's own doc). Unlike
2138
- * evalSet's embedded (flattening) "membership"/"qualifier" cases — used when this
2139
- * shape is nested inside a boolean fold — this keeps the inheritance provenance
2140
- * (`inheritedNotOwn`/`viaLabel`/`ownerLabel`) so renderComposite can disclose an
2141
- * ancestor-sourced answer ("TaskController has no own public methods — inherited
2142
- * from Controller: render.") rather than ever silently presenting an ancestor's
2143
- * members as the owner's own. A directory scope (no single owner individual) has
2144
- * no inheritance chain to walk — unaffected by item 6, same behavior as before. */
1628
+ /** Top-level "<kind> of/in <owner>" membership eval, covering both a bare
1629
+ * membership node and a qualifier node wrapping one. Keeps inheritance
1630
+ * provenance (`inheritedNotOwn`/`viaLabel`/`ownerLabel`) so renderComposite
1631
+ * can disclose an ancestor-sourced answer instead of presenting it as the
1632
+ * owner's own. */
2145
1633
  function evalMembershipComposite(graph, ast, opts) {
2146
1634
  const qualNode = ast.node === "qualifier" && ast.inner.node === "membership" ? ast : null;
2147
1635
  const memNode = qualNode ? qualNode.inner : ast;
@@ -2169,14 +1657,10 @@ function evalMembershipComposite(graph, ast, opts) {
2169
1657
  };
2170
1658
  }
2171
1659
 
2172
- /** EXISTENCE eval — "is there a/an <kind> [called/named <term>] [in <module>]": a
2173
- * direct membership/name check against the graph, never routed through the
2174
- * relation-verb machinery. A named check resolves the term against the SAME
2175
- * tiered resolveObject() every other named-lookup shape uses (expectedClass pins
2176
- * the pool to the asked kind, so "is there a class called Store" can never
2177
- * resolve to a same-named function/module); a scope clause resolves the module
2178
- * the same way and narrows the check to that module's own `defines` edges
2179
- * (refineToEntities — the same primitive members-of-a-module questions use). */
1660
+ /** Existence eval — a direct membership/name check against the graph, never
1661
+ * routed through the relation-verb machinery. `expectedClass` pins the
1662
+ * resolve pool so "is there a class called Store" can't resolve to a
1663
+ * same-named function/module. */
2180
1664
  function evalExists(graph, ast) {
2181
1665
  const { entityType, term, scopeModule } = ast;
2182
1666
  let scopeMatch = null;
@@ -2200,13 +1684,9 @@ function evalExists(graph, ast) {
2200
1684
  return { compositeKind: "exists", entityType, term: null, scopeModule, scopeMatch, matches: pool };
2201
1685
  }
2202
1686
 
2203
- /** QUALIFIER-CHECK eval resolve the term (a context pronoun binds through
2204
- * contextId, exactly like resolveTermOrContext's every other caller), then
2205
- * read the SAME qualHolds() predicate the set-filter path already uses.
2206
- * `holds` here means "the STATEMENT AS ASKED is true" (negation already
2207
- * folded in), so the renderer can answer Yes/No directly off it without
2208
- * re-deriving the negation. An unresolved term is an honest miss, never a
2209
- * guess — no different from any other named-object lookup. */
1687
+ /** Qualifier-check eval: resolves the term (a context pronoun binds through
1688
+ * contextId), then reads qualHolds(). `holds` means "the statement as asked
1689
+ * is true" (negation already folded in). */
2210
1690
  function evalQualCheck(graph, ast, opts) {
2211
1691
  const { term, qualifier, negated } = ast;
2212
1692
  const r = resolveTermOrContext(graph, term, opts.contextId);
@@ -2230,17 +1710,13 @@ export function evalComposite(graph, ast, opts = {}) {
2230
1710
  if (ast.node === "recentCommits") return evalRecentCommits(graph);
2231
1711
  if (ast.node === "commitFilter") return evalCommitFilter(graph, ast);
2232
1712
  if (ast.node === "anaphora") return evalAnaphora(graph, ast, opts);
2233
- // membership inheritance cascade (HANDOVER item 6), TOP-LEVEL: a bare "<kind> of
2234
- // <owner>" node, or a qualifier wrapping one ("public methods of <owner>") — see
2235
- // evalMembershipComposite's own doc for why the two share one function and why
2236
- // this keeps disclosure provenance the embedded evalSet path deliberately drops.
1713
+ // Membership inheritance cascade, top-level (keeps disclosure provenance
1714
+ // the embedded evalSet path drops).
2237
1715
  if (ast.node === "membership" || (ast.node === "qualifier" && ast.inner.node === "membership")) {
2238
1716
  return evalMembershipComposite(graph, ast, opts);
2239
1717
  }
2240
- // predicate-find (Workstream 2), TOP-LEVEL: unlike evalSet's "find" case (used
2241
- // when a find-seed is embedded inside a boolean/qualifier fold, §6), this keeps
2242
- // the broad-pass provenance so renderComposite can label a "related, not exact"
2243
- // hit distinctly rather than presenting it as an unqualified match.
1718
+ // Predicate-find, top-level: keeps broad-pass provenance so renderComposite
1719
+ // can label a "related, not exact" hit distinctly.
2244
1720
  if (ast.node === "find") {
2245
1721
  const { narrow, broad } = computeFind(graph, ast.entityType, ast.term);
2246
1722
  return {
@@ -2248,10 +1724,8 @@ export function evalComposite(graph, ast, opts = {}) {
2248
1724
  matches: narrow.length ? narrow : broad, broad: !narrow.length && broad.length > 0,
2249
1725
  };
2250
1726
  }
2251
- // plural-anaphora object (parsePluralAnaphoraObject): a genuinely EMPTY `prev` means
2252
- // "those"/"them" has no antecedent at all the same honest "needs a previous answer"
2253
- // miss evalAnaphora's own no-prev branch gives "of those"/"count them", rather than
2254
- // evalSet's ordinary (and here misleading) empty-set "nothing in the index matches".
1727
+ // Plural-anaphora object: an empty `prev` means "those"/"them" has no
1728
+ // antecedent, an honest miss rather than evalSet's misleading empty-set message.
2255
1729
  if ((ast.node === "reverseSet" || ast.node === "forwardSet") && ast.inner.node === "prevSet"
2256
1730
  && !(Array.isArray(opts.prev) && opts.prev.length)) {
2257
1731
  return { compositeMiss: true, reason: "no-prev", matches: [] };
@@ -2259,22 +1733,20 @@ export function evalComposite(graph, ast, opts = {}) {
2259
1733
  return { compositeKind: "set", matches: evalSet(graph, ast, opts), entityType: ast.entityType || null };
2260
1734
  }
2261
1735
 
2262
- // ---- compositional RENDER templated, same "honest miss vs cited hit" discipline
2263
- // as renderCore. ----
1736
+ // ---- compositional render: templated, same "honest miss vs cited hit"
1737
+ // discipline as renderCore. ----
2264
1738
 
2265
1739
  const compositeList = (matches) => listJoin(matches.slice(0, OVERFLOW_CAP)
2266
1740
  .map((m) => (["Function", "Method"].includes(m.class) ? `${m.label}()` : m.label)))
2267
1741
  + (matches.length > OVERFLOW_CAP ? `, …and ${matches.length - OVERFLOW_CAP} more` : "");
2268
1742
 
2269
- /** A compositional worked example for the rephrase hint (§honest miss now shows a
2270
- * compositional phrasing too). */
1743
+ /** A compositional worked example for the rephrase hint. */
2271
1744
  export function compositionalHint() {
2272
1745
  return 'compositional queries also work: "which functions call X and call Y", "what calls something that imports X", "public methods of X", "list functions" / "show me the classes", "how many classes", "which module has the most imports", "find me the payment class", or (after a listing) "which of those are tested"';
2273
1746
  }
2274
1747
 
2275
- /** A short citation line for a SINGLE predicate-find hit — the module it lives in,
2276
- * when known (mirrors the plain reverse-shape render's grouping convention, just
2277
- * condensed to one line since there is exactly one hit to cite). */
1748
+ /** A short citation line for a single predicate-find hit — the module it
1749
+ * lives in, when known. */
2278
1750
  function describeFindHit(ind) {
2279
1751
  const label = ["Function", "Method"].includes(ind.class) ? `${ind.label}()` : ind.label;
2280
1752
  if (ind.class === "Module") return label;
@@ -2289,9 +1761,6 @@ function renderComposite(parsed, result) {
2289
1761
  }
2290
1762
  return { content: `couldn't compile this compositional question${result.reason ? ` (${result.reason})` : ""}. ${compositionalHint()}.`, miss: true, ambiguous: false };
2291
1763
  }
2292
- // exists: "is there a/an <kind> [called/named <term>] [in <module>]" — an
2293
- // honest Yes/No membership check, never routed through the relation-verb
2294
- // machinery (see parseExistence's own doc for the bug this fixes).
2295
1764
  if (result.compositeKind === "exists") {
2296
1765
  if (result.scopeMiss) {
2297
1766
  return { content: `no module matching "${result.scopeModule}" found in the index.`, miss: true, ambiguous: false };
@@ -2313,12 +1782,6 @@ function renderComposite(parsed, result) {
2313
1782
  }
2314
1783
  return { content: `Yes — ${compositeList(result.matches)}${scopeSuffix}.`, miss: false, ambiguous: false, matches: result.matches };
2315
1784
  }
2316
- // qualCheck: "is <term> [a/an] <qualifier> […]" — the single-entity sibling of
2317
- // "is there a/an <kind> …" above: a direct Yes/No over one already-named/-
2318
- // focused individual, never a set listing. `result.holds` already has the
2319
- // negation folded in (evalQualCheck), so the renderer states the actual truth
2320
- // plainly — "No — X is tested." for "is X not tested" when X IS tested, never
2321
- // an echo of the (now-false) question's own wording.
2322
1785
  if (result.compositeKind === "qualCheck") {
2323
1786
  if (result.qualCheckMiss === "pronoun") {
2324
1787
  return { content: `"${result.term}" needs a selected node to refer to — click a node first, or name it directly.`, miss: true, ambiguous: false };
@@ -2342,24 +1805,16 @@ function renderComposite(parsed, result) {
2342
1805
  if (!result.matches.length) {
2343
1806
  return { content: `no ${nounFor(result.entityType, 2)} in this index.`, miss: true, ambiguous: false, matches: [] };
2344
1807
  }
2345
- // an unscoped list that overflowed the cap gets a light hint to narrow by module
2346
- // but only for kinds that live IN a module (a "modules in <module>" or "commits in
2347
- // <module>" scope is meaningless); the scoped forms are already narrow, no hint.
2348
- // Memory-graph classes (Fact/Utterance/Session/Source/Rule, dynamicClassQuery
2349
- // above) never support a module scope either — there's no such parse for them —
2350
- // so they're excluded here too rather than hinting at an unsupported shape.
1808
+ // Kinds that don't live in a module (or have no module-scope parse at
1809
+ // all, like memory-graph classes) get no narrow-by-module hint.
2351
1810
  const scopeable = !["Module", "Commit", "Fact", "Utterance", "Session", "Source", "Rule"].includes(result.entityType);
2352
1811
  const hint = (!result.scoped && scopeable && result.matches.length > OVERFLOW_CAP)
2353
1812
  ? ` — narrow with "${nounFor(result.entityType, 2)} in <module>"`
2354
1813
  : "";
2355
1814
  return { content: `${compositeList(result.matches)}${hint}.`, miss: false, ambiguous: false, matches: result.matches };
2356
1815
  }
2357
- // membership inheritance cascade (HANDOVER item 6): a zero-hit is the ordinary
2358
- // set-producing honest miss below; a non-empty INHERITED result (the owner's own
2359
- // set was empty, an ancestor's wasn't) is disclosed OUT LOUD — "X has no own
2360
- // <kind> — inherited from <ancestor>: …" — never silently presented as though
2361
- // the owner declared them itself (never-fabricate, the same discipline
2362
- // computeFind's "related, not exact" broad pass documents for predicate-find).
1816
+ // A non-empty inherited result is disclosed out loud ("X has no own <kind>
1817
+ // inherited from <ancestor>: …"), never silently presented as the owner's own.
2363
1818
  if (result.compositeKind === "membership") {
2364
1819
  if (!result.matches.length) {
2365
1820
  return { content: `nothing in the index matches that${result.entityType ? ` (${nounFor(result.entityType, 2)})` : ""}. ${touchesRephraseHint()}`, miss: true, ambiguous: false, matches: [] };
@@ -2374,10 +1829,9 @@ function renderComposite(parsed, result) {
2374
1829
  }
2375
1830
  return { content: `${compositeList(result.matches)}.`, miss: false, ambiguous: false, matches: result.matches };
2376
1831
  }
2377
- // predicate-find (Workstream 2): zero hits -> an honest miss naming BOTH the type
1832
+ // predicate-find: zero hits -> an honest miss naming BOTH the type
2378
1833
  // and the term; the broad ("related, not exact") pass is ALWAYS clearly labeled,
2379
- // never presented as an unqualified match the confident-wrong discipline Bug
2380
- // C's grain-aware resolution established; one hit -> a short citation; many hits
1834
+ // never presented as an unqualified match; one hit -> a short citation; many hits
2381
1835
  // -> the standard compositeList/OVERFLOW_CAP convention, reused verbatim.
2382
1836
  if (result.compositeKind === "find") {
2383
1837
  const typeNoun = nounFor(result.entityType, 1);
@@ -2393,10 +1847,10 @@ function renderComposite(parsed, result) {
2393
1847
  }
2394
1848
  return { content: `${cited}.`, miss: false, ambiguous: false, matches: result.matches };
2395
1849
  }
2396
- // Seonix Batch 3 (3b): bare "recent/latest/newest commits" — a real dated commit
1850
+ // Bare "recent/latest/newest commits" — a real dated commit
2397
1851
  // list, newest first, capped at HISTORY_CAP for consistency with renderFileHistory/
2398
1852
  // renderSymbolHistory's own listing convention (codegraph.mjs) — never the false
2399
- // "no Commit found matching 'recent'" find-miss this used to fall into.
1853
+ // "no Commit found matching 'recent'" find-miss this would otherwise fall into.
2400
1854
  if (result.compositeKind === "recentCommits") {
2401
1855
  if (!result.matches.length) return { content: `no commits recorded in this index.`, miss: true, ambiguous: false, matches: [] };
2402
1856
  const dateOf = (c) => String((c.attributes || []).find((a) => a.key === "date")?.value || "");
@@ -2411,7 +1865,7 @@ function renderComposite(parsed, result) {
2411
1865
  miss: false, ambiguous: false, matches: result.matches,
2412
1866
  };
2413
1867
  }
2414
- // Track 1 temporal lever (remainder): "what changed since/before/after/on
1868
+ // "what changed since/before/after/on
2415
1869
  // <date-or-commit>" — same dated-list rendering convention as recentCommits just
2416
1870
  // above, scoped to the resolved pivot. An unresolvable pivot names itself and the
2417
1871
  // two supported pivot shapes (never a silent guess); a resolved pivot with no
@@ -2480,57 +1934,35 @@ function renderComposite(parsed, result) {
2480
1934
  return { content: `${compositeList(result.matches)}.`, miss: false, ambiguous: false, matches: result.matches };
2481
1935
  }
2482
1936
 
2483
- /** The rephrase hint shown on a grammar miss — generated from the SAME tables the parser
2484
- * uses, so it can never suggest a phrasing the grammar doesn't actually support (§6.3). */
1937
+ /** The rephrase hint shown on a grammar miss — generated from the same
1938
+ * tables the parser uses, so it can never suggest an unsupported phrasing. */
2485
1939
  export function rephraseHint() {
2486
- // "touched" used to sit in the <imports|calls|uses|inherits from|tests> cross-product
2487
- // above, combined with <functions|classes|modules> but `touches` is a Commit->Module/
2488
- // symbol edge (ask-vocab.mjs's RELATIONS.touches comment), so a Function/Class/Module is
2489
- // NEVER the subject of a touch: "which modules touched X" always misses, for every X, no
2490
- // matter the graph (verified against test/fixtures/entities.fixture.json — every reverse
2491
- // combination with "touched" and a functions/classes/modules subject returns zero
2492
- // matches). The real reverse subject for this edge is Commit — "which commits touched
2493
- // <name>" below, mirroring the working "who touched <name>" nudge used elsewhere
2494
- // (chat.mjs's nudgeAnswer).
1940
+ // `touches` is a Commit->Module/symbol edge, so a Function/Class/Module is
1941
+ // never the subject of a touch; the real reverse subject is Commit
1942
+ // ("which commits touched <name>" below).
2495
1943
  return '"which <functions|classes|modules> <imports|calls|uses|inherits from|tests> <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 commits touched <name>" 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). '
2496
1944
  + compositionalHint();
2497
1945
  }
2498
1946
 
2499
- /** A short, honest nudge for the touches/history-family of correct-but-unhelpful
2500
- * honest misses (CEFR decision log, BENCHMARK_CEFR_ENGLISH_1.7.0.md item 1:
2501
- * g-c1-temp-7, g-c1-temp-3, g-b1-pron-1/4/5, hm-unknown-fn, hm-unknown-module all
2502
- * scored `rephrase: 0` despite being correct empty results — the miss said WHAT
2503
- * wasn't found but gave no nudge toward a question that WOULD work). Points at the
2504
- * same "who touched X" / "/describe X" shapes that produce a real answer elsewhere
2505
- * in this file (see whenShape/whoLastShape's own success template, "X was last
2506
- * touched by commit …", a few lines below) — never promises any PARTICULAR name
2507
- * will resolve, since the whole point of the miss it's attached to is that this
2508
- * one didn't. Sibling of rephraseHint() above: same purpose, narrower vocabulary,
2509
- * reused verbatim by every touches/history-family miss template rather than each
2510
- * one inventing its own wording. */
1947
+ /** A short, honest nudge for the touches/history-family of correct-but-
1948
+ * unhelpful misses, pointing at "who touched X" / "/describe X" shapes that
1949
+ * produce a real answer elsewhere. Reused verbatim by every miss template
1950
+ * in this family. */
2511
1951
  export function touchesRephraseHint() {
2512
1952
  return 'Try "who touched <a module that actually has commits>" or "/describe <module>" to see what\'s in the index.';
2513
1953
  }
2514
1954
 
2515
- // ---- §4 object-term resolution — mechanical, no embeddings, tiered, stop at first hit ----
1955
+ // ---- object-term resolution — mechanical, no embeddings, tiered, stop at first hit ----
2516
1956
 
2517
1957
  function componentSet(s) {
2518
1958
  return new Set(String(s).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
2519
1959
  }
2520
1960
 
2521
- /** A minimal, CLOSED derivational-suffix normalizer for tier 3's Module-basename
2522
- * bridge (item 9 fix, 2026-07-09 playtest-freeze dead-end) deliberately NOT a
2523
- * general stemmer (prose.mjs's own tokenizer comment explicitly avoids a stemmer
2524
- * dependency; this stays a few hand-picked suffixes, same "closed set over general
2525
- * rule" preference as everywhere else in this file). Strips exactly one of a small
2526
- * suffix set ("ing"/"er"/"ers"/"or"/"ors" — the gerund and agent-noun endings that
2527
- * regularly pair up in English: "logging"/"logger", "routing"/"router") off the
2528
- * END of the word, then collapses a doubled trailing letter the strip exposed (the
2529
- * consonant-doubling spelling short CVC roots take before -ing/-er: "log" ->
2530
- * "logging"/"logger", both reducing here to "log"). Length-floored at 5 BEFORE
2531
- * stripping so it can never fire on a short word and reopen the accidental-
2532
- * short-word-match bug tier 3's other floors guard against; returns the word
2533
- * unchanged (so callers can detect "no-op" via `=== w`) when no suffix matches. */
1961
+ /** A minimal, closed derivational-suffix normalizer for tier 3's
1962
+ * Module-basename bridge (not a general stemmer): strips one gerund/agent-noun
1963
+ * suffix ("ing"/"er"/"ers"/"or"/"ors") and collapses a doubled trailing
1964
+ * consonant ("logging"/"logger" -> "log"). Length-floored at 5 to avoid
1965
+ * short-word false matches; returns the word unchanged when no suffix matches. */
2534
1966
  function derivationalStem(w) {
2535
1967
  if (w.length < 5) return w;
2536
1968
  const stripped = w.replace(/(ing|ers|ors|er|or)$/, "");
@@ -2540,17 +1972,10 @@ function derivationalStem(w) {
2540
1972
  : stripped;
2541
1973
  }
2542
1974
 
2543
- /** Strip EXPLICIT separator characters only (path slashes, hyphens, underscores, a
2544
- * trailing file extension) — never camelCase boundaries — so a candidate's own
2545
- * label/path collapses to the same joined lowercase token a naming-convention-blind
2546
- * user would type: "PaymentSystem" -> "paymentsystem", "payment-system" ->
2547
- * "paymentsystem", "westfield-payment-system/src/MyCode.cs" ->
2548
- * "westfieldpaymentsystemsrcmycode", "IPaymentSystemImpl.cs" -> "ipaymentsystemimpl".
2549
- * Used by the compound-term tier just below (multi-word query bridge, 2026-07-09
2550
- * compound-name fix, item: "match symbols where the question breaks a symbol into
2551
- * 2 words"). The extension strip is deliberately the SAME single-trailing-
2552
- * extension regex tier 3's own `stem` computation already uses elsewhere in this
2553
- * file — not reinvented. */
1975
+ /** Strip explicit separator characters only (path slashes, hyphens,
1976
+ * underscores, trailing extension) — never camelCase boundaries — so a
1977
+ * label collapses to the same joined lowercase token a naming-convention-
1978
+ * blind user would type ("PaymentSystem"/"payment-system" -> "paymentsystem"). */
2554
1979
  function joinedForm(label) {
2555
1980
  return String(label || "")
2556
1981
  .replace(/\.[a-z0-9]+$/i, "")
@@ -2558,11 +1983,9 @@ function joinedForm(label) {
2558
1983
  .replace(/[/\-_.]+/g, "");
2559
1984
  }
2560
1985
 
2561
- /** Same joined-token normalization as joinedForm(), applied to the QUERY side of a
2562
- * multi-word term: a leading article is stripped first (mirrors LEADING_ARTICLE_RE
2563
- * below — "the payment system" and "payment system" must produce the identical
2564
- * joined form), then whitespace/hyphens/underscores between words collapse out —
2565
- * "the payment system" -> "payment system" -> "paymentsystem". */
1986
+ /** Same joined-token normalization as joinedForm(), applied to the query side
1987
+ * of a multi-word term: a leading article is stripped first so "the payment
1988
+ * system" and "payment system" produce the identical joined form. */
2566
1989
  function joinedQueryForm(term) {
2567
1990
  return String(term || "")
2568
1991
  .trim()
@@ -2571,63 +1994,31 @@ function joinedQueryForm(term) {
2571
1994
  .replace(/[\s\-_]+/g, "");
2572
1995
  }
2573
1996
 
2574
- /** Resolve a free-text object/subject term against the graph's individuals, in priority
2575
- * order (§4, generalized beyond the module-coupling worked example to cover every verb
2576
- * family's object grain `inherits`/`calls` resolve against Class/Function names, not
2577
- * just modules): a sha-shaped term ("[commit ]<hex≥7>") first resolves against Commit
2578
- * individuals by unique id/label prefix (see the inline comment), then (1) exact
2579
- * label/id match, (2) an `ext:` unresolved-target match (today
2580
- * ext: targets are edge-endpoint STRINGS with no individual of their own — e.g. an
2581
- * unimported inherits base, or any import target — so this tier returns a synthetic
2582
- * {id:"ext:<name>", label:<name>, class:null} match rather than an `individuals` lookup;
2583
- * see PLAN_MECHANICAL_CHAT.md §10 on why external `imports` targets land here rather
2584
- * than tier 1/3 today), (3) boundary-aware substring/component match, (4) a prose-index
2585
- * fallback (PLAN_PROSE_INDEX.md §6): the term, tokenized the same way as a docstring, is
2586
- * looked up against `graph.proseIndex` via `lookupByProseTokens` — an exact WORD-level
2587
- * overlap match against a symbol's decomposed-identifier or doc-comment tokens, never a
2588
- * substring/fuzzy guess (the same "no wrong edge" standard as tiers 1-3, just over a
2589
- * different token source: prose rather than the literal identifier). Only ever consulted
2590
- * once every literal-identifier tier above has failed, and the result is tagged
2591
- * `matchedVia: "prose"` so a caller can tell the match came from prose content rather
2592
- * than the symbol's own name — the render layer does not currently read this (it treats
2593
- * a resolved match as a resolved match, same "honest miss vs genuine hit" binary tiers
2594
- * 1-3 already use), but the field is there for any caller that wants to surface it. (5)
2595
- * a bounded Damerau-Levenshtein pass against labels AND label components (two-level
2596
- * fuzzy, 2026-07-02): a UNIQUE within-bound match resolves, tagged `matchedVia:
2597
- * "fuzzy"` so render() can say "assuming you meant <label>" out loud; multiple
2598
- * matches at the same best distance are an honest ambiguity listing the candidates.
2599
- * Never applied to sha-shaped terms (the commit namespace is exact-or-ambiguous
2600
- * only) nor to terms under 4 chars (the bound would cover half of everything).
2601
- * (6) no match at all — an honest miss. Returns {match, candidates, tier, ambiguous
2602
- * [, matchedVia]} — ambiguous on a true tier-3 score tie, a tier-4 overlap-count
2603
- * tie, or a tier-5 distance tie.
1997
+ /** Resolve a free-text object/subject term against the graph's individuals, in
1998
+ * priority order: a sha-shaped term first resolves against Commit
1999
+ * individuals by unique id/label prefix, then (1) exact label/id match, (2)
2000
+ * an `ext:` unresolved-target match (a synthetic match with no real
2001
+ * individual), (3) boundary-aware substring/component match, (4) a
2002
+ * prose-index fallback (exact word-level overlap against decomposed-
2003
+ * identifier or doc-comment tokens, tagged `matchedVia: "prose"`), (5) a
2004
+ * bounded Damerau-Levenshtein pass against labels and components, tagged
2005
+ * `matchedVia: "fuzzy"` never applied to sha-shaped or sub-4-char terms.
2006
+ * (6) no match: an honest miss. Returns {match, candidates, tier, ambiguous
2007
+ * [, matchedVia]}; ambiguous on a same-tier score/distance tie.
2604
2008
  *
2605
- * `opts.expectedClass` (grain-aware resolution, Bug C+D fix): when set, narrows
2606
- * the candidate POOL to `i.class === expectedClass` before every pool-driven tier
2607
- * (exact/tier-3/tier-5) the ranking code within each tier is untouched, only the
2608
- * universe it ranks over shrinks. The ext: tier (synthetic matches with
2609
- * `class: null`, never a real individual) is skipped outright when a class is
2610
- * expected — it can never BE that class. The prose tier (tier 4) filters its hits
2611
- * to the expected class before picking a winner. Every existing call site passes
2612
- * no 3rd argument, so `expectedClass` defaults to null and behavior is
2613
- * byte-identical to before this option existed — this is purely opt-in narrowing
2614
- * for a caller (traverse()'s reverse case) that already knows what class the
2615
- * relation's object slot expects ("which modules import logger" must never
2616
- * resolve "logger" to a same-stem Class). */
2009
+ * `opts.expectedClass`, when set, narrows the candidate pool before every
2010
+ * pool-driven tier opt-in grain-aware resolution so "which modules import
2011
+ * logger" never resolves "logger" to a same-stem Class. */
2617
2012
  function resolveObjectCore(graph, term, { expectedClass = null } = {}) {
2618
2013
  const t = String(term || "").trim();
2619
2014
  if (!t) return { match: null, candidates: [], tier: null, ambiguous: false };
2620
2015
  const tLc = t.toLowerCase();
2621
2016
  const pool = expectedClass ? graph.individuals.filter((i) => i.class === expectedClass) : graph.individuals;
2622
2017
 
2623
- // commit-sha tier (checked first, only for sha-shaped terms): "ef74e44e25c8",
2624
- // "commit ef74e44e25c8", "commit:ef74e44", or a full 40-char sha resolve against
2625
- // Commit individuals by id/label prefix (ids are commit:<full-sha>, labels the
2626
- // 12-char short sha), case-insensitive. A UNIQUE prefix is exact-grade over the
2627
- // closed commit namespace (tier 1); a prefix shared by more than one commit is an
2628
- // honest ambiguity listing the candidates — never "the first one"; a hex-looking
2629
- // word matching NO commit falls through to the ordinary tiers unchanged (it may
2630
- // be a real code identifier).
2018
+ // Commit-sha tier: a hex-looking term resolves against Commit individuals
2019
+ // by id/label prefix. A unique prefix is exact-grade (tier 1); a shared
2020
+ // prefix is an honest ambiguity; no match falls through (it may be a real
2021
+ // code identifier) unless the explicit "commit" noun declared intent.
2631
2022
  const shaTerm = tLc.match(/^(commit[:\s])?([0-9a-f]{7,40})$/);
2632
2023
  if (shaTerm) {
2633
2024
  const sha = shaTerm[2];
@@ -2635,19 +2026,14 @@ function resolveObjectCore(graph, term, { expectedClass = null } = {}) {
2635
2026
  && (String(i.id).toLowerCase().startsWith(`commit:${sha}`) || String(i.label).toLowerCase().startsWith(sha)));
2636
2027
  if (hits.length === 1) return { match: hits[0], candidates: [], tier: 1, ambiguous: false };
2637
2028
  if (hits.length > 1) return { match: hits[0], candidates: hits.slice(1, 5), tier: 1, ambiguous: true };
2638
- // the explicit "commit" noun declares intent — with no matching commit, falling
2639
- // through would let the WORD "commit" component-match the Commit schema node (or
2640
- // any identifier containing it): a guess, not a resolution. Bare hex still falls
2641
- // through (it may be a real code identifier).
2642
2029
  if (shaTerm[1]) return { match: null, candidates: [], tier: null, ambiguous: false };
2643
2030
  }
2644
2031
 
2645
2032
  const exact = pool.find((i) => String(i.label).toLowerCase() === tLc || String(i.id).toLowerCase() === tLc);
2646
2033
  if (exact) return { match: exact, candidates: [], tier: 1, ambiguous: false };
2647
2034
 
2648
- // ext: targets never have their own individual (they're a raw edge-endpoint id) — find
2649
- // the actual (case-preserved) id off a real edge rather than reconstructing it, so a
2650
- // typo'd term can't silently "resolve" to an ext: id nothing in the graph references.
2035
+ // ext: targets have no individual of their own; find the case-preserved id
2036
+ // off a real edge so a typo'd term can't silently "resolve" to one.
2651
2037
  const extLc = `ext:${tLc}`;
2652
2038
  let extId = null;
2653
2039
  outer: for (const g of graph.relations) {
@@ -2655,22 +2041,12 @@ function resolveObjectCore(graph, term, { expectedClass = null } = {}) {
2655
2041
  if (String(e.object).toLowerCase() === extLc) { extId = e.object; break outer; }
2656
2042
  }
2657
2043
  }
2658
- // ext: matches are synthetic (class: null, no real individual) — with a class
2659
- // expected, they can never satisfy it, so skip this tier entirely rather than
2660
- // returning a match whose class silently doesn't match what the caller asked for.
2661
2044
  if (extId && !expectedClass) return { match: { id: extId, label: t, class: null }, candidates: [], tier: 2, ambiguous: false };
2662
2045
 
2663
- // tier 3 two disjoint regimes (dotted-symbol fix, 2026-07-02, advisor-verified
2664
- // bug): a DOTTED term with no slash ("res.json", "Widget.render", "walk.mjs") is
2665
- // symbol-shaped (object.member / Class.method / a bare file name), and the old
2666
- // any-substring-of-any-label pass let it land on a module whose PATH merely
2667
- // contains the text ("res.json" -> test/res.json.js — the wrong grain presented
2668
- // as if the term were that file). Such terms now match only (a) symbol labels
2669
- // (whole-term containment, or the ".member" suffix when the owner alias differs:
2670
- // "res.json" -> Response.json), and (b) module labels by EXACT basename equality
2671
- // ("walk.mjs" -> src/walk.mjs — extension-stripped basename equality is
2672
- // deliberately NOT used; that is precisely the phantom-path vector). Symbol
2673
- // matches outrank module matches. Undotted/slashed terms keep the original pass.
2046
+ // Tier 3, two regimes: a dotted term with no slash ("res.json") is
2047
+ // symbol-shaped, so it matches only symbol labels or a module by exact
2048
+ // basename equality (never a phantom substring-of-path match); undotted/
2049
+ // slashed terms keep the original containment pass.
2674
2050
  const scored = [];
2675
2051
  const dotted = !tLc.includes("/") && /^[\w$]+(\.[\w$]+)+$/.test(tLc);
2676
2052
  if (dotted) {
@@ -2686,106 +2062,43 @@ function resolveObjectCore(graph, term, { expectedClass = null } = {}) {
2686
2062
  }
2687
2063
  }
2688
2064
  } else {
2689
- // Root-cause fix (Tier-2 playtest cycle 9, targeted substring-match sweep):
2690
- // this raw containment check has no minimum-length floor, so a short
2691
- // closed-vocabulary word is a near-certain ACCIDENTAL substring of SOME
2692
- // real label confirmed empirically against the shipped mini-webapp
2693
- // fixture: "so"->sendJson, "or"->Store, "a"->Task, "is"->listTasks
2694
- // (ambiguous), "in"->Logger.info, "on"->sendJson, "at"->createApp,
2695
- // "to"->Store, all via this exact branch. Cycle 8 patched three of these
2696
- // ("and"/"also"/"so"/"then"/"now" and bare "it") one word at a time at
2697
- // individual chat.mjs CALL SITES (STACCATO_LEAKED_CONNECTIVES, the
2698
- // pronoun-reuse guard) — necessary there because those bugs are about
2699
- // FOCUS bookkeeping, not resolution per se, but leaving resolveObject
2700
- // itself unguarded meant every OTHER caller (existence checks, expectedClass
2701
- // lookups, etc.) stayed exposed to the same trap for every not-yet-hit
2702
- // short word. Gating the containment check at the same floor tier 5's own
2703
- // fuzzy pass already uses (`tLc.length >= 4` below) closes it at the
2704
- // source. A whole-token component match (just below) is unaffected by this
2705
- // floor — it requires an EXACT path/identifier segment equality, never a
2706
- // raw substring, so a genuinely short real identifier ("db", "fs") is
2707
- // still resolvable by literally matching a whole segment.
2065
+ // Raw containment has no minimum-length floor, so a short word is a
2066
+ // near-certain accidental substring of some real label ("so"->sendJson).
2067
+ // Gating it at the same floor tier 5's fuzzy pass uses (>= 4 chars, below)
2068
+ // closes it; a whole-token component match is unaffected since it
2069
+ // requires exact segment equality, not a substring.
2708
2070
  const termComps = [...componentSet(t)];
2709
- // A SLASHED term's final path segment, extension stripped ("src/nope.mjs"
2710
- // -> "nope", "cover app/lib/b.mjs" -> "b") — the semantically load-bearing
2711
- // FILENAME STEM, as opposed to a directory segment or a leaked verb noise
2712
- // word. Isolated from the actual whitespace-delimited PATH TOKEN (not the
2713
- // raw multi-word string as a whole) — "app/lib/f.mjs but untested" (a
2714
- // trailing-noise leak, distinct from the leading-verb-noise shape above)
2715
- // has no extension at the end of the whole string, so splitting the whole
2716
- // string would strand "f.mjs but untested" as a bogus non-matching "stem";
2717
- // finding the one token that itself contains "/" keeps the path term
2718
- // intact regardless of what noise surrounds it on either side. Only
2719
- // slash-shaped terms compute this; a bare identifier/multi-word query has
2720
- // no path structure to anchor on, so it's null and the gate below is a
2721
- // no-op for those (unaffected — original ANY-overlap behavior).
2071
+ // A slashed term's final path segment, extension stripped — the
2072
+ // filename stem, isolated from surrounding noise ("app/lib/f.mjs but
2073
+ // untested" must not strand "f.mjs but untested" as a bogus stem).
2722
2074
  const pathToken = tLc.split(/\s+/).find((tok) => tok.includes("/"));
2723
2075
  const slashStem = pathToken ? pathToken.split("/").pop().replace(/\.[a-z0-9]+$/, "") : null;
2724
- // Compound-term bridge (2026-07-09, "match symbols where the question breaks a
2725
- // symbol into 2 [words]"): a query with 2+ SPACE-SEPARATED words ("payment
2726
- // system", "the payment system") has no separator of its own to compare against
2727
- // a label's literal spelling — the tiers above/below all compare tLc verbatim,
2728
- // so "payment system" never equals/contains/overlaps "PaymentSystem" or
2729
- // "payment-system" by those checks even though a human reads them as the same
2730
- // concept. Computed ONCE per query (article-stripped, space-collapsed — see
2731
- // joinedQueryForm) and checked per-candidate below via each candidate's OWN
2732
- // joinedForm(). Single-word queries are unaffected: isMultiWord is false, the
2733
- // branch below never fires, and they resolve exactly as before through the
2734
- // tiers already in this loop.
2076
+ // Compound-term bridge: a query with 2+ space-separated words ("payment
2077
+ // system") is compared against each candidate's own joinedForm(), since
2078
+ // the literal tLc comparisons above/below never match a spaceless label
2079
+ // ("PaymentSystem"). Single-word queries are unaffected.
2735
2080
  const qWords = t.trim().replace(/^(?:the|a|an)\s+/i, "").trim().split(/\s+/).filter(Boolean);
2736
2081
  const isMultiWord = qWords.length >= 2;
2737
2082
  const qJoined = isMultiWord ? joinedQueryForm(t) : null;
2738
2083
  for (const m of pool) {
2739
2084
  const label = String(m.label || "").toLowerCase();
2740
- // Basename-exact/prefix/suffix tier (large-scale-fixture bug, 2026-07-09): a bare
2741
- // term that IS a file's basename ("verify-shipped" -> scripts/verify-shipped.mjs)
2742
- // must outrank every sibling that merely shares a directory or a component
2743
- // ("verify") with it checked BEFORE the raw-containment/overlap passes below so
2744
- // an exact stem match always wins over a same-directory partial. Only meaningful
2745
- // for a bare (unslashed) term, since `stem` is compared directly against the whole
2746
- // `tLc` — a slashed query term (e.g. "src/nope.mjs") already contains "/" and can
2747
- // never equal/prefix/suffix a bare stem, so it falls through unaffected to the
2748
- // existing slashStem-gated overlap logic just below, unchanged.
2749
- // The prefix/suffix half (not the exact-equality half) shares the SAME sub-4-char
2750
- // floor as the containment tier just below it — an unguarded stem.startsWith(tLc)
2751
- // reintroduces the exact short-word accidental-match bug that floor was added to
2752
- // close (e.g. bare "so" prefix-matching "someOtherFile"'s stem). A full stem
2753
- // EQUALITY, at any length, is never an accidental substring — "db"/"fs"-shaped
2754
- // short real identifiers must still resolve — so it stays unguarded.
2085
+ // Basename-exact/prefix/suffix tier: a bare term that IS a file's
2086
+ // basename outranks a sibling that merely shares a directory/component.
2087
+ // Prefix/suffix shares tier 5's sub-4-char floor; exact equality is
2088
+ // never an accidental substring, so it stays unguarded (short real
2089
+ // identifiers like "db"/"fs" must still resolve).
2755
2090
  const stem = label.split("/").pop().replace(/\.[a-z0-9]+$/, "");
2756
2091
  if (stem === tLc) { scored.push({ ind: m, score: 5000 }); continue; }
2757
2092
  if (tLc.length >= 4 && (stem.startsWith(tLc) || stem.endsWith(tLc))) {
2758
2093
  scored.push({ ind: m, score: 4000 - Math.abs(stem.length - tLc.length) });
2759
2094
  continue;
2760
2095
  }
2761
- // Compound-term bridge (2026-07-09): the multi-word analog of the exact/
2762
- // prefix-suffix tier just above a query that breaks a single joined symbol
2763
- // into 2+ words ("payment system") is compared against the candidate's OWN
2764
- // joined form (separators stripped, camelCase left alone), not its literal
2765
- // spelling. An exact joined match ("payment system" == "PaymentSystem"'s
2766
- // "paymentsystem") is scored the SAME as the single-word exact-stem tier
2767
- // above (5000) — it is equally strong evidence, just phrased with spaces.
2768
- // A CONTAINMENT match ("payment system" found inside "westfield-payment-
2769
- // system"'s or "IPaymentSystemImpl.cs"'s joined form) is scored strictly
2770
- // BELOW every single-word tier above (a real single-word substring/prefix/
2771
- // suffix hit is stronger evidence than a multi-word query merely appearing
2772
- // somewhere in a longer joined string) but ABOVE the raw component-overlap
2773
- // tier further below (score <= 10). CONTAINMENT is additionally gated on the
2774
- // candidate label carrying an EXPLICIT separator (path slash, hyphen,
2775
- // underscore, or a real file extension) — a pure-camelCase label with none
2776
- // of those (e.g. Function "calculateTotalPrice") is deliberately left to
2777
- // tier 4's prose/decomposed-identifier fallback below, which already owns
2778
- // exactly that "query words are a sub-sequence of a compound identifier's
2779
- // OWN decomposed tokens" territory (frozen test: "total price" ->
2780
- // calculateTotalPrice must resolve at tier 4 via matchedVia:"prose", not
2781
- // tier 3) — without this gate, EVERY multi-word query touching prose
2782
- // territory would be silently reclassified as a tier-3 containment hit and
2783
- // break that precedent. The EXACT tier just above has no such gate: an
2784
- // exact joined-form equality is unambiguous evidence regardless of whether
2785
- // the label happens to use an explicit separator (PascalCase "PaymentSystem"
2786
- // included) — only the fuzzier CONTAINMENT check needs the extra guard.
2787
- // Gated on isMultiWord so a single-word query is never affected (it already
2788
- // resolves via the tiers above/below, unchanged).
2096
+ // Compound-term bridge, multi-word analog of the tier above: an exact
2097
+ // joined-form match scores the same as exact-stem; a containment match
2098
+ // scores below every single-word tier but above raw overlap, and is
2099
+ // gated on an explicit separator so a pure-camelCase label is left to
2100
+ // tier 4's prose fallback instead (frozen test: "total price" ->
2101
+ // calculateTotalPrice must resolve via matchedVia:"prose", not tier 3).
2789
2102
  if (isMultiWord) {
2790
2103
  const candJoined = joinedForm(m.label);
2791
2104
  if (candJoined && candJoined === qJoined) { scored.push({ ind: m, score: 5000 }); continue; }
@@ -2795,29 +2108,11 @@ function resolveObjectCore(graph, term, { expectedClass = null } = {}) {
2795
2108
  continue;
2796
2109
  }
2797
2110
  }
2798
- // Derivational-suffix basename bridge (item 9 fix, 2026-07-09): a bare term
2799
- // that is a MODULE's own basename one gerund/agent-noun suffix-swap away
2800
- // ("logging" for src/lib/logger.mjs's basename "logger" neither is a
2801
- // substring/prefix/suffix of the other, so the tiers just above miss it)
2802
- // is still a real NAME match, not free text — Module-only (mirrors the
2803
- // dotted-branch's own Module-only exact-basename special case just above
2804
- // in this file), specifically so it can never also fire for a same-stem
2805
- // Class/Method/Function sharing the same root ("Logger", "Logger.info")
2806
- // and manufacture a false three-way tie; a bare-word class-ambiguity like
2807
- // that is exactly the documented, already-accepted expectedClass-gated
2808
- // case this function's own docblock calls out ("logger" -> Class), left
2809
- // untouched. Scored below the literal exact/prefix/suffix tiers above (a
2810
- // real substring is always stronger evidence) but above plain containment/
2811
- // overlap (a genuine one-suffix-away basename match is still far more
2812
- // specific than an accidental shared word). Guarded against tier 5's OWN
2813
- // territory: a term that is merely a near-miss TYPO of an already-close
2814
- // literal stem ("loging" for "logging", 1 edit away) must still fall
2815
- // through to the bounded-fuzzy tier and be ANNOUNCED ("assuming you
2816
- // meant…") — it is not a distinct derivational word-form, it is the same
2817
- // word misspelled — so this bridge only fires when the term is OUTSIDE
2818
- // the fuzzy tier's own distance bound (a real morphological pair like
2819
- // "logging"/"logger" is 3 edits apart, well past tier 5's 2-edit budget
2820
- // for words this length, so there is no overlap between the two tiers).
2111
+ // Derivational-suffix basename bridge: a term that's a Module's own
2112
+ // basename one suffix-swap away ("logging" for basename "logger") is
2113
+ // still a name matchModule-only, so it can't collide with a
2114
+ // same-stem Class. Guarded against tier 5's territory: a near-miss typo
2115
+ // ("loging" for "logging") must still fall through to bounded-fuzzy.
2821
2116
  if (m.class === "Module") {
2822
2117
  const termRoot = derivationalStem(tLc);
2823
2118
  if (termRoot !== tLc && termRoot === derivationalStem(stem)) {
@@ -2839,7 +2134,7 @@ function resolveObjectCore(graph, term, { expectedClass = null } = {}) {
2839
2134
  // real module that merely shares its generic directory/extension
2840
2135
  // segments ("src", "mjs") with every other module in the pool (found
2841
2136
  // via the existence recognizer's "is there a class in src/nope.mjs"
2842
- // scope clause, Tier-2 playtest cycle 9: it ambiguously "matched"
2137
+ // scope clause: it would otherwise ambiguously "match"
2843
2138
  // src/core/model.mjs even though no such module exists — the same
2844
2139
  // accidental-match disease as the short-word substring bug just above,
2845
2140
  // just triggered by GENERIC components instead of raw containment).
@@ -2871,7 +2166,7 @@ function resolveObjectCore(graph, term, { expectedClass = null } = {}) {
2871
2166
  };
2872
2167
  }
2873
2168
 
2874
- // tier 4: prose-index fallback (PLAN_PROSE_INDEX.md §6) — see the function doc above.
2169
+ // tier 4: prose-index fallback — see the function doc above.
2875
2170
  // The typeof guard is the same viewer-bundle boundary as defaultNlp(): viz.mjs's
2876
2171
  // askSource strips the prose.mjs import but does not inline prose.mjs, so in the
2877
2172
  // browser `lookupByProseTokens` is an undeclared identifier — without the guard,
@@ -2882,12 +2177,10 @@ function resolveObjectCore(graph, term, { expectedClass = null } = {}) {
2882
2177
  // reappearing through a side door — a dotted term names an identifier, and
2883
2178
  // identifiers resolve by label (tiers above) or the bounded fuzzy pass
2884
2179
  // below, or they honestly miss. The SAME side door was open for SLASHED
2885
- // path terms too (Tier-2 playtest cycle 9, existence-recognizer follow-up):
2886
- // "src/nope.mjs" — a nonexistent module — no longer false-matches tier 3
2180
+ // path terms too: "src/nope.mjs" a nonexistent module — no longer false-matches tier 3
2887
2181
  // (the AND-across-components fix just above), but fell through to THIS
2888
2182
  // prose tier and ambiguously "matched" real modules anyway, because
2889
- // lookupByProseTokens scores by ANY-token overlap (sum-scored, by design,
2890
- // for genuine prose ranking) and "src"/"mjs" are near-universal path/
2183
+ // lookupByProseTokens scores by ANY-token overlap and "src"/"mjs" are near-universal path/
2891
2184
  // extension tokens shared by every module in the pool — the identical
2892
2185
  // accidental-match disease, just one tier further down. A slash-shaped term
2893
2186
  // names a literal path exactly like a dotted term names a literal symbol;
@@ -2923,11 +2216,8 @@ function resolveObjectCore(graph, term, { expectedClass = null } = {}) {
2923
2216
  }
2924
2217
  }
2925
2218
 
2926
- // tier 5: bounded fuzzy (see the function doc) every exact tier above missed
2927
- // (or tier 4 tied), so a typo'd identifier gets one honest chance against labels
2928
- // and their components. sha-shaped terms never reach here (guard above);
2929
- // sub-4-char terms are excluded because a 1-edit budget on 3 chars matches far
2930
- // too much to ever be a unique intent.
2219
+ // Tier 5: bounded fuzzy, one honest chance for a typo'd identifier. Sub-4-char
2220
+ // terms are excluded a 1-edit budget on 3 chars matches far too much.
2931
2221
  if (!shaTerm && tLc.length >= 4) {
2932
2222
  const bound = fuzzyBound(tLc);
2933
2223
  let best = bound + 1;
@@ -2947,53 +2237,29 @@ function resolveObjectCore(graph, term, { expectedClass = null } = {}) {
2947
2237
  return { match: hits[0], candidates: [], tier: 5, ambiguous: false, matchedVia: "fuzzy" };
2948
2238
  }
2949
2239
  if (best <= bound && hits.length > 1 && !proseResult) {
2950
- // equidistant fuzzy tie with no prose evidence either — honest ambiguity.
2951
2240
  const [bestInd, ...rest] = hits;
2952
2241
  return { match: bestInd, candidates: rest.slice(0, 4), tier: 5, ambiguous: true, matchedVia: "fuzzy" };
2953
2242
  }
2954
2243
  }
2955
- // fuzzy couldn't resolve uniquely: surface the prose tie (when there was one)
2956
- // exactly as before, else the honest miss.
2957
2244
  return proseResult || { match: null, candidates: [], tier: null, ambiguous: false };
2958
2245
  }
2959
2246
 
2960
2247
  /** A leading article is pure noise on a structural entity term ("the logger" ==
2961
2248
  * "logger") — mirrors memory/core.mjs's normFactTerm article-strip for taught
2962
- * facts (Tier 5, T1), applied here on the graph-resolution side instead. */
2249
+ * facts, applied here on the graph-resolution side instead. */
2963
2250
  const LEADING_ARTICLE_RE = /^(?:the|a|an)\s+/i;
2964
2251
 
2965
- /** A trailing GENERIC GRAIN WORD ("the logger MODULE", "the Task CLASS") is a
2966
- * TYPE HINT a real user attaches to disambiguate WHICH grain they mean — but
2967
- * resolveObjectCore's plain word-overlap scoring (tier 3) can't read
2968
- * grammatical role, so the grain word instead becomes an ordinary overlapping
2969
- * component and can manufacture an accidental TIE between the actual module and
2970
- * any same-stem Class/Method sharing its name (Tier 6 playtest, found live:
2971
- * "the logger module" tied mod:src/lib/logger.mjs against fn:...#Logger and
2972
- * fn:...#Logger.info, all scoring identically on the shared "logger" component
2973
- * once "module"/"the" themselves matched nothing — the caller's own
2974
- * `!ambiguous` gate then silently declined the whole thing, walling
2975
- * moduleOrientLane/"describe the logger module"/"where is the logger module
2976
- * defined"/etc. even though a human reads "module" as fully disambiguating).
2977
- * Reuses ENTITY_TO_TYPE (ask-vocab.mjs) — the SAME closed noun→grain table the
2978
- * grammar's own entity-slot parsing already trusts — so this never invents a
2979
- * new vocabulary, only a new place the existing one gets consulted. */
2252
+ /** A trailing generic grain word ("the logger MODULE") is a type hint to
2253
+ * disambiguate which grain is meant, but tier 3's plain word-overlap scoring
2254
+ * can't read grammatical role it becomes an ordinary component and can tie
2255
+ * the module against a same-stem Class/Method. Reuses ENTITY_TO_TYPE. */
2980
2256
  const TRAILING_GRAIN_WORD_RE = new RegExp(`\\s+(${Object.keys(ENTITY_TO_TYPE).join("|")})$`, "i");
2981
2257
 
2982
- /** resolveObject: the grain-aware disambiguation PRE-PASS, wrapping
2983
- * resolveObjectCore (the tiered resolver, unchanged) only when the CALLER
2984
- * hasn't already pinned an expectedClass (a caller that already knows the
2985
- * class, e.g. traverse()'s reverse case, needs no help). Tries, in order:
2986
- * (1) a trailing grain word ("module"/"class"/"function"/"method"/…, after a
2987
- * leading-article strip) narrows the pool to that ONE grain and retries on
2988
- * just the head noun — closing exactly the accidental-tie class this
2989
- * docblock above describes; (2) failing that, a plain leading-article strip
2990
- * alone (no grain word) — "the logger" resolves the same way bare "logger"
2991
- * always has. Either retry is used ONLY on an unambiguous hit; any miss/tie
2992
- * falls through unchanged to the ORIGINAL (unstripped) term via
2993
- * resolveObjectCore, so this is purely additive — a term that already
2994
- * resolved before resolves exactly the same way now (a multi-word "the X
2995
- * module"-shaped term never equals a real label outright, so the exact-match
2996
- * tier the pre-pass could theoretically shadow is never actually in play). */
2258
+ /** resolveObject: a grain-aware disambiguation pre-pass wrapping
2259
+ * resolveObjectCore, only when the caller hasn't pinned an expectedClass.
2260
+ * Tries a trailing grain word (narrows the pool, retries the head noun),
2261
+ * then a plain leading-article strip. Either retry is used only on an
2262
+ * unambiguous hit; any miss/tie falls through unchanged to the original term. */
2997
2263
  export function resolveObject(graph, term, opts = {}) {
2998
2264
  const { expectedClass = null } = opts;
2999
2265
  if (!expectedClass) {
@@ -3076,26 +2342,14 @@ function commitTouches(graph, commit, entityType, extra = {}) {
3076
2342
  };
3077
2343
  }
3078
2344
 
3079
- /** Safety net (paired with the render-branch fix earlier in this file's history): a
3080
- * {shape, kind, entityType} combination must be explicitly listed here to receive
3081
- * real non-"direct" modifier behavior. Anything parsing to a non-"direct" modifier
3082
- * that ISN'T listed gets an honest "not supported yet" response from render() —
3083
- * never a silent fallback to direct-only behavior. This means a future
3084
- * MODIFIER_TO_KIND addition that forgets to wire traverse()/render() for it fails
3085
- * loud here, by construction, rather than quietly behaving as if the modifier had
3086
- * never been given (the exact bug class this file's own render-routing fix, above,
3087
- * just caught). Today's only non-"direct" value is "transitive" (PLAN_MECHANICAL_
3088
- * CHAT.md P1), wired below for reverse-shape imports/calls closures over
3089
- * impactClosure (codegraph.mjs) — module-coarse only; the fine-grained
3090
- * callsSymbol/touchesSymbol siblings and every other predicate kind have no
3091
- * closure primitive yet, and forward-shape currently never parses a non-"direct"
3092
- * modifier at all (both parsing strategies hardcode modifier:"direct" for it). */
2345
+ /** Safety net: a {shape, kind, entityType} combination must be explicitly
2346
+ * listed here to receive real non-"direct" modifier behavior; anything else
2347
+ * gets an honest "not supported yet" response, never a silent fallback to
2348
+ * direct-only behavior. */
3093
2349
  function modifierIsWired(shape, kind, entityType) {
3094
2350
  return shape === "reverse" && (kind === "imports" || kind === "calls") && (!entityType || entityType === "Module");
3095
2351
  }
3096
- // Matches renderImpact's own default (codegraph.mjs) — impactClosure is reused as-is,
3097
- // not reimplemented, so its own depth convention is the honest one to inherit.
3098
- const TRANSITIVE_MAX_DEPTH = 8;
2352
+ const TRANSITIVE_MAX_DEPTH = 8; // matches renderImpact's own default (codegraph.mjs)
3099
2353
 
3100
2354
  /** Compile a parsed query into a graph lookup. Pure given (graph, parsed, opts).
3101
2355
  * `opts.contextId` resolves a context pronoun ("this"/"it"/…) when the parse
@@ -3104,20 +2358,12 @@ const TRANSITIVE_MAX_DEPTH = 8;
3104
2358
  * `matches` is always an array of individuals (or edge records for "ask"). */
3105
2359
  export function traverse(graph, parsed, { contextId = null, prev = null, pinnedObjMatch = null } = {}) {
3106
2360
  if (!parsed) return { matches: [], objMatch: null, candidates: [], traversal: null, ambiguous: false };
3107
- // compositional AST (PLAN §5.16 P3) — the new grammar's nodes carry a `node` tag;
3108
- // everything else (simple clauses, ambiguousParse) flows through the original path
3109
- // below completely unchanged.
2361
+ // Compositional AST nodes carry a `node` tag; everything else flows through
2362
+ // the original path below unchanged.
3110
2363
  if (parsed.node) return evalComposite(graph, parsed, { contextId, prev });
3111
- // (fix, 2026-07-11) A same-class parse-level tie ({ambiguousParse, candidates})
3112
- // used to short-circuit here with an empty result, so renderCore's ambiguousParse
3113
- // branch could only ever describe each reading ("1) meta X or 2) Y — try
3114
- // rephrasing"), never actually answer any of them — an honest admission of
3115
- // ambiguity with no real content behind it. Every candidate IS a normal,
3116
- // individually-resolvable parse (merge.mjs only ties same-class DISTINCT parses,
3117
- // never nests another ambiguousParse inside one), so each one can be traversed
3118
- // and rendered for real right here — the combined answer stays deterministic on
3119
- // the same input (no guessing, no picking a winner) while actually telling the
3120
- // user what each reading resolves to, instead of making them ask twice.
2364
+ // Every candidate in a same-class parse-level tie is independently
2365
+ // resolvable, so each is traversed and rendered for real here instead of
2366
+ // just describing the ambiguity with no content behind it.
3121
2367
  if (parsed.ambiguousParse) {
3122
2368
  const branches = parsed.candidates.map((c) => {
3123
2369
  const branchResult = traverse(graph, c, { contextId, prev });
@@ -3127,12 +2373,9 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3127
2373
  }
3128
2374
  const { shape, kind, entityType } = parsed;
3129
2375
 
3130
- // meta: a question about the graph's OWN vocabulary ("what does cochange mean", "what
3131
- // is a Commit") looked up against the SchemaClass/SchemaPredicate individuals
3132
- // schema-docs.mjs's ingestSchemaDocs merged into the graph, not a code-edge traversal.
3133
- // Matched by exact (case-insensitive) label ("cochange", "Commit") OR the raw `token`
3134
- // attribute a SchemaPredicate also carries ("mgx:callsSymbol") — never substring/fuzzy,
3135
- // same discipline as resolveObject's own tiers: a real term match or an honest miss.
2376
+ // meta: a question about the graph's own vocabulary, looked up against the
2377
+ // SchemaClass/SchemaPredicate individuals schema-docs.mjs merged into the
2378
+ // graph, not a code-edge traversal. Exact label or `token` attribute match only.
3136
2379
  if (shape === "meta") {
3137
2380
  const term = String(parsed.object || "").trim();
3138
2381
  const termLc = term.toLowerCase();
@@ -3143,11 +2386,6 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3143
2386
  return token && String(token).toLowerCase() === termLc;
3144
2387
  });
3145
2388
  if (!match) {
3146
- // META FALLBACK TO REAL ENTITIES (0.8.2 WS1; widened + extracted to
3147
- // metaFallbackEntityAnswer, HANDOVER.md 2026-07-10 item 6) — see that
3148
- // function's own docblock for the full "what is a Record"/"what does
3149
- // fnAlpha mean" history. A unique hit renders straight from its own text
3150
- // (render()'s metaCodeClass branch just passes it through).
3151
2389
  const fallback = metaFallbackEntityAnswer(graph, term);
3152
2390
  if (fallback) {
3153
2391
  return {
@@ -3164,13 +2402,8 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3164
2402
  };
3165
2403
  }
3166
2404
 
3167
- // mentions: "where is X mentioned" (2026-07-02 query families) — the prose
3168
- // surface, not an edge traversal: list the individuals whose decomposed
3169
- // identifier / doc-comment tokens contain the term's words (the same index
3170
- // resolveObject's tier 4 consults, surfaced directly). The term itself is NOT
3171
- // resolved to an entity first — the question is about mentions of the words,
3172
- // which is exactly what the prose index stores. typeof guard: same viewer-
3173
- // bundle boundary as tier 4 (prose.mjs is never inlined).
2405
+ // mentions: "where is X mentioned" — the prose surface (resolveObject's
2406
+ // tier-4 index), not an edge traversal or entity resolution.
3174
2407
  if (shape === "mentions") {
3175
2408
  const term = String(parsed.object || "").trim();
3176
2409
  const hits = typeof lookupByProseTokens === "function" ? lookupByProseTokens(graph.proseIndex, term) : [];
@@ -3181,10 +2414,8 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3181
2414
  };
3182
2415
  }
3183
2416
 
3184
- // §modifier support gate (safety net, see modifierIsWired's own doc above) — checked
3185
- // BEFORE object resolution, so an unsupported modifier+kind combination gets its own
3186
- // honest capability-gap message rather than masquerading as an object-miss, or worse,
3187
- // silently behaving as if "transitively"/"indirectly" had never been said.
2417
+ // Checked before object resolution, so an unsupported modifier+kind
2418
+ // combination gets its own honest capability-gap message.
3188
2419
  if (parsed.modifier && parsed.modifier !== "direct" && !modifierIsWired(shape, kind, entityType)) {
3189
2420
  return {
3190
2421
  matches: [], objMatch: null, candidates: [], ambiguous: false,
@@ -3196,33 +2427,17 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3196
2427
  if (shape === "ask") {
3197
2428
  const subj = resolveTermOrContext(graph, parsed.subject, contextId);
3198
2429
  const obj = resolveTermOrContext(graph, parsed.object, contextId);
3199
- // BENCHMARK_CONVERSATION_1.8.14.md item 7(b): a term that resolved only via a
3200
- // TIED, ambiguous fuzzy/prose match (e.g. a garbled object phrase like "handler
3201
- // I think" trailing hedge noise a plain declarative leaked into the object
3202
- // slot) used to fall straight through as if `obj`/`subj` were a confident
3203
- // single match, so a randomly-first-picked TIED candidate (once, live, a raw
3204
- // Commit whose "label" IS its short hash, "c3d4e5f6a1b2") rode straight into
3205
- // the Yes/No render's "No — no <kind> edge found from A to B" sentence — an
3206
- // internal id leaking into user-facing text off the back of an unresolved tie,
3207
- // not a real resolution. Every OTHER shape in this file already declines
3208
- // rather than guessing on a tie (resolveObject's own "never a guess" contract,
3209
- // its docblock above); this shape is the one place that never checked the
3210
- // `ambiguous` flag its own resolver already computed. Declining here (leaving
3211
- // subjMatch unset, same as the `!subj.match || !obj.match` miss just below)
3212
- // reaches the SAME "couldn't resolve one of the terms in this question" honest
3213
- // miss render() already uses — never a confident wrong answer, and never a
3214
- // raw internal id surfacing off a coin-flip pick among tied candidates.
2430
+ // A tied, ambiguous fuzzy/prose match must decline here too (this shape
2431
+ // otherwise never checked `ambiguous`), or a coin-flip candidate's raw id
2432
+ // could leak into the Yes/No render's sentence.
3215
2433
  if (!subj.match || !obj.match || subj.ambiguous || obj.ambiguous) {
3216
2434
  return {
3217
2435
  matches: [], objMatch: obj.match, candidates: obj.candidates, traversal: null, ambiguous: false, answer: null,
3218
2436
  unresolvedPronoun: !!(subj.unresolvedPronoun || obj.unresolvedPronoun),
3219
2437
  };
3220
2438
  }
3221
- // touches edges are stored commit -> entity, so when the question names the
3222
- // commit on the OBJECT side ("was walk.mjs touched by commit X"), orient the
3223
- // edge test by where the commit actually is instead of failing on direction;
3224
- // a commit subject is also checked at the symbol grain ("does commit X touch
3225
- // <function>" lives on touchesSymbol, not the module-coarse kind).
2439
+ // touches edges are stored commit -> entity; orient by where the commit
2440
+ // actually is when it's named on the object side.
3226
2441
  let [from, to] = [subj.match, obj.match];
3227
2442
  let kinds = kindsFor(kind); // "uses" checks the whole union ("does X use Y")
3228
2443
  if (kind === "touches") {
@@ -3236,33 +2451,16 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3236
2451
  };
3237
2452
  }
3238
2453
 
3239
- // reverse and forward both resolve one named term ("object" in the parsed shape — for
3240
- // forward it is the query's grammatical subject, e.g. "what does X import" -> parsed.object = X).
3241
- //
3242
- // altObject pruning (PLAN_CONVERSATION.md Finding 2): noise-strip.mjs's bare
3243
- // "where"/"mentions" reading may carry `parsed.altObject` the SAME reading
3244
- // with a wink-POS-flagged, plausibly-noise light verb ALSO dropped ("store
3245
- // router" -> "router"; see that file's own doc for why the signal needs full-
3246
- // sentence context and can't be decided there, where there is no graph).
3247
- // This is the one place both the alternate reading and the graph are
3248
- // available together, so it's where the pruning actually happens — mirroring
3249
- // resolveObject's own grain-word retry just above (try a variant, keep it
3250
- // ONLY on an unambiguous hit, else fall through unchanged) and
3251
- // grammar/ace.mjs's parseAceAmbiguous ("keep only complete, valid parses,
3252
- // dead ends pruned"). Four outcomes: primary misses/ties + alt resolves
3253
- // cleanly -> the alt reading wins (the dead end is pruned); primary resolves
3254
- // cleanly -> untouched, regardless of what the alt does (byte-identical to
3255
- // before this existed); both resolve cleanly to the SAME entity -> untouched
3256
- // either way; both resolve cleanly to DIFFERENT entities -> genuine
3257
- // ambiguity, surfaced the same honest way resolveObject's own tier ties
3258
- // already are, never silently guessed.
3259
- // ENTITY-TIE BRANCHES (breadth-first ambiguity, PLAN_BREADTH_FIRST_NLU.md §1): a
3260
- // recursive traverse() call for one already-tied candidate arrives here with
3261
- // `pinnedObjMatch` set — skip re-resolution entirely (no re-derived tie is
3262
- // possible, so `ambiguous` is structurally false on every such call) rather
3263
- // than re-resolving by label text, which would risk a second individual
3264
- // sharing the same label, a stale `altObject` re-triggering a spurious new
3265
- // tie, or the test-variant-collision guard misfiring on a non-Module label.
2454
+ // reverse and forward both resolve one named term ("object" in the parsed
2455
+ // shape). altObject pruning: noise-strip.mjs's bare "where"/"mentions"
2456
+ // reading may carry `parsed.altObject` — a variant with a plausibly-noise
2457
+ // light verb also dropped. Tried here (where both readings and the graph
2458
+ // are available together): the alt reading wins only when the primary
2459
+ // misses/ties and the alt resolves cleanly; two clean, different resolves
2460
+ // become a genuine ambiguity, never a silent guess.
2461
+ // A recursive traverse() call for an already-tied candidate arrives with
2462
+ // `pinnedObjMatch` set skip re-resolution entirely rather than
2463
+ // re-resolving by label text, which risks a spurious new tie.
3266
2464
  let objRes;
3267
2465
  if (pinnedObjMatch) {
3268
2466
  objRes = { match: pinnedObjMatch, candidates: [], ambiguous: false, matchedVia: null };
@@ -3278,24 +2476,10 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3278
2476
  objRes = { ...objRes, ambiguous: true, candidates: [altRes.match, ...(objRes.candidates || [])] };
3279
2477
  }
3280
2478
  }
3281
- // TEST-VARIANT COLLISION (CHATBENCH decision-log item 2, am-tests-cover: "which
3282
- // tests cover b.mjs"): a "tests"-kind query's object may also honestly name its
3283
- // OWN conventional test-variant sibling ("b.mjs" -> "b.test.mjs"/"b.spec.mjs"),
3284
- // a DIFFERENT real Module app/lib/b.mjs and app/unit-tests/b.test.mjs both
3285
- // plausibly answer "b.mjs" when the question is specifically about test
3286
- // coverage. Deliberately scoped to kind==="tests" (never touches, imports,
3287
- // calls, …) — the SAME bare filename in an unrelated query ("has store.mjs
3288
- // been touched", chatflow-agents-debt-remeasure.test.mjs BUG-B's ground truth,
3289
- // examples/mini-webapp genuinely has the same store.mjs/store.test.mjs pair) has
3290
- // no such self-referential reading and must stay untouched. ALSO scoped to a
3291
- // BARE (unslashed) term — same convention as resolveObjectCore's own `dotted`
3292
- // tier just above resolveObject's call site: a query that already spells out
3293
- // the full path ("which functions test src/core/store.mjs",
3294
- // chatflow-tier4.test.mjs Batch 4/5; "app/lib/b.mjs", chatflow-tier2.test.mjs
3295
- // T18) is already unambiguous by construction and must stay untouched — only
3296
- // the bare basename is genuinely open to either reading. Same discipline as
3297
- // the altObject prune just above: only a CLEAN primary match plus a genuinely
3298
- // DIFFERENT real Module promotes to honest ambiguity, never a silent guess.
2479
+ // Test-variant collision ("which tests cover b.mjs"): a "tests"-kind
2480
+ // query's bare object may also name its own conventional test-variant
2481
+ // sibling ("b.mjs" -> "b.test.mjs"), a different real Module. Scoped to
2482
+ // kind==="tests" and a bare (unslashed) term only.
3299
2483
  if (parsed.kind === "tests" && objRes.match && !objRes.ambiguous && !String(parsed.object || "").includes("/")) {
3300
2484
  const stripTestInfix = (base) => base.replace(/\.(?:test|spec|tests)(?=\.[^.]+$)/, "");
3301
2485
  const termBase = stripTestInfix(String(parsed.object || "").trim().toLowerCase());
@@ -3309,7 +2493,7 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3309
2493
  }
3310
2494
  const { match: objMatch, candidates, ambiguous, unresolvedPronoun, matchedVia } = objRes;
3311
2495
  if (!objMatch) return { matches: [], objMatch: null, candidates, traversal: null, ambiguous: false, unresolvedPronoun };
3312
- // BREADTH-FIRST ENTITY-TIE RESOLUTION (PLAN_BREADTH_FIRST_NLU.md §1): mirrors the
2496
+ // BREADTH-FIRST ENTITY-TIE RESOLUTION: mirrors the
3313
2497
  // parsed.ambiguousParse branch above — every tied candidate is independently
3314
2498
  // traversed and rendered for real (via the pinnedObjMatch short-circuit just
3315
2499
  // above), never left as a bare name list. Capped at OVERFLOW_CAP BEFORE
@@ -3327,7 +2511,7 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3327
2511
  return { matches: [], objMatch, candidates, traversal: null, ambiguous: true, branches };
3328
2512
  }
3329
2513
 
3330
- // where: "where is X [defined]" (2026-07-02 query families) — the resolved
2514
+ // where: "where is X [defined]" — the resolved
3331
2515
  // entity IS the answer; render() reads its class + site attribute ("path:
3332
2516
  // start[-end]", seon:startsAt) for the module/line citation.
3333
2517
  if (shape === "where") {
@@ -3338,8 +2522,7 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3338
2522
  };
3339
2523
  }
3340
2524
 
3341
- // when: "when did X change" / "when was X last touched" (2026-07-02 query
3342
- // families) — the commits whose touch edges reach X, newest commit date first
2525
+ // when: "when did X change" / "when was X last touched" the commits whose touch edges reach X, newest commit date first
3343
2526
  // (mgx:commitDate, ISO-8601, so a lexical sort IS the date sort; undated
3344
2527
  // commits sort last and render() says so honestly). Checked BEFORE the
3345
2528
  // commit-as-subject flip: "when did <sha> change" asks for the commit's own
@@ -3367,7 +2550,7 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3367
2550
  };
3368
2551
  }
3369
2552
 
3370
- // who-last (HANDOVER.md 2026-07-10 item 5): "who last touched X" — the SAME
2553
+ // who-last: "who last touched X" — the SAME
3371
2554
  // newest-commit-first resolution as "when" just above (single most-recent
3372
2555
  // toucher, not the full touch history), rendered as the commit's AUTHOR
3373
2556
  // instead of its date. A dedicated shape rather than reusing "when" outright:
@@ -3391,40 +2574,23 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3391
2574
  };
3392
2575
  }
3393
2576
 
3394
- // commit-as-subject flip: touches edges are stored commit -> entity, so when the
3395
- // RESOLVED term of a touches question is itself a Commit "which changes touch
3396
- // commit ef74e44e25c8" (reverse), "what did commit abc1234 touch" (forward),
3397
- // "what changed in abc1234" (casual reverse) — the honest reading is "what did
3398
- // that commit touch": read the edges FROM the commit, grain-selected by the asked
3399
- // entity type, instead of scanning for edges INTO it (a commit is never a touch
3400
- // target, so the un-flipped scan would render a misleading blank).
2577
+ // Commit-as-subject flip: touches edges are stored commit -> entity, so
2578
+ // when the resolved term is itself a Commit, read edges FROM it instead of
2579
+ // scanning for edges into it (a commit is never a touch target).
3401
2580
  if (kind === "touches" && objMatch.class === "Commit") {
3402
2581
  return commitTouches(graph, objMatch, entityType, { candidates, ambiguous, matchedVia });
3403
2582
  }
3404
2583
 
3405
2584
  if (shape === "forward") {
3406
- // FORWARD CALL UNION (0.8.2 WS1): a kind with a symbol-grain sibling scans the
3407
- // UNION coarse+sibling when the resolved SUBJECT is itself a fine symbol
3408
- // "what does Widget.render call" lives on callsSymbol (fn->fn), which the
3409
- // module-coarse scan alone can never reach (a coarse edge's subject is a
3410
- // module, so a Function/Method subject rendered a false "no calls edges" while
3411
- // the reverse direction answered). Module subjects never carry a sibling edge,
3412
- // so their scan — and the traversal receipt — stays byte-identical. The receipt
3413
- // names what was actually scanned ("calls+callsSymbol edges where subject = X").
2585
+ // A kind with a symbol-grain sibling scans the union coarse+sibling when
2586
+ // the resolved subject is itself a fine symbol ("what does Widget.render
2587
+ // call" lives on callsSymbol, not the module-coarse edge).
3414
2588
  const fwdSibling = SYMBOL_GRAIN_SIBLING[kind];
3415
2589
  const subjIsFineSymbol = !!(fwdSibling && objMatch.class && FINE_ENTITY_TYPES.has(objMatch.class));
3416
2590
  const fwdKinds = subjIsFineSymbol ? [...new Set([...kindsFor(kind), fwdSibling])] : kindsFor(kind);
3417
- // FORWARD GRAIN CHECK (PLAN_CONVERSATION.md Finding 3): entityType flows in from
3418
- // parseKeywordSpot for every forward query, but until now was consulted ONLY by
3419
- // the commit-as-subject flip above every other forward query scanned blind,
3420
- // regardless of what class the kind's edges actually target. A kind whose real
3421
- // observed target classes never include the asked entityType (nor its
3422
- // FINE_CLASS_SIBLING family partner) can never honestly answer it — "what
3423
- // modules does X have" via `defines`, whose real targets are only
3424
- // {Class,Attribute,Method,Function}, never Module — so this is an honest decline,
3425
- // not a blind filter that would silently produce a false empty. "Change" is the
3426
- // touches-family wildcard pseudo-type (no individual is ever classed "Change"),
3427
- // exempted the same way the reverse branch exempts it (see its own comment above).
2591
+ // A kind whose real target classes never include the asked entityType
2592
+ // (nor its FINE_CLASS_SIBLING partner) can never honestly answer it an
2593
+ // honest decline, not a blind filter producing a false empty.
3428
2594
  if (entityType && entityType !== "Change") {
3429
2595
  const wantClasses = classesForKinds(graph, fwdKinds);
3430
2596
  const siblingClass = FINE_CLASS_SIBLING[entityType];
@@ -3438,14 +2604,9 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3438
2604
  }
3439
2605
  const edges = fwdKinds.flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.subject === objMatch.id);
3440
2606
  const targets = edges.map((e) => graph.byId.get(e.object)).filter(Boolean);
3441
- // dedupe only on the widened scan — the coarse-only path keeps its exact shape.
3442
2607
  const deduped = subjIsFineSymbol ? uniqueById(targets) : targets;
3443
- // PER-TRAVERSAL GRAIN FILTER (same Finding 3 fix): once grain passes above (or
3444
- // entityType is null/"Change"), still keep only the matches of the ASKED class —
3445
- // mirrors the reverse branch's own subjects.filter + sibling-widen-on-empty
3446
- // fallback just below, so a forward answer never leaks a wrong-class match once
3447
- // an entityType was actually asked for ("which functions does saveStore call"
3448
- // must not render a Class as if it were a function).
2608
+ // Keep only matches of the asked class, so a forward answer never leaks
2609
+ // a wrong-class match once an entityType was actually asked for.
3449
2610
  let matches = deduped;
3450
2611
  let filterNote = "";
3451
2612
  if (entityType && entityType !== "Change") {
@@ -3462,17 +2623,11 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3462
2623
  return { matches, objMatch, candidates, traversal: `${fwdKinds.join("+")} edges where subject = ${objMatch.label}${filterNote}`, ambiguous, matchedVia };
3463
2624
  }
3464
2625
 
3465
- // reverse + transitive (PLAN_MECHANICAL_CHAT.md P1): the gate above guarantees kind is
3466
- // "imports" or "calls" and entityType is null/"Module" here. Reuses impactClosure
3467
- // (codegraph.mjs) AS-IS rather than reimplementing a closure impactClosure's own
3468
- // dependents map is a REVERSE closure over imports+calls edges TOGETHER (renderImpact's
3469
- // "what would break" framing), not a strict single-predicate chain, so a query for
3470
- // "transitively imports" and one for "transitively calls" both resolve to the SAME
3471
- // mixed reverse-dependency closure. That's a real, deliberate scope decision (matching
3472
- // the plan's own instruction to wire onto "renderImpact's existing closure traversal"
3473
- // rather than build a new predicate-pure one) — the traversal receipt below says so
3474
- // honestly rather than implying a narrower single-predicate result than what was
3475
- // actually computed.
2626
+ // Reverse + transitive: reuses impactClosure (codegraph.mjs) as-is.
2627
+ // impactClosure's dependents map is a reverse closure over imports+calls
2628
+ // edges together, so "transitively imports" and "transitively calls" both
2629
+ // resolve to the same mixed closure a deliberate scope decision, stated
2630
+ // honestly in the traversal receipt below.
3476
2631
  if (parsed.modifier === "transitive") {
3477
2632
  const levels = impactClosure(graph, objMatch, { maxDepth: TRANSITIVE_MAX_DEPTH });
3478
2633
  const matches = levels.flat().map((d) => graph.byId.get(d.id)).filter(Boolean);
@@ -3482,17 +2637,11 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3482
2637
  };
3483
2638
  }
3484
2639
 
3485
- // reverse: "which <entityType> R <objMatch>". GRAIN-AWARE (Cycle 5, lever 3): a kind
3486
- // that carries a symbol-grain sibling reads off the SIBLING when a fine SUBJECT grain
3487
- // was asked for ("which functions call X" callsSymbol). It ALSO reads off the sibling
3488
- // when the RESOLVED OBJECT is itself a fine symbol, for EVERY kind with a sibling — not
3489
- // only touches: the module-coarse edge (calls Module→Module, touches Commit→Module) can
3490
- // NEVER point at a function/method, so a bare "what calls fnAlpha" scanning the coarse
3491
- // `calls` edges returned a FALSE empty ("No modules found …") while the graph records a
3492
- // real symbol-level caller (Widget.render --callsSymbol--> fnAlpha). The honest answer
3493
- // reads off callsSymbol at symbol grain; a truly-uncalled symbol still renders the
3494
- // honest empty, now with the accurate callsSymbol receipt. (Previously scoped to touches
3495
- // only, which left this exact callsSymbol caller invisible — a genuine correctness bug.)
2640
+ // reverse: "which <entityType> R <objMatch>". Grain-aware: a kind with a
2641
+ // symbol-grain sibling reads off the sibling when a fine subject grain was
2642
+ // asked for, or when the resolved object is itself a fine symbol the
2643
+ // module-coarse edge can never point at a function/method, so a bare "what
2644
+ // calls fnAlpha" would otherwise return a false empty.
3496
2645
  const symbolKind = SYMBOL_GRAIN_SIBLING[kind];
3497
2646
  const objIsFineSymbol = !!(objMatch.class && FINE_ENTITY_TYPES.has(objMatch.class));
3498
2647
  if (symbolKind && (FINE_ENTITY_TYPES.has(entityType) || objIsFineSymbol)) {
@@ -3500,12 +2649,8 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3500
2649
  const subjects = uniqueById(edges.map((e) => graph.byId.get(e.subject)).filter(Boolean));
3501
2650
  let matches = (!entityType || entityType === "Change") ? subjects : subjects.filter((i) => i.class === entityType);
3502
2651
  let widenNote = "";
3503
- // FINE-GRAIN FAMILY FALLBACK (0.8.2 WS1): when the exact-class filter comes back
3504
- // EMPTY and the asked grain is Function/Method, retry with the family sibling
3505
- // "which functions call fnAlpha" must not hide the recorded caller Widget.render
3506
- // just because the extractor stored it as class Method. Fallback-only by
3507
- // construction (the exact filter must be empty first), so every currently
3508
- // non-empty answer is byte-identical; the widening is said in the traversal.
2652
+ // Fallback-only: when the exact-class filter is empty and the asked
2653
+ // grain is Function/Method, retry with the family sibling class.
3509
2654
  const siblingClass = FINE_CLASS_SIBLING[entityType];
3510
2655
  if (!matches.length && siblingClass) {
3511
2656
  const widened = subjects.filter((i) => i.class === siblingClass);
@@ -3514,40 +2659,12 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3514
2659
  widenNote = `, widened to ${siblingClass} subjects (no ${entityType} recorded)`;
3515
2660
  }
3516
2661
  }
3517
- // "touches"/"calls" up-refine composability (HANDOVER 2026-07-12, Class-to-
3518
- // module up-refinement; extended to `calls` in the 2026-07-12 follow-up
3519
- // "who calls Router" wrongly returned empty for a Class whose calls are only
3520
- // recorded at module-coarse grain): an EMPTY touchesSymbol/callsSymbol lookup
3521
- // on a resolved CLASS is not decisive the way it is for a Function/Method — a
3522
- // Class reads naturally as "the file/unit that holds it", so "who touched
3523
- // <Class>"/"who calls <Class>" with no recorded symbol-precise edge should
2662
+ // Class-to-module up-refinement: an empty touchesSymbol/callsSymbol
2663
+ // lookup on a resolved Class with no recorded `contains` members should
3524
2664
  // still answer from the class's containing module's real touches/calls,
3525
- // rather than a confident-looking-but-possibly-wrong "nothing touched/calls
3526
- // it". Deliberately NOT widened to every FINE_ENTITY_TYPES member: "how many
3527
- // commits touched fnAlpha" (a Function) is pinned elsewhere (ask-combo.test.mjs's
3528
- // grain-aware COUNT lever) to stay an honest 0 rather than a module-grain
3529
- // false hit — symbol-level counting precision for functions/methods is a
3530
- // deliberate, separate guarantee this change must not erode.
3531
- //
3532
- // Up-refine eligibility is gated on the Class having NO recorded `contains`
3533
- // members: a class the extractor actually populated (Widget, Logger — both
3534
- // carry real `contains` edges) reads as a fully-modeled unit whose own empty
3535
- // symbol-grain scan IS the honest answer; a class with zero recorded members
3536
- // (Router, Button — the extractor only ever saw its declaration, never a
3537
- // body) is exactly the shape where the coarser module-level edge is the only
3538
- // real signal recorded at all.
3539
- //
3540
- // Even when eligible, only actually fall through to the shared grain-aware
3541
- // up-refine block below when a containing module can be resolved RIGHT NOW.
3542
- // That block's own "no containing module found" case renders a DIFFERENT,
3543
- // wrongGrainMiss-shaped honest miss ("'Button' resolved to the class Button,
3544
- // but this question needs a module…") than the plain zero-match miss this
3545
- // symbol-grain scan already renders ("No modules found whose module directly
3546
- // calls Button…"). A Class with no `contains` members AND no `defines` edge
3547
- // naming its module (the synthetic chatflow-tier2 fixture's Button, which the
3548
- // extractor recorded a bare declaration for but never wired into `defines`)
3549
- // must keep the latter, plain-miss wording — eligibility alone doesn't
3550
- // guarantee the up-refine can actually complete.
2665
+ // rather than a confident-looking-but-wrong "nothing touched/calls it".
2666
+ // Not widened to Function/Method symbol-level counting precision there
2667
+ // is a separate, deliberate guarantee this must not erode.
3551
2668
  const upRefineEligible = (kind === "touches" || kind === "calls") && objMatch.class === "Class"
3552
2669
  && !edgesOfKind(graph, "contains").some((e) => e.subject === objMatch.id);
3553
2670
  const upRefineModule = upRefineEligible ? graph.byId.get(moduleIdOf(graph, objMatch) || "") : null;
@@ -3556,18 +2673,11 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3556
2673
  }
3557
2674
  }
3558
2675
 
3559
- // §grain-aware object resolution (Bug C+D, HANDOVER follow-up #2, checked BEFORE
3560
- // the edge filter below): a predicate's OBJECT slot carries one particular class
3561
- // (kindObjectClass) resolveObject itself is blind to that, so a same-stem term
3562
- // ("logger") can resolve to the WRONG grain (a Class named Logger) instead of the
3563
- // Module the "imports"/"calls"/… edge actually points at, and the edge filter
3564
- // below then legitimately returns [] for the wrong-grain id — a confident-wrong
3565
- // empty, not an honest miss. `wantClass` is null for a kind whose edges span more
3566
- // than one object class (e.g. "contains") — no grain check applies there, byte-
3567
- // identical to before. objMatch.class === null (an ext: synthetic match, no real
3568
- // individual — see resolveObject's tier 2) is likewise never grain-checked: it has
3569
- // no better class to compare against, and is already the most specific resolution
3570
- // available.
2676
+ // Grain-aware object resolution, checked before the edge filter below: a
2677
+ // predicate's object slot carries one particular class (kindObjectClass),
2678
+ // but resolveObject is blind to that a same-stem term ("logger") could
2679
+ // resolve to the wrong grain (Class Logger) instead of the Module the edge
2680
+ // actually targets, producing a confident-wrong empty.
3571
2681
  let gObjMatch = objMatch;
3572
2682
  let gCandidates = candidates;
3573
2683
  let gAmbiguous = ambiguous;
@@ -3575,9 +2685,7 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3575
2685
  let grainRefinedNote = "";
3576
2686
  const wantClass = kindObjectClass(graph, kind);
3577
2687
  if (wantClass && gObjMatch.class && gObjMatch.class !== wantClass) {
3578
- // (1) retry resolution SCOPED to the expected class — "logger" now only
3579
- // considers Module individuals, so it lands on src/lib/logger.mjs instead of
3580
- // the same-stem Class (fixes Bug C).
2688
+ // (1) retry resolution scoped to the expected class.
3581
2689
  const retry = resolveObject(graph, parsed.object, { expectedClass: wantClass });
3582
2690
  if (retry.match && !retry.ambiguous) {
3583
2691
  gObjMatch = retry.match;
@@ -3585,22 +2693,9 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3585
2693
  gAmbiguous = retry.ambiguous;
3586
2694
  gMatchedVia = retry.matchedVia;
3587
2695
  } else if (wantClass === "Module") {
3588
- // (2) up-refine to the containing module driven by kindObjectClass
3589
- // itself (any kind whose real object-class is ALWAYS Module: tests,
3590
- // cochange, imports, touches, …), not a hardcoded kind name list, so a
3591
- // kind newly recorded as Module->Module in the graph gets this for free.
3592
- // No same-grain alternative exists here (the retry above genuinely found
3593
- // nothing), but the resolved fine-grain entity (a Function/Class, say)
3594
- // DOES live in a module, and that module is the real, honest subject of
3595
- // the question ("does createTask have tests" — Bug D; "who touched Bar",
3596
- // "what modules import Bar" — the same up-refine extended past
3597
- // tests/cochange, HANDOVER 2026-07-12). `calls` computes to Module here
3598
- // too, but never actually reaches this branch with a wrong-grain object:
3599
- // the symbolKind branch above already intercepts every Class/Function/…
3600
- // object for `calls` unconditionally (its empty-result IS decisive, see
3601
- // that branch's own comment), so this is inert-but-correct for it. Up-
3602
- // refine via the same moduleIdOf qualHolds's "tested" case already uses
3603
- // (see its divergence comment above).
2696
+ // (2) up-refine to the containing module (any kind whose real
2697
+ // object-class is always Module): the resolved fine-grain entity lives
2698
+ // in a module, and that module is the real subject of the question.
3604
2699
  const mid = moduleIdOf(graph, gObjMatch);
3605
2700
  const mod = mid && graph.byId.get(mid);
3606
2701
  if (mod) {
@@ -3614,9 +2709,8 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3614
2709
  };
3615
2710
  }
3616
2711
  } else {
3617
- // (3) neither a same-grain resolution nor an up-refinement applies an
3618
- // honest wrong-grain miss, distinct from both "unresolved" (the existing
3619
- // objMatch-null branch below, untouched) and "resolved + genuinely empty".
2712
+ // (3) neither applies an honest wrong-grain miss, distinct from
2713
+ // "unresolved" and "resolved + genuinely empty".
3620
2714
  return {
3621
2715
  matches: [], objMatch: gObjMatch, candidates: gCandidates, ambiguous: gAmbiguous, matchedVia: gMatchedVia,
3622
2716
  wrongGrainMiss: true, wantClass,
@@ -3625,21 +2719,15 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3625
2719
  }
3626
2720
  }
3627
2721
 
3628
- // General case: some predicates are already fine-grained (inherits: Class->Class, contains:
3629
- // Class->Member) and some are module-coarse (imports/calls/tests/cochange: Module->Module).
3630
- // Rather than assume one or the other, check what the edge's actual subjects ARE: if they
3631
- // already match the requested entityType, use them directly (inherits); only when they're
3632
- // Module individuals and a FINER entityType was asked for do we refine via `defines`
3633
- // (imports) — never blindly treat an edge's subject id as if it were always a module id.
2722
+ // General case: some predicates are already fine-grained (inherits) and
2723
+ // some are module-coarse (imports/calls/tests/cochange). Check what the
2724
+ // edge's actual subjects are: use them directly if they already match the
2725
+ // requested entityType; only refine via `defines` when they're Modules and
2726
+ // a finer entityType was asked for.
3634
2727
  let edges = kindsFor(kind).flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.object === gObjMatch.id);
3635
- // cochange (Module<->Module, mgx:changeCoupledWith) is a SYMMETRIC relation but
3636
- // stored as ONE directed edge per pair (extractor convention, not a meaningful
3637
- // subject/object direction) "which modules cochange with X" must also match
3638
- // when X is the STORED SUBJECT of the pair, reading the OTHER endpoint (Track-1
3639
- // trio, temporal lever). Flip subject<->object on that side so the subjects-
3640
- // collection loop below (which reads e.subject) picks up the partner uniformly;
3641
- // the object-side match above is untouched, so an existing non-empty answer is
3642
- // byte-identical.
2728
+ // cochange is symmetric but stored as one directed edge per pair, so
2729
+ // "which modules cochange with X" must also match when X is the stored
2730
+ // subject — flip subject<->object on that side.
3643
2731
  if (kind === "cochange") {
3644
2732
  edges = edges.concat(
3645
2733
  kindsFor(kind).flatMap((k) => edgesOfKind(graph, k))
@@ -3649,18 +2737,15 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3649
2737
  }
3650
2738
  let extNote = "";
3651
2739
  if (!edges.length && gObjMatch.class) {
3652
- // Unresolved ext:<Name> endpoints with the SAME name as the resolved entity:
3653
- // the extractor declined to assert identity (e.g. commander's every "class X
3654
- // extends Command" edge points at ext:Command, never the Class node), so a
3655
- // strict id match renders a FALSE blank. Count them by NAME instead and say
3656
- // so in the receipt — name-grade evidence, labeled as such, same standard as
3657
- // resolveObject's own ext: tier.
2740
+ // Unresolved ext:<Name> endpoints sharing the resolved entity's name: the
2741
+ // extractor declined to assert identity, so a strict id match renders a
2742
+ // false blank. Count them by name instead and say so in the receipt.
3658
2743
  const extId = `ext:${String(gObjMatch.label).toLowerCase()}`;
3659
2744
  edges = kindsFor(kind).flatMap((k) => edgesOfKind(graph, k)).filter((e) => String(e.object).toLowerCase() === extId);
3660
2745
  if (edges.length) extNote = ` (by name, via unresolved ${extId} references)`;
3661
2746
  }
3662
- // dedupe by id: a union kind ("uses") can reach the same subject through two
3663
- // legs (a module that both imports AND calls X), and one answer must list it once.
2747
+ // Dedupe by id: a union kind ("uses") can reach the same subject through
2748
+ // two legs, and one answer must list it once.
3664
2749
  const subjects = [];
3665
2750
  const seenSubjects = new Set();
3666
2751
  for (const e of edges) {
@@ -3668,10 +2753,8 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3668
2753
  if (s && !seenSubjects.has(s.id)) { seenSubjects.add(s.id); subjects.push(s); }
3669
2754
  }
3670
2755
  let matches, grainNote = "";
3671
- // "Change" (ask-vocab.mjs's pseudo-type) is a wildcard here: "which changes touch
3672
- // walk.mjs" means the touch edges' own subjects — the commits not a node class
3673
- // to filter by (no individual is ever class "Change", so filtering would always
3674
- // produce a false blank).
2756
+ // "Change" is a wildcard: "which changes touch walk.mjs" means the touch
2757
+ // edges' own subjects (commits), not a node class to filter by.
3675
2758
  if (!entityType || entityType === "Change") {
3676
2759
  matches = subjects;
3677
2760
  } else {
@@ -3693,8 +2776,8 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3693
2776
  };
3694
2777
  }
3695
2778
 
3696
- // ---- §5 templated renderer string interpolation + grouping/pluralization/overflow rules,
3697
- // never generation; every sentence is read off a matched edge/individual. ----
2779
+ // ---- templated renderer: string interpolation + grouping/pluralization/
2780
+ // overflow rules, never generation. ----
3698
2781
 
3699
2782
  function moduleLabelOf(ind) {
3700
2783
  if (ind.class === "Module") return ind.label;
@@ -3709,11 +2792,8 @@ function symbolLabelOf(ind) {
3709
2792
  return ["Function", "Method"].includes(ind.class) ? `function ${label}()` : label;
3710
2793
  }
3711
2794
 
3712
- /** A FRIENDLY commit reference for a "who touched X" list the raw sha alone reads as
3713
- * noise, so when the Commit individual carries an author (mgx:commitAuthor → key
3714
- * "author") name them beside it. The label is already the graph's short ref (the
3715
- * builder stores sha.slice(0,12)), so it is used verbatim. Degrades gracefully: a
3716
- * commit with no recorded author renders the sha alone, exactly as before. */
2795
+ /** A friendly commit reference for a "who touched X" list: names the author
2796
+ * beside the sha when recorded, else renders the sha alone. */
3717
2797
  function commitRefOf(ind) {
3718
2798
  const sha = String(ind.label || ind.id || "");
3719
2799
  const author = (ind.attributes || []).find((a) => a.key === "author")?.value;
@@ -3733,19 +2813,11 @@ function describeParse(p) {
3733
2813
  return `${ent}${p.kind} "${obj}"`;
3734
2814
  }
3735
2815
 
3736
- /** PLAN_BREADTH_FIRST_NLU.md §Track 6 (operator directive) the canonical
3737
- * restatement of what a query was understood to mean, in BOTH forms the
3738
- * operator asked for: an English gloss in tmct's own preferred phrasing
3739
- * (`english`) and a compact, machine-parsable notation of the same
3740
- * structured fact (`machine`) — a simple `shape(kind, args...)` call form,
3741
- * not raw JSON, so it stays human-readable at a glance too. Every clause is
3742
- * a plain template read off `parsed`'s own already-compiled fields, never
3743
- * generated — the same discipline as `describeParse`/`render()`. Returns
3744
- * `null` when there's nothing to canonicalize (`parsed` itself is null).
3745
- * Scoped to the flat query shapes `traverse()`/`render()` operate on; a
3746
- * compositional AST (`parsed.node`) gets an honest, coarser fallback —
3747
- * full per-node-type canonicalization is real future work, not silently
3748
- * faked here. */
2816
+ /** The canonical restatement of what a query was understood to mean: an
2817
+ * English gloss (`english`) and a compact `shape(kind, args...)` notation
2818
+ * (`machine`), both read off `parsed`'s already-compiled fields. A
2819
+ * compositional AST gets a coarse fallback per-node-type canonicalization
2820
+ * isn't implemented yet. */
3749
2821
  function canonicalOf(parsed) {
3750
2822
  if (!parsed) return null;
3751
2823
  if (parsed.ambiguousParse) {
@@ -3755,7 +2827,6 @@ function canonicalOf(parsed) {
3755
2827
  };
3756
2828
  }
3757
2829
  if (parsed.node) {
3758
- // Compositional AST — coarse, honest fallback (see docblock above).
3759
2830
  return { english: `a compositional query (${parsed.node})`, machine: `composite(${parsed.node})` };
3760
2831
  }
3761
2832
  const q = (s) => JSON.stringify(String(s ?? ""));
@@ -3784,11 +2855,9 @@ function canonicalOf(parsed) {
3784
2855
  return { english, machine };
3785
2856
  }
3786
2857
 
3787
- /** Render a compiled query result into {content, miss, ambiguous, matches?, candidates?}.
3788
- * Every branch is a template, not generation §5's grouping/pluralization/overflow rules.
3789
- * A tier-5 fuzzy object resolution is ANNOUNCED, not silent: the answer is prefixed
3790
- * "assuming you meant <label>:" so the correction is on the record next to the result
3791
- * (an unannounced fuzzy hit would be indistinguishable from an exact one — a guess). */
2858
+ /** Render a compiled query result into {content, miss, ambiguous, matches?,
2859
+ * candidates?}. A tier-5 fuzzy object resolution is announced, not silent:
2860
+ * prefixed "assuming you meant <label>:" so the correction is on the record. */
3792
2861
  export function render(parsed, result) {
3793
2862
  const r = renderCore(parsed, result);
3794
2863
  if (result && result.matchedVia === "fuzzy" && result.objMatch && !r.ambiguous) {
@@ -3849,7 +2918,7 @@ function renderCore(parsed, result) {
3849
2918
  miss: true, ambiguous: false,
3850
2919
  };
3851
2920
  }
3852
- // forward-shape honest miss (PLAN_CONVERSATION.md Finding 3): the resolved SUBJECT
2921
+ // forward-shape honest miss: the resolved SUBJECT
3853
2922
  // is real, but the asked entityType can never appear among this kind's own real
3854
2923
  // target classes at all — distinct from wrongGrainMiss above (which is about the
3855
2924
  // resolved OBJECT term's class on the reverse side) and from the plain forward
@@ -3869,8 +2938,7 @@ function renderCore(parsed, result) {
3869
2938
  miss: true, ambiguous: false,
3870
2939
  };
3871
2940
  }
3872
- // meta fallback hit (0.8.2 WS1, widened + extracted to metaFallbackEntityAnswer,
3873
- // HANDOVER.md 2026-07-10 item 6, see traverse's meta branch): the term is not
2941
+ // meta fallback hit (see traverse's meta branch, metaFallbackEntityAnswer): the term is not
3874
2942
  // schema vocabulary but IS a unique code-graph entity (Class/Function/Method/
3875
2943
  // GlobalVariable/Attribute) — its pre-rendered describe-style one-liner is
3876
2944
  // passed straight through, so this stays byte-identical to whatever chat.mjs's
@@ -3900,18 +2968,9 @@ function renderCore(parsed, result) {
3900
2968
  };
3901
2969
  }
3902
2970
  if (!result.objMatch && (!result.candidates || result.candidates.length === 0) && parsed.shape !== "ask") {
3903
- // name what kind of thing was looked for: a sha-shaped term was checked against
3904
- // the commit namespace, a dotted slash-free term against symbol labels a
3905
- // generic "no module matching" would misreport both. Those two TERM-SHAPE
3906
- // reads keep priority (a bare "a.mjs" is deliberately read as a symbol-ish
3907
- // shorthand regardless of the stated entity type — frozen by
3908
- // test/chat.test.mjs's "no symbol matching \"a.mjs\"" case); only the
3909
- // remaining catch-all default (unconditionally "module") is replaced by the
3910
- // parsed AST's own entityType when one is present (Tier-2 playtest, cycle 8
3911
- // — "which classes inherit from Widget" used to say "no MODULE matching
3912
- // 'Widget'" even though entityType="Class" was sitting right there unused,
3913
- // and "Widget" is neither sha- nor dotted-symbol-shaped so the catch-all is
3914
- // exactly what fired).
2971
+ // Name what kind of thing was looked for: a sha-shaped term reads as
2972
+ // "commit", a dotted slash-free term as "symbol" (both keep priority over
2973
+ // entityType); otherwise fall back to the parsed AST's own entityType.
3915
2974
  const objText = String(parsed.object || "").trim();
3916
2975
  const fallback = parsed.entityType && PLURAL_FORMS[parsed.entityType] ? nounFor(parsed.entityType, 1) : "module";
3917
2976
  const what = /^(?:commit[:\s])?[0-9a-f]{7,40}$/i.test(objText) ? "commit"
@@ -3922,20 +2981,13 @@ function renderCore(parsed, result) {
3922
2981
  };
3923
2982
  }
3924
2983
  if (result.ambiguous) {
3925
- // the candidates say what KIND of thing is ambiguous — a shared commit-sha
3926
- // prefix must read "more than one commit", not "module". Name the actual
3927
- // candidates in the prose (not just the structured `candidates` field) —
3928
- // "narrow the term" is not itself actionable if the reader can't see what
3929
- // it's ambiguous between; mirrors the mentionsShape branch's listing above.
2984
+ // Name the actual candidates in the prose, not just the structured field
2985
+ // "narrow the term" isn't actionable if the reader can't see the options.
3930
2986
  const pool = [result.objMatch, ...(result.candidates || [])].filter(Boolean);
3931
2987
  const noun = pool.length && pool.every((i) => i.class === "Commit") ? "commit" : "module";
3932
2988
  const shown = pool.slice(0, OVERFLOW_CAP).map((i) => i.label);
3933
2989
  const extra = pool.length > OVERFLOW_CAP ? `, …and ${pool.length - OVERFLOW_CAP} more` : "";
3934
2990
  const lead = `"${parsed.object}" matches more than one ${noun} ambiguously — did you mean ${listJoin(shown)}${extra}? Try one of those. If you're not sure, narrow it to one name.`;
3935
- // BREADTH-FIRST (PLAN_BREADTH_FIRST_NLU.md §1): strictly additive to `lead` —
3936
- // every currently-pinned assertion (test/chat-cefr-1.6.1-decision-log.test.mjs,
3937
- // chatbench/graded-pool.jsonl's am-tests-cover) is a substring check against
3938
- // `lead` alone, so appending each branch's real answer never breaks a pin.
3939
2991
  const content = (result.branches && result.branches.length)
3940
2992
  ? `${lead}\n${result.branches.map((b, i) => `${i + 1}) ${b.candidate.label}: ${b.rendered.content}`).join("\n")}`
3941
2993
  : lead;
@@ -3954,14 +3006,9 @@ function renderCore(parsed, result) {
3954
3006
  }
3955
3007
  const m = String(result.site || "").match(/^(.*):(\d+)(?:-(\d+))?$/);
3956
3008
  if (m) {
3957
- // "is defined in" stays UNVARIED here on purpose: chatbench/graded-pool-max.jsonl
3958
- // pins this exact substring as ground truth for "where is X defined" cases
3959
- // (g-a1-svo-7/12/18/22/40/43, g-a2-noise-svo-3/9/13/14/15 2 of which,
3960
- // g-a1-svo-12 and g-a2-noise-svo-13, are in the promoted always-run subset),
3961
- // and SKILL_BENCHMARK_CEFR_ENGLISH.md declares that pool append-only/never
3962
- // edited mid-arc. The other two "defined in" call sites (metaFallbackEntityAnswer
3963
- // and the composite exists-hit above) answer a DIFFERENT query shape ("what is a
3964
- // X" / "is there a X"), so they carry the variety instead.
3009
+ // "is defined in" stays unvaried here on purpose — pinned ground truth
3010
+ // for "where is X defined". The other two "defined in" call sites
3011
+ // answer a different query shape and carry the phrasing variety instead.
3965
3012
  const lines = m[3] && m[3] !== m[2] ? `lines ${m[2]}-${m[3]}` : `line ${m[2]}`;
3966
3013
  return { content: `${symbolLabelOf(ind)} is defined in ${m[1]} at ${lines}.`, miss: false, ambiguous: false, matches: result.matches };
3967
3014
  }
@@ -3996,11 +3043,9 @@ function renderCore(parsed, result) {
3996
3043
  miss: false, ambiguous: false, matches: result.matches,
3997
3044
  };
3998
3045
  }
3999
- // who-last: newest touching commit's AUTHOR (HANDOVER.md 2026-07-10 item 5) the
4000
- // superlative "who" mirror of whenShape just above. "who last touched X" used to
4001
- // fall into the ordinary reverse-list render below and name EVERY toucher; this
4002
- // answers with the single most recent one instead. Unlike whenShape, no date is
4003
- // needed to answer "who" — an undated-but-authored commit still resolves.
3046
+ // who-last: newest touching commit's author. "who last touched X" would
3047
+ // otherwise fall into the ordinary reverse-list render and name every
3048
+ // toucher; this answers with just the most recent one.
4004
3049
  if (result.whoLastShape) {
4005
3050
  const subject = result.objMatch.label;
4006
3051
  if (!result.matches.length) {
@@ -4020,11 +3065,9 @@ function renderCore(parsed, result) {
4020
3065
  miss: false, ambiguous: false, matches: result.matches,
4021
3066
  };
4022
3067
  }
4023
- // commit-as-subject answers ("which changes touch commit X", "what did commit X
4024
- // touch"): cite the commit, group the touched entities by CLASS — modules and
4025
- // symbols are different grains of the same answer, and flattening them into one
4026
- // undifferentiated list would hide which is which. Same OVERFLOW_CAP as the
4027
- // other list templates; zero hits is the standard honest blank, commit cited.
3068
+ // Commit-as-subject answers: cite the commit, group touched entities by
3069
+ // class modules and symbols are different grains, so flattening would
3070
+ // hide which is which.
4028
3071
  if (result.commitSubject) {
4029
3072
  const cite = `commit ${result.objMatch.label}`;
4030
3073
  if (!result.matches.length) {
@@ -4047,34 +3090,23 @@ function renderCore(parsed, result) {
4047
3090
  if (!result.objMatch || !result.subjMatch) {
4048
3091
  return { content: `couldn't resolve one of the terms in this question.`, miss: true, ambiguous: false };
4049
3092
  }
4050
- // the yes render is plain words — the traversal string IS "<kind> edge from
4051
- // <A> to <B>", so it reads as the sentence itself, not a parenthetical receipt
4052
- // (the receipt still rides on the result's traversal field for why/verbose).
4053
3093
  return {
4054
3094
  content: result.answer ? `Yes — ${result.traversal}.` : `No — no ${parsed.kind} edge found from ${result.subjMatch.label} to ${result.objMatch.label}.`,
4055
3095
  miss: !result.answer, ambiguous: false,
4056
3096
  };
4057
3097
  }
4058
3098
  if (!result.matches.length) {
4059
- // forward: parsed.object is the GIVEN subject ("what does X import" -> X), not a
4060
- // search target "No modules found that X." reads as broken grammar (and X's own
4061
- // relation edges are simply absent, not "not found"), so this shape gets its own,
4062
- // subject-first phrasing rather than reusing reverse's "found ... that OBJECT" template.
3099
+ // Forward: parsed.object is the given subject, not a search target, so it
3100
+ // gets its own subject-first phrasing rather than reverse's template.
4063
3101
  if (parsed.shape === "forward") {
4064
3102
  return {
4065
3103
  content: `${result.objMatch.label} has no ${verbFor(parsed.kind)} edges in the index.`,
4066
3104
  miss: true, ambiguous: false,
4067
3105
  };
4068
3106
  }
4069
- // "what tests cover X" / "what tests X" the tests themselves are the search
4070
- // target (no explicit entity keyword entityType null), and "tests" reads as a
4071
- // verb phrase, so the generic "No <modules> found whose module directly tests <obj>"
4072
- // template garbles: it mislabels the searched kind as "modules" and lets the user's
4073
- // leaked verb ride into the object ("…tests cover touch X"). Any leading relation
4074
- // verb (cover/touch/check/verify/… — LEADING_RELATION_VERB_RE, built from the
4075
- // ask-vocab verb table) is stripped, so the honest empty reads as the natural
4076
- // "No tests cover X." The frozen entity-keyword form ("which modules test X",
4077
- // entityType="Module") keeps its pinned wording below.
3107
+ // "what tests cover X" has no explicit entity keyword and "tests" reads
3108
+ // as a verb phrase, so any leaked leading relation verb is stripped
3109
+ // before rendering the honest empty.
4078
3110
  if (parsed.kind === "tests" && !parsed.entityType) {
4079
3111
  const stripped = String(parsed.object || "").replace(LEADING_RELATION_VERB_RE, "").trim();
4080
3112
  const obj = stripped || String(parsed.object || "").trim();
@@ -4083,35 +3115,26 @@ function renderCore(parsed, result) {
4083
3115
  miss: true, ambiguous: false,
4084
3116
  };
4085
3117
  }
4086
- // NOTE (Cycle 5): a voice-nit rephrasing ("that directly <verb>") was reverted —
4087
- // the frozen v1 cases.jsonl pins the "whose module directly <verb>s X" wording
4088
- // (hm-empty-result-calls / tf-wat-calls / ns-wondering), and the case set is
4089
- // append-only/sacred mid-arc, so the honest-miss phrasing stays as-is.
4090
3118
  const entityWord = nounFor(parsed.entityType || "Module", 2);
4091
3119
  return {
4092
3120
  content: `No ${entityWord} found whose module directly ${verbFor(parsed.kind)} ${parsed.object}. ${touchesRephraseHint()}`,
4093
3121
  miss: true, ambiguous: false,
4094
3122
  };
4095
3123
  }
4096
- // Route by the MATCHED entities' actual class, not just the parsed hint — a reverse
4097
- // query phrased without an explicit entity keyword ("what imports X", entityType null)
4098
- // still resolves to Module individuals for a module-level relation like "imports", and
4099
- // grouping those by-module (module label as its own "symbol" label) reads as nonsense
4100
- // ("in a.mjs there is a.mjs"). The fine-grained per-symbol grouping below is only
4101
- // meaningful when the matches are sub-module entities (functions/classes/etc) — a
4102
- // Commit list ("which commits touched X") has no containing module to group by, so
4103
- // anything that is not a fine entity takes the flat join.
3124
+ // Route by the matched entities' actual class, not just the parsed hint —
3125
+ // grouping module-level matches by-module would read as nonsense ("in
3126
+ // a.mjs there is a.mjs"). Fine-grained grouping only applies to sub-module
3127
+ // entities.
4104
3128
  if (parsed.shape === "forward" || parsed.entityType === "Module" || result.matches.every((m) => !FINE_ENTITY_TYPES.has(m.class))) {
4105
- // A reverse "who touched X" resolves to Commit individuals — render friendly refs
4106
- // (short sha + author) instead of the raw stored sha; every other flat list (module
4107
- // labels, etc.) keeps its own label verbatim.
3129
+ // A reverse "who touched X" resolves to Commit individuals — render
3130
+ // friendly refs (short sha + author) instead of the raw stored sha.
4108
3131
  const shown = result.matches.slice(0, OVERFLOW_CAP).map((m) => m.class === "Commit" ? commitRefOf(m) : m.label);
4109
3132
  const extra = result.matches.length > OVERFLOW_CAP ? `, …and ${result.matches.length - OVERFLOW_CAP} more` : "";
4110
3133
  return { content: shown.join(" and ") + extra + ".", miss: false, ambiguous: false, matches: result.matches };
4111
3134
  }
4112
- // reverse, fine-grained entity: group by module, one clause per module (§5 grouping rule)
3135
+ // reverse, fine-grained entity: group by module, one clause per module —
4113
3136
  // the FIRST module states "in {module} there is …"; each SUBSEQUENT module states
4114
- // "there is … in {module}" (module trails, not leads), matching the plan's worked example.
3137
+ // "there is … in {module}" (module trails, not leads).
4115
3138
  const byModule = new Map();
4116
3139
  for (const m of result.matches.slice(0, OVERFLOW_CAP)) {
4117
3140
  const mod = moduleLabelOf(m);
@@ -4127,25 +3150,16 @@ function renderCore(parsed, result) {
4127
3150
  }
4128
3151
 
4129
3152
  // ============================================================================
4130
- // §progressive-relaxation cascade (SHRDLU in a code graph, with a Zork parser's
4131
- // forgiveness) a controlled loop that wraps the WHOLE existing parse and runs
4132
- // ONLY when the direct parse of the normalized query would MISS. A clean direct hit
4133
- // never enters the cascade (it stays instant and exact); the cascade only ever DROPS
4134
- // noise/unmatched words or NORMALISES a near-canonical word to the closed vocabulary,
4135
- // re-attempting the full parse (compositional + templates + keyword-spot) after each
4136
- // transform, and bottoms out in the SAME honest miss + rephrase hint the engine
4137
- // already returned — never inventing a term or guessing an entity. Deterministic:
4138
- // same input → same cascade path. All of it is plain JS over the already-imported
4139
- // tables + resolveObject/parseQuery, so it survives the viewer bundle's import strip.
3153
+ // progressive-relaxation cascade: a controlled loop that runs only when the
3154
+ // direct parse of the normalized query would miss. Only ever drops noise/
3155
+ // unmatched words or normalises a near-canonical word, re-attempting the
3156
+ // full parse after each transform, bottoming out in the same honest miss +
3157
+ // rephrase hint never inventing a term or guessing an entity.
4140
3158
  // ============================================================================
4141
3159
 
4142
-
4143
- /** Every token the CLOSED grammar gives QUERY MEANING to relation verbs, entity
4144
- * nouns, modifiers, qualifiers, aggregate/superlative triggers, edge-degree nouns,
4145
- * boolean connectives, placeholder nouns, anaphora/meta/where/mention markers,
4146
- * relative pronouns, and the small synonym keys. The noise-strip pass will NEVER
4147
- * remove one of these, and the drop-unmatched pass always keeps them: they carry the
4148
- * intent, only the packaging around them is negotiable. */
3160
+ /** Every token the closed grammar gives query meaning to. The noise-strip
3161
+ * pass will never remove one of these, and the drop-unmatched pass always
3162
+ * keeps them: they carry the intent, only the packaging is negotiable. */
4149
3163
  const CONTENT_VOCAB = new Set([
4150
3164
  ...wordsOf(Object.keys(VERB_TO_KIND)), ...wordsOf(Object.keys(ENTITY_TO_TYPE)),
4151
3165
  ...wordsOf(Object.keys(MODIFIER_TO_KIND)), ...wordsOf(Object.keys(QUALIFIERS)),
@@ -4156,46 +3170,35 @@ const CONTENT_VOCAB = new Set([
4156
3170
  ...wordsOf(Object.keys(CASCADE_SYNONYMS)),
4157
3171
  ]);
4158
3172
 
4159
- /** Structural scaffolding words — question words, articles-in-questions, frame verbs,
4160
- * and context pronouns. Not "content", but they hold a sentence together, so the
4161
- * drop-unmatched pass keeps them (dropping "what"/"of" would corrupt the grammar);
4162
- * the noise-strip pass may still remove the few of these that are ALSO curated noise
4163
- * ("the"/"a"/"show"/"me") — the two sets overlap on purpose. */
3173
+ /** Structural scaffolding words — question words, frame verbs, context
3174
+ * pronouns. Not "content", but they hold a sentence together, so the
3175
+ * drop-unmatched pass keeps them; the noise-strip pass may still remove the
3176
+ * few that are also curated noise ("the"/"a"/"show"/"me"). */
4164
3177
  const STRUCTURAL_WORDS = new Set([...STOPWORDS, ...FRAME_WORDS, ...CONTEXT_PRONOUNS]);
4165
3178
  const CASCADE_NOISE_SET = new Set(wordsOf(CASCADE_NOISE));
4166
- /** Every token that carries NO graph meaning of its own curated noise (articles,
4167
- * politeness, vocatives, presentation frames) PLUS the structural scaffolding
4168
- * (question words, context pronouns). The bare-kind-noun terminal rule (relaxParse's
4169
- * Layer 4) treats a query as "just a kind noun wrapped in packaging" only when every
4170
- * non-kind token is one of these — so an unknown qualifier ("shiny") or a relation
4171
- * verb, being neither, still blocks the default and preserves the honest miss. */
3179
+ /** Every token that carries no graph meaning of its own. The bare-kind-noun
3180
+ * terminal rule treats a query as "just a kind noun wrapped in packaging"
3181
+ * only when every non-kind token is one of these. */
4172
3182
  const NOISE_OR_SCAFFOLD = new Set([...CASCADE_NOISE_SET, ...STRUCTURAL_WORDS]);
4173
3183
 
4174
- /** The aggregate/list TRIGGER words the cascade's drop-unmatched pass will fuzzy-correct
4175
- * a typo toward (Gap 2, trigger-typo work). Curated (not derived from LIST_TRIGGERS'
4176
- * multi-word phrases) so the target set stays clean single verbs — "many", "count",
4177
- * "list", "show", … — and never drags in a stray "down"/"off"/"out" from a phrasal
4178
- * trigger that would mis-correct an unrelated token. */
3184
+ /** The aggregate/list trigger words the cascade's drop-unmatched pass will
3185
+ * fuzzy-correct a typo toward. Curated to clean single verbs so a phrasal
3186
+ * trigger's stray word ("down"/"off") never mis-corrects an unrelated token. */
4179
3187
  const TRIGGER_FUZZY_WORDS = [
4180
3188
  "many", "count", "number", "quantity", "total", "tally",
4181
3189
  "list", "show", "display", "print", "dump", "enumerate", "name",
4182
3190
  ];
4183
- /** Closed-vocab words a plain unknown may be fuzzy-corrected TOWARD before the cascade
4184
- * discards it: relation verbs, entity kind nouns, and the aggregate/list triggers. A
4185
- * correction fires only on a token already bound for the drop pile (grammar doesn't own
4186
- * it, no entity resolves) and only for a UNIQUE within-bound target, so it strictly
4187
- * beats dropping — a typo of a trigger keeps its intent instead of being lost. Excludes
4188
- * STOPWORDS/structural words (a random unknown must never bend into "what"/"the") and
4189
- * <4-char words (at the small bound they match half of English). */
3191
+ /** Closed-vocab words a plain unknown may be fuzzy-corrected toward before
3192
+ * the cascade discards it. Only fires on a unique within-bound target;
3193
+ * excludes stopwords and <4-char words (too many accidental matches). */
4190
3194
  const CASCADE_FUZZY_TARGETS = [...new Set([
4191
3195
  ...wordsOf(Object.keys(VERB_TO_KIND)),
4192
3196
  ...Object.keys(ENTITY_TO_TYPE),
4193
3197
  ...TRIGGER_FUZZY_WORDS,
4194
3198
  ])].filter((wd) => /^[a-z]+$/.test(wd) && wd.length >= 4 && !STOPWORDS.has(wd));
4195
3199
 
4196
- /** UNIQUE within-bound fuzzy correction of `w` toward CASCADE_FUZZY_TARGETS, or null —
4197
- * a distance tie between two distinct targets is refused (honest-miss discipline at the
4198
- * vocabulary level, cf. fuzzyVocabWord). */
3200
+ /** Unique within-bound fuzzy correction of `w` toward CASCADE_FUZZY_TARGETS,
3201
+ * or null — a distance tie between two distinct targets is refused. */
4199
3202
  function fuzzyCascadeWord(w) {
4200
3203
  const bound = fuzzyBound(w);
4201
3204
  let best = bound + 1; let hit = null; let tied = false;
@@ -4207,23 +3210,12 @@ function fuzzyCascadeWord(w) {
4207
3210
  return best <= bound && !tied ? hit : null;
4208
3211
  }
4209
3212
 
4210
- /** The typoschema-term trap guard (chatbench cycle 2, CHATBENCH_001 L3 the
4211
- * tf-modles hard fail). A term that resolves ONLY via the tier-5 bounded-fuzzy
4212
- * pass onto one of the graph's OWN vocabulary individuals (a SchemaClass/
4213
- * SchemaPredicate that ingestSchemaDocs merged in), while the same word ALSO
4214
- * fuzzy-corrects to an entity KIND NOUN of the closed grammar ("modles" →
4215
- * "modules"), is a typo'd kind noun, not a question about the schema term:
4216
- * without this guard, "which modles import a.mjs" silently pivoted onto the
4217
- * CLASS Module and confidently answered a question the visitor never asked
4218
- * ("No — no imports edge found from Module to app/lib/a.mjs"). Reporting the
4219
- * parse unanswerable sends it to the relaxation cascade, whose drop-unmatched
4220
- * layer restores the kind noun (fuzzyCascadeWord) and whose winning re-parse is
4221
- * ANNOUNCED as a repair receipt ('read as "which modules import a.mjs" — …').
4222
- * If the cascade cannot produce a real answer, the original parse still stands
4223
- * (ask() keeps the direct parse when relaxParse returns null), so a genuine
4224
- * schema-adjacent question is never turned into a new kind of miss. Exact and
4225
- * substring/prose matches are untouched — the guard reads matchedVia:"fuzzy"
4226
- * only, and only when the kind-noun reading exists. */
3213
+ /** The typo->schema-term trap guard: a term that resolves only via tier-5
3214
+ * bounded-fuzzy onto a schema individual, while the same word also
3215
+ * fuzzy-corrects to an entity kind noun ("modles" -> "modules"), is a
3216
+ * typo'd kind noun, not a schema question. Reporting it unanswerable sends
3217
+ * it to the relaxation cascade instead of confidently answering the wrong
3218
+ * question. Exact/substring/prose matches are untouched. */
4227
3219
  function schemaTypoTrap(resolution, term) {
4228
3220
  if (!resolution?.match || resolution.matchedVia !== "fuzzy" || resolution.ambiguous) return false;
4229
3221
  const cls = resolution.match.class;
@@ -4233,17 +3225,11 @@ function schemaTypoTrap(resolution, term) {
4233
3225
  return !!kindNoun && kindNoun !== lc && !!ENTITY_TO_TYPE[kindNoun];
4234
3226
  }
4235
3227
 
4236
- /** Is `parsed` a genuinely ANSWERABLE query — one that both parsed AND (for the simple
4237
- * clauses) resolves its named term(s) to a graph entity? A composite non-miss node,
4238
- * an ambiguous parse, and a meta/mentions surface all count; an unresolved-context
4239
- * pronoun is its OWN specific honest miss (kept, not relaxed). Returns:
4240
- * true — a real, executable answer (even if it later renders an empty set / "No")
4241
- * "ambiguous"/"pronoun" — a specific outcome to keep, distinct from relaxable
4242
- * false — no parse at all, a compositional {node:"miss"}, an unresolved term,
4243
- * or a fuzzy-only schema-individual hit with a kind-noun reading
4244
- * (schemaTypoTrap above — relaxable, so the cascade can re-read it)
4245
- * ask() starts the cascade ONLY on `false`, and accepts a relaxed attempt ONLY on the
4246
- * strict `true` (so the cascade can never "rescue" a query into another kind of miss). */
3228
+ /** Is `parsed` a genuinely answerable query — one that both parsed and (for
3229
+ * simple clauses) resolves its named term(s)? Returns true (real, executable
3230
+ * answer), "ambiguous"/"pronoun" (a specific outcome to keep, not relaxed),
3231
+ * or false (no parse, a compositional miss, an unresolved term, or a
3232
+ * schemaTypoTrap hit relaxable). ask() starts the cascade only on false. */
4247
3233
  function answerable(graph, parsed, contextId) {
4248
3234
  if (!parsed) return false;
4249
3235
  if (parsed.ambiguousParse) return "ambiguous";
@@ -4260,9 +3246,8 @@ function answerable(graph, parsed, contextId) {
4260
3246
  return true;
4261
3247
  }
4262
3248
 
4263
- /** Whole-query help/orientation request show the hint directly (never the relaxation
4264
- * loop, never a pretend answer). Matches only when the ENTIRE normalized query is a
4265
- * curated HELP_TRIGGER, so a symbol named "help" in a real question is untouched. */
3249
+ /** Whole-query help/orientation request -> show the hint directly. Matches
3250
+ * only when the entire normalized query is a curated HELP_TRIGGER. */
4266
3251
  function isHelpRequest(query) {
4267
3252
  const q = String(query || "").trim().toLowerCase().replace(/[?.!\s]+$/, "");
4268
3253
  return HELP_TRIGGERS.includes(q);
@@ -4305,18 +3290,11 @@ export function relaxParse(graph, query, { nlp = undefined, contextId = null, pr
4305
3290
  const r = resolveObject(graph, t);
4306
3291
  return !!r.match && r.tier != null && r.tier <= 3;
4307
3292
  };
4308
- // Does a term string carry at least one REAL word one the grammar doesn't already
4309
- // own as vocabulary/scaffolding? Guards against a relaxation that drops the actual
4310
- // asked term and lets a bare marker slide into its place ("where is [X] defined" →
4311
- // "where is defined", "defined" is a WHERE_MARKER, never the thing being located).
4312
- // A bare CONTEXT PRONOUN ("it"/"this"/"that"/"here"/…) is the one exception: it IS
4313
- // the real, deliberate object here — it resolves through contextId, not through
4314
- // vocabulary the grammar "already owns" as scaffolding — so it must count as a real
4315
- // term rather than being mistaken for a dropped-into-place marker. Without this, a
4316
- // relaxed candidate whose object survived layer 2 as a lone pronoun ("what else is
4317
- // in that class" → drop "class"/"else" → "what does that contain") was rejected as
4318
- // if it named nothing at all, even though the pronoun resolves to a real, answerable
4319
- // focus (0.9.15 Tier-1 single-touch playtest).
3293
+ // Does a term string carry at least one real word, one the grammar doesn't
3294
+ // already own as vocabulary/scaffolding? Guards against a relaxation that
3295
+ // drops the actual asked term and lets a bare marker slide into its place.
3296
+ // A bare context pronoun is the one exception it resolves through
3297
+ // contextId, so it counts as a real term.
4320
3298
  const hasRealTerm = (s) => {
4321
3299
  const whole = String(s || "").trim().toLowerCase();
4322
3300
  if (CONTEXT_PRONOUNS.includes(whole)) return true;
@@ -4325,10 +3303,9 @@ export function relaxParse(graph, query, { nlp = undefined, contextId = null, pr
4325
3303
  return !CONTENT_VOCAB.has(lc) && !STRUCTURAL_WORDS.has(lc);
4326
3304
  });
4327
3305
  };
4328
- // Accept a relaxed attempt ONLY if it is a genuinely answerable parse (terms resolve)
4329
- // AND it renders a REAL positive answer — never another empty/miss (relaxation earns a
4330
- // win only by turning a miss into an answer, never a differently-worded miss) — and
4331
- // never by promoting a bare marker to the asked term.
3306
+ // Accept a relaxed attempt only if it's genuinely answerable and renders a
3307
+ // real positive answer — relaxation earns a win only by turning a miss
3308
+ // into an answer, never a differently-worded miss.
4332
3309
  const TERM_SHAPES = new Set(["reverse", "forward", "where", "when", "ask"]);
4333
3310
  const attempt = (toks) => {
4334
3311
  const text = toks.join(" ");
@@ -4372,11 +3349,9 @@ export function relaxParse(graph, query, { nlp = undefined, contextId = null, pr
4372
3349
  survivors.push(t);
4373
3350
  continue;
4374
3351
  }
4375
- // Gap 2 — before dropping an unmatched plain token, try a bounded fuzzy-correct to a
4376
- // UNIQUE closed-vocab word (verbs, entity kinds, aggregate/list triggers): a typo of
4377
- // a TRIGGER ("manyn"→"many", "coutn"→"count", "liist"→"list") is restored, not
4378
- // discarded, so the count/list intent survives. Only a unique within-bound hit; else
4379
- // the token is genuinely unrecoverable and drops exactly as before.
3352
+ // Before dropping an unmatched token, try a bounded fuzzy-correct to a
3353
+ // unique closed-vocab word, so a typo'd trigger ("manyn"->"many") is
3354
+ // restored rather than discarded.
4380
3355
  const fix = fuzzyCascadeWord(lc);
4381
3356
  if (fix && fix !== lc) { survivors.push(fix); corrected.push(`${t}→${fix}`); continue; }
4382
3357
  nowDropped.push(t);
@@ -4403,90 +3378,54 @@ export function relaxParse(graph, query, { nlp = undefined, contextId = null, pr
4403
3378
  if (hit) return done(hit);
4404
3379
  }
4405
3380
 
4406
- // Layer 4 (terminal) — BARE KIND NOUN a bounded DEFAULT ACTION. When noise-strip,
4407
- // drop-unmatched and synonym-normalise have all failed to yield an answerable parse,
4408
- // give the operator's "vague enough to land" case a sensible answer instead of an
4409
- // honest miss: a query that is ONLY a kind noun (class/classes, function/functions,
4410
- // module, method, attribute, variable, commit, ) wrapped in articles/noise/question
4411
- // words DEFAULTS TO A COUNT of that kind ("the classes" / "classes" / "tell me the
4412
- // classes" "20 classes."). Count, not list: a bare unscoped list of 647 functions is
4413
- // noise, whereas the count is the cheap useful answer the asker can then drill into
4414
- // ("list them"). Deterministic — count for every kind, no cardinality cap.
4415
- //
4416
- // We classify the ORIGINAL normalized tokens (`from`), NOT the layer-mutated `tokens`:
4417
- // drop-unmatched has by now EATEN any unknown qualifier, so "the shiny classes" would
4418
- // otherwise look identical to a bare "classes". Reading the whole phrase keeps the
4419
- // discipline exact — the rule fires ONLY when every non-kind token is pure packaging
4420
- // (NOISE_OR_SCAFFOLD). A dangling unknown qualifier ("the shiny classes"), a relation
4421
- // verb, a marker, or a real term is neither noise nor a kind noun, so it lands in
4422
- // `others`, blocks the default, and the honest miss (or the real compositional query,
4423
- // if a lower layer already rescued it) stands.
3381
+ // Layer 4 (terminal) — bare kind noun -> a bounded default action. A query
3382
+ // that is only a kind noun wrapped in articles/noise/question words
3383
+ // defaults to a count of that kind ("the classes" -> "20 classes."), never
3384
+ // a list (unscoped lists of hundreds are noise; count is the cheap useful
3385
+ // answer). Classifies the original normalized tokens (`from`), not the
3386
+ // layer-mutated `tokens` drop-unmatched has already eaten any unknown
3387
+ // qualifier, so "the shiny classes" must be told apart from bare "classes".
4424
3388
  const bareLc = splitWords(from).map((t) => t.toLowerCase());
4425
3389
  const kindWords = [];
4426
3390
  const others = [];
4427
3391
  for (const t of bareLc) {
4428
3392
  if (NOISE_OR_SCAFFOLD.has(t)) continue;
4429
- // real entity kinds only — "change"/"changes" is ask-vocab's pseudo-type (never a
4430
- // node class), so it is not a countable kind; it falls into `others`.
3393
+ // "Change" is ask-vocab's pseudo-type, never a node class, so it's not countable.
4431
3394
  const et = ENTITY_TO_TYPE[t];
4432
3395
  if (et && et !== "Change") kindWords.push(t);
4433
3396
  else others.push(t);
4434
3397
  }
4435
3398
  if (kindWords.length === 1 && others.length === 0) {
4436
- // reuse the whole aggregate pipeline (parseAggregate count node renderer): a
4437
- // synthesized "count <kind>" is the exact query the cascade's other count paths land.
3399
+ // Reuse the whole aggregate pipeline: a synthesized "count <kind>" is the
3400
+ // exact query the cascade's other count paths land.
4438
3401
  const hit = attempt(["count", kindWords[0]]);
4439
3402
  if (hit) { steps.push(`bare kind "${kindWords[0]}" → count`); return done(hit); }
4440
3403
  }
4441
- // A LONE unknown noun wrapped only in packaging ("the bananas") is left to the generic
4442
- // honest miss (the rephrase hint already NAMES the kinds): a crisper "isn't a listable
4443
- // kind" miss here would fire on every one-word non-query the same way ("tell me a joke"),
4444
- // which chat.mjs's own surface deliberately answers with the general hint — so the
4445
- // bare-noun default is a COUNT of a KNOWN kind only, never a re-worded miss.
4446
3404
 
4447
- return null; // exhausted — the honest bottom of the cascade (caller keeps the original miss)
3405
+ return null; // exhausted — the honest bottom of the cascade
4448
3406
  }
4449
3407
 
4450
- // ---- orchestration — the tmct_ask entry point (§6.3: parse -> resolve -> traverse -> render) ----
3408
+ // ---- orchestration — the tmct_ask entry point (parse -> resolve -> traverse -> render) ----
4451
3409
 
4452
3410
  /** Answer a free-text question over the graph, mechanically. `opts.contextId`
4453
- * resolves a context pronoun ("this"/"it"/…) — wired from a UI's currently-
4454
- * selected node when one exists; omit it in the bare CLI surface, where
4455
- * a pronoun then produces an honest miss rather than a guess. `opts.nlp`
4456
- * overrides the lemma/POS adapter (see parseQuery) leave it undefined and
4457
- * a Node process picks up wink automatically while the inlined viewer stays
4458
- * adapter-less by construction. `opts.prev` is the id array of the LAST answer's
4459
- * matches thread it from a chat loop so a follow-up anaphora question ("which of
4460
- * those are tested", "how many of them call X") filters/counts the prior result
4461
- * set; omit it and anaphora questions produce an honest "needs a previous answer"
4462
- * miss. Returns the full {content, tmct_ask:
4463
- * {mechanical,parsed,matches,traversal,miss,ambiguous,candidates?}} envelope
4464
- * §6.2 specifies. Zero generative model calls. */
4465
- // Seonix Batch 3 (3b), singular subject: "the last commit"/"the latest commit"/"the
4466
- // most recent commit" — literal-phrase substitution, checked as a whole-word match
4467
- // (not anchored to the whole line, since it may sit mid-sentence as the subject of a
4468
- // longer question, e.g. "what did the last commit touch").
3411
+ * resolves a context pronoun, wired from a UI's currently-selected node;
3412
+ * omit it in the bare CLI surface. `opts.nlp` overrides the lemma/POS
3413
+ * adapter. `opts.prev` is the id array of the last answer's matches, for
3414
+ * anaphora follow-ups; omit it and anaphora questions produce an honest
3415
+ * "needs a previous answer" miss. Returns {content, tmct_ask:
3416
+ * {mechanical,parsed,matches,traversal,miss,ambiguous,candidates?}}. Zero
3417
+ * generative model calls. */
4469
3418
  const LAST_COMMIT_PHRASE_RE = /\b(?:the\s+)?(?:last|latest|most\s+recent)\s+commit\b/i;
4470
3419
 
4471
- /** resolveObject has no notion of "the newest Commit individual" it only matches
4472
- * literal graph labels, and the bare word "commit" itself component-matches the
4473
- * Commit SchemaClass node (a known risk noted around resolveObject's own tier-3
4474
- * comments), so "what did the last commit touch" used to render a false "Commit
4475
- * has no touches edges in the index" instead of an honest answer. Fixed by textual
4476
- * substitution BEFORE the normal parse/resolve pipeline runs: the phrase is swapped
4477
- * for "commit <newest-sha>" (the SAME dateOf/localeCompare sort every other
4478
- * Commit-date reader in this file already uses), so the rest of the pipeline sees
4479
- * exactly what it would for "what did commit <realsha> touch" and needs no other
4480
- * change. A graph with no commits, or no date on any commit, leaves the query text
4481
- * untouched — an honest miss downstream, never a guess at which commit is "last". */
4482
- // A bare "when was/did commit X" with no change-verb tail at all (the shape left
4483
- // once "the latest commit" is substituted out of "when was the latest commit") —
4484
- // grammar.mjs's T8 "when" template requires a touches-family verb to fire, so this
4485
- // would otherwise honestly miss even though traverse()'s own "when" branch already
4486
- // special-cases a Commit OBJECT to answer with its own date (see the commit-as-
4487
- // subject flip's sibling branch, just above the flip itself). Bridged by appending
4488
- // the neutral "touched" tail — never for a query that already names its own verb
4489
- // ("what did the last commit touch" is untouched).
3420
+ /** resolveObject has no notion of "the newest Commit individual", and the
3421
+ * bare word "commit" itself component-matches the Commit SchemaClass node,
3422
+ * so "what did the last commit touch" used to render a false empty. Fixed
3423
+ * by textual substitution before the normal parse/resolve pipeline runs:
3424
+ * the phrase is swapped for "commit <newest-sha>". A graph with no commits,
3425
+ * or no dated commit, leaves the query text untouched. */
3426
+ // A bare "when was/did commit X" with no change-verb tail (the shape left
3427
+ // once "the latest commit" is substituted out) needs a "touched" tail
3428
+ // appended, since grammar.mjs's T8 "when" template requires a verb to fire.
4490
3429
  const BARE_WHEN_COMMIT_RE = /^when\s+(?:was|were|is|did|does|do)\s+commit\s+[0-9a-fA-F:]+$/i;
4491
3430
 
4492
3431
  function substituteLastCommitPhrase(graph, query) {
@@ -4502,31 +3441,14 @@ function substituteLastCommitPhrase(graph, query) {
4502
3441
  return BARE_WHEN_COMMIT_RE.test(bareTrimmed) ? `${bareTrimmed} touched` : out;
4503
3442
  }
4504
3443
 
4505
- // ---- dynamic memory-graph class count/list (PLAN_BREADTH_FIRST_NLU.md (d),
4506
- // ROADMAP.md "What's next" (d)): a real "list/count all X of class Y" shape for
4507
- // MEMORY-graph classes (Fact/Utterance/Session/Source/Rule, or any class a taught
4508
- // individual actually carries), reachable via ask.mjs alone the gap live-testing
4509
- // during the viz chat panel's build confirmed ("how many facts are there"/"list
4510
- // facts"/"what is a Fact" all missed against a real memory graph, since the only
4511
- // working machinery for this shape lived in chat.mjs's heavier factAnswer cascade,
4512
- // out of the browser bundle's ask.mjs-only scope).
4513
- //
4514
- // ENTITY_TO_TYPE (ask-vocab.mjs) is a CLOSED table of code-graph nouns only
4515
- // (module/function/class/…) — memory-graph classes are open-ended (taught, not a
4516
- // fixed vocabulary), so they can't be added to that table the same way. Instead,
4517
- // this resolves the noun against whatever classes ACTUALLY have at least one
4518
- // individual in THIS graph right now (never guesses a class exists with zero
4519
- // evidence — same zero-fabrication discipline as everything else here) and
4520
- // reuses the exact SAME count/list AST + traverse()+render() path every
4521
- // code-graph count/list query already runs through (evalComposite's
4522
- // "allOfClass"/"count"/"list" nodes, already generic over any `individual.class`
4523
- // string — see `evalSet`'s "allOfClass" case and renderComposite's "count"/"list"
4524
- // branches above) — no new render logic, no new miss/hit wording invented.
4525
- //
4526
- // Fires ONLY as a fallback in ask() after the normal cascade already produced an
4527
- // honest miss, and is skipped entirely for any noun ENTITY_TO_TYPE already owns
4528
- // (so a real code-graph "list modules"/"how many classes" answer, including its
4529
- // own honest empty-graph miss wording, is never intercepted or changed).
3444
+ // ---- dynamic memory-graph class count/list: a "list/count all X of class Y"
3445
+ // shape for memory-graph classes (Fact/Utterance/Session/Source/Rule, or any
3446
+ // taught class), reachable via ask.mjs alone. ENTITY_TO_TYPE is a closed
3447
+ // code-graph noun table, so this instead resolves against whatever classes
3448
+ // actually have an individual in this graph, reusing the same count/list
3449
+ // AST + traverse()/render() path every code-graph query runs through. Fires
3450
+ // only as a fallback after the normal cascade already produced an honest
3451
+ // miss, and is skipped for any noun ENTITY_TO_TYPE already owns. ----
4530
3452
  function singularCandidates(word) {
4531
3453
  const w = String(word || "").toLowerCase();
4532
3454
  const c = new Set([w]);
@@ -4545,16 +3467,13 @@ function resolveDynamicClass(graph, word) {
4545
3467
  }
4546
3468
  const DYNAMIC_LIST_TRIGGER_RE = /^(?:list|show(?:\s+me)?)\s+(?:all\s+|the\s+)?([a-z][a-z'-]*)\s*(.*)$/i;
4547
3469
  const DYNAMIC_COUNT_TRIGGER_RE = /^(?:how\s+many|number\s+of|count(?:\s+the)?)\s+([a-z][a-z'-]*)\s*(.*)$/i;
4548
- // A closed set of harmless trailing fillers ("are there", "do you know", …) — an
4549
- // empty tail is the plain "list facts"/"how many facts" shape; anything else
4550
- // (a real restrictor like "that mention X") is NOT this shape and is left alone
4551
- // so it stays whatever honest miss the normal cascade already produced.
3470
+ // A closed set of harmless trailing fillers; anything else (a real
3471
+ // restrictor like "that mention X") is left alone.
4552
3472
  const DYNAMIC_TAIL_OK_RE = /^(?:are there(?:\s+in\s+total)?|is there|do you know(?:\s+about)?|do you have|exist(?:s)?|are known|in (?:the |a )?(?:graph|memory)|you know(?:\s+about)?)?[?.!\s]*$/i;
4553
3473
 
4554
- /** Compile "list/how many <memory-class-noun>" into the same count/list AST every
4555
- * code-graph count/list query already builds, or null when this isn't that shape
4556
- * (wrong trigger, a real restrictor tail, ENTITY_TO_TYPE already owns the noun, or
4557
- * no individual in THIS graph actually carries that class). */
3474
+ /** Compile "list/how many <memory-class-noun>" into the same count/list AST
3475
+ * every code-graph count/list query already builds, or null when this isn't
3476
+ * that shape. */
4558
3477
  function dynamicClassQuery(graph, query) {
4559
3478
  const q = String(query || "").trim();
4560
3479
  const listM = q.match(DYNAMIC_LIST_TRIGGER_RE);
@@ -4568,9 +3487,11 @@ function dynamicClassQuery(graph, query) {
4568
3487
  return listM ? { node: "list", entityType, base, scoped: false } : { node: "count", entityType, base };
4569
3488
  }
4570
3489
 
3490
+ // Matches T5's own bare-object capture (grammar.mjs meta-whatis), reused below to
3491
+ // extract the term for the article-insertion fallback rather than duplicating it.
3492
+ const BARE_META_WHATIS_RE = /^what\s+(?:is|are)\s+(?:an?\s+)?(.+?)[?.!\s]*$/i;
3493
+
4571
3494
  export function ask(graph, query, { contextId = null, nlp = undefined, prev = null } = {}) {
4572
- // Explicit help/orientation request → the rephrase hint directly (the honest bottom
4573
- // of the cascade, reached on demand), never a pretend answer or a relaxation attempt.
4574
3495
  if (isHelpRequest(query)) {
4575
3496
  return {
4576
3497
  content: rephraseHint(),
@@ -4580,17 +3501,12 @@ export function ask(graph, query, { contextId = null, nlp = undefined, prev = nu
4580
3501
  },
4581
3502
  };
4582
3503
  }
4583
- // Seonix Batch 3 (3b), singular subject: substitute "the last/latest/most recent
4584
- // commit" for the real newest Commit's own id BEFORE anything else runs, so the
4585
- // rest of the pipeline (direct parse, relaxation, resolveObject) never has to know
4586
- // this phrase existed — see substituteLastCommitPhrase's own doc above.
4587
3504
  query = substituteLastCommitPhrase(graph, query);
4588
3505
  const directFull = parseQueryFull(query, { nlp });
4589
3506
  const direct = directFull.parsed;
4590
- // The relaxation cascade fires ONLY when the DIRECT parse would miss (no parse, a
4591
- // compositional {node:"miss"}, or an unresolved named term) a clean hit, an
4592
- // ambiguous parse, an unresolved-pronoun miss, and a real-but-empty answer all keep
4593
- // the direct parse untouched (a hit stays instant and exact).
3507
+ // The relaxation cascade fires only when the direct parse would miss; a
3508
+ // clean hit, an ambiguous parse, an unresolved-pronoun miss, and a
3509
+ // real-but-empty answer all keep the direct parse untouched.
4594
3510
  let parsed = direct;
4595
3511
  let relaxed = null;
4596
3512
  if (answerable(graph, direct, contextId) === false) {
@@ -4599,11 +3515,8 @@ export function ask(graph, query, { contextId = null, nlp = undefined, prev = nu
4599
3515
  }
4600
3516
  let result = traverse(graph, parsed, { contextId, prev });
4601
3517
  let rendered = render(parsed, result);
4602
- // Dynamic memory-graph class count/list fallback (PLAN_BREADTH_FIRST_NLU.md (d))
4603
- // — fires ONLY once everything above already produced an honest miss, and only
4604
- // replaces it when the fallback itself produces a real (non-miss) answer, so a
4605
- // genuine "no X in this index" miss for an ENTITY_TO_TYPE-owned noun is never
4606
- // touched (dynamicClassQuery declines those itself — see its own doc above).
3518
+ // Dynamic memory-graph class count/list fallback, only once everything
3519
+ // above already produced an honest miss.
4607
3520
  if (rendered.miss && !rendered.ambiguous) {
4608
3521
  const dyn = dynamicClassQuery(graph, query);
4609
3522
  if (dyn) {
@@ -4612,37 +3525,33 @@ export function ask(graph, query, { contextId = null, nlp = undefined, prev = nu
4612
3525
  if (!dynRendered.miss) { parsed = dyn; result = dynResult; rendered = dynRendered; relaxed = null; }
4613
3526
  }
4614
3527
  }
4615
- // If relaxation materially rewrote the query and produced a real answer, note it
4616
- // lightly (terse, honest) so the reader knows how the question was read.
3528
+ // Bare "what is X" (no article) meta fallback, narrow to ask() and never
3529
+ // touching the outer `parsed` even on a miss chat.mjs's own gates key off
3530
+ // `!envelope.parsed`, so populating it here would steal turns from lanes
3531
+ // like moduleOrientLane ("what is X for"). Fires only once nothing else parsed.
3532
+ if (parsed === null && rendered.miss && !rendered.ambiguous) {
3533
+ const bareM = String(query || "").trim().match(BARE_META_WHATIS_RE);
3534
+ const bareTerm = bareM?.[1]?.trim();
3535
+ if (bareTerm && !/\s+(?:for|about)$/i.test(bareTerm)) {
3536
+ const bareParsed = parseQuery(`what is a ${bareTerm}`, { nlp });
3537
+ if (bareParsed?.shape === "meta") {
3538
+ result = traverse(graph, bareParsed, { contextId, prev });
3539
+ rendered = render(bareParsed, result);
3540
+ }
3541
+ }
3542
+ }
3543
+ // If relaxation materially rewrote the query and produced a real answer,
3544
+ // note it lightly so the reader knows how the question was read.
4617
3545
  let content = (relaxed && !rendered.miss && relaxed.to !== relaxed.from)
4618
3546
  ? `read as "${relaxed.to}" — ${rendered.content}`
4619
3547
  : rendered.content;
4620
- // ALTERNATES ON HITS (breadth-first, PLAN_BREADTH_FIRST_NLU.md §3): a genuine
4621
- // distinct-class alternate reading from a different strategy (merge.mjs's
4622
- // `alternates`) used to be silently discarded on every call, including a real
4623
- // hit surface it now, answered for real via the same traverse()+render()
4624
- // idiom the ambiguousParse/entity-tie branches already use. Scoped tightly to
4625
- // the ONE case this is unambiguously safe: the direct parse (untouched by
4626
- // relaxation, itself not already an ambiguous/miss result) produced a genuine
4627
- // answer. Relaxation rewrote the query, so `directFull`'s alternates no
4628
- // longer describe the question actually answered — skipped rather than shown
4629
- // stale.
4630
- //
4631
- // REAL-ANSWER-ONLY, never the bare "ask it that way" pointer (live-caught,
4632
- // 2026-07-11): a lower-precedence strategy's "alternate" is often pure noise,
4633
- // not a genuine second reading — e.g. keyword-spot misreading a stripped
4634
- // filler phrase as the query's SUBJECT ("hey man which modules import X" ->
4635
- // an "ask" parse with subject:"hey man"). That never resolves to a real graph
4636
- // entity, so `alternateLines`'s default "if you mean X then ask it that way"
4637
- // fallback would surface exactly the low-value dead-end nudge this whole plan
4638
- // exists to eliminate — worse, it's non-deterministic across equivalent
4639
- // phrasings (test/interpret.test.mjs's own noise-strip parity check caught
4640
- // this: "hey man which modules import X" must answer byte-identically to the
4641
- // clean phrasing, and a garbage alternate broke that). So: compute each
4642
- // alternate's real answer directly (not via alternateLines' fallback-prone
4643
- // default) and only ever append lines for alternates that resolved to
4644
- // something real — an alternate that can't be answered is dropped silently,
4645
- // never padded with an unhelpful pointer.
3548
+ // A genuine distinct-class alternate reading (merge.mjs's `alternates`)
3549
+ // is surfaced on a real direct hit, computing each alternate's own real
3550
+ // answer directly rather than via alternateLines' fallback-prone
3551
+ // "ask it that way" pointer an alternate that can't be answered for
3552
+ // real is dropped silently. Skipped when relaxation rewrote the query,
3553
+ // since directFull's alternates would no longer describe the question
3554
+ // actually answered.
4646
3555
  if (!relaxed && !rendered.miss && !rendered.ambiguous && directFull.alternates.length) {
4647
3556
  const answered = directFull.alternates
4648
3557
  .map((a) => {
@@ -4663,13 +3572,6 @@ export function ask(graph, query, { contextId = null, nlp = undefined, prev = nu
4663
3572
  tmct_ask: {
4664
3573
  mechanical: true,
4665
3574
  parsed: (parsed && !parsed.ambiguousParse) ? parsed : null,
4666
- // PLAN_BREADTH_FIRST_NLU.md §Track 6 (operator directive): the canonical
4667
- // restatement of what the request was understood to mean, ALWAYS present
4668
- // when anything parsed at all — not gated on ambiguity/miss the way the
4669
- // ambiguity-branch labels are. `english` is the human-readable gloss in
4670
- // tmct's own phrasing; `machine` is the same fact in a compact,
4671
- // machine-parsable notation (a plain `shape(kind, args...)` call form).
4672
- // Both are read straight off `parsed` — never generated.
4673
3575
  canonical: canonicalOf(parsed),
4674
3576
  matches: (result.matches || []).map((m) => ({
4675
3577
  id: m.id, label: m.label, type: m.class, module: m.class ? moduleLabelOf(m) : undefined,
@@ -4677,16 +3579,12 @@ export function ask(graph, query, { contextId = null, nlp = undefined, prev = nu
4677
3579
  traversal: result.traversal || null,
4678
3580
  miss: !!rendered.miss,
4679
3581
  ambiguous: !!rendered.ambiguous,
4680
- // The relaxation trace: null when the direct parse was used as-is (a clean hit or
4681
- // an honest miss the cascade couldn't/shouldn't rescue), else what the cascade
4682
- // dropped/normalised to reach an answer. A caller can assert relaxed===null to
4683
- // prove the cascade never touched a direct hit.
3582
+ // null when the direct parse was used as-is; a caller can assert
3583
+ // relaxed===null to prove the cascade never touched a direct hit.
4684
3584
  relaxed,
4685
- // Confidence provenance: "prose" when resolveObject fell through to the tier-4
4686
- // prose-index fallback (PLAN_PROSE_INDEX.md §6 matched what the symbol talks
4687
- // about, not its name); "fuzzy" when the tier-5 bounded-edit-distance pass
4688
- // resolved a typo'd term (the rendered content also announces it: "assuming you
4689
- // meant <label>"); null for every literal-identifier tier.
3585
+ // "prose" (tier-4 prose-index fallback) or "fuzzy" (tier-5 bounded
3586
+ // edit-distance, announced in the content as "assuming you meant …");
3587
+ // null for every literal-identifier tier.
4690
3588
  matchedVia: result.matchedVia || null,
4691
3589
  ...(rendered.ambiguous ? { candidates: rendered.candidates } : {}),
4692
3590
  },