@polycode-projects/the-mechanical-code-talker 0.3.0 → 0.5.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,97 @@
1
+ # grammar-rules.toml — the data-driven grammar-rule table (Phase 7, lever 2)
2
+ # (PLAN_RESPONSE_FINISHING.md, "The grammar pass (lever 2)").
3
+ #
4
+ # Each [[rule]] is a corrective grammar rule applied by applyGrammar() in
5
+ # src/finish.mjs over the PROSE spans of a segmented answer — NEVER the flat
6
+ # string, and NEVER a protected span (entity / path / number / code / provenance
7
+ # / receipt). The engine reads STRUCTURE, never guesses from surface: an article
8
+ # rule that would touch the word inside the following protected span refuses to
9
+ # fire, agreement reads the following number's value, and so on.
10
+ #
11
+ # CONTRACT (the plan's law): a rule's NEUTRAL behaviour is BYTE-STABLE. The only
12
+ # byte changes a rule may introduce are GENUINE fixes to defects tmct itself
13
+ # generates ("a artifact"). `kind` selects the built-in handler; `enabled=false`
14
+ # PARKS a rule out of the live answer path; the remaining keys are that handler's
15
+ # applicability conditions + parameters. Rules apply in file order; the set is
16
+ # chosen to commute so finish() is idempotent: finish(finish(x)) === finish(x).
17
+ #
18
+ # SEQUENCING (PLAN_RESPONSE_FINISHING.md, "one grammar rule per tuning cycle"):
19
+ # LIVE as of cycle 005: ARTICLE-SELECTION (cycle 4), plus the two SAFE defect
20
+ # fixes TERMINAL-PUNCTUATION and SUBJECT-VERB-AGREEMENT — each byte-stable when
21
+ # neutral, no product-voice change. CAPITALISATION and LIST are fully implemented
22
+ # and golden-tested IN ISOLATION but remain PARKED (enabled=false). The cycle-006
23
+ # judged A/B was assessed and DROPPED: `capitalise` rewrites the sentence-initial
24
+ # char, which regresses ~10 frozen v1 cases.jsonl whose case-sensitive answerMatch
25
+ # pins lowercase openers ("can't count", "no symbol matching", "assuming you meant",
26
+ # …) — the same sacred-case collision that reverted the voice-nit; and `list` has
27
+ # zero 3-item "X and Y and Z" targets in the judged set (a no-op). Deferred to a
28
+ # post-arc case-set refresh where the openers can be re-pinned deliberately. They
29
+ # rewrite established product bytes (tmct's
30
+ # lowercase openers and repeated "and" joins are an intentional VOICE, not a
31
+ # grammar defect), so activating them is a per-rule tuning-cycle decision with
32
+ # its own bench + showcase reconcile, not a blanket flip. `enabled=false` keeps
33
+ # them inert in finish(); the goldens force-enable each rule to prove its
34
+ # behaviour independent of the live flag.
35
+
36
+ # 1. Article selection — a/an by the following word's phonetic onset. The live
37
+ # defect this fixes: the assert echo "every module is a artifact" -> "an
38
+ # artifact". Reads the next word (whether in-span or the leading token of the
39
+ # following protected span); refuses at a boundary it cannot read safely.
40
+ [[rule]]
41
+ id = "article-selection"
42
+ kind = "article"
43
+ enabled = true
44
+ registers = [] # [] = every register
45
+ description = "a/an agreement with the following word's phonetic onset"
46
+ # Spelling-vowel words that begin with a CONSONANT sound (take 'a').
47
+ consonant_sound_vowels = ["uni", "use", "user", "usa", "usu", "ubi", "eu", "ewe", "one", "once"]
48
+ # Spelling-consonant words that begin with a VOWEL sound (take 'an'): silent h.
49
+ vowel_sound_consonants = ["hour", "honest", "honour", "honor", "heir", "herb"]
50
+
51
+ # 2. Subject–verb agreement — an existential copula agrees with the count that
52
+ # follows it ("there is 3 classes" -> "there are 3 classes"; "there are 1
53
+ # class" -> "there is 1 class"). Structure-driven: the plurality is READ from
54
+ # the following number span's value (or a protected span's explicit `plural`
55
+ # flag), never guessed. Neutral on already-correct agreement.
56
+ [[rule]]
57
+ id = "subject-verb-agreement"
58
+ kind = "agreement"
59
+ enabled = true # LIVE (cycle 005) — structure-driven existential agreement, byte-stable when neutral
60
+ registers = []
61
+ description = "existential copula agrees with the following count/plurality"
62
+ singular = ["is", "was", "has"]
63
+ plural = ["are", "were", "have"]
64
+
65
+ # 3. Sentence capitalisation — capitalise the first alphabetic character of a
66
+ # sentence-initial prose span. Never fires when the answer opens on a
67
+ # protected span (a path/entity opener is left exactly as grounded).
68
+ [[rule]]
69
+ id = "sentence-capitalisation"
70
+ kind = "capitalise"
71
+ enabled = false # PARKED — implemented + golden-tested, not live this cycle
72
+ registers = []
73
+ description = "capitalise the first alphabetic of a prose-initial span"
74
+
75
+ # 4. List punctuation — a series joined by repeated " and " connectives becomes
76
+ # a comma series with a single terminal conjunction ("a and b and c" -> "a, b
77
+ # and c"). Operates ONLY on the prose connective spans, never the entity spans
78
+ # they join; a two-item list ("a and b") is already correct and untouched.
79
+ [[rule]]
80
+ id = "list-punctuation"
81
+ kind = "list"
82
+ enabled = false # PARKED — implemented + golden-tested, not live this cycle
83
+ registers = []
84
+ description = "repeated 'and' joins in a 3+ item series become a comma series"
85
+ connective = " and "
86
+ separator = ", "
87
+
88
+ # 5. Terminal punctuation — exactly one sentence-final stop: a run of 2+ trailing
89
+ # stops in the final prose span collapses to one ("done.." -> "done."). Adds
90
+ # nothing where a fragment/list answer legitimately ends without a stop.
91
+ [[rule]]
92
+ id = "terminal-punctuation"
93
+ kind = "terminal"
94
+ enabled = true # LIVE (cycle 005) — pure defect fix, narrowest blast radius (trailing doubled stop)
95
+ registers = []
96
+ description = "collapse a run of trailing sentence stops to a single stop"
97
+ stops = [".", "!", "?"]
@@ -53,3 +53,16 @@
53
53
  {"id":"nudge-precision","class":"nudge","register":"friendly","template":"The closer you get to a shape like \"{example}\", the sharper my answer gets."}
54
54
  {"id":"nudge-commands","class":"nudge","register":"friendly","template":"If prose fails you, the slash commands always work — try {command}."}
55
55
  {"id":"nudge-narrower","class":"nudge","register":"friendly","template":"That matched {count} things — too many to be useful. Narrow it with a module or class name."}
56
+ {"id":"conversational-greeting","class":"conversational","register":"friendly","template":"Hi. Ask me about this codebase — imports, calls, definitions, history — or /help."}
57
+ {"id":"conversational-greeting-hello-there","class":"conversational","register":"friendly","template":"Hello there. (A hollow voice says, \"fool.\") Ask me about this codebase, or /help."}
58
+ {"id":"conversational-greeting-good-morning","class":"conversational","register":"friendly","template":"Good morning. Ask me about this codebase, or /help."}
59
+ {"id":"conversational-greeting-good-afternoon","class":"conversational","register":"friendly","template":"Good afternoon. Ask me about this codebase, or /help."}
60
+ {"id":"conversational-greeting-good-evening","class":"conversational","register":"friendly","template":"Good evening. Ask me about this codebase, or /help."}
61
+ {"id":"conversational-thanks","class":"conversational","register":"friendly","template":"Any time. Ask another, or /help for what I can do."}
62
+ {"id":"conversational-farewell","class":"conversational","register":"friendly","template":"Bye — flushing the session log. Come back with a question any time."}
63
+ {"id":"orientation-friendly","class":"orientation","register":"friendly","template":"I answer questions about THIS codebase's structure — imports, calls, definitions,\nhistory and counts. For example:\n which modules import walk.mjs\n what calls buildContextBundle\n how many classes are there\n/help for commands, /stats for an overview of the graph."}
64
+ {"id":"miss-no-previous-answer","class":"miss","register":"friendly","template":"No previous answer to expand yet — ask me a question first, then say \"why\" or \"say more\"."}
65
+ {"id":"technical-density","class":"count","register":"technical","template":"{subject} carries {count} {noun} across {scope} — a concentration well above what a codebase of this size typically sustains ({provenance})."}
66
+ {"id":"technical-comparison","class":"count","register":"technical","template":"At {count} {noun}, {subject} sits {comparison} the comparable-project baseline, a divergence that reflects deliberate structure rather than measurement noise ({provenance})."}
67
+ {"id":"technical-superlative","class":"count","register":"technical","template":"No {noun} in {scope} is more {metric} than {subject}; it leads the next candidate by a clear margin of {count} ({provenance})."}
68
+ {"id":"technical-ratio","class":"count","register":"technical","template":"{subject} sustains a ratio of {count} {noun} per {unit}, placing it in the upper band for projects of comparable {scope} ({provenance})."}
package/package.json CHANGED
@@ -1,12 +1,38 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; no codebase index of its own.",
7
+ "keywords": [
8
+ "chatbot",
9
+ "no-llm",
10
+ "offline",
11
+ "deterministic",
12
+ "eliza",
13
+ "parry",
14
+ "nlp",
15
+ "wink-nlp",
16
+ "owl",
17
+ "rdf",
18
+ "ontology",
19
+ "controlled-natural-language",
20
+ "ace",
21
+ "knowledge-graph",
22
+ "provenance",
23
+ "code-navigation",
24
+ "cli"
25
+ ],
7
26
  "license": "MPL-2.0",
8
27
  "author": "Polycode Limited",
9
28
  "homepage": "https://polycode-projects.gitlab.io/the-mechanical-code-talker/",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://gitlab.com/polycode-projects/the-mechanical-code-talker.git"
32
+ },
33
+ "bugs": {
34
+ "url": "https://gitlab.com/polycode-projects/the-mechanical-code-talker/-/issues"
35
+ },
10
36
  "engines": {
11
37
  "node": ">=24"
12
38
  },
@@ -47,7 +73,9 @@
47
73
  "test": "node --test \"test/**/*.test.mjs\"",
48
74
  "chat": "node bin/tmct.mjs",
49
75
  "chatbench:run": "node chatbench/run.mjs",
50
- "chatbench:judge": "node chatbench/judge.mjs"
76
+ "chatbench:judge": "node chatbench/judge.mjs",
77
+ "audit": "npm audit --audit-level=high",
78
+ "audit:fix": "npm audit fix"
51
79
  },
52
80
  "devDependencies": {
53
81
  "ink-testing-library": "^4.0.0"
package/src/ask-nlp.mjs CHANGED
@@ -9,13 +9,13 @@
9
9
  // the bounded edit-distance tier still work, browser and Node alike). Keeping the
10
10
  // ~1MB CJS model out of the page is the point of the split.
11
11
  //
12
- // wink-nlp and wink-eng-lite-web-model are CJS loaded via createRequire, the
13
- // same Node-module-resolution approach viz.mjs uses to locate cytoscape (resolve
14
- // through the module system, never a guessed path). The require happens lazily on
15
- // first use and failure is cached as null: a checkout without the optional deps
16
- // installed answers exactly like the browser bundle, it never throws.
12
+ // wink-nlp and wink-eng-lite-web-model are loaded through the shared leaf loader
13
+ // src/wink-model.mjs (Node `createRequire` fallback + a browser registration seam),
14
+ // so this file no longer carries its own Node-only load block. The load happens
15
+ // lazily on first use and failure is cached as null: a checkout without the optional
16
+ // deps installed answers exactly like the browser bundle, it never throws.
17
17
 
18
- import { createRequire } from "node:module";
18
+ import { winkInstance } from "./wink-model.mjs";
19
19
 
20
20
  let cached; // undefined = not tried yet; null = unavailable (tried once, honestly off)
21
21
 
@@ -25,10 +25,8 @@ let cached; // undefined = not tried yet; null = unavailable (tried once, honest
25
25
  export function nlpAdapter() {
26
26
  if (cached !== undefined) return cached;
27
27
  try {
28
- const require = createRequire(import.meta.url);
29
- const winkNLP = require("wink-nlp");
30
- const model = require("wink-eng-lite-web-model");
31
- const nlp = winkNLP(model);
28
+ const nlp = winkInstance();
29
+ if (!nlp) { cached = null; return cached; }
32
30
  const its = nlp.its;
33
31
  cached = {
34
32
  /** Lowercase lemma of a single token ("imported" -> "import"); the word
package/src/ask-vocab.mjs CHANGED
@@ -220,6 +220,28 @@ export const MODIFIER_TO_KIND = Object.freeze({
220
220
  transitively: "transitive", indirectly: "transitive",
221
221
  });
222
222
 
223
+ // ---- reversible-passive participles (Cycle 6, PLAN_CYCLE_4.md) — past participles ->
224
+ // relation kind, for the agent-marked passive "X is <participle> by Y". Kept SEPARATE
225
+ // from VERB_TO_KIND on purpose: these forms are NOT standalone active verbs in this
226
+ // grammar ("defined" belongs to the multi-word "is defined in" and to the WHERE_MARKERS
227
+ // location routing; bare "inherited" has no active key), so folding them into
228
+ // VERB_TO_KIND would silently re-route "where is X defined" and other queries. This
229
+ // table is consulted ONLY by the keyword strategy's passive path, which has already
230
+ // confirmed a passive auxiliary AND an agent-marking "by" — so an active query is never
231
+ // affected. Most common participles ("imported"/"tested"/"called"/"covered") already
232
+ // reach VERB_TO_KIND via the lemma tier; this table backfills the two families the lemma
233
+ // tier can't (defines/inherits) plus the obvious siblings, so the passive works
234
+ // adapter-free too. ----
235
+ export const PASSIVE_PARTICIPLE_TO_KIND = Object.freeze({
236
+ imported: "imports", called: "calls", used: "uses",
237
+ tested: "tests", covered: "tests", verified: "tests", exercised: "tests", checked: "tests",
238
+ defined: "defines", declared: "defines",
239
+ inherited: "inherits", extended: "inherits", subclassed: "inherits",
240
+ contained: "contains",
241
+ exported: "reexports", "re-exported": "reexports", exposed: "reexports",
242
+ touched: "touches", changed: "touches", modified: "touches", edited: "touches", updated: "touches",
243
+ });
244
+
223
245
  // ---- §3.5 normalization — contractions/informal spellings that would otherwise
224
246
  // block a match, expanded BEFORE parsing (BOTH the anchored-template strategy
225
247
  // and the independent keyword-spotting strategy see the same normalized text —
package/src/ask.mjs CHANGED
@@ -51,7 +51,7 @@ import {
51
51
  // grammar, split out of this file: normalization pre-pass, the two parsing
52
52
  // strategies, and the bounded-fuzzy service. Re-exported below where existing
53
53
  // callers/tests import them from here.
54
- import { normalizeQuery, applyNegationFrames, STOPWORDS, splitWords, wordsOf } from "./interpret/normalize.mjs";
54
+ import { normalizeQuery, applyNegationFrames, matchNegationSet, STOPWORDS, splitWords, wordsOf } from "./interpret/normalize.mjs";
55
55
  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";
@@ -244,7 +244,9 @@ function parseSimpleClause(text, nlp) {
244
244
  function parseComposite(text, nlp) {
245
245
  const w = splitWords(text);
246
246
  const lc = w.map((x) => x.toLowerCase());
247
- return parseAnaphora(w, lc, nlp)
247
+ return parseNegation(text, nlp, 0)
248
+ || parseForwardNegation(w, lc, nlp)
249
+ || parseAnaphora(w, lc, nlp)
248
250
  || parseAggregate(w, lc, nlp)
249
251
  || parseSuperlative(w, lc, nlp)
250
252
  || parseList(w, lc, nlp, 0)
@@ -252,11 +254,129 @@ function parseComposite(text, nlp) {
252
254
  || parseRelationalOrQualified(w, lc, nlp, 0);
253
255
  }
254
256
 
257
+ // B1 NEGATION (Cycle 5, PLAN_CYCLE_4.md) — the SET COMPLEMENT. "which X do not <verb>
258
+ // Y" / "X that don't <verb> Y" / "modules not importing Y" / "which X are not
259
+ // <qualifier>" compiles to allOfClass(kind) DIFFERENCE (the positive result set),
260
+ // reusing the EXISTING machinery: evalBoolean already folds a "difference" atom, and
261
+ // the allOfClass node is a ready-made bounded universe of a kind. The only new work is
262
+ // recognizing the negation marker (matchNegationSet, normalize.mjs) and assembling the
263
+ // boolean-difference AST — no new traversal primitive. Regression guards, all tested:
264
+ // (1) honest-empty stays honest — an EMPTY complement ("which functions are not
265
+ // exported", where the only function is exported) renders the standard honest
266
+ // "nothing matches" miss, never invents a member and never re-trips the literal-
267
+ // 'not' trap (the "not" is consumed here, so it can't leak into an object term);
268
+ // (2) BOUNDED UNIVERSE only — the universe is the queried kind within the loaded
269
+ // graph; the "Change" pseudo-type (ask-vocab.mjs) is a wildcard, not a stored
270
+ // enumerable class, so a complement over "changes" is REFUSED honestly rather
271
+ // than answered over an empty universe;
272
+ // (3) active-voice/positive queries are untouched — parseNegation returns null unless
273
+ // matchNegationSet finds an explicit set-negation marker.
274
+ function complementAst(entityType, diffAtom) {
275
+ return {
276
+ node: "boolean",
277
+ entityType,
278
+ atoms: [
279
+ { op: "seed", kind: "set", ast: { node: "allOfClass", entityType } },
280
+ diffAtom,
281
+ ],
282
+ };
283
+ }
284
+
285
+ function parseNegation(text, nlp, depth = 0) {
286
+ const neg = matchNegationSet(text);
287
+ if (!neg) return null; // no set-negation marker → not this shape
288
+ const noun = entityNoun(neg.entWord);
289
+ // a set complement needs a CONCRETE, enumerable kind. A placeholder ("things") has no
290
+ // bounded universe; the "Change" pseudo-type is a wildcard over the touch traversal,
291
+ // never a stored class, so its complement is ill-defined and must be refused honestly.
292
+ if (!noun || noun.placeholder || !noun.entityType) return null;
293
+ const entityType = noun.entityType;
294
+ if (entityType === "Change") {
295
+ return { node: "miss", reason: `"${neg.entWord}" isn't an enumerable kind — a set complement needs a concrete kind (functions, classes, modules, …)` };
296
+ }
297
+ const predWords = splitWords(neg.predicate);
298
+ const predLc = predWords.map((x) => x.toLowerCase());
299
+ // (a) qualifier negation ("not tested" / "not exported"): difference the qualifier
300
+ // set off the class — equivalent to the negated qualifier, an honest empty when none.
301
+ if (predLc.length && predLc.every((x) => QUALIFIERS[x])) {
302
+ return complementAst(entityType, { op: "difference", kind: "qual", filters: predLc });
303
+ }
304
+ const vh = findPhrase(predLc, VERB_TO_KIND);
305
+ if (!vh) return { node: "miss", reason: "a negated set query needs a known relation verb (import, call, inherit from, test, …)" };
306
+ const objWords = predWords.filter((_, i) => (i < vh.start || i >= vh.end) && !STOPWORDS.has(predLc[i]) && predLc[i] !== "from");
307
+ // (b) existential object ("do not import anything" / "define nothing"): the complement
308
+ // is the class MINUS the subjects that have ANY edge of this kind.
309
+ if (!objWords.length) {
310
+ return complementAst(entityType, { op: "difference", kind: "set", ast: { node: "existsEdge", entityType, kind: vh.kind } });
311
+ }
312
+ // (c) concrete object ("do not import a.mjs"): the class MINUS the POSITIVE result
313
+ // set, parsed through the existing clause/relational machinery (never re-negating —
314
+ // the reconstructed positive text carries no "not").
315
+ const positive = parseSetPhrase(`which ${neg.entWord} ${neg.predicate}`, nlp, depth + 1);
316
+ if (!positive || positive.node === "miss") {
317
+ return { node: "miss", reason: (positive && positive.reason) || "the negated clause didn't parse" };
318
+ }
319
+ return complementAst(entityType, { op: "difference", kind: "set", ast: positive });
320
+ }
321
+
322
+ // B1 FORWARD NEGATION (Cycle 5, pron+neg) — the SUBJECT-side complement's mirror: "what
323
+ // does[n't] <subj> <verb>" ("what doesn't it import", "what does app/lib/e.mjs not import")
324
+ // is every individual of the verb's OBJECT grain that <subj> does NOT reach via that verb.
325
+ // Distinct from parseNegation (which negates a queried KIND — "which modules do not import
326
+ // X"): here the negation sits on a FORWARD clause whose subject is a named term or a focus
327
+ // pronoun, so the universe is inferred from the verb's own edges (imports → Module) rather
328
+ // than a stated kind noun. The subject is resolved LATE (at eval, through the same
329
+ // contextId a plain "it" uses), so pronoun-binding composes with the complement for free.
330
+ // Refused honestly (empty) when the verb's object grain is ambiguous or the subject can't
331
+ // resolve — never a guess. Runs AFTER parseNegation, so the stated-kind form is unaffected.
332
+ const FWD_NEG_FRAME = new Set(["what", "which", "thing", "things", "one", "ones", "stuff"]);
333
+ function parseForwardNegation(w, lc, nlp) {
334
+ let i = 0;
335
+ while (i < lc.length && FWD_NEG_FRAME.has(lc[i])) i += 1;
336
+ if (!["do", "does", "did"].includes(lc[i])) return null; // need the auxiliary lead
337
+ i += 1;
338
+ const rest = w.slice(i);
339
+ const restLc = lc.slice(i);
340
+ const notIdx = restLc.indexOf("not");
341
+ if (notIdx < 0) return null; // no negation → not this shape
342
+ const vh = findPhrase(restLc, VERB_TO_KIND);
343
+ if (!vh) return null; // no relation verb → not this shape
344
+ // the subject term is whatever survives after removing "not", the verb phrase, "from",
345
+ // and question scaffolding — a bare pronoun "it" (not a stopword) survives and binds to
346
+ // the focus at eval time; a named module/symbol survives and resolves directly.
347
+ const subjTokens = rest.filter((_, j) => j !== notIdx && (j < vh.start || j >= vh.end)
348
+ && restLc[j] !== "from" && !STOPWORDS.has(restLc[j]));
349
+ const subjectTerm = subjTokens.join(" ").trim();
350
+ if (!subjectTerm) return null;
351
+ return { node: "forwardComplement", kind: vh.kind, subjectTerm };
352
+ }
353
+
354
+ /** The single OBJECT class a forward relation kind points at across the loaded graph
355
+ * (imports → Module), or null when its objects span more than one class (an ambiguous
356
+ * grain the complement's universe can't be pinned to). Ext: endpoints have no individual,
357
+ * so they don't muddy the class vote. Used by the forwardComplement evaluator to bound
358
+ * the universe it differences the positive forward set out of. */
359
+ function kindObjectClass(graph, kind) {
360
+ const classes = new Set();
361
+ for (const k of kindsFor(kind)) {
362
+ for (const e of edgesOfKind(graph, k)) {
363
+ const o = graph.byId.get(e.object);
364
+ if (o && o.class) classes.add(o.class);
365
+ }
366
+ }
367
+ return classes.size === 1 ? [...classes][0] : null;
368
+ }
369
+
255
370
  /** A set-producing sub-expression (used for nested inner clauses, boolean branches,
256
371
  * and count restrictors): nested first, then the relational/qualifier/boolean
257
372
  * parser, then a bare simple clause. Carries `depth` for the nesting cap. */
258
373
  function parseSetPhrase(text, nlp, depth) {
259
374
  if (depth > MAX_COMPOSE_DEPTH) return { node: "miss", reason: "too deep to resolve" };
375
+ // a set-negation clause can appear as a count restrictor ("how many classes are not
376
+ // tested"), a list filter, or a boolean branch — try the complement frame first so
377
+ // those compositions get the bounded-complement for free.
378
+ const negated = parseNegation(text, nlp, depth);
379
+ if (negated) return negated;
260
380
  const w = splitWords(text);
261
381
  const lc = w.map((x) => x.toLowerCase());
262
382
  const nested = parseNested(w, lc, nlp, depth);
@@ -302,12 +422,21 @@ function parseNested(w, lc, nlp, depth) {
302
422
  * uncompilable), or null. */
303
423
  function parseAnaphora(w, lc, nlp) {
304
424
  let p = -1;
425
+ let viaOf = false;
305
426
  for (let i = 1; i < lc.length; i += 1) {
306
- if (ANAPHORA_TRIGGERS.includes(lc[i]) && lc[i - 1] === "of") { p = i; break; }
427
+ if (!ANAPHORA_TRIGGERS.includes(lc[i])) continue;
428
+ if (lc[i - 1] === "of") { p = i; viaOf = true; break; } // "how many of those", "which of them"
429
+ // BARE anaphoric pronoun as the FINAL word, directly after a count/list trigger
430
+ // ("count them", "count those", "list them") — the discourse-reference count/list over
431
+ // the previous answer with no "of" (Cycle 5, disc+count). Pinned to the terminal
432
+ // position so a mid-sentence "these"/"those" used as a determiner ("list these
433
+ // functions") is left for the ordinary list/clause path, not seized as an anaphor.
434
+ const headSoFar = lc.slice(0, i).join(" ");
435
+ if (i === lc.length - 1 && (AGGREGATE_TRIGGERS.includes(headSoFar) || LIST_TRIGGERS.includes(headSoFar))) { p = i; break; }
307
436
  }
308
437
  if (p < 0) return null;
309
- const head = lc.slice(0, p - 1).join(" ");
310
- const mode = /^(how many|how much|count)\b/.test(head) ? "count" : "list";
438
+ const head = (viaOf ? lc.slice(0, p - 1) : lc.slice(0, p)).join(" ");
439
+ const mode = AGGREGATE_TRIGGERS.includes(head) || /^(how many|how much|count|number|quantity|total)\b/.test(head) ? "count" : "list";
311
440
  const filter = parsePredicateFilter(w.slice(p + 1), nlp);
312
441
  if (filter === undefined) return { node: "miss", reason: "the follow-up filter didn't parse" };
313
442
  return { node: "anaphora", mode, filter };
@@ -694,6 +823,23 @@ function evalSet(graph, ast, opts) {
694
823
  switch (ast.node) {
695
824
  case "clause": return traverse(graph, ast.clause, opts).matches || [];
696
825
  case "allOfClass": return graph.individuals.filter((i) => i.class === ast.entityType);
826
+ // the SUBJECTS that have ANY edge of a kind (the existential "modules that import
827
+ // anything") — the positive set an existential negation ("do not import anything")
828
+ // differences off allOfClass to yield "modules that import nothing".
829
+ case "existsEdge": {
830
+ const subs = new Set(kindsFor(ast.kind).flatMap((k) => edgesOfKind(graph, k)).map((e) => e.subject));
831
+ return graph.individuals.filter((i) => subs.has(i.id) && (!ast.entityType || i.class === ast.entityType));
832
+ }
833
+ // forward complement: the verb's object-grain universe MINUS what the (late-resolved,
834
+ // focus-bindable) subject reaches via that verb — "what doesn't it import".
835
+ case "forwardComplement": {
836
+ const r = resolveTermOrContext(graph, ast.subjectTerm, opts && opts.contextId);
837
+ if (!r.match) return []; // unresolved subject / focus-less pronoun → honest empty
838
+ const universeType = kindObjectClass(graph, ast.kind);
839
+ if (!universeType) return []; // ambiguous object grain → refuse honestly
840
+ const positive = new Set(forwardOverSet(graph, ast.kind, new Set([r.match.id])).map((x) => x.id));
841
+ return graph.individuals.filter((i) => i.class === universeType && !positive.has(i.id));
842
+ }
697
843
  case "reverseSet": {
698
844
  const ids = new Set(evalSet(graph, ast.inner, opts).map((i) => i.id));
699
845
  return reverseOverSet(graph, ast.kind, ast.entityType, ids);
@@ -1323,11 +1469,23 @@ export function traverse(graph, parsed, { contextId = null, prev = null } = {})
1323
1469
  };
1324
1470
  }
1325
1471
 
1326
- // reverse: "which <entityType> R <objMatch>"
1472
+ // reverse: "which <entityType> R <objMatch>". GRAIN-AWARE (Cycle 5, lever 3): a kind
1473
+ // that carries a symbol-grain sibling reads off the SIBLING when a fine SUBJECT grain
1474
+ // was asked for ("which functions call X" → callsSymbol). Additionally, for `touches`
1475
+ // specifically, when the RESOLVED OBJECT is itself a fine symbol the answer MUST read off
1476
+ // touchesSymbol: touches is Commit→Module (module-coarse) and can NEVER point at a
1477
+ // symbol, so "how many commits touched Widget.render" used to scan the module-grain
1478
+ // edges and return a false 0 — the count belongs at symbol grain. This object-driven
1479
+ // switch is scoped to touches on purpose: calls/callsSymbol already resolve a fine
1480
+ // object through the fine-entityType branch, and a null-entityType "what calls <fn>"
1481
+ // deliberately keeps its module-coarse `calls` receipt (the honest-empty showcase pins
1482
+ // it), so widening the switch to calls would silently change that answer.
1327
1483
  const symbolKind = SYMBOL_GRAIN_SIBLING[kind];
1328
- if (symbolKind && FINE_ENTITY_TYPES.has(entityType)) {
1484
+ const objIsTouchedSymbol = kind === "touches" && !!(objMatch.class && FINE_ENTITY_TYPES.has(objMatch.class));
1485
+ if (symbolKind && (FINE_ENTITY_TYPES.has(entityType) || objIsTouchedSymbol)) {
1329
1486
  const edges = edgesOfKind(graph, symbolKind).filter((e) => e.object === objMatch.id);
1330
- const matches = edges.map((e) => graph.byId.get(e.subject)).filter((i) => i && i.class === entityType);
1487
+ const subjects = uniqueById(edges.map((e) => graph.byId.get(e.subject)).filter(Boolean));
1488
+ const matches = (!entityType || entityType === "Change") ? subjects : subjects.filter((i) => i.class === entityType);
1331
1489
  return { matches, objMatch, candidates, traversal: `${symbolKind} edges where object = ${objMatch.label}`, ambiguous, matchedVia };
1332
1490
  }
1333
1491
 
@@ -1584,6 +1742,10 @@ function renderCore(parsed, result) {
1584
1742
  miss: true, ambiguous: false,
1585
1743
  };
1586
1744
  }
1745
+ // NOTE (Cycle 5): a voice-nit rephrasing ("that directly <verb>") was reverted —
1746
+ // the frozen v1 cases.jsonl pins the "whose module directly <verb>s X" wording
1747
+ // (hm-empty-result-calls / tf-wat-calls / ns-wondering), and the case set is
1748
+ // append-only/sacred mid-arc, so the honest-miss phrasing stays as-is.
1587
1749
  const entityWord = nounFor(parsed.entityType || "Module", 2);
1588
1750
  return {
1589
1751
  content: `No ${entityWord} found whose module directly ${verbFor(parsed.kind)} ${parsed.object}. (traversal: ${result.traversal || "no traversal resolved"})`,