@polycode-projects/the-mechanical-code-talker 1.5.5 → 1.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +123 -14
- package/ROADMAP.md +233 -1392
- package/bin/tmct.mjs +479 -98
- package/corpus/README.md +3 -0
- package/corpus/generated/README.md +43 -0
- package/corpus/generated/ace-surface-variants.jsonl +17 -0
- package/corpus/generated/manifest.json +9 -0
- package/corpus/tier2/generate.mjs +14668 -0
- package/corpus/tier2/human-examples-large.jsonl +1928 -0
- package/corpus/tier2/human-examples-medium.jsonl +356 -0
- package/corpus/tier2/human-examples.jsonl +120 -0
- package/corpus/tier2/human-large.jsonl +12001 -0
- package/corpus/tier2/human-medium.jsonl +944 -0
- package/corpus/tier2/human.jsonl +664 -0
- package/corpus/tier2/manifest.json +42 -0
- package/package.json +14 -8
- package/src/answer-variants.json +47 -0
- package/src/answer-variants.mjs +67 -0
- package/src/ask-browser-entry.mjs +34 -0
- package/src/ask-browser.bundle.js +5095 -0
- package/src/ask-vocab.mjs +93 -8
- package/src/ask.mjs +451 -49
- package/src/chat.mjs +1273 -137
- package/src/cli-args.mjs +164 -0
- package/src/codegraph.mjs +170 -32
- package/src/extensions.mjs +100 -19
- package/src/grammar/ace.mjs +85 -3
- package/src/grammar/lexicon-core.json +9531 -63
- package/src/grammar/lexicon.mjs +58 -8
- package/src/graph-merge.mjs +114 -0
- package/src/index.mjs +14 -0
- package/src/init.mjs +40 -14
- package/src/interpret/normalize.mjs +75 -1
- package/src/interpret/strategies/grammar.mjs +10 -0
- package/src/interpret/strategies/keywords.mjs +20 -0
- package/src/interpret/strategies/noise-strip.mjs +73 -4
- package/src/memory/core.mjs +466 -8
- package/src/router/goal-reasoner.mjs +41 -7
- package/src/router/guardrail.mjs +37 -7
- package/src/router/resolver.mjs +50 -4
- package/src/sessions.mjs +5 -1
- package/src/source.mjs +54 -1
- package/src/syllogise.mjs +398 -27
- package/src/toml-config.mjs +13 -4
- package/src/viz.mjs +541 -0
package/src/ask.mjs
CHANGED
|
@@ -44,7 +44,7 @@ import {
|
|
|
44
44
|
CONTEXT_PRONOUNS, META_MEANING_VERBS,
|
|
45
45
|
WHERE_MARKERS, MENTION_MARKERS,
|
|
46
46
|
RELATIVE_PRONOUNS, PLACEHOLDER_NOUNS, BOOLEAN_CONNECTIVES, QUALIFIERS,
|
|
47
|
-
AGGREGATE_TRIGGERS, LIST_TRIGGERS, SUPERLATIVE_EXTREMES, EDGE_NOUN_TO_METRIC, ANAPHORA_TRIGGERS,
|
|
47
|
+
AGGREGATE_TRIGGERS, LIST_TRIGGERS, SUPERLATIVE_EXTREMES, EDGE_NOUN_TO_METRIC, METRIC_IMPLIES_ENTITY, ANAPHORA_TRIGGERS,
|
|
48
48
|
MEMBERSHIP_KINDS, CASCADE_NOISE, CASCADE_SYNONYMS, HELP_TRIGGERS,
|
|
49
49
|
} from "./ask-vocab.mjs";
|
|
50
50
|
// The interpretation layer (ROADMAP items 8/10/13) — the movable conversational
|
|
@@ -56,8 +56,9 @@ import { editDistance, fuzzyBound } from "./interpret/fuzzy.mjs";
|
|
|
56
56
|
import { parseAnchored } from "./interpret/strategies/grammar.mjs";
|
|
57
57
|
import { parseKeywordSpot, findPhrase } from "./interpret/strategies/keywords.mjs";
|
|
58
58
|
import { runStrategiesSync } from "./interpret/pipeline.mjs";
|
|
59
|
-
import { mergeStrategyResults } from "./interpret/merge.mjs";
|
|
59
|
+
import { mergeStrategyResults, alternateLines } from "./interpret/merge.mjs";
|
|
60
60
|
import { lookupByProseTokens } from "./prose.mjs";
|
|
61
|
+
import { pickPhrase } from "./answer-variants.mjs";
|
|
61
62
|
|
|
62
63
|
// Normalization stays importable from its original site (tests + chat surface).
|
|
63
64
|
export { normalizeQuery, applyNegationFrames };
|
|
@@ -222,12 +223,66 @@ function defaultNlp() {
|
|
|
222
223
|
* test); leaving it undefined picks the deterministic default (defaultNlp).
|
|
223
224
|
* Pure given (query, adapter) — the adapter itself is a fixed model, no
|
|
224
225
|
* sampling. */
|
|
226
|
+
// SCHEMA-TERM / COMMON-WORD "WHAT DOES X MEAN" DISAMBIGUATION (CHATBENCH decision-log
|
|
227
|
+
// item 1, g-a1-naming-8: "what does tests mean"). When the object term X is ITSELF a
|
|
228
|
+
// real RELATIONS/VERB_TO_KIND keyword ("tests", "imports", …), keyword-spot
|
|
229
|
+
// independently reads the sentence as a "reverse"-shaped query — kind:X, object:"mean"
|
|
230
|
+
// — because "mean"/"means" (META_MEANING_VERBS, ask-vocab.mjs §7) is deliberately kept
|
|
231
|
+
// OUT of the relation tables (see that table's own comment): keyword-spot has no idea
|
|
232
|
+
// it just consumed the meta question's own verb as if it were an object noun. That
|
|
233
|
+
// reading can never resolve to anything real ("mean" is never a graph entity) — it's a
|
|
234
|
+
// spurious parse, not a genuine second reading — yet it collides with the grammar
|
|
235
|
+
// strategy's own clean "meta" parse of the SAME sentence to manufacture the legacy
|
|
236
|
+
// {ambiguousParse} surface ("this could mean more than one thing: 1) meta X or 2) X
|
|
237
|
+
// 'mean' — try rephrasing"). Pruned here whenever the winning class's candidates are
|
|
238
|
+
// EXACTLY [a meta-shape parse, a same-precedence parse whose object is a
|
|
239
|
+
// META_MEANING_VERB] — collapsing back to the meta parse alone, so the term's own
|
|
240
|
+
// schema-predicate definition answers directly instead of the unhelpful two-way punt.
|
|
241
|
+
//
|
|
242
|
+
// EXCEPT "imports": am-meta-imports (chatbench/graded-pool.jsonl) and
|
|
243
|
+
// quickwins.test.mjs's "fix1: the frozen am-meta-imports ambiguity is NOT admitted"
|
|
244
|
+
// both lock this EXACT ambiguity in as the intended, honest answer for that one term —
|
|
245
|
+
// "what does imports mean" is byte-identical input to both am-meta-imports (which
|
|
246
|
+
// requires the ambiguous answer) and g-a1-naming-9 (which wants the plain definition).
|
|
247
|
+
// A deterministic function cannot satisfy both on the same input; widening the prune to
|
|
248
|
+
// "imports" would silently flip am-meta-imports from passing to failing, a real
|
|
249
|
+
// regression this project's own decision rule (SKILL_BENCHMARK_CEFR_ENGLISH.md §1)
|
|
250
|
+
// forbids. So "imports" keeps its existing ambiguous answer — the same judgment call
|
|
251
|
+
// chat.mjs's relationTermOf already makes for it (see that function's own docblock) —
|
|
252
|
+
// while every OTHER relation term this collision can hit ("tests" included) is fixed
|
|
253
|
+
// generally, not as a one-off patch.
|
|
254
|
+
const FROZEN_META_AMBIGUOUS_TERMS = new Set(["imports"]);
|
|
255
|
+
function pruneSpuriousMeaningAmbiguity(parsed) {
|
|
256
|
+
if (!parsed?.ambiguousParse || !Array.isArray(parsed.candidates) || parsed.candidates.length !== 2) return parsed;
|
|
257
|
+
const metaC = parsed.candidates.find((c) => c?.shape === "meta");
|
|
258
|
+
const other = parsed.candidates.find((c) => c !== metaC);
|
|
259
|
+
if (!metaC || !other) return parsed;
|
|
260
|
+
const term = String(metaC.object || "").trim().toLowerCase();
|
|
261
|
+
if (!term || FROZEN_META_AMBIGUOUS_TERMS.has(term)) return parsed;
|
|
262
|
+
const otherObject = String(other.object || "").trim().toLowerCase();
|
|
263
|
+
if (!wordsOf(META_MEANING_VERBS).includes(otherObject)) return parsed;
|
|
264
|
+
return metaC;
|
|
265
|
+
}
|
|
266
|
+
|
|
225
267
|
export function parseQuery(query, { nlp = undefined } = {}) {
|
|
268
|
+
return parseQueryFull(query, { nlp }).parsed;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Sibling of `parseQuery` that also surfaces what `parseQuery` has always
|
|
272
|
+
* discarded: `merge.mjs`'s `class`/`alternates` (PLAN_BREADTH_FIRST_NLU.md §3)
|
|
273
|
+
* — a genuine, distinct-class alternate reading a different strategy produced,
|
|
274
|
+
* silently dropped on every hit until now. `parseQuery`'s own contract stays
|
|
275
|
+
* byte-identical (it's defined in terms of this function's `.parsed` field,
|
|
276
|
+
* above) — 142 existing call sites are untouched. Returns
|
|
277
|
+
* `{parsed, alternates, class}`; `alternates`/`class` are `[]`/`null` on the
|
|
278
|
+
* compositional-parse path (a structurally separate grammar layer that never
|
|
279
|
+
* reaches `mergeStrategyResults`) or on a total miss. */
|
|
280
|
+
export function parseQueryFull(query, { nlp = undefined } = {}) {
|
|
226
281
|
const adapter = nlp === undefined ? defaultNlp() : nlp;
|
|
227
282
|
const raw = String(query || "").trim().replace(/\s+/g, " ");
|
|
228
|
-
if (!raw) return null;
|
|
283
|
+
if (!raw) return { parsed: null, alternates: [], class: null };
|
|
229
284
|
const text = applyPhrasingFrames(applyNegationFrames(normalizeQuery(raw)));
|
|
230
|
-
if (!text) return null;
|
|
285
|
+
if (!text) return { parsed: null, alternates: [], class: null };
|
|
231
286
|
// COMPOSITIONAL PARSE PATH (PLAN §5.16 P3) — the new PRIMARY layer: a recursive
|
|
232
287
|
// descent over CLAUSES for the compositional shapes (nested/relative, boolean,
|
|
233
288
|
// qualifiers, aggregates, superlatives, anaphora). It fires ONLY when a
|
|
@@ -237,9 +292,14 @@ export function parseQuery(query, { nlp = undefined } = {}) {
|
|
|
237
292
|
// the phrase cannot be compiled, it returns an honest {node:"miss"} rather than
|
|
238
293
|
// letting keyword-spot guess at a composition it never expressed.
|
|
239
294
|
const composite = parseComposite(text, adapter);
|
|
240
|
-
if (composite) return composite;
|
|
295
|
+
if (composite) return { parsed: composite, alternates: [], class: null };
|
|
241
296
|
const merged = mergeStrategyResults(runStrategiesSync(text, { nlp: adapter, raw }));
|
|
242
|
-
|
|
297
|
+
if (!merged) return { parsed: null, alternates: [], class: null };
|
|
298
|
+
return {
|
|
299
|
+
parsed: pruneSpuriousMeaningAmbiguity(merged.parsed),
|
|
300
|
+
alternates: merged.alternates || [],
|
|
301
|
+
class: merged.class || null,
|
|
302
|
+
};
|
|
243
303
|
}
|
|
244
304
|
|
|
245
305
|
// ============================================================================
|
|
@@ -431,19 +491,28 @@ function parseForwardNegation(w, lc, nlp) {
|
|
|
431
491
|
return { node: "forwardComplement", kind: vh.kind, subjectTerm };
|
|
432
492
|
}
|
|
433
493
|
|
|
434
|
-
/** The
|
|
435
|
-
* (
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
* the
|
|
439
|
-
function
|
|
494
|
+
/** The full set of OBJECT classes observed across every edge of the given stored
|
|
495
|
+
* kinds (already expanded past any query-side union — callers pass `kindsFor(kind)`
|
|
496
|
+
* or an equivalent list). Ext: endpoints have no individual, so they don't muddy
|
|
497
|
+
* the class vote. Shared by `kindObjectClass` (collapses to a single class or null)
|
|
498
|
+
* and the forward branch's grain check (needs the full set, not the collapse). */
|
|
499
|
+
function classesForKinds(graph, kinds) {
|
|
440
500
|
const classes = new Set();
|
|
441
|
-
for (const k of
|
|
501
|
+
for (const k of kinds) {
|
|
442
502
|
for (const e of edgesOfKind(graph, k)) {
|
|
443
503
|
const o = graph.byId.get(e.object);
|
|
444
504
|
if (o && o.class) classes.add(o.class);
|
|
445
505
|
}
|
|
446
506
|
}
|
|
507
|
+
return classes;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** The single OBJECT class a forward relation kind points at across the loaded graph
|
|
511
|
+
* (imports → Module), or null when its objects span more than one class (an ambiguous
|
|
512
|
+
* grain the complement's universe can't be pinned to). Used by the forwardComplement
|
|
513
|
+
* evaluator to bound the universe it differences the positive forward set out of. */
|
|
514
|
+
function kindObjectClass(graph, kind) {
|
|
515
|
+
const classes = classesForKinds(graph, kindsFor(kind));
|
|
447
516
|
return classes.size === 1 ? [...classes][0] : null;
|
|
448
517
|
}
|
|
449
518
|
|
|
@@ -474,21 +543,36 @@ function parseSetPhrase(text, nlp, depth) {
|
|
|
474
543
|
* that call X", handled by parseRelationalOrQualified) by requiring a VERB before the
|
|
475
544
|
* relative noun (i.e. the noun is not the leading subject). Returns a reverse/forward
|
|
476
545
|
* Set node, an honest miss (marker present, uncompilable), or null (no object-relative
|
|
477
|
-
* marker → let another production try).
|
|
546
|
+
* marker → let another production try).
|
|
547
|
+
*
|
|
548
|
+
* The marker is either an explicit relative pronoun ("the module THAT imports X") or a
|
|
549
|
+
* REDUCED relative clause with the pronoun dropped ("the module IMPORTING X" — a gerund
|
|
550
|
+
* verb right where the pronoun+verb would go means the same thing). Root-cause fix for
|
|
551
|
+
* g-c1-temp-8 (2026-07-12): "who touched the module importing X" has no "that", so this
|
|
552
|
+
* loop used to find no marker at all, return null, and let the query fall through to the
|
|
553
|
+
* legacy (non-compositional) strategy pipeline — which then misread the leading verb
|
|
554
|
+
* "touched" itself as the flat ASK shape's subject TERM ("does 'touched' import X"),
|
|
555
|
+
* never resolving to a real entity. "the module THAT imports X" (explicit pronoun)
|
|
556
|
+
* already routed correctly through this same function; the gerund form is the identical
|
|
557
|
+
* nested-set shape and is now recognized the same way. */
|
|
478
558
|
function parseNested(w, lc, nlp, depth) {
|
|
479
559
|
for (let r = 1; r < lc.length; r += 1) {
|
|
480
|
-
|
|
481
|
-
|
|
560
|
+
const isPronoun = RELATIVE_PRONOUNS.includes(lc[r]);
|
|
561
|
+
const isGerundMarker = !isPronoun && isGerundVerb(lc[r]);
|
|
562
|
+
if (!isPronoun && !isGerundMarker) continue;
|
|
563
|
+
if (isPronoun && r + 1 >= lc.length) continue; // nothing after "that"
|
|
482
564
|
const noun = entityNoun(lc[r - 1]);
|
|
483
|
-
if (!noun) continue; //
|
|
565
|
+
if (!noun) continue; // marker not preceded by a noun
|
|
484
566
|
const head = w.slice(0, r - 1); // outer clause words, minus the placeholder noun
|
|
485
567
|
if (!head.length) continue; // noun is the leading subject → subject-relative, not this shape
|
|
486
568
|
const outer = parseSimpleClause([...head, NEST_SENTINEL].join(" "), nlp);
|
|
487
569
|
if (!outer || (outer.shape !== "reverse" && outer.shape !== "forward")) continue;
|
|
488
570
|
if (outer.modifier && outer.modifier !== "direct") continue; // no transitive-over-set closure primitive
|
|
489
571
|
// build the inner sub-query: "which <placeholder-noun> <inner-text>" — recurses,
|
|
490
|
-
// so the inner may itself be nested/boolean/qualified (depth ≥2).
|
|
491
|
-
|
|
572
|
+
// so the inner may itself be nested/boolean/qualified (depth ≥2). An explicit
|
|
573
|
+
// relative pronoun is consumed (skip past it, start at r+1); a gerund marker IS
|
|
574
|
+
// the predicate's own verb, so it stays in the inner text (start AT r).
|
|
575
|
+
const innerText = `which ${lc[r - 1]} ${w.slice(isPronoun ? r + 1 : r).join(" ")}`;
|
|
492
576
|
const inner = parseSetPhrase(innerText, nlp, depth + 1);
|
|
493
577
|
if (!inner || inner.node === "miss") return inner ? { node: "miss", reason: inner.reason || "inner clause didn't parse" } : { node: "miss", reason: "inner clause didn't parse" };
|
|
494
578
|
return { node: outer.shape === "reverse" ? "reverseSet" : "forwardSet", kind: outer.kind, entityType: outer.entityType, inner };
|
|
@@ -922,11 +1006,9 @@ function parseSuperlative(w, lc, nlp) {
|
|
|
922
1006
|
if (SUPERLATIVE_EXTREMES[lc[i]]) { ext = SUPERLATIVE_EXTREMES[lc[i]]; extIdx = i; break; }
|
|
923
1007
|
}
|
|
924
1008
|
if (!ext) return null;
|
|
925
|
-
//
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
if (!entWord) return { node: "miss", reason: "a superlative needs an entity kind (module, class, function, …)" };
|
|
929
|
-
// edge noun after the extreme (imports/callers/methods/…)
|
|
1009
|
+
// edge noun after the extreme (imports/callers/methods/…) — computed BEFORE
|
|
1010
|
+
// the entity-noun check below, so a metric with a single implied entity class
|
|
1011
|
+
// (METRIC_IMPLIES_ENTITY) can supply a default when no explicit noun is given.
|
|
930
1012
|
let metric = null; let metricNoun = null;
|
|
931
1013
|
for (let i = extIdx; i < lc.length; i += 1) {
|
|
932
1014
|
if (EDGE_NOUN_TO_METRIC[lc[i]]) { metric = EDGE_NOUN_TO_METRIC[lc[i]]; metricNoun = lc[i]; break; }
|
|
@@ -945,10 +1027,18 @@ function parseSuperlative(w, lc, nlp) {
|
|
|
945
1027
|
}
|
|
946
1028
|
const connectivity = lc.includes("connected") || lc.slice(extIdx, extIdx + 2).join(" ") === "most connected"
|
|
947
1029
|
|| ["largest", "biggest", "smallest"].includes(lc[extIdx]);
|
|
948
|
-
if (!metric) {
|
|
949
|
-
|
|
950
|
-
|
|
1030
|
+
if (!metric && connectivity) { metric = EDGE_NOUN_TO_METRIC.connections; metricNoun = "connections"; }
|
|
1031
|
+
// entity noun anywhere (first match, deterministic); else default from a
|
|
1032
|
+
// metric that implies exactly one entity class ("test(s)" always ranks
|
|
1033
|
+
// Modules, the one declared exception — see METRIC_IMPLIES_ENTITY — so "what
|
|
1034
|
+
// most needs a test" resolves without also requiring the word "module").
|
|
1035
|
+
let entityType; let entWord = null;
|
|
1036
|
+
for (const x of lc) { const n = entityNoun(x); if (n && !n.placeholder) { entityType = n.entityType; entWord = x; break; } }
|
|
1037
|
+
if (!entWord) {
|
|
1038
|
+
entityType = metricNoun ? METRIC_IMPLIES_ENTITY[metricNoun] : undefined;
|
|
1039
|
+
if (!entityType) return { node: "miss", reason: "a superlative needs an entity kind (module, class, function, …)" };
|
|
951
1040
|
}
|
|
1041
|
+
if (!metric) return { node: "miss", reason: "name what to rank by (imports, callers, methods, tests, or connections)" };
|
|
952
1042
|
return { node: "superlative", entityType, metric, metricNoun, extreme: ext };
|
|
953
1043
|
}
|
|
954
1044
|
|
|
@@ -1455,7 +1545,7 @@ export function metaFallbackEntityAnswer(graph, term) {
|
|
|
1455
1545
|
|| null;
|
|
1456
1546
|
const noun = nounFor(hit.class, 1);
|
|
1457
1547
|
const article = noun === "attribute" ? "an" : "a";
|
|
1458
|
-
const definedIn = modLabel ? `, defined in ${modLabel}` : "";
|
|
1548
|
+
const definedIn = modLabel ? `, ${pickPhrase("defined-in", hit.id, "defined in")} ${modLabel}` : "";
|
|
1459
1549
|
const followUp = hit.class === "Class" ? ` or "which classes inherit from ${hit.label}"` : "";
|
|
1460
1550
|
return {
|
|
1461
1551
|
text: `${hit.label} is ${article} ${noun} in this codebase${definedIn} — try "describe ${hit.label}"${followUp}.`,
|
|
@@ -2146,7 +2236,7 @@ function renderComposite(parsed, result) {
|
|
|
2146
2236
|
}
|
|
2147
2237
|
const hit = result.matches[0];
|
|
2148
2238
|
const modLabel = moduleLabelOf(hit);
|
|
2149
|
-
const definedIn = hit.class === "Module" ? "" : (modLabel && modLabel !== "(unknown module)" ? `, defined in ${modLabel}` : "");
|
|
2239
|
+
const definedIn = hit.class === "Module" ? "" : (modLabel && modLabel !== "(unknown module)" ? `, ${pickPhrase("defined-in", hit.id, "defined in")} ${modLabel}` : "");
|
|
2150
2240
|
return { content: `Yes — ${hit.label} is a ${kindSingular}${definedIn}.`, miss: false, ambiguous: false, matches: result.matches };
|
|
2151
2241
|
}
|
|
2152
2242
|
if (!result.matches.length) {
|
|
@@ -2200,7 +2290,7 @@ function renderComposite(parsed, result) {
|
|
|
2200
2290
|
// computeFind's "related, not exact" broad pass documents for predicate-find).
|
|
2201
2291
|
if (result.compositeKind === "membership") {
|
|
2202
2292
|
if (!result.matches.length) {
|
|
2203
|
-
return { content: `nothing in the index matches that${result.entityType ? ` (${nounFor(result.entityType, 2)})` : ""}
|
|
2293
|
+
return { content: `nothing in the index matches that${result.entityType ? ` (${nounFor(result.entityType, 2)})` : ""}. ${touchesRephraseHint()}`, miss: true, ambiguous: false, matches: [] };
|
|
2204
2294
|
}
|
|
2205
2295
|
if (result.inheritedNotOwn) {
|
|
2206
2296
|
const kindPlural = nounFor(result.entityType, 2);
|
|
@@ -2293,10 +2383,10 @@ function renderComposite(parsed, result) {
|
|
|
2293
2383
|
const setNoun = result.entityType ? nounFor(result.entityType, n || 2) : (n === 1 ? "entity" : "entities");
|
|
2294
2384
|
const wasWere = n === 1 ? "was" : "were";
|
|
2295
2385
|
if (!n) {
|
|
2296
|
-
return { content: `nothing in the index matches the inner set, so there is no change history to date
|
|
2386
|
+
return { content: `nothing in the index matches the inner set, so there is no change history to date. ${touchesRephraseHint()}`, miss: true, ambiguous: false, matches: [] };
|
|
2297
2387
|
}
|
|
2298
2388
|
if (!result.matches.length) {
|
|
2299
|
-
return { content: `no recorded commit touched the ${n} ${setNoun} in that set in this index
|
|
2389
|
+
return { content: `no recorded commit touched the ${n} ${setNoun} in that set in this index. ${touchesRephraseHint()}`, miss: true, ambiguous: false, matches: [] };
|
|
2300
2390
|
}
|
|
2301
2391
|
const newest = result.matches[0];
|
|
2302
2392
|
const date = (newest.attributes || []).find((a) => a.key === "date")?.value || "";
|
|
@@ -2313,7 +2403,7 @@ function renderComposite(parsed, result) {
|
|
|
2313
2403
|
}
|
|
2314
2404
|
// set-producing
|
|
2315
2405
|
if (!result.matches.length) {
|
|
2316
|
-
return { content: `nothing in the index matches that${result.entityType ? ` (${nounFor(result.entityType, 2)})` : ""}
|
|
2406
|
+
return { content: `nothing in the index matches that${result.entityType ? ` (${nounFor(result.entityType, 2)})` : ""}. ${touchesRephraseHint()}`, miss: true, ambiguous: false, matches: [] };
|
|
2317
2407
|
}
|
|
2318
2408
|
return { content: `${compositeList(result.matches)}.`, miss: false, ambiguous: false, matches: result.matches };
|
|
2319
2409
|
}
|
|
@@ -2321,10 +2411,35 @@ function renderComposite(parsed, result) {
|
|
|
2321
2411
|
/** The rephrase hint shown on a grammar miss — generated from the SAME tables the parser
|
|
2322
2412
|
* uses, so it can never suggest a phrasing the grammar doesn't actually support (§6.3). */
|
|
2323
2413
|
export function rephraseHint() {
|
|
2324
|
-
|
|
2414
|
+
// "touched" used to sit in the <imports|calls|uses|inherits from|tests> cross-product
|
|
2415
|
+
// above, combined with <functions|classes|modules> — but `touches` is a Commit->Module/
|
|
2416
|
+
// symbol edge (ask-vocab.mjs's RELATIONS.touches comment), so a Function/Class/Module is
|
|
2417
|
+
// NEVER the subject of a touch: "which modules touched X" always misses, for every X, no
|
|
2418
|
+
// matter the graph (verified against test/fixtures/entities.fixture.json — every reverse
|
|
2419
|
+
// combination with "touched" and a functions/classes/modules subject returns zero
|
|
2420
|
+
// matches). The real reverse subject for this edge is Commit — "which commits touched
|
|
2421
|
+
// <name>" below, mirroring the working "who touched <name>" nudge used elsewhere
|
|
2422
|
+
// (chat.mjs's nudgeAnswer).
|
|
2423
|
+
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). '
|
|
2325
2424
|
+ compositionalHint();
|
|
2326
2425
|
}
|
|
2327
2426
|
|
|
2427
|
+
/** A short, honest nudge for the touches/history-family of correct-but-unhelpful
|
|
2428
|
+
* honest misses (CEFR decision log, BENCHMARK_CEFR_ENGLISH_1.7.0.md item 1:
|
|
2429
|
+
* g-c1-temp-7, g-c1-temp-3, g-b1-pron-1/4/5, hm-unknown-fn, hm-unknown-module all
|
|
2430
|
+
* scored `rephrase: 0` despite being correct empty results — the miss said WHAT
|
|
2431
|
+
* wasn't found but gave no nudge toward a question that WOULD work). Points at the
|
|
2432
|
+
* same "who touched X" / "/describe X" shapes that produce a real answer elsewhere
|
|
2433
|
+
* in this file (see whenShape/whoLastShape's own success template, "X was last
|
|
2434
|
+
* touched by commit …", a few lines below) — never promises any PARTICULAR name
|
|
2435
|
+
* will resolve, since the whole point of the miss it's attached to is that this
|
|
2436
|
+
* one didn't. Sibling of rephraseHint() above: same purpose, narrower vocabulary,
|
|
2437
|
+
* reused verbatim by every touches/history-family miss template rather than each
|
|
2438
|
+
* one inventing its own wording. */
|
|
2439
|
+
export function touchesRephraseHint() {
|
|
2440
|
+
return 'Try "who touched <a module that actually has commits>" or "/describe <module>" to see what\'s in the index.';
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2328
2443
|
// ---- §4 object-term resolution — mechanical, no embeddings, tiered, stop at first hit ----
|
|
2329
2444
|
|
|
2330
2445
|
function componentSet(s) {
|
|
@@ -2915,13 +3030,29 @@ const TRANSITIVE_MAX_DEPTH = 8;
|
|
|
2915
3030
|
* needed one. Returns {matches, objMatch, candidates, traversal, ambiguous,
|
|
2916
3031
|
* answer?, unresolvedPronoun?} — `answer` only set for the "ask" shape.
|
|
2917
3032
|
* `matches` is always an array of individuals (or edge records for "ask"). */
|
|
2918
|
-
export function traverse(graph, parsed, { contextId = null, prev = null } = {}) {
|
|
3033
|
+
export function traverse(graph, parsed, { contextId = null, prev = null, pinnedObjMatch = null } = {}) {
|
|
2919
3034
|
if (!parsed) return { matches: [], objMatch: null, candidates: [], traversal: null, ambiguous: false };
|
|
2920
3035
|
// compositional AST (PLAN §5.16 P3) — the new grammar's nodes carry a `node` tag;
|
|
2921
3036
|
// everything else (simple clauses, ambiguousParse) flows through the original path
|
|
2922
3037
|
// below completely unchanged.
|
|
2923
3038
|
if (parsed.node) return evalComposite(graph, parsed, { contextId, prev });
|
|
2924
|
-
|
|
3039
|
+
// (fix, 2026-07-11) A same-class parse-level tie ({ambiguousParse, candidates})
|
|
3040
|
+
// used to short-circuit here with an empty result, so renderCore's ambiguousParse
|
|
3041
|
+
// branch could only ever describe each reading ("1) meta X or 2) Y — try
|
|
3042
|
+
// rephrasing"), never actually answer any of them — an honest admission of
|
|
3043
|
+
// ambiguity with no real content behind it. Every candidate IS a normal,
|
|
3044
|
+
// individually-resolvable parse (merge.mjs only ties same-class DISTINCT parses,
|
|
3045
|
+
// never nests another ambiguousParse inside one), so each one can be traversed
|
|
3046
|
+
// and rendered for real right here — the combined answer stays deterministic on
|
|
3047
|
+
// the same input (no guessing, no picking a winner) while actually telling the
|
|
3048
|
+
// user what each reading resolves to, instead of making them ask twice.
|
|
3049
|
+
if (parsed.ambiguousParse) {
|
|
3050
|
+
const branches = parsed.candidates.map((c) => {
|
|
3051
|
+
const branchResult = traverse(graph, c, { contextId, prev });
|
|
3052
|
+
return { parsed: c, result: branchResult, rendered: render(c, branchResult) };
|
|
3053
|
+
});
|
|
3054
|
+
return { matches: [], objMatch: null, candidates: parsed.candidates, traversal: null, ambiguous: true, branches };
|
|
3055
|
+
}
|
|
2925
3056
|
const { shape, kind, entityType } = parsed;
|
|
2926
3057
|
|
|
2927
3058
|
// meta: a question about the graph's OWN vocabulary ("what does cochange mean", "what
|
|
@@ -3019,8 +3150,94 @@ export function traverse(graph, parsed, { contextId = null, prev = null } = {})
|
|
|
3019
3150
|
|
|
3020
3151
|
// reverse and forward both resolve one named term ("object" in the parsed shape — for
|
|
3021
3152
|
// forward it is the query's grammatical subject, e.g. "what does X import" -> parsed.object = X).
|
|
3022
|
-
|
|
3153
|
+
//
|
|
3154
|
+
// altObject pruning (PLAN_CONVERSATION.md Finding 2): noise-strip.mjs's bare
|
|
3155
|
+
// "where"/"mentions" reading may carry `parsed.altObject` — the SAME reading
|
|
3156
|
+
// with a wink-POS-flagged, plausibly-noise light verb ALSO dropped ("store
|
|
3157
|
+
// router" -> "router"; see that file's own doc for why the signal needs full-
|
|
3158
|
+
// sentence context and can't be decided there, where there is no graph).
|
|
3159
|
+
// This is the one place both the alternate reading and the graph are
|
|
3160
|
+
// available together, so it's where the pruning actually happens — mirroring
|
|
3161
|
+
// resolveObject's own grain-word retry just above (try a variant, keep it
|
|
3162
|
+
// ONLY on an unambiguous hit, else fall through unchanged) and
|
|
3163
|
+
// grammar/ace.mjs's parseAceAmbiguous ("keep only complete, valid parses,
|
|
3164
|
+
// dead ends pruned"). Four outcomes: primary misses/ties + alt resolves
|
|
3165
|
+
// cleanly -> the alt reading wins (the dead end is pruned); primary resolves
|
|
3166
|
+
// cleanly -> untouched, regardless of what the alt does (byte-identical to
|
|
3167
|
+
// before this existed); both resolve cleanly to the SAME entity -> untouched
|
|
3168
|
+
// either way; both resolve cleanly to DIFFERENT entities -> genuine
|
|
3169
|
+
// ambiguity, surfaced the same honest way resolveObject's own tier ties
|
|
3170
|
+
// already are, never silently guessed.
|
|
3171
|
+
// ENTITY-TIE BRANCHES (breadth-first ambiguity, PLAN_BREADTH_FIRST_NLU.md §1): a
|
|
3172
|
+
// recursive traverse() call for one already-tied candidate arrives here with
|
|
3173
|
+
// `pinnedObjMatch` set — skip re-resolution entirely (no re-derived tie is
|
|
3174
|
+
// possible, so `ambiguous` is structurally false on every such call) rather
|
|
3175
|
+
// than re-resolving by label text, which would risk a second individual
|
|
3176
|
+
// sharing the same label, a stale `altObject` re-triggering a spurious new
|
|
3177
|
+
// tie, or the test-variant-collision guard misfiring on a non-Module label.
|
|
3178
|
+
let objRes;
|
|
3179
|
+
if (pinnedObjMatch) {
|
|
3180
|
+
objRes = { match: pinnedObjMatch, candidates: [], ambiguous: false, matchedVia: null };
|
|
3181
|
+
} else {
|
|
3182
|
+
objRes = resolveTermOrContext(graph, parsed.object, contextId);
|
|
3183
|
+
if (parsed.altObject && parsed.altObject !== parsed.object) {
|
|
3184
|
+
const altRes = resolveTermOrContext(graph, parsed.altObject, contextId);
|
|
3185
|
+
const primaryClean = !!objRes.match && !objRes.ambiguous;
|
|
3186
|
+
const altClean = !!altRes.match && !altRes.ambiguous;
|
|
3187
|
+
if (!primaryClean && altClean) {
|
|
3188
|
+
objRes = altRes;
|
|
3189
|
+
} else if (primaryClean && altClean && objRes.match.id !== altRes.match.id) {
|
|
3190
|
+
objRes = { ...objRes, ambiguous: true, candidates: [altRes.match, ...(objRes.candidates || [])] };
|
|
3191
|
+
}
|
|
3192
|
+
}
|
|
3193
|
+
// TEST-VARIANT COLLISION (CHATBENCH decision-log item 2, am-tests-cover: "which
|
|
3194
|
+
// tests cover b.mjs"): a "tests"-kind query's object may also honestly name its
|
|
3195
|
+
// OWN conventional test-variant sibling ("b.mjs" -> "b.test.mjs"/"b.spec.mjs"),
|
|
3196
|
+
// a DIFFERENT real Module — app/lib/b.mjs and app/unit-tests/b.test.mjs both
|
|
3197
|
+
// plausibly answer "b.mjs" when the question is specifically about test
|
|
3198
|
+
// coverage. Deliberately scoped to kind==="tests" (never touches, imports,
|
|
3199
|
+
// calls, …) — the SAME bare filename in an unrelated query ("has store.mjs
|
|
3200
|
+
// been touched", chatflow-agents-debt-remeasure.test.mjs BUG-B's ground truth,
|
|
3201
|
+
// examples/mini-webapp genuinely has the same store.mjs/store.test.mjs pair) has
|
|
3202
|
+
// no such self-referential reading and must stay untouched. ALSO scoped to a
|
|
3203
|
+
// BARE (unslashed) term — same convention as resolveObjectCore's own `dotted`
|
|
3204
|
+
// tier just above resolveObject's call site: a query that already spells out
|
|
3205
|
+
// the full path ("which functions test src/core/store.mjs",
|
|
3206
|
+
// chatflow-tier4.test.mjs Batch 4/5; "app/lib/b.mjs", chatflow-tier2.test.mjs
|
|
3207
|
+
// T18) is already unambiguous by construction and must stay untouched — only
|
|
3208
|
+
// the bare basename is genuinely open to either reading. Same discipline as
|
|
3209
|
+
// the altObject prune just above: only a CLEAN primary match plus a genuinely
|
|
3210
|
+
// DIFFERENT real Module promotes to honest ambiguity, never a silent guess.
|
|
3211
|
+
if (parsed.kind === "tests" && objRes.match && !objRes.ambiguous && !String(parsed.object || "").includes("/")) {
|
|
3212
|
+
const stripTestInfix = (base) => base.replace(/\.(?:test|spec|tests)(?=\.[^.]+$)/, "");
|
|
3213
|
+
const termBase = stripTestInfix(String(parsed.object || "").trim().toLowerCase());
|
|
3214
|
+
const collision = (graph.individuals || []).find((i) => {
|
|
3215
|
+
if (i.class !== "Module" || i.id === objRes.match.id) return false;
|
|
3216
|
+
const base = String(i.label || "").toLowerCase().split("/").pop();
|
|
3217
|
+
return base !== stripTestInfix(base) && stripTestInfix(base) === termBase;
|
|
3218
|
+
});
|
|
3219
|
+
if (collision) objRes = { ...objRes, ambiguous: true, candidates: [collision, ...(objRes.candidates || [])] };
|
|
3220
|
+
}
|
|
3221
|
+
}
|
|
3222
|
+
const { match: objMatch, candidates, ambiguous, unresolvedPronoun, matchedVia } = objRes;
|
|
3023
3223
|
if (!objMatch) return { matches: [], objMatch: null, candidates, traversal: null, ambiguous: false, unresolvedPronoun };
|
|
3224
|
+
// BREADTH-FIRST ENTITY-TIE RESOLUTION (PLAN_BREADTH_FIRST_NLU.md §1): mirrors the
|
|
3225
|
+
// parsed.ambiguousParse branch above — every tied candidate is independently
|
|
3226
|
+
// traversed and rendered for real (via the pinnedObjMatch short-circuit just
|
|
3227
|
+
// above), never left as a bare name list. Capped at OVERFLOW_CAP BEFORE
|
|
3228
|
+
// traversing (not just before rendering) to avoid wasted work on candidates
|
|
3229
|
+
// that won't be shown. One accepted, narrow trade-off: a general reverse-case
|
|
3230
|
+
// query whose grain-refine retry below would previously have silently
|
|
3231
|
+
// recovered to a different class-correct match now short-circuits to branches
|
|
3232
|
+
// instead — not exercised by any pinned test/bench case today.
|
|
3233
|
+
if (ambiguous) {
|
|
3234
|
+
const pool = uniqueById([objMatch, ...(candidates || [])]).slice(0, OVERFLOW_CAP);
|
|
3235
|
+
const branches = pool.map((c) => {
|
|
3236
|
+
const branchResult = traverse(graph, parsed, { contextId, prev, pinnedObjMatch: c });
|
|
3237
|
+
return { candidate: c, result: branchResult, rendered: render(parsed, branchResult) };
|
|
3238
|
+
});
|
|
3239
|
+
return { matches: [], objMatch, candidates, traversal: null, ambiguous: true, branches };
|
|
3240
|
+
}
|
|
3024
3241
|
|
|
3025
3242
|
// where: "where is X [defined]" (2026-07-02 query families) — the resolved
|
|
3026
3243
|
// entity IS the answer; render() reads its class + site attribute ("path:
|
|
@@ -3109,11 +3326,52 @@ export function traverse(graph, parsed, { contextId = null, prev = null } = {})
|
|
|
3109
3326
|
const fwdSibling = SYMBOL_GRAIN_SIBLING[kind];
|
|
3110
3327
|
const subjIsFineSymbol = !!(fwdSibling && objMatch.class && FINE_ENTITY_TYPES.has(objMatch.class));
|
|
3111
3328
|
const fwdKinds = subjIsFineSymbol ? [...new Set([...kindsFor(kind), fwdSibling])] : kindsFor(kind);
|
|
3329
|
+
// FORWARD GRAIN CHECK (PLAN_CONVERSATION.md Finding 3): entityType flows in from
|
|
3330
|
+
// parseKeywordSpot for every forward query, but until now was consulted ONLY by
|
|
3331
|
+
// the commit-as-subject flip above — every other forward query scanned blind,
|
|
3332
|
+
// regardless of what class the kind's edges actually target. A kind whose real
|
|
3333
|
+
// observed target classes never include the asked entityType (nor its
|
|
3334
|
+
// FINE_CLASS_SIBLING family partner) can never honestly answer it — "what
|
|
3335
|
+
// modules does X have" via `defines`, whose real targets are only
|
|
3336
|
+
// {Class,Attribute,Method,Function}, never Module — so this is an honest decline,
|
|
3337
|
+
// not a blind filter that would silently produce a false empty. "Change" is the
|
|
3338
|
+
// touches-family wildcard pseudo-type (no individual is ever classed "Change"),
|
|
3339
|
+
// exempted the same way the reverse branch exempts it (see its own comment above).
|
|
3340
|
+
if (entityType && entityType !== "Change") {
|
|
3341
|
+
const wantClasses = classesForKinds(graph, fwdKinds);
|
|
3342
|
+
const siblingClass = FINE_CLASS_SIBLING[entityType];
|
|
3343
|
+
if (!wantClasses.has(entityType) && !(siblingClass && wantClasses.has(siblingClass))) {
|
|
3344
|
+
return {
|
|
3345
|
+
matches: [], objMatch, candidates, ambiguous, matchedVia,
|
|
3346
|
+
forwardGrainMiss: true, wantClasses: [...wantClasses],
|
|
3347
|
+
traversal: `${fwdKinds.join("+")} edges where subject = ${objMatch.label} (grain mismatch: this "${kind}" relation never targets a ${entityType})`,
|
|
3348
|
+
};
|
|
3349
|
+
}
|
|
3350
|
+
}
|
|
3112
3351
|
const edges = fwdKinds.flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.subject === objMatch.id);
|
|
3113
3352
|
const targets = edges.map((e) => graph.byId.get(e.object)).filter(Boolean);
|
|
3114
3353
|
// dedupe only on the widened scan — the coarse-only path keeps its exact shape.
|
|
3115
|
-
const
|
|
3116
|
-
|
|
3354
|
+
const deduped = subjIsFineSymbol ? uniqueById(targets) : targets;
|
|
3355
|
+
// PER-TRAVERSAL GRAIN FILTER (same Finding 3 fix): once grain passes above (or
|
|
3356
|
+
// entityType is null/"Change"), still keep only the matches of the ASKED class —
|
|
3357
|
+
// mirrors the reverse branch's own subjects.filter + sibling-widen-on-empty
|
|
3358
|
+
// fallback just below, so a forward answer never leaks a wrong-class match once
|
|
3359
|
+
// an entityType was actually asked for ("which functions does saveStore call"
|
|
3360
|
+
// must not render a Class as if it were a function).
|
|
3361
|
+
let matches = deduped;
|
|
3362
|
+
let filterNote = "";
|
|
3363
|
+
if (entityType && entityType !== "Change") {
|
|
3364
|
+
matches = deduped.filter((m) => m.class === entityType);
|
|
3365
|
+
const siblingClass = FINE_CLASS_SIBLING[entityType];
|
|
3366
|
+
if (!matches.length && siblingClass) {
|
|
3367
|
+
const widened = deduped.filter((m) => m.class === siblingClass);
|
|
3368
|
+
if (widened.length) {
|
|
3369
|
+
matches = widened;
|
|
3370
|
+
filterNote = `, widened to ${siblingClass} subjects (no ${entityType} recorded)`;
|
|
3371
|
+
}
|
|
3372
|
+
}
|
|
3373
|
+
}
|
|
3374
|
+
return { matches, objMatch, candidates, traversal: `${fwdKinds.join("+")} edges where subject = ${objMatch.label}${filterNote}`, ambiguous, matchedVia };
|
|
3117
3375
|
}
|
|
3118
3376
|
|
|
3119
3377
|
// reverse + transitive (PLAN_MECHANICAL_CHAT.md P1): the gate above guarantees kind is
|
|
@@ -3338,6 +3596,57 @@ function describeParse(p) {
|
|
|
3338
3596
|
return `${ent}${p.kind} "${obj}"`;
|
|
3339
3597
|
}
|
|
3340
3598
|
|
|
3599
|
+
/** PLAN_BREADTH_FIRST_NLU.md §Track 6 (operator directive) — the canonical
|
|
3600
|
+
* restatement of what a query was understood to mean, in BOTH forms the
|
|
3601
|
+
* operator asked for: an English gloss in tmct's own preferred phrasing
|
|
3602
|
+
* (`english`) and a compact, machine-parsable notation of the same
|
|
3603
|
+
* structured fact (`machine`) — a simple `shape(kind, args...)` call form,
|
|
3604
|
+
* not raw JSON, so it stays human-readable at a glance too. Every clause is
|
|
3605
|
+
* a plain template read off `parsed`'s own already-compiled fields, never
|
|
3606
|
+
* generated — the same discipline as `describeParse`/`render()`. Returns
|
|
3607
|
+
* `null` when there's nothing to canonicalize (`parsed` itself is null).
|
|
3608
|
+
* Scoped to the flat query shapes `traverse()`/`render()` operate on; a
|
|
3609
|
+
* compositional AST (`parsed.node`) gets an honest, coarser fallback —
|
|
3610
|
+
* full per-node-type canonicalization is real future work, not silently
|
|
3611
|
+
* faked here. */
|
|
3612
|
+
function canonicalOf(parsed) {
|
|
3613
|
+
if (!parsed) return null;
|
|
3614
|
+
if (parsed.ambiguousParse) {
|
|
3615
|
+
return {
|
|
3616
|
+
english: `ambiguous: ${parsed.candidates.map(describeParse).join(" — or — ")}`,
|
|
3617
|
+
machine: `ambiguousParse(${parsed.candidates.map((c) => canonicalOf(c)?.machine).join(", ")})`,
|
|
3618
|
+
};
|
|
3619
|
+
}
|
|
3620
|
+
if (parsed.node) {
|
|
3621
|
+
// Compositional AST — coarse, honest fallback (see docblock above).
|
|
3622
|
+
return { english: `a compositional query (${parsed.node})`, machine: `composite(${parsed.node})` };
|
|
3623
|
+
}
|
|
3624
|
+
const q = (s) => JSON.stringify(String(s ?? ""));
|
|
3625
|
+
const args = [];
|
|
3626
|
+
if (parsed.kind) args.push(parsed.kind);
|
|
3627
|
+
if (parsed.entityType) args.push(`entityType=${parsed.entityType}`);
|
|
3628
|
+
if (parsed.modifier && parsed.modifier !== "direct") args.push(`modifier=${parsed.modifier}`);
|
|
3629
|
+
if (parsed.subject != null) args.push(`subject=${q(parsed.subject)}`);
|
|
3630
|
+
if (parsed.object != null) args.push(q(parsed.object));
|
|
3631
|
+
const machine = `${parsed.shape}(${args.join(", ")})`;
|
|
3632
|
+
let english;
|
|
3633
|
+
if (parsed.shape === "ask") {
|
|
3634
|
+
english = `does "${parsed.subject}" ${verbFor(parsed.kind)} "${parsed.object}"?`;
|
|
3635
|
+
} else if (parsed.shape === "meta") {
|
|
3636
|
+
english = `what does "${parsed.object}" mean, in this graph's own vocabulary?`;
|
|
3637
|
+
} else if (parsed.shape === "mentions") {
|
|
3638
|
+
english = `where is "${parsed.object}" mentioned?`;
|
|
3639
|
+
} else if (parsed.shape === "where") {
|
|
3640
|
+
english = `where is "${parsed.object}" defined?`;
|
|
3641
|
+
} else {
|
|
3642
|
+
const ent = parsed.entityType ? nounFor(parsed.entityType, 2) + " that " : "";
|
|
3643
|
+
english = parsed.shape === "forward"
|
|
3644
|
+
? `what "${parsed.object}" itself ${verbFor(parsed.kind)}`
|
|
3645
|
+
: `${ent}${verbFor(parsed.kind)} "${parsed.object}"`;
|
|
3646
|
+
}
|
|
3647
|
+
return { english, machine };
|
|
3648
|
+
}
|
|
3649
|
+
|
|
3341
3650
|
/** Render a compiled query result into {content, miss, ambiguous, matches?, candidates?}.
|
|
3342
3651
|
* Every branch is a template, not generation — §5's grouping/pluralization/overflow rules.
|
|
3343
3652
|
* A tier-5 fuzzy object resolution is ANNOUNCED, not silent: the answer is prefixed
|
|
@@ -3357,6 +3666,21 @@ function renderCore(parsed, result) {
|
|
|
3357
3666
|
}
|
|
3358
3667
|
if (parsed.node) return renderComposite(parsed, result);
|
|
3359
3668
|
if (parsed.ambiguousParse) {
|
|
3669
|
+
// Each branch was actually resolved in traverse() above (never guessed at) —
|
|
3670
|
+
// show every reading's real, effective answer, not just its one-line label, so
|
|
3671
|
+
// the same input always reproduces the same full multi-reading answer (copy the
|
|
3672
|
+
// same prompt back in and get the identical result, not a coin flip).
|
|
3673
|
+
if (result.branches && result.branches.length) {
|
|
3674
|
+
const options = result.branches
|
|
3675
|
+
.map((b, i) => `${i + 1}) as ${describeParse(b.parsed)}: ${b.rendered.content}`)
|
|
3676
|
+
.join("\n");
|
|
3677
|
+
return {
|
|
3678
|
+
content: `this could mean more than one thing:\n${options}\n(ask one of these directly, or try rephrasing more specifically, to get just that reading)`,
|
|
3679
|
+
miss: false, ambiguous: true, candidates: parsed.candidates.map(describeParse),
|
|
3680
|
+
};
|
|
3681
|
+
}
|
|
3682
|
+
// Fallback (no `result.branches` — e.g. a caller invoking render() directly
|
|
3683
|
+
// with a hand-built result, bypassing traverse()): the old bare-label listing.
|
|
3360
3684
|
const options = parsed.candidates.map((p, i) => `${i + 1}) ${describeParse(p)}`).join(" or ");
|
|
3361
3685
|
return {
|
|
3362
3686
|
content: `this could mean more than one thing: ${options} — try rephrasing more specifically.`,
|
|
@@ -3388,6 +3712,19 @@ function renderCore(parsed, result) {
|
|
|
3388
3712
|
miss: true, ambiguous: false,
|
|
3389
3713
|
};
|
|
3390
3714
|
}
|
|
3715
|
+
// forward-shape honest miss (PLAN_CONVERSATION.md Finding 3): the resolved SUBJECT
|
|
3716
|
+
// is real, but the asked entityType can never appear among this kind's own real
|
|
3717
|
+
// target classes at all — distinct from wrongGrainMiss above (which is about the
|
|
3718
|
+
// resolved OBJECT term's class on the reverse side) and from the plain forward
|
|
3719
|
+
// zero-hit template below (which is a genuinely empty edge scan, not a class the
|
|
3720
|
+
// relation can never produce).
|
|
3721
|
+
if (result.forwardGrainMiss) {
|
|
3722
|
+
const wantNouns = result.wantClasses.map((c) => nounFor(c, 2));
|
|
3723
|
+
return {
|
|
3724
|
+
content: `${result.objMatch.label}'s "${verbFor(parsed.kind)}" relation in this index never produces ${nounFor(parsed.entityType, 2)} — only ${listJoin(wantNouns)}.`,
|
|
3725
|
+
miss: true, ambiguous: false,
|
|
3726
|
+
};
|
|
3727
|
+
}
|
|
3391
3728
|
if (parsed.shape === "meta") {
|
|
3392
3729
|
if (!result.objMatch) {
|
|
3393
3730
|
return {
|
|
@@ -3443,7 +3780,7 @@ function renderCore(parsed, result) {
|
|
|
3443
3780
|
const what = /^(?:commit[:\s])?[0-9a-f]{7,40}$/i.test(objText) ? "commit"
|
|
3444
3781
|
: (!objText.includes("/") && /^[\w$]+(\.[\w$]+)+$/.test(objText) ? "symbol" : fallback);
|
|
3445
3782
|
return {
|
|
3446
|
-
content: `no ${what} matching "${parsed.object}" found in the index
|
|
3783
|
+
content: `no ${what} matching "${parsed.object}" found in the index. ${touchesRephraseHint()}`,
|
|
3447
3784
|
miss: true, ambiguous: false, candidates: [],
|
|
3448
3785
|
};
|
|
3449
3786
|
}
|
|
@@ -3457,9 +3794,16 @@ function renderCore(parsed, result) {
|
|
|
3457
3794
|
const noun = pool.length && pool.every((i) => i.class === "Commit") ? "commit" : "module";
|
|
3458
3795
|
const shown = pool.slice(0, OVERFLOW_CAP).map((i) => i.label);
|
|
3459
3796
|
const extra = pool.length > OVERFLOW_CAP ? `, …and ${pool.length - OVERFLOW_CAP} more` : "";
|
|
3797
|
+
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.`;
|
|
3798
|
+
// BREADTH-FIRST (PLAN_BREADTH_FIRST_NLU.md §1): strictly additive to `lead` —
|
|
3799
|
+
// every currently-pinned assertion (test/chat-cefr-1.6.1-decision-log.test.mjs,
|
|
3800
|
+
// chatbench/graded-pool.jsonl's am-tests-cover) is a substring check against
|
|
3801
|
+
// `lead` alone, so appending each branch's real answer never breaks a pin.
|
|
3802
|
+
const content = (result.branches && result.branches.length)
|
|
3803
|
+
? `${lead}\n${result.branches.map((b, i) => `${i + 1}) ${b.candidate.label}: ${b.rendered.content}`).join("\n")}`
|
|
3804
|
+
: lead;
|
|
3460
3805
|
return {
|
|
3461
|
-
content:
|
|
3462
|
-
miss: false, ambiguous: true, candidates: pool.map((i) => i.label),
|
|
3806
|
+
content, miss: false, ambiguous: true, candidates: pool.map((i) => i.label),
|
|
3463
3807
|
};
|
|
3464
3808
|
}
|
|
3465
3809
|
// where: the resolved entity's own location, cited off the site attribute.
|
|
@@ -3473,6 +3817,14 @@ function renderCore(parsed, result) {
|
|
|
3473
3817
|
}
|
|
3474
3818
|
const m = String(result.site || "").match(/^(.*):(\d+)(?:-(\d+))?$/);
|
|
3475
3819
|
if (m) {
|
|
3820
|
+
// "is defined in" stays UNVARIED here on purpose: chatbench/graded-pool-max.jsonl
|
|
3821
|
+
// pins this exact substring as ground truth for "where is X defined" cases
|
|
3822
|
+
// (g-a1-svo-7/12/18/22/40/43, g-a2-noise-svo-3/9/13/14/15 — 2 of which,
|
|
3823
|
+
// g-a1-svo-12 and g-a2-noise-svo-13, are in the promoted always-run subset),
|
|
3824
|
+
// and SKILL_BENCHMARK_CEFR_ENGLISH.md declares that pool append-only/never
|
|
3825
|
+
// edited mid-arc. The other two "defined in" call sites (metaFallbackEntityAnswer
|
|
3826
|
+
// and the composite exists-hit above) answer a DIFFERENT query shape ("what is a
|
|
3827
|
+
// X" / "is there a X"), so they carry the variety instead.
|
|
3476
3828
|
const lines = m[3] && m[3] !== m[2] ? `lines ${m[2]}-${m[3]}` : `line ${m[2]}`;
|
|
3477
3829
|
return { content: `${symbolLabelOf(ind)} is defined in ${m[1]} at ${lines}.`, miss: false, ambiguous: false, matches: result.matches };
|
|
3478
3830
|
}
|
|
@@ -3486,7 +3838,7 @@ function renderCore(parsed, result) {
|
|
|
3486
3838
|
if (result.whenShape) {
|
|
3487
3839
|
const subject = result.objMatch.label;
|
|
3488
3840
|
if (!result.matches.length) {
|
|
3489
|
-
return { content: `no recorded commit touches ${subject} in this index
|
|
3841
|
+
return { content: `no recorded commit touches ${subject} in this index. ${touchesRephraseHint()}`, miss: true, ambiguous: false };
|
|
3490
3842
|
}
|
|
3491
3843
|
const newest = result.matches[0];
|
|
3492
3844
|
const date = (newest.attributes || []).find((a) => a.key === "date")?.value || "";
|
|
@@ -3499,7 +3851,7 @@ function renderCore(parsed, result) {
|
|
|
3499
3851
|
const msg = (newest.attributes || []).find((a) => a.key === "message")?.value || "";
|
|
3500
3852
|
const day = String(date).slice(0, 10);
|
|
3501
3853
|
if (newest.id === result.objMatch.id) {
|
|
3502
|
-
return { content: `commit ${newest.label} is dated ${day}${msg ? ` ("${msg}")` : ""}.`, miss: false, ambiguous: false, matches: result.matches };
|
|
3854
|
+
return { content: `commit ${newest.label} ${pickPhrase("is-dated", newest.id, "is dated")} ${day}${msg ? ` ("${msg}")` : ""}.`, miss: false, ambiguous: false, matches: result.matches };
|
|
3503
3855
|
}
|
|
3504
3856
|
const more = result.matches.length - 1;
|
|
3505
3857
|
return {
|
|
@@ -3515,7 +3867,7 @@ function renderCore(parsed, result) {
|
|
|
3515
3867
|
if (result.whoLastShape) {
|
|
3516
3868
|
const subject = result.objMatch.label;
|
|
3517
3869
|
if (!result.matches.length) {
|
|
3518
|
-
return { content: `no recorded commit touches ${subject} in this index
|
|
3870
|
+
return { content: `no recorded commit touches ${subject} in this index. ${touchesRephraseHint()}`, miss: true, ambiguous: false };
|
|
3519
3871
|
}
|
|
3520
3872
|
const newest = result.matches[0];
|
|
3521
3873
|
const author = (newest.attributes || []).find((a) => a.key === "author")?.value;
|
|
@@ -3600,7 +3952,7 @@ function renderCore(parsed, result) {
|
|
|
3600
3952
|
// append-only/sacred mid-arc, so the honest-miss phrasing stays as-is.
|
|
3601
3953
|
const entityWord = nounFor(parsed.entityType || "Module", 2);
|
|
3602
3954
|
return {
|
|
3603
|
-
content: `No ${entityWord} found whose module directly ${verbFor(parsed.kind)} ${parsed.object}
|
|
3955
|
+
content: `No ${entityWord} found whose module directly ${verbFor(parsed.kind)} ${parsed.object}. ${touchesRephraseHint()}`,
|
|
3604
3956
|
miss: true, ambiguous: false,
|
|
3605
3957
|
};
|
|
3606
3958
|
}
|
|
@@ -4020,7 +4372,7 @@ export function ask(graph, query, { contextId = null, nlp = undefined, prev = nu
|
|
|
4020
4372
|
return {
|
|
4021
4373
|
content: rephraseHint(),
|
|
4022
4374
|
tmct_ask: {
|
|
4023
|
-
mechanical: true, parsed: null, matches: [], traversal: null,
|
|
4375
|
+
mechanical: true, parsed: null, canonical: null, matches: [], traversal: null,
|
|
4024
4376
|
miss: true, ambiguous: false, matchedVia: null, help: true, relaxed: null,
|
|
4025
4377
|
},
|
|
4026
4378
|
};
|
|
@@ -4030,7 +4382,8 @@ export function ask(graph, query, { contextId = null, nlp = undefined, prev = nu
|
|
|
4030
4382
|
// rest of the pipeline (direct parse, relaxation, resolveObject) never has to know
|
|
4031
4383
|
// this phrase existed — see substituteLastCommitPhrase's own doc above.
|
|
4032
4384
|
query = substituteLastCommitPhrase(graph, query);
|
|
4033
|
-
const
|
|
4385
|
+
const directFull = parseQueryFull(query, { nlp });
|
|
4386
|
+
const direct = directFull.parsed;
|
|
4034
4387
|
// The relaxation cascade fires ONLY when the DIRECT parse would miss (no parse, a
|
|
4035
4388
|
// compositional {node:"miss"}, or an unresolved named term) — a clean hit, an
|
|
4036
4389
|
// ambiguous parse, an unresolved-pronoun miss, and a real-but-empty answer all keep
|
|
@@ -4045,14 +4398,63 @@ export function ask(graph, query, { contextId = null, nlp = undefined, prev = nu
|
|
|
4045
4398
|
const rendered = render(parsed, result);
|
|
4046
4399
|
// If relaxation materially rewrote the query and produced a real answer, note it
|
|
4047
4400
|
// lightly (terse, honest) so the reader knows how the question was read.
|
|
4048
|
-
|
|
4401
|
+
let content = (relaxed && !rendered.miss && relaxed.to !== relaxed.from)
|
|
4049
4402
|
? `read as "${relaxed.to}" — ${rendered.content}`
|
|
4050
4403
|
: rendered.content;
|
|
4404
|
+
// ALTERNATES ON HITS (breadth-first, PLAN_BREADTH_FIRST_NLU.md §3): a genuine
|
|
4405
|
+
// distinct-class alternate reading from a different strategy (merge.mjs's
|
|
4406
|
+
// `alternates`) used to be silently discarded on every call, including a real
|
|
4407
|
+
// hit — surface it now, answered for real via the same traverse()+render()
|
|
4408
|
+
// idiom the ambiguousParse/entity-tie branches already use. Scoped tightly to
|
|
4409
|
+
// the ONE case this is unambiguously safe: the direct parse (untouched by
|
|
4410
|
+
// relaxation, itself not already an ambiguous/miss result) produced a genuine
|
|
4411
|
+
// answer. Relaxation rewrote the query, so `directFull`'s alternates no
|
|
4412
|
+
// longer describe the question actually answered — skipped rather than shown
|
|
4413
|
+
// stale.
|
|
4414
|
+
//
|
|
4415
|
+
// REAL-ANSWER-ONLY, never the bare "ask it that way" pointer (live-caught,
|
|
4416
|
+
// 2026-07-11): a lower-precedence strategy's "alternate" is often pure noise,
|
|
4417
|
+
// not a genuine second reading — e.g. keyword-spot misreading a stripped
|
|
4418
|
+
// filler phrase as the query's SUBJECT ("hey man which modules import X" ->
|
|
4419
|
+
// an "ask" parse with subject:"hey man"). That never resolves to a real graph
|
|
4420
|
+
// entity, so `alternateLines`'s default "if you mean X then ask it that way"
|
|
4421
|
+
// fallback would surface exactly the low-value dead-end nudge this whole plan
|
|
4422
|
+
// exists to eliminate — worse, it's non-deterministic across equivalent
|
|
4423
|
+
// phrasings (test/interpret.test.mjs's own noise-strip parity check caught
|
|
4424
|
+
// this: "hey man which modules import X" must answer byte-identically to the
|
|
4425
|
+
// clean phrasing, and a garbage alternate broke that). So: compute each
|
|
4426
|
+
// alternate's real answer directly (not via alternateLines' fallback-prone
|
|
4427
|
+
// default) and only ever append lines for alternates that resolved to
|
|
4428
|
+
// something real — an alternate that can't be answered is dropped silently,
|
|
4429
|
+
// never padded with an unhelpful pointer.
|
|
4430
|
+
if (!relaxed && !rendered.miss && !rendered.ambiguous && directFull.alternates.length) {
|
|
4431
|
+
const answered = directFull.alternates
|
|
4432
|
+
.map((a) => {
|
|
4433
|
+
const altResult = traverse(graph, a.parsed, { contextId, prev });
|
|
4434
|
+
const altRendered = render(a.parsed, altResult);
|
|
4435
|
+
return altRendered.miss ? null : { a, text: altRendered.content };
|
|
4436
|
+
})
|
|
4437
|
+
.filter(Boolean);
|
|
4438
|
+
if (answered.length) {
|
|
4439
|
+
const lines = alternateLines(answered.map((x) => x.a), {
|
|
4440
|
+
answerFor: (a) => answered.find((x) => x.a === a)?.text || null,
|
|
4441
|
+
});
|
|
4442
|
+
content = `${content}\n${lines.join("\n")}`;
|
|
4443
|
+
}
|
|
4444
|
+
}
|
|
4051
4445
|
return {
|
|
4052
4446
|
content,
|
|
4053
4447
|
tmct_ask: {
|
|
4054
4448
|
mechanical: true,
|
|
4055
4449
|
parsed: (parsed && !parsed.ambiguousParse) ? parsed : null,
|
|
4450
|
+
// PLAN_BREADTH_FIRST_NLU.md §Track 6 (operator directive): the canonical
|
|
4451
|
+
// restatement of what the request was understood to mean, ALWAYS present
|
|
4452
|
+
// when anything parsed at all — not gated on ambiguity/miss the way the
|
|
4453
|
+
// ambiguity-branch labels are. `english` is the human-readable gloss in
|
|
4454
|
+
// tmct's own phrasing; `machine` is the same fact in a compact,
|
|
4455
|
+
// machine-parsable notation (a plain `shape(kind, args...)` call form).
|
|
4456
|
+
// Both are read straight off `parsed` — never generated.
|
|
4457
|
+
canonical: canonicalOf(parsed),
|
|
4056
4458
|
matches: (result.matches || []).map((m) => ({
|
|
4057
4459
|
id: m.id, label: m.label, type: m.class, module: m.class ? moduleLabelOf(m) : undefined,
|
|
4058
4460
|
})),
|