@polycode-projects/the-mechanical-code-talker 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,148 @@
1
+ // interpret/merge.mjs — class-grouped merging of strategy results (ROADMAP item 8).
2
+ // Grown from ask.mjs's original two-way merge ("one strategy hit -> use it; both
3
+ // hit and agree -> use it; both hit and DISAGREE -> a genuine parse-level
4
+ // ambiguity; neither hits -> the honest grammar miss") into a general rule over N
5
+ // strategies and N result CLASSES:
6
+ //
7
+ // · SAME-class candidates merge and rank — identical parses (sameParse) dedupe
8
+ // onto the highest-precedence strategy's candidate, exactly as the legacy
9
+ // merge returned "either" result on agreement; multiple DISTINCT parses
10
+ // surviving in the winning class are the legacy {ambiguousParse, candidates}
11
+ // surface, rendered by ask.mjs as "this could mean more than one thing: …".
12
+ // · DISTINCT-class groups produce the ambiguity SURROUND: the winning class
13
+ // answers, and each other class's best candidate is listed as an
14
+ // "if you mean X then …" line (alternateLines below) — the generalization of
15
+ // the engine's existing announced-correction shapes ("assuming you meant …").
16
+ //
17
+ // Winner selection is deterministic: the class holding the highest-confidence
18
+ // candidate wins; a confidence tie goes to the earlier-registered strategy. The
19
+ // two legacy strategies share one class ("graph-query"), so their merge reduces
20
+ // exactly to the original behavior by construction.
21
+
22
+ const DEFAULT_CONFIDENCE = 0.5;
23
+
24
+ // "commit abc1234" and bare "abc1234" are the SAME term once resolveObject's
25
+ // commit-sha tier strips the noun — the anchored strategy captures the noun inside
26
+ // its object span while keyword-spot consumes it as the entity keyword, so without
27
+ // this the two strategies would "disagree" over a word that names no different thing.
28
+ const cmpTerm = (s) => String(s || "").trim().toLowerCase().replace(/\s+/g, " ").replace(/^commit\s+(?=[0-9a-f]{7,40}$)/, "");
29
+
30
+ /** Do two independently-produced parses mean the same graph query? Same
31
+ * shape, same relation kind, and matching term(s) (both subject and object
32
+ * for "ask"; just object otherwise) — anything less is a genuine
33
+ * disagreement, not a near-miss to paper over. */
34
+ export function sameParse(p, q) {
35
+ if (p.shape !== q.shape || p.kind !== q.kind) return false;
36
+ if (p.shape === "ask") return cmpTerm(p.subject) === cmpTerm(q.subject) && cmpTerm(p.object) === cmpTerm(q.object);
37
+ return cmpTerm(p.object) === cmpTerm(q.object);
38
+ }
39
+
40
+ /** Merge an array of strategy results ({strategyId, class, candidates:[{parsed,
41
+ * confidence?, note?}]}), in strategy-registration (= precedence) order, into
42
+ * { class, parsed, winner, alternates } | null
43
+ * where `parsed` is the winning class's single merged parse OR the legacy
44
+ * {ambiguousParse: true, candidates} tie, `winner` is the winning candidate
45
+ * record, and `alternates` lists each OTHER class's best candidate (its parse
46
+ * not sameParse-equal to a winning one — a cross-class agreement is agreement,
47
+ * not an alternative reading). Null when nothing parsed anywhere. */
48
+ // Candidate `via` values that mark an APPROXIMATE reading (a bounded-edit-
49
+ // distance or lemma rewrite happened on the way to this parse). The engine's
50
+ // tier discipline — "exact curated match always wins; lower tiers fire only on
51
+ // a miss" — must survive the merge: an approximate candidate is DISCARDED
52
+ // outright whenever any exact candidate parsed at all (rule a), and dedupe keys
53
+ // on (parse, via) so an approximate twin can never collapse onto (or stand in
54
+ // for) an exact parse and smuggle its "assuming you meant …" announcement into
55
+ // an exact answer (rule b). A candidate with no `via` (or via:"exact") is exact
56
+ // — the legacy strategies set none.
57
+ const APPROXIMATE_VIAS = new Set(["fuzzy", "lemma", "spell"]);
58
+ const isApproximate = (c) => APPROXIMATE_VIAS.has(c.via);
59
+
60
+ export function mergeStrategyResults(results) {
61
+ const valid = (results || []).filter((r) => r && Array.isArray(r.candidates) && r.candidates.length);
62
+ if (!valid.length) return null;
63
+ // flatten candidates in precedence order (validity-checked)
64
+ let flat = [];
65
+ for (const r of valid) {
66
+ for (const c of r.candidates) {
67
+ if (!c || !c.parsed) continue;
68
+ flat.push({
69
+ parsed: c.parsed,
70
+ confidence: typeof c.confidence === "number" ? c.confidence : DEFAULT_CONFIDENCE,
71
+ note: c.note || null,
72
+ via: c.via || null,
73
+ strategyId: r.strategyId,
74
+ class: r.class,
75
+ });
76
+ }
77
+ }
78
+ // rule (a): any exact parse anywhere discards every approximate candidate —
79
+ // exact curated evidence always wins, so a fuzzy/lemma reading may only ever
80
+ // compete when NOTHING parsed exactly.
81
+ if (flat.some((c) => !isApproximate(c))) flat = flat.filter((c) => !isApproximate(c));
82
+ if (!flat.length) return null;
83
+ // group by class, preserving precedence order
84
+ const groups = new Map();
85
+ for (const c of flat) {
86
+ if (!groups.has(c.class)) groups.set(c.class, []);
87
+ groups.get(c.class).push(c);
88
+ }
89
+ // within-class dedupe: an identical parse collapses onto its first (highest-
90
+ // precedence) occurrence — the survivor keeps the strongest confidence and
91
+ // counts how many strategies agreed on it. Dedupe keys on (parse, via) — rule
92
+ // (b) — so distinct provenances never merge (moot after rule (a) globally
93
+ // splits exact from approximate, but held here as its own invariant).
94
+ const merged = [];
95
+ for (const [cls, cands] of groups) {
96
+ const distinct = [];
97
+ for (const c of cands) {
98
+ const dup = distinct.find((d) => sameParse(d.parsed, c.parsed) && d.via === c.via);
99
+ if (dup) {
100
+ dup.agreed += 1;
101
+ dup.confidence = Math.max(dup.confidence, c.confidence);
102
+ continue;
103
+ }
104
+ distinct.push({ ...c, agreed: 1 });
105
+ }
106
+ if (distinct.length) merged.push({ class: cls, candidates: distinct });
107
+ }
108
+ if (!merged.length) return null;
109
+ const top = (g) => Math.max(...g.candidates.map((c) => c.confidence));
110
+ let winner = merged[0];
111
+ for (const g of merged) if (top(g) > top(winner)) winner = g;
112
+ // >1 DISTINCT parse in the winning class -> the legacy parse-level ambiguity,
113
+ // surfaced honestly (never a silently-preferred guess) — byte-identical to the
114
+ // original two-strategy disagreement shape.
115
+ const parsed = winner.candidates.length === 1
116
+ ? winner.candidates[0].parsed
117
+ : { ambiguousParse: true, candidates: winner.candidates.map((c) => c.parsed) };
118
+ const alternates = merged
119
+ .filter((g) => g !== winner)
120
+ .map((g) => g.candidates[0])
121
+ .filter((a) => !winner.candidates.some((w) => sameParse(w.parsed, a.parsed)));
122
+ return { class: winner.class, parsed, winner: winner.candidates[0], alternates };
123
+ }
124
+
125
+ /** One-line, honest rephrasing of an alternate's parse — the default `describe`
126
+ * for alternateLines. Template only, reads straight off the parsed fields (the
127
+ * same discipline as ask.mjs's describeParse; callers with richer noun tables
128
+ * may pass their own describe). */
129
+ export function describeAlternate(p) {
130
+ if (!p) return "something else";
131
+ if (p.ambiguousParse) return "one of several readings";
132
+ const obj = p.object ?? p.subject ?? "?";
133
+ return `${p.kind} "${obj}"`;
134
+ }
135
+
136
+ /** The distinct-class ambiguity SURROUND: one "if you mean X then …" line per
137
+ * alternate. `answerFor(alternate)` (optional) supplies the "then …" tail — a
138
+ * caller holding the graph can answer each alternate reading outright; without
139
+ * it the line honestly points at the rephrase instead of pretending to answer.
140
+ * Message shape generalizes the engine's announced-correction precedents
141
+ * ("assuming you meant …" / "this could mean more than one thing: …"). */
142
+ export function alternateLines(alternates, { describe = describeAlternate, answerFor = null } = {}) {
143
+ return (alternates || []).map((a) => {
144
+ const meaning = a.note || describe(a.parsed);
145
+ const tail = (answerFor && answerFor(a)) || "ask it that way";
146
+ return `if you mean ${meaning} then ${tail}`;
147
+ });
148
+ }
@@ -0,0 +1,117 @@
1
+ // interpret/normalize.mjs — the input-normalization pass (ROADMAP item 10) and the
2
+ // shared text-prep helpers every interpretation strategy reads. Extracted MOVE-only
3
+ // from ask.mjs (item 13, chat/primitives split): the code here is the §3.5
4
+ // normalization pipeline exactly as it ran inside ask.mjs — contractions expanded,
5
+ // curated misspelling/wrong-word corrections applied, g-dropped words restored,
6
+ // filler/politeness stripped, then the small closed set of rhetorical frames
7
+ // rewritten to the canonical form of the SAME question. Pure, deterministic,
8
+ // idempotent — both parsing strategies (and any future one) see identical text.
9
+ //
10
+ // This is the pipeline's documented PRE-PASS: interpret/pipeline.mjs runs
11
+ // normalizeInput() once, hands every strategy the normalized text (plus the raw
12
+ // text in ctx.raw), and records whether normalization changed the input.
13
+
14
+ import {
15
+ CONTRACTIONS, MISSPELLINGS, WRONG_WORDS, G_DROP, FILLER_WORDS,
16
+ NEGATION_FRAMES, COMMIT_CONTENT_FRAMES,
17
+ } from "../ask-vocab.mjs";
18
+
19
+ export function escapeRegex(s) {
20
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
21
+ }
22
+
23
+ // ---- §3.5 normalization — runs before EITHER parsing strategy sees the text ----
24
+
25
+ /** contraction/informal-spelling table -> word-boundary regex, longest phrase
26
+ * first (so "there's" doesn't get shadowed by a shorter overlapping entry). */
27
+ const tableRe = (table) => new RegExp(
28
+ "\\b(" + Object.keys(table).sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b",
29
+ "gi",
30
+ );
31
+ const CONTRACTION_RE = tableRe(CONTRACTIONS);
32
+ // misspelling/wrong-word CORRECTIONS (ask-vocab.mjs) — same mechanism, applied
33
+ // after contractions: restore the intended spelling first, then map misused
34
+ // words to their canonical schema term. Deterministic and curated, so they run
35
+ // BEFORE either parse strategy and ahead of the bounded edit-distance fallback.
36
+ // The trailing lookahead refuses to rewrite a word glued to a dotted extension:
37
+ // WRONG_WORDS entries are real English words that plausibly NAME modules
38
+ // ("revision.mjs", "property.py"), and a correction that corrupts an object
39
+ // term would be a guess — the exact thing these tables exist to avoid.
40
+ const correctionRe = (table) => new RegExp(
41
+ "\\b(" + Object.keys(table).sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b(?!\\.[a-z0-9])",
42
+ "gi",
43
+ );
44
+ const MISSPELLING_RE = correctionRe(MISSPELLINGS);
45
+ const WRONG_WORD_RE = correctionRe(WRONG_WORDS);
46
+
47
+ /** Free-text -> normalized free-text: contractions expanded, g-dropped words
48
+ * restored, filler/politeness words stripped. Idempotent and pure — the same
49
+ * input always normalizes the same way, so both parsing strategies see
50
+ * identical text and their outputs are directly comparable. Deliberately
51
+ * does NOT force lowercase: object/subject terms (module names like
52
+ * "myFile", class names like "Base") are meaningfully cased, and every
53
+ * substitution below already matches case-insensitively (`i`/`gi` flags) —
54
+ * forcing the whole string to lowercase would silently corrupt every parsed
55
+ * term's case instead. */
56
+ export function normalizeQuery(text) {
57
+ let q = String(text || "");
58
+ q = q.replace(CONTRACTION_RE, (m) => CONTRACTIONS[m.toLowerCase()]);
59
+ q = q.replace(MISSPELLING_RE, (m) => MISSPELLINGS[m.toLowerCase()]);
60
+ q = q.replace(WRONG_WORD_RE, (m) => WRONG_WORDS[m.toLowerCase()]);
61
+ q = q.replace(G_DROP, "$1ing");
62
+ if (FILLER_WORDS.length) {
63
+ const fillerRe = new RegExp(
64
+ "\\b(" + [...FILLER_WORDS].sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b",
65
+ "gi",
66
+ );
67
+ q = q.replace(fillerRe, " ");
68
+ }
69
+ // emphatic trailing punctuation (item 10): a run of terminal "?" collapses to
70
+ // one — the anchored templates consume exactly one optional trailing "?", so
71
+ // "…walk.mjs??" otherwise leaks a stray "?" into the captured object term (the
72
+ // keyword-spot strategy already strips the whole run, and the two strategies
73
+ // then "disagreed" over punctuation that was never part of the intent).
74
+ q = q.replace(/\?{2,}\s*$/, "?");
75
+ return q.replace(/\s+/g, " ").trim();
76
+ }
77
+
78
+ /** Recognized rhetorical/idiomatic constructions rewritten to the canonical form
79
+ * of the SAME question before either parse strategy sees the text — a small
80
+ * closed pattern set, not a general rewriter. Two families, tried in order:
81
+ * COMMIT_CONTENT_FRAMES first ("what was in commit <sha>" -> "what did <sha>
82
+ * touch"; sha-anchored, so it can't swallow a containment question), then the
83
+ * §3.6 negative-rhetorical NEGATION_FRAMES. First matching frame across both wins
84
+ * and rewriting stops; unmatched text passes through unchanged. */
85
+ export function applyNegationFrames(text) {
86
+ for (const frame of [...COMMIT_CONTENT_FRAMES, ...NEGATION_FRAMES]) {
87
+ const m = text.match(frame.re);
88
+ if (m) return frame.to(m).replace(/\s+/g, " ").trim();
89
+ }
90
+ return text;
91
+ }
92
+
93
+ // ---- shared text-prep helpers (used by the strategies, the compositional
94
+ // grammar, and ask.mjs's relaxation cascade alike) ----
95
+
96
+ /** Question/auxiliary/article scaffolding the decomposition strategies skip when
97
+ * splitting residual words into subject/object terms. Shared so every strategy
98
+ * (and the cascade's structural-word set) reads the SAME list. */
99
+ export const STOPWORDS = new Set([
100
+ "what", "who", "which", "where", "when", "why", "how",
101
+ "does", "do", "did", "is", "are", "was", "were", "the", "a", "an", "of", "to", "from", "at", "in", "on",
102
+ "there", "something", "anything", "nothing", "one", "any",
103
+ // temporal filler in when-questions ("when was X last touched") — a symbol
104
+ // literally named "last" would be the accepted residual cost, same trade as
105
+ // every other stopword.
106
+ "last",
107
+ ]);
108
+
109
+ /** Split free text into words: trailing "?" run stripped, commas treated as
110
+ * spaces, mid-word "." preserved (object terms are routinely dotted file/module
111
+ * names — "a.py", "utils.mjs"). */
112
+ export const splitWords = (text) => String(text).replace(/\?+\s*$/, "").replace(/,/g, " ").split(/\s+/).filter(Boolean);
113
+
114
+ /** Flatten a phrase list into its lowercase constituent words — the standard way
115
+ * a vocab table's multi-word phrases feed a word-level set (the cascade's
116
+ * content-vocab union, the noise-strip KEEP set). */
117
+ export const wordsOf = (arr) => arr.flatMap((p) => String(p).toLowerCase().split(" "));
@@ -0,0 +1,112 @@
1
+ // interpret/pipeline.mjs — the multi-strategy interpretation pipeline (ROADMAP
2
+ // item 8): run the request through ALL the classes of thing it could be, parse it
3
+ // with each class's own strategy, then merge same-class results and surround
4
+ // distinct-class results with "if you mean X then …" (interpret/merge.mjs).
5
+ //
6
+ // A STRATEGY is a plain object:
7
+ // { id, class, run(text, ctx) -> {strategyId, class, candidates:[{parsed,
8
+ // confidence?, note?, via?}]} | null }
9
+ // `via` marks an APPROXIMATE reading ("fuzzy"/"lemma"/"spell"): the merge
10
+ // discards approximate candidates whenever anything parsed exactly (the tier
11
+ // discipline — exact curated match always wins — held across strategies).
12
+ // `run` may be sync or async (Promise-returning); a strategy that THROWS or
13
+ // rejects is dropped for that request — one broken strategy never takes the
14
+ // pipeline down. Strategies are registered in the STRATEGIES array below in
15
+ // PRECEDENCE order (earlier wins same-class dedupe ties and confidence ties) —
16
+ // a new strategy (e.g. the Phase-2 ACE grammar, interpret/strategies/ace.mjs)
17
+ // joins by pushing an entry here, not by editing the pipeline.
18
+ //
19
+ // NORMALIZATION PRE-PASS (ROADMAP item 10): interpret() normalizes the input
20
+ // once (normalizeInput below — the same §3.5 pipeline + rhetorical frames
21
+ // ask.mjs always ran) and hands every strategy the normalized text; the raw
22
+ // text rides along in ctx.raw, and the returned record says whether
23
+ // normalization changed the input (`normalizationChanged`), so a repaired
24
+ // spelling/contraction is on the record, never silent.
25
+
26
+ import { normalizeQuery, applyNegationFrames } from "./normalize.mjs";
27
+ import { grammarStrategy } from "./strategies/grammar.mjs";
28
+ import { keywordSpotStrategy } from "./strategies/keywords.mjs";
29
+ import { noiseStripStrategy } from "./strategies/noise-strip.mjs";
30
+ import { mergeStrategyResults } from "./merge.mjs";
31
+ // Optional Node-only wink adapter — same viewer-bundle boundary as ask.mjs: an
32
+ // inlining bundle strips this import and the `typeof` read below degrades to
33
+ // adapter-less parsing instead of throwing over an undeclared identifier.
34
+ import { nlpAdapter } from "../ask-nlp.mjs";
35
+
36
+ /** The registered strategies, in precedence order. grammar + keyword-spot are
37
+ * the two legacy parsers (one shared class, "graph-query" — their merge is
38
+ * byte-identical to the original two-way agree/disagree behavior); noise-strip
39
+ * is the item-10 tolerant fallback (its own class; it only fires when the
40
+ * anchored grammar missed the text as-given, so it can never displace an
41
+ * existing template parse). interpret/strategies/ace.mjs (Phase 2) will
42
+ * register here the same way. */
43
+ export const STRATEGIES = [grammarStrategy, keywordSpotStrategy, noiseStripStrategy];
44
+
45
+ /** The documented normalization pre-pass: whitespace-collapse + the §3.5
46
+ * normalization pipeline + the closed rhetorical-frame rewrites, applied ONCE
47
+ * before any strategy runs. Returns {raw, text, changed}. */
48
+ export function normalizeInput(input) {
49
+ const raw = String(input || "").trim().replace(/\s+/g, " ");
50
+ const text = raw ? applyNegationFrames(normalizeQuery(raw)) : "";
51
+ return { raw, text, changed: text !== raw };
52
+ }
53
+
54
+ function defaultNlp() {
55
+ return typeof nlpAdapter === "function" ? nlpAdapter() : null;
56
+ }
57
+
58
+ /** Synchronous strategy run — the path ask.mjs's parseQuery routes through (its
59
+ * callers are synchronous). Skips a strategy that returns a Promise (an async
60
+ * strategy can only participate via interpret()); a throwing strategy is
61
+ * dropped, never a crash. Returns the strategy results in precedence order. */
62
+ export function runStrategiesSync(text, ctx = {}, strategies = STRATEGIES) {
63
+ const results = [];
64
+ for (const s of strategies) {
65
+ try {
66
+ const r = s.run(text, ctx);
67
+ if (r && typeof r.then !== "function") results.push(r);
68
+ } catch {
69
+ // dropped — one broken strategy never takes the request down
70
+ }
71
+ }
72
+ return results;
73
+ }
74
+
75
+ /** The pipeline entry point: normalize once, run every registered strategy over
76
+ * the normalized text (Promise.all — strategies are independent), merge, and
77
+ * return the full interpretation record:
78
+ * { raw, normalized, normalizationChanged,
79
+ * results, // every strategy's own result, precedence order
80
+ * parsed, class, // the merged winner (parsed may be the legacy
81
+ * // {ambiguousParse, candidates} tie), or null
82
+ * alternates } // distinct-class runners-up for the
83
+ * // "if you mean X then …" surround
84
+ * `ctx.strategies` overrides the registry (tests, embedders); `ctx.nlp`
85
+ * overrides the lemma/POS adapter exactly as ask()'s own option does. Pure
86
+ * given (text, strategies, adapter) — no graph access here: resolving terms
87
+ * against a graph stays ask.mjs's job downstream. */
88
+ export async function interpret(text, ctx = {}) {
89
+ const strategies = ctx.strategies || STRATEGIES;
90
+ const { raw, text: normalized, changed } = normalizeInput(text);
91
+ const record = {
92
+ raw, normalized, normalizationChanged: changed,
93
+ results: [], parsed: null, class: null, alternates: [],
94
+ };
95
+ if (!normalized) return record;
96
+ const runCtx = { ...ctx, nlp: ctx.nlp === undefined ? defaultNlp() : ctx.nlp, raw };
97
+ const settled = await Promise.all(strategies.map(async (s) => {
98
+ try {
99
+ return (await s.run(normalized, runCtx)) || null;
100
+ } catch {
101
+ return null; // dropped — a rejecting/throwing strategy never crashes interpret
102
+ }
103
+ }));
104
+ record.results = settled.filter((r) => r && Array.isArray(r.candidates) && r.candidates.length);
105
+ const merged = mergeStrategyResults(record.results);
106
+ if (merged) {
107
+ record.parsed = merged.parsed;
108
+ record.class = merged.class;
109
+ record.alternates = merged.alternates;
110
+ }
111
+ return record;
112
+ }
@@ -0,0 +1,137 @@
1
+ // interpret/strategies/grammar.mjs — strategy 1: the anchored-template grammar,
2
+ // extracted MOVE-only from ask.mjs (item 13). The original P0 grammar: the whole
3
+ // (normalized) string must match one of TEMPLATES start-to-end, in fixed
4
+ // precedence order; first fit wins, never ambiguous at the template level (a
5
+ // question matching two shapes is a design smell we test against). Unweakened.
6
+
7
+ import {
8
+ VERB_TO_KIND, ENTITY_TO_TYPE, MODIFIER_TO_KIND,
9
+ META_MEANING_VERBS, WHERE_MARKERS, MENTION_MARKERS,
10
+ } from "../../ask-vocab.mjs";
11
+ import { escapeRegex } from "../normalize.mjs";
12
+
13
+ const VERB_ALT = Object.keys(VERB_TO_KIND).sort((a, b) => b.length - a.length).map(escapeRegex).join("|");
14
+ const ENTITY_ALT = Object.keys(ENTITY_TO_TYPE).sort((a, b) => b.length - a.length).map(escapeRegex).join("|");
15
+ const MODIFIER_ALT = Object.keys(MODIFIER_TO_KIND).sort((a, b) => b.length - a.length).map(escapeRegex).join("|");
16
+ const META_ALT = META_MEANING_VERBS.slice().sort((a, b) => b.length - a.length).map(escapeRegex).join("|");
17
+
18
+ const TEMPLATES = [
19
+ // T1 ASK: "does X import Y" / "is X a subclass of Y" -> Yes/No. Tried FIRST: it starts with
20
+ // does/is/do/did, which the reverse/forward templates below never match (those start with
21
+ // which/what), so precedence between T1 and the rest is structural, not a tie-break guess.
22
+ // "did" joins does/do for the past-tense commit forms ("did commit <sha> touch X").
23
+ {
24
+ name: "ask",
25
+ re: new RegExp(`^(?:does|do|did)\\s+(.+?)\\s+(${VERB_ALT})\\s+(.+?)\\??$`, "i"),
26
+ build: (m) => ({
27
+ shape: "ask", entityType: null, modifier: "direct",
28
+ kind: VERB_TO_KIND[m[2].toLowerCase()], subject: m[1].trim(), object: m[3].trim(),
29
+ }),
30
+ },
31
+ // T2 reverse: "which <entity> [<modifier>] <verb> <object>" — the operator's own example shape.
32
+ {
33
+ name: "reverse",
34
+ re: new RegExp(`^which\\s+(${ENTITY_ALT})\\s+(?:(${MODIFIER_ALT})\\s+)?(${VERB_ALT})\\s+(.+?)\\??$`, "i"),
35
+ build: (m) => ({
36
+ shape: "reverse",
37
+ entityType: ENTITY_TO_TYPE[m[1].toLowerCase()],
38
+ modifier: m[2] ? MODIFIER_TO_KIND[m[2].toLowerCase()] : "direct",
39
+ kind: VERB_TO_KIND[m[3].toLowerCase()],
40
+ object: m[4].trim(),
41
+ }),
42
+ },
43
+ // T3 forward: "what does <object> <verb>" — X is given, list its R-related things.
44
+ // "did" joins does/do for the past-tense commit forms ("what did commit <sha> touch").
45
+ {
46
+ name: "forward",
47
+ re: new RegExp(`^what\\s+(?:does|do|did)\\s+(.+?)\\s+(${VERB_ALT})\\??$`, "i"),
48
+ build: (m) => ({
49
+ shape: "forward", entityType: null, modifier: "direct",
50
+ kind: VERB_TO_KIND[m[2].toLowerCase()], object: m[1].trim(),
51
+ }),
52
+ },
53
+ // T4 meta: "what does <term> mean" — a question about the GRAPH'S OWN VOCABULARY
54
+ // (a SchemaClass/SchemaPredicate label, e.g. "cochange", or a raw prop token, e.g.
55
+ // "mgx:callsSymbol"), not a graph traversal over code edges. Tried after T3: T3 also
56
+ // starts "what does/do", but T3 only fires when the tail is a relation VERB_ALT
57
+ // phrase ("import"/"calls"/…), which "mean"/"means"/etc never are (disjoint tables —
58
+ // ask-vocab.mjs's file comment explains why they're kept separate), so the two never
59
+ // actually compete for the same input.
60
+ {
61
+ name: "meta-mean",
62
+ re: new RegExp(`^what\\s+(?:does|do|is|are)\\s+(.+?)\\s+(?:${META_ALT})\\??$`, "i"),
63
+ build: (m) => ({ shape: "meta", entityType: null, modifier: "direct", kind: "meta", object: m[1].trim() }),
64
+ },
65
+ // T5 meta: "what is a/an <term>" — the OTHER worked phrasing ("what is a Commit").
66
+ // The indefinite article is REQUIRED (not optional): a bare "what is <anything>"
67
+ // would also swallow "what is the meaning of this codebase" (an existing, deliberately
68
+ // honest grammar-miss regression case — ask.test.mjs/ask-dual-strategy.test.mjs both
69
+ // assert it stays null), which never mentions "a"/"an" before its tail. Requiring the
70
+ // article keeps this template's reach to the one worked shape without reopening that.
71
+ {
72
+ name: "meta-whatis",
73
+ re: new RegExp(`^what\\s+(?:is|are)\\s+(?:an?)\\s+(.+?)\\??$`, "i"),
74
+ build: (m) => ({ shape: "meta", entityType: null, modifier: "direct", kind: "meta", object: m[1].trim() }),
75
+ },
76
+ // T6 mention: "where is <term> mentioned/referenced" — the prose/mentions surface
77
+ // (2026-07-02 query families). Tried BEFORE T7: T7's trailing marker is optional,
78
+ // so without this ordering it would swallow the mention question and lose the
79
+ // marker that distinguishes "locate the definition" from "list the prose mentions".
80
+ {
81
+ name: "mention",
82
+ re: new RegExp(`^where\\s+(?:is|are|was|were)\\s+(.+?)\\s+(?:${MENTION_MARKERS.map(escapeRegex).join("|")})\\??$`, "i"),
83
+ build: (m) => ({ shape: "mentions", entityType: null, modifier: "direct", kind: "mentions", object: m[1].trim() }),
84
+ },
85
+ // T7 where: "where is <term> [defined|declared|located|implemented]" — definition
86
+ // location off the site attribute / defining module. "where" starts no other
87
+ // template, so precedence against T1-T5 is structural.
88
+ {
89
+ name: "where",
90
+ re: new RegExp(`^where\\s+(?:is|are|was|were)\\s+(.+?)(?:\\s+(?:${WHERE_MARKERS.map(escapeRegex).join("|")}))?\\??$`, "i"),
91
+ build: (m) => ({ shape: "where", entityType: null, modifier: "direct", kind: "where", object: m[1].trim() }),
92
+ },
93
+ // T8 when: "when did <term> [last] change/touched/updated…" — temporal shape over
94
+ // the touches edges + commit date attributes. The verb slot reuses VERB_ALT, but
95
+ // only the touches family carries dates to answer with, so build() rejects any
96
+ // other kind (returning null falls through — parseAnchored tolerates it) rather
97
+ // than pretending "when did X import Y" has a temporal answer.
98
+ {
99
+ name: "when",
100
+ re: new RegExp(`^when\\s+(?:did|does|do|was|were|is)\\s+(.+?)\\s+(?:last\\s+)?(${VERB_ALT})\\??$`, "i"),
101
+ build: (m) => (VERB_TO_KIND[m[2].toLowerCase()] === "touches"
102
+ ? { shape: "when", entityType: null, modifier: "direct", kind: "touches", object: m[1].trim() }
103
+ : null),
104
+ },
105
+ ];
106
+
107
+ /** Strategy 1: the original P0 anchored grammar — the whole (normalized) string
108
+ * must match one of TEMPLATES start-to-end. A build() may return null to reject
109
+ * a structural match on curated grounds (T8's non-temporal verbs); the scan then
110
+ * simply continues, exactly as if the regex had not matched. Pure. */
111
+ export function parseAnchored(text) {
112
+ for (const t of TEMPLATES) {
113
+ const m = text.match(t.re);
114
+ if (m) {
115
+ const parsed = t.build(m);
116
+ if (parsed) return parsed;
117
+ }
118
+ }
119
+ return null;
120
+ }
121
+
122
+ /** Pipeline registration (interpret/pipeline.mjs): the anchored grammar as a
123
+ * strategy. Class "graph-query" — shared with keyword-spot, so the two merge
124
+ * (agree/disagree) exactly as the legacy two-way merge did. Confidence 0.9:
125
+ * the highest of the registered strategies (a full-sentence template match is
126
+ * the engine's most precise evidence), which also makes "graph-query" the
127
+ * winning class whenever this strategy fires. */
128
+ export const grammarStrategy = {
129
+ id: "grammar",
130
+ class: "graph-query",
131
+ run(text) {
132
+ const parsed = parseAnchored(text);
133
+ return parsed
134
+ ? { strategyId: "grammar", class: "graph-query", candidates: [{ parsed, confidence: 0.9 }] }
135
+ : null;
136
+ },
137
+ };