@polycode-projects/seonix 0.4.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.
package/bin/cli.mjs CHANGED
@@ -81,7 +81,12 @@ async function runDigest(args) {
81
81
  let autoSelected = null; // for the header, when `query` drove selection
82
82
  if (!modules.length && args.query) {
83
83
  const graph = parseEntities(await source.fetchEntities(configFor(repoPath)));
84
- const ranked = searchModulesRanked(graph, args.query);
84
+ // SHIPPED DEFAULT (0.5.0): the no-MCP digest's query-mode auto-locate resolves literal-mention ON
85
+ // (a fresh invocation with no seonix.toml), disable-able via `literal_mention:false`. Kept in
86
+ // lockstep with the `seonix_locate` handler so `cli digest '{query}'` ≡ `cli seonix_locate` for the
87
+ // same query. A strict no-op unless the query carries a ≥3-component dotted path / repo-relative
88
+ // path; searchModulesRanked derives rawQuery from the query when literalMention is on.
89
+ const ranked = searchModulesRanked(graph, args.query, { literalMention: args.literal_mention !== false });
85
90
  const scoreGapK = args.score_gap === false ? null : (Number.isFinite(args.score_gap) ? args.score_gap : DEFAULT_SCORE_GAP);
86
91
  modules = selectRankedModules(ranked, { top_k: Number.isFinite(args.top_k) ? args.top_k : 2, scoreGapK }).slice(0, DIGEST_MODULE_CAP);
87
92
  autoSelected = modules;
@@ -277,6 +282,18 @@ async function main() {
277
282
  implOfInterface: !!args.impl_of_interface, // E1b: C# impl-of-interface boost
278
283
  beamSearch: !!args.beam_search, // §5.15: multi-ply discriminative expansion
279
284
  ...(Number.isFinite(Number(args.beam_width)) ? { beamWidth: Number(args.beam_width) } : {}),
285
+ // B018 §8.1.3 literal-mention lever: match verbatim dotted-name/path mentions in the RAW
286
+ // query (which the locate tokenizer destroys). searchModulesRanked derives rawQuery from the
287
+ // query arg when literalMention is on; the rig passes the raw problem as the query, so literal
288
+ // matching keys off the untokenized text. raw_query is forwarded too for callers that normalize
289
+ // the query arg separately from the raw problem text.
290
+ // SHIPPED DEFAULT (0.5.0): literal-mention is ON for a fresh invocation (no arg, no
291
+ // seonix.toml) — pass `literal_mention:false` to disable. It is a strict no-op on queries
292
+ // with no ≥3-component dotted path / repo-relative path, so it never perturbs the cells the
293
+ // headline B018 numbers were measured on. The low-level scoreModules default (codegraph.mjs)
294
+ // stays literalMention=false; the product surface opts in explicitly, right here.
295
+ literalMention: args.literal_mention !== false,
296
+ ...(args.raw_query != null ? { rawQuery: String(args.raw_query) } : {}),
280
297
  });
281
298
  process.stdout.write(ranked.map((r) => `${r.path}\t${r.score}`).join("\n") + "\n");
282
299
  } catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/seonix",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "SEONIX — a deterministic, offline, $0 code-graph tool that makes a cheap coding agent edit like an expensive one. Indexes a repo with Python ast + git (zero model calls) and renders a bounded edit-digest.",
package/src/ask-vocab.mjs CHANGED
@@ -298,6 +298,18 @@ export const MISSPELLINGS = Object.freeze({
298
298
  "waht": "what",
299
299
  "dose": "does", "doess": "does",
300
300
  "teh": "the",
301
+ // aggregate/list TRIGGER words (2026-07-02, trigger-typo work) — a typo of a count
302
+ // or list trigger used to be DROPPED as unmatched by the relaxation cascade, losing
303
+ // the aggregate/list INTENT entirely ("how manyn classes" → the count was lost);
304
+ // curated here so the intended trigger is restored BEFORE parsing (the general
305
+ // bounded fuzzy path in ask.mjs's cascade is the backstop for uncurated typos).
306
+ "manyn": "many", "mnay": "many", "amny": "many", "mnany": "many",
307
+ "coutn": "count", "conut": "count", "cuont": "count", "ocunt": "count",
308
+ "numer": "number", "nubmer": "number", "numbr": "number", "nmuber": "number",
309
+ "lst": "list", "lsit": "list", "ilst": "list",
310
+ "shwo": "show", "hsow": "show",
311
+ "dispaly": "display", "dsiplay": "display",
312
+ "funtcions": "functions", "funciton": "function", "funcitons": "functions",
301
313
  });
302
314
 
303
315
  /** Words used INCORRECTLY but with clear intent, mapped to the canonical schema
@@ -473,8 +485,53 @@ export const QUALIFIERS = Object.freeze({
473
485
 
474
486
  /** Aggregate/count triggers: "how many <kind> …", "count <kind>s", "number of
475
487
  * <kind>". Answered by counting a class of individuals or a clause's result set —
476
- * no header magic, a straight count over the graph (ask.mjs). */
477
- export const AGGREGATE_TRIGGERS = Object.freeze(["how many", "how much", "count", "number of", "count of"]);
488
+ * no header magic, a straight count over the graph (ask.mjs). Register-spread the
489
+ * same way RELATIONS' verbs are (2026-07-02): the count question has a formal
490
+ * ("how many"/"number of"), a neutral/imperative ("count"/"count up"), and a
491
+ * quantity ("quantity of"/"total number of") register a developer actually types.
492
+ * Judgement calls, kept OUT deliberately: bare "tally"/"sum"/"total" — those are
493
+ * already mapped to "count" by CASCADE_SYNONYMS (ask.mjs's relaxation layer), and a
494
+ * cascade test asserts "tally the classes" reaches the count via that synonym path,
495
+ * so promoting them to direct triggers here would both duplicate the mapping and
496
+ * break that test; and single-word "total"/"sum" would false-match identifier
497
+ * fragments ("total price", "sum of squares") the count intent never meant. */
498
+ export const AGGREGATE_TRIGGERS = Object.freeze([
499
+ // formal ("the number of classes" reaches "number of" once the cascade strips the
500
+ // leading article — keeping the trigger list clear of "the" so it never enters
501
+ // CONTENT_VOCAB and blocks the article's own noise-strip)
502
+ "how many", "how much", "how many of", "number of",
503
+ "total number of", "quantity of",
504
+ // neutral / imperative
505
+ "count", "count up", "count of", "tot up",
506
+ ]);
507
+
508
+ /** LIST triggers (2026-07-02, list shape) — the many ways a developer asks to SEE the
509
+ * individuals of a kind ("list functions", "show me the classes", "what are the
510
+ * modules"). Read by ask.mjs's parseList (a sibling of the count node): a trigger
511
+ * followed by an entity kind noun lists that class (capped at OVERFLOW_CAP), a
512
+ * trailing scope/predicate narrows it, and an unknown kind after a clear list trigger
513
+ * is an honest miss naming the kinds. Two registers, wide-but-deliberate (the operator's
514
+ * "err toward inclusion", bounded by the file-header discipline: a phrase earns its
515
+ * place only if it genuinely means "enumerate these" and won't false-match an unrelated
516
+ * identifier — parseList further requires a real entity kind to follow, so a stray
517
+ * "show"/"name" in another question is never seized):
518
+ * · IMPERATIVE — "<verb> [me/us] [the] <kind>". Bare determiners/objects (the/a/all/
519
+ * me/us) after the verb are skipped by parseList's LIST_SKIP, so only the verb stem
520
+ * is listed here (not every "... the"/"... all" inflection).
521
+ * · INTERROGATIVE — "what/which are [the] <kind>"; the bare "what <kind>"/"which
522
+ * <kind>" (+ optional "are there") form is handled directly in parseList, not here.
523
+ * Kept OUT on purpose: "tell me" / "give me" collide only where normalizeQuery already
524
+ * strips them as FILLER_WORDS ("tell me the classes" → "the classes"), so "tell me" is
525
+ * omitted (it never survives to parseList); "gimme" is omitted because CONTRACTIONS
526
+ * rewrites it to "give me" before parseList runs. */
527
+ export const LIST_TRIGGERS = Object.freeze([
528
+ // imperative — "<verb> [me/us] [the] <kind>"
529
+ "list", "show", "show me", "show us", "display", "print", "print out",
530
+ "dump", "enumerate", "name", "give me", "get me", "spit out", "rattle off",
531
+ "run down", "run through", "ls",
532
+ // interrogative — "what/which are [the] <kind>"
533
+ "what are", "which are",
534
+ ]);
478
535
 
479
536
  /** Superlative extremes -> ranking direction. "most/greatest/highest/biggest/
480
537
  * largest/most-connected" rank descending; "fewest/least/smallest/lowest" rank
@@ -552,6 +609,13 @@ export const CASCADE_NOISE = Object.freeze([
552
609
  // the aggregate/where parsers already tolerate a stray "the"/"a", so stripping is
553
610
  // belt-and-braces, not load-bearing)
554
611
  "the", "a", "an", "some",
612
+ // topic lead-in filler — "what about the modules", "how about classes": "about"
613
+ // carries no graph meaning here, so stripping it lets the bare kind noun surface for
614
+ // the cascade's bare-kind-noun terminal rule (ask.mjs). ("what"/"how" are structural
615
+ // question words the drop-pass keeps; only the "about" between them and the kind is
616
+ // noise.) A module literally named "about" is safe-listed by relaxParse's resolvesExact
617
+ // guard, same as every other noise token.
618
+ "about",
555
619
  // politeness / hedges (single-token; multi-word "could you"/"please" etc. are
556
620
  // FILLER_WORDS, stripped earlier during normalization)
557
621
  "please", "pls", "plz", "kindly", "just", "simply", "maybe", "perhaps",
package/src/ask.mjs CHANGED
@@ -42,7 +42,7 @@ import {
42
42
  CONTEXT_PRONOUNS, NEGATION_FRAMES, COMMIT_CONTENT_FRAMES, META_MEANING_VERBS,
43
43
  WHERE_MARKERS, MENTION_MARKERS,
44
44
  RELATIVE_PRONOUNS, PLACEHOLDER_NOUNS, BOOLEAN_CONNECTIVES, QUALIFIERS,
45
- AGGREGATE_TRIGGERS, SUPERLATIVE_EXTREMES, EDGE_NOUN_TO_METRIC, ANAPHORA_TRIGGERS,
45
+ AGGREGATE_TRIGGERS, LIST_TRIGGERS, SUPERLATIVE_EXTREMES, EDGE_NOUN_TO_METRIC, ANAPHORA_TRIGGERS,
46
46
  MEMBERSHIP_KINDS, CASCADE_NOISE, CASCADE_SYNONYMS, HELP_TRIGGERS,
47
47
  } from "./ask-vocab.mjs";
48
48
  import { lookupByProseTokens } from "./prose.mjs";
@@ -625,6 +625,9 @@ export function parseQuery(query, { nlp = undefined } = {}) {
625
625
  // {node:"boolean", entityType, atoms:[{op,kind,ast|filters}…]} — set algebra
626
626
  // over the SAME subject (and/or/but-not); op ∈ seed/intersection/union/difference
627
627
  // {node:"count", entityType, base} — aggregate: |eval(base)|
628
+ // {node:"list", entityType, base, scoped} — list the individuals of eval(base),
629
+ // capped at OVERFLOW_CAP; `scoped` suppresses the "narrow with …" hint when the
630
+ // list was already restricted (a module scope or predicate tail)
628
631
  // {node:"superlative", entityType, metric, metricNoun, extreme} — rank by degree
629
632
  // {node:"anaphora", mode, filter} — over ask()'s `prev` id array
630
633
  // {node:"miss", reason} — a compositional marker was seen
@@ -672,6 +675,7 @@ function parseComposite(text, nlp) {
672
675
  return parseAnaphora(w, lc, nlp)
673
676
  || parseAggregate(w, lc, nlp)
674
677
  || parseSuperlative(w, lc, nlp)
678
+ || parseList(w, lc, nlp, 0)
675
679
  || parseNested(w, lc, nlp, 0)
676
680
  || parseRelationalOrQualified(w, lc, nlp, 0);
677
681
  }
@@ -755,9 +759,22 @@ function parsePredicateFilter(words, nlp) {
755
759
  return undefined;
756
760
  }
757
761
 
762
+ /** Trailing "and that's the whole question" filler an aggregate/list tail can carry
763
+ * ("how many classes are there", "list functions in total", "which classes exist in
764
+ * the index") — a count/list over a bare kind is frequently phrased with such a tail,
765
+ * and it must NOT be mistaken for a restrictor (that's the exact bug behind "how many
766
+ * classes are there" → the count-restrictor miss). Combined with STOPWORDS (which
767
+ * already carries are/there/is/in/the/…) at the call site, so only the non-stopword
768
+ * extras live here. A tail with ANY word outside this ∪ STOPWORDS is a real restrictor. */
769
+ const AGG_TAIL_FILLER = new Set([
770
+ "total", "altogether", "overall", "exist", "exists", "existing", "present",
771
+ "here", "now", "currently", "graph", "index", "codebase", "repo", "repository",
772
+ ]);
773
+
758
774
  /** AGGREGATE / COUNT: "how many <entity> [<restrictor>]", "count <entity>",
759
775
  * "number of <entity> that …". A bare "how many classes" counts the class of
760
- * individuals; a restrictor tail counts a clause's result set. */
776
+ * individuals; a restrictor tail counts a clause's result set; a purely-filler tail
777
+ * ("… are there", "… in total") is treated as no restrictor (a bare count). */
761
778
  function parseAggregate(w, lc, nlp) {
762
779
  const trig = AGGREGATE_TRIGGERS.find((t) => lc.slice(0, t.split(" ").length).join(" ") === t);
763
780
  if (!trig) return null;
@@ -770,8 +787,9 @@ function parseAggregate(w, lc, nlp) {
770
787
  const entWord = lc[i];
771
788
  i += 1;
772
789
  const tail = w.slice(i);
790
+ const tailMeaningful = lc.slice(i).some((t) => !STOPWORDS.has(t) && !AGG_TAIL_FILLER.has(t));
773
791
  let base;
774
- if (tail.length) {
792
+ if (tailMeaningful) {
775
793
  const setAst = parseSetPhrase(`which ${entWord} ${tail.join(" ")}`, nlp, 1);
776
794
  if (!setAst || setAst.node === "miss") return { node: "miss", reason: "the count restrictor didn't parse" };
777
795
  base = setAst;
@@ -782,6 +800,79 @@ function parseAggregate(w, lc, nlp) {
782
800
  return { node: "count", entityType: noun.entityType, base };
783
801
  }
784
802
 
803
+ // Determiners/objects skipped after a LIST trigger verb ("show me THE classes") — a
804
+ // superset of the aggregate skip so "give me all the modules" reaches the kind noun.
805
+ const LIST_SKIP = new Set(["the", "a", "an", "all", "me", "us"]);
806
+ const LIST_TRIGGERS_SORTED = [...LIST_TRIGGERS].sort((a, b) => b.split(" ").length - a.split(" ").length);
807
+ // The listable node classes, named in the honest miss and the empty-index message.
808
+ const LISTABLE_KINDS = "functions, classes, methods, modules, attributes, variables, or commits";
809
+
810
+ /** LIST: "list <kind>", "show me the <kind>s", "what are the <kind>", "list <kind> in
811
+ * <module>". A sibling of the count node — it enumerates the individuals of a class
812
+ * (rendered under OVERFLOW_CAP) instead of counting them. Fires on a LIST_TRIGGERS
813
+ * verb, OR the bare interrogative "what/which <kind>" — but the interrogative form is
814
+ * gated to a filler-only tail so an ordinary reverse query ("which functions call X")
815
+ * is NOT hijacked into a list (it must stay a simple clause; the compat tests pin it).
816
+ * A scope/predicate tail ("in walk.mjs", "that call X") is delegated to parseSetPhrase
817
+ * (reusing membership/relational/boolean), and its `scoped` flag suppresses the
818
+ * "narrow with …" hint. An unknown kind after a clear imperative trigger ("list
819
+ * bananas") is an honest miss naming the listable kinds; anything less certain falls
820
+ * through (null) to the existing parser/cascade rather than guessing. */
821
+ function parseList(w, lc, nlp, depth) {
822
+ let i = 0;
823
+ let interrogative = false;
824
+ let matched = null;
825
+ for (const t of LIST_TRIGGERS_SORTED) {
826
+ const tw = t.split(" ");
827
+ if (lc.slice(0, tw.length).join(" ") === t) { matched = t; i = tw.length; break; }
828
+ }
829
+ if (!matched) {
830
+ if (lc[0] === "what" || lc[0] === "which") { interrogative = true; i = 1; }
831
+ else return null;
832
+ }
833
+ while (i < lc.length && LIST_SKIP.has(lc[i])) i += 1;
834
+ const quals = [];
835
+ while (i < lc.length && QUALIFIERS[lc[i]]) { quals.push(lc[i]); i += 1; }
836
+ const noun = i < lc.length ? entityNoun(lc[i]) : null;
837
+ // "Change" is ask-vocab.mjs's pseudo-type (no node is ever class "Change"), so it is
838
+ // not a listable class — fall through rather than render a false empty.
839
+ if (!noun || noun.placeholder || noun.entityType === "Change") {
840
+ // A clear imperative "list <one unknown plain word>" is an honest miss that NAMES
841
+ // the kinds; a verb-led or multi-word tail, or the interrogative form, is too
842
+ // uncertain to claim as a list — fall through to the existing parser/cascade.
843
+ if (!interrogative && i < lc.length && i === lc.length - 1
844
+ && /^[a-z]+$/.test(lc[i]) && !VERB_TO_KIND[lc[i]] && !PLACEHOLDER_NOUNS.includes(lc[i])) {
845
+ return { node: "miss", reason: `"${lc[i]}" isn't a listable kind — try ${LISTABLE_KINDS}` };
846
+ }
847
+ return null;
848
+ }
849
+ const entityType = noun.entityType;
850
+ const entWord = lc[i];
851
+ i += 1;
852
+ const tail = w.slice(i);
853
+ const tailMeaningful = lc.slice(i).some((t) => !STOPWORDS.has(t) && !AGG_TAIL_FILLER.has(t));
854
+ // The bare interrogative "what/which <kind>" is a list ONLY with an explicit
855
+ // list-confirming filler tail ("… are there", "… that exist"): a real predicate
856
+ // ("which functions call X") is a reverse query, and a *bare* "which methods" is left
857
+ // alone deliberately — otherwise the relaxation cascade could drop an unknown
858
+ // qualifier ("which shiny methods" → "which methods") and silently list everything,
859
+ // erasing the honest "unknown qualifier" miss. Imperative triggers ("list methods")
860
+ // carry their own list intent, so they need no such tail.
861
+ if (interrogative && (tailMeaningful || tail.length === 0)) return null;
862
+ let base;
863
+ let scoped = false;
864
+ if (tailMeaningful) {
865
+ const setAst = parseSetPhrase(`which ${[...quals, entWord, ...tail].join(" ")}`, nlp, (depth || 0) + 1);
866
+ if (!setAst || setAst.node === "miss") return { node: "miss", reason: (setAst && setAst.reason) || "the list filter didn't parse" };
867
+ base = setAst;
868
+ scoped = true;
869
+ } else {
870
+ base = { node: "allOfClass", entityType };
871
+ if (quals.length) base = { node: "qualifier", filters: quals, inner: base };
872
+ }
873
+ return { node: "list", entityType, base, scoped };
874
+ }
875
+
785
876
  /** SUPERLATIVE: "which <entity> has the most/fewest <edge-noun>", "the most-connected
786
877
  * <entity>", "the largest <entity>". Ranks individuals of <entity> by a degree
787
878
  * metric over the classified edge groups. An unrecognized edge noun is an honest
@@ -1139,6 +1230,7 @@ function evalSuperlative(graph, ast) {
1139
1230
  export function evalComposite(graph, ast, opts = {}) {
1140
1231
  if (ast.node === "miss") return { compositeMiss: true, reason: ast.reason || null, matches: [] };
1141
1232
  if (ast.node === "count") return { compositeKind: "count", count: evalSet(graph, ast.base, opts).length, entityType: ast.entityType, matches: [] };
1233
+ if (ast.node === "list") return { compositeKind: "list", matches: evalSet(graph, ast.base, opts), entityType: ast.entityType, scoped: ast.scoped };
1142
1234
  if (ast.node === "superlative") return evalSuperlative(graph, ast);
1143
1235
  if (ast.node === "anaphora") return evalAnaphora(graph, ast, opts);
1144
1236
  return { compositeKind: "set", matches: evalSet(graph, ast, opts), entityType: ast.entityType || null };
@@ -1154,7 +1246,7 @@ const compositeList = (matches) => listJoin(matches.slice(0, OVERFLOW_CAP)
1154
1246
  /** A compositional worked example for the rephrase hint (§honest miss now shows a
1155
1247
  * compositional phrasing too). */
1156
1248
  export function compositionalHint() {
1157
- return 'compositional queries also work: "which functions call X and call Y", "what calls something that imports X", "public methods of X", "how many classes", "which module has the most imports", or (after a listing) "which of those are tested"';
1249
+ return 'compositional queries also work: "which functions call X and call Y", "what calls something that imports X", "public methods of X", "list functions" / "show me the classes", "how many classes", "which module has the most imports", or (after a listing) "which of those are tested"';
1158
1250
  }
1159
1251
 
1160
1252
  function renderComposite(parsed, result) {
@@ -1168,6 +1260,19 @@ function renderComposite(parsed, result) {
1168
1260
  const noun = result.entityType ? nounFor(result.entityType, result.count) : (result.count === 1 ? "result" : "results");
1169
1261
  return { content: `${result.count} ${noun}.`, miss: false, ambiguous: false, matches: [] };
1170
1262
  }
1263
+ if (result.compositeKind === "list") {
1264
+ if (!result.matches.length) {
1265
+ return { content: `no ${nounFor(result.entityType, 2)} in this index.`, miss: true, ambiguous: false, matches: [] };
1266
+ }
1267
+ // an unscoped list that overflowed the cap gets a light hint to narrow by module —
1268
+ // but only for kinds that live IN a module (a "modules in <module>" or "commits in
1269
+ // <module>" scope is meaningless); the scoped forms are already narrow, no hint.
1270
+ const scopeable = !["Module", "Commit"].includes(result.entityType);
1271
+ const hint = (!result.scoped && scopeable && result.matches.length > OVERFLOW_CAP)
1272
+ ? ` — narrow with "${nounFor(result.entityType, 2)} in <module>"`
1273
+ : "";
1274
+ return { content: `${compositeList(result.matches)}${hint}.`, miss: false, ambiguous: false, matches: result.matches };
1275
+ }
1171
1276
  if (result.compositeKind === "superlative") {
1172
1277
  if (!result.matches.length) return { content: `no ${nounFor(result.entityType, 2)} to rank in this index.`, miss: true, ambiguous: false };
1173
1278
  const lead = result.extreme === "most" ? "the most" : "the fewest";
@@ -1981,6 +2086,49 @@ const CONTENT_VOCAB = new Set([
1981
2086
  * ("the"/"a"/"show"/"me") — the two sets overlap on purpose. */
1982
2087
  const STRUCTURAL_WORDS = new Set([...STOPWORDS, ...FRAME_WORDS, ...CONTEXT_PRONOUNS]);
1983
2088
  const CASCADE_NOISE_SET = new Set(wordsOf(CASCADE_NOISE));
2089
+ /** Every token that carries NO graph meaning of its own — curated noise (articles,
2090
+ * politeness, vocatives, presentation frames) PLUS the structural scaffolding
2091
+ * (question words, context pronouns). The bare-kind-noun terminal rule (relaxParse's
2092
+ * Layer 4) treats a query as "just a kind noun wrapped in packaging" only when every
2093
+ * non-kind token is one of these — so an unknown qualifier ("shiny") or a relation
2094
+ * verb, being neither, still blocks the default and preserves the honest miss. */
2095
+ const NOISE_OR_SCAFFOLD = new Set([...CASCADE_NOISE_SET, ...STRUCTURAL_WORDS]);
2096
+
2097
+ /** The aggregate/list TRIGGER words the cascade's drop-unmatched pass will fuzzy-correct
2098
+ * a typo toward (Gap 2, trigger-typo work). Curated (not derived from LIST_TRIGGERS'
2099
+ * multi-word phrases) so the target set stays clean single verbs — "many", "count",
2100
+ * "list", "show", … — and never drags in a stray "down"/"off"/"out" from a phrasal
2101
+ * trigger that would mis-correct an unrelated token. */
2102
+ const TRIGGER_FUZZY_WORDS = [
2103
+ "many", "count", "number", "quantity", "total", "tally",
2104
+ "list", "show", "display", "print", "dump", "enumerate", "name",
2105
+ ];
2106
+ /** Closed-vocab words a plain unknown may be fuzzy-corrected TOWARD before the cascade
2107
+ * discards it: relation verbs, entity kind nouns, and the aggregate/list triggers. A
2108
+ * correction fires only on a token already bound for the drop pile (grammar doesn't own
2109
+ * it, no entity resolves) and only for a UNIQUE within-bound target, so it strictly
2110
+ * beats dropping — a typo of a trigger keeps its intent instead of being lost. Excludes
2111
+ * STOPWORDS/structural words (a random unknown must never bend into "what"/"the") and
2112
+ * <4-char words (at the small bound they match half of English). */
2113
+ const CASCADE_FUZZY_TARGETS = [...new Set([
2114
+ ...wordsOf(Object.keys(VERB_TO_KIND)),
2115
+ ...Object.keys(ENTITY_TO_TYPE),
2116
+ ...TRIGGER_FUZZY_WORDS,
2117
+ ])].filter((wd) => /^[a-z]+$/.test(wd) && wd.length >= 4 && !STOPWORDS.has(wd));
2118
+
2119
+ /** UNIQUE within-bound fuzzy correction of `w` toward CASCADE_FUZZY_TARGETS, or null —
2120
+ * a distance tie between two distinct targets is refused (honest-miss discipline at the
2121
+ * vocabulary level, cf. fuzzyVocabWord). */
2122
+ function fuzzyCascadeWord(w) {
2123
+ const bound = fuzzyBound(w);
2124
+ let best = bound + 1; let hit = null; let tied = false;
2125
+ for (const target of CASCADE_FUZZY_TARGETS) {
2126
+ const d = editDistance(w, target, Math.min(best, bound));
2127
+ if (d < best) { best = d; hit = target; tied = false; }
2128
+ else if (d === best && d <= bound && target !== hit) tied = true;
2129
+ }
2130
+ return best <= bound && !tied ? hit : null;
2131
+ }
1984
2132
 
1985
2133
  /** Is `parsed` a genuinely ANSWERABLE query — one that both parsed AND (for the simple
1986
2134
  * clauses) resolves its named term(s) to a graph entity? A composite non-miss node,
@@ -2099,16 +2247,28 @@ export function relaxParse(graph, query, { nlp = undefined, contextId = null, pr
2099
2247
  // Layer 2 — DROP-UNMATCHED (plain-lowercase unknowns beside the real terms)
2100
2248
  const survivors = [];
2101
2249
  const nowDropped = [];
2250
+ const corrected = [];
2102
2251
  for (const t of tokens) {
2103
2252
  const lc = t.toLowerCase();
2104
2253
  const plain = /^[a-z]+$/.test(lc);
2105
- const keep = !plain || CONTENT_VOCAB.has(lc) || STRUCTURAL_WORDS.has(lc) || resolvesLiteral(t);
2106
- if (keep) survivors.push(t); else nowDropped.push(t);
2107
- }
2108
- if (nowDropped.length && survivors.length) {
2254
+ if (!plain || CONTENT_VOCAB.has(lc) || STRUCTURAL_WORDS.has(lc) || resolvesLiteral(t)) {
2255
+ survivors.push(t);
2256
+ continue;
2257
+ }
2258
+ // Gap 2 — before dropping an unmatched plain token, try a bounded fuzzy-correct to a
2259
+ // UNIQUE closed-vocab word (verbs, entity kinds, aggregate/list triggers): a typo of
2260
+ // a TRIGGER ("manyn"→"many", "coutn"→"count", "liist"→"list") is restored, not
2261
+ // discarded, so the count/list intent survives. Only a unique within-bound hit; else
2262
+ // the token is genuinely unrecoverable and drops exactly as before.
2263
+ const fix = fuzzyCascadeWord(lc);
2264
+ if (fix && fix !== lc) { survivors.push(fix); corrected.push(`${t}→${fix}`); continue; }
2265
+ nowDropped.push(t);
2266
+ }
2267
+ if ((corrected.length || nowDropped.length) && survivors.length) {
2109
2268
  tokens = survivors;
2110
2269
  dropped.push(...nowDropped);
2111
- steps.push(`drop unmatched ${JSON.stringify(nowDropped)} → "${tokens.join(" ")}"`);
2270
+ if (corrected.length) steps.push(`fuzzy-correct ${JSON.stringify(corrected)} → "${tokens.join(" ")}"`);
2271
+ if (nowDropped.length) steps.push(`drop unmatched ${JSON.stringify(nowDropped)} → "${tokens.join(" ")}"`);
2112
2272
  const hit = attempt(tokens);
2113
2273
  if (hit) return done(hit);
2114
2274
  }
@@ -2126,6 +2286,47 @@ export function relaxParse(graph, query, { nlp = undefined, contextId = null, pr
2126
2286
  if (hit) return done(hit);
2127
2287
  }
2128
2288
 
2289
+ // Layer 4 (terminal) — BARE KIND NOUN → a bounded DEFAULT ACTION. When noise-strip,
2290
+ // drop-unmatched and synonym-normalise have all failed to yield an answerable parse,
2291
+ // give the operator's "vague enough to land" case a sensible answer instead of an
2292
+ // honest miss: a query that is ONLY a kind noun (class/classes, function/functions,
2293
+ // module, method, attribute, variable, commit, …) wrapped in articles/noise/question
2294
+ // words DEFAULTS TO A COUNT of that kind ("the classes" / "classes" / "tell me the
2295
+ // classes" → "20 classes."). Count, not list: a bare unscoped list of 647 functions is
2296
+ // noise, whereas the count is the cheap useful answer the asker can then drill into
2297
+ // ("list them"). Deterministic — count for every kind, no cardinality cap.
2298
+ //
2299
+ // We classify the ORIGINAL normalized tokens (`from`), NOT the layer-mutated `tokens`:
2300
+ // drop-unmatched has by now EATEN any unknown qualifier, so "the shiny classes" would
2301
+ // otherwise look identical to a bare "classes". Reading the whole phrase keeps the
2302
+ // discipline exact — the rule fires ONLY when every non-kind token is pure packaging
2303
+ // (NOISE_OR_SCAFFOLD). A dangling unknown qualifier ("the shiny classes"), a relation
2304
+ // verb, a marker, or a real term is neither noise nor a kind noun, so it lands in
2305
+ // `others`, blocks the default, and the honest miss (or the real compositional query,
2306
+ // if a lower layer already rescued it) stands.
2307
+ const bareLc = splitWords(from).map((t) => t.toLowerCase());
2308
+ const kindWords = [];
2309
+ const others = [];
2310
+ for (const t of bareLc) {
2311
+ if (NOISE_OR_SCAFFOLD.has(t)) continue;
2312
+ // real entity kinds only — "change"/"changes" is ask-vocab's pseudo-type (never a
2313
+ // node class), so it is not a countable kind; it falls into `others`.
2314
+ const et = ENTITY_TO_TYPE[t];
2315
+ if (et && et !== "Change") kindWords.push(t);
2316
+ else others.push(t);
2317
+ }
2318
+ if (kindWords.length === 1 && others.length === 0) {
2319
+ // reuse the whole aggregate pipeline (parseAggregate → count node → renderer): a
2320
+ // synthesized "count <kind>" is the exact query the cascade's other count paths land.
2321
+ const hit = attempt(["count", kindWords[0]]);
2322
+ if (hit) { steps.push(`bare kind "${kindWords[0]}" → count`); return done(hit); }
2323
+ }
2324
+ // A LONE unknown noun wrapped only in packaging ("the bananas") is left to the generic
2325
+ // honest miss (the rephrase hint already NAMES the kinds): a crisper "isn't a listable
2326
+ // kind" miss here would fire on every one-word non-query the same way ("tell me a joke"),
2327
+ // which chat.mjs's own surface deliberately answers with the general hint — so the
2328
+ // bare-noun default is a COUNT of a KNOWN kind only, never a re-worded miss.
2329
+
2129
2330
  return null; // exhausted — the honest bottom of the cascade (caller keeps the original miss)
2130
2331
  }
2131
2332
 
package/src/codegraph.mjs CHANGED
@@ -563,6 +563,27 @@ const BEAM_OVERFLOW_CAP = 4; // near-miss safety valve size
563
563
  const BEAM_PLIES = 2; // hops of expansion
564
564
  const BEAM_EDGE_GROUPS = [["imports"], ["calls", "callsSymbol"], ["inherits"], ["cochange"]];
565
565
 
566
+ // ---- SPIRAL expansion (opt-in, default off; BEAM_RESEARCH.md's "fix #2/#3" made concrete) ------
567
+ // Deterministic bounded-radius ego walk from the lexical seeds, ordered fewest-arcs-first, with a
568
+ // degree-quantile hub gate. UNLIKE beamExpand it MAY introduce modules that had no lexical match
569
+ // (it walks the graph from the seeds), so it can in principle lift a lexically-invisible truth into
570
+ // top-k — the whole point. cochange is dropped (temporal-coupling noise; see the research synthesis).
571
+ // • spiralDepth — max hop radius from the seeds (bounded ego expansion). Default 3.
572
+ // • mostDistinctiveBeams — degree-quantile gate q∈(0,1]: at each expansion step keep only the
573
+ // lowest-degree ⌊q·n⌋ candidates (drop the top (1−q) hubs); q=1.0 keeps
574
+ // all. Never empties the frontier (keeps ≥1 — the least-connected).
575
+ // • spiralNodeLimit — emit budget: how many newly-reached nodes the spiral surfaces. Held at
576
+ // 12 (MID-tier digest breadth, a KNOWN-DOABLE token budget) — a fixed
577
+ // budget, NOT a recall dial.
578
+ const SPIRAL_DEPTH_DEFAULT = 3;
579
+ const SPIRAL_NODE_LIMIT_DEFAULT = 12;
580
+ const SPIRAL_Q_DEFAULT = 0.9; // mild hub pruning (drop only the densest 10%) — the centre point
581
+ const SPIRAL_EXPAND_KINDS = ["imports", "calls", "callsSymbol", "inherits"]; // cochange dropped
582
+ const SPIRAL_EMIT_FRAC = 0.5; // a newly-surfaced node's base score = maxSeed × this …
583
+ const SPIRAL_HOP_DECAY = 0.6; // … decayed by this per hop from the seeds (bounded < maxSeed, so a walked-in node never dominates rank 1)
584
+ const SPIRAL_PROX_FRAC = 0.2; // an ALREADY-matched module the spiral re-reaches gets a bounded nudge …
585
+ const SPIRAL_PROX_CAP_FRAC = 0.35; // … capped at this × its own score (same shape as every other proximity family)
586
+
566
587
  /** embedRank: per-module embeddable text — path components + defined symbol names + doc
567
588
  * first-lines, ALL already in the graph (never re-reads source), bounded by the EMB_TEXT_*
568
589
  * caps. Built once per graph and cached alongside the vectors in EMB_CACHE. */
@@ -685,13 +706,106 @@ function beamExpand(graph, scored, beamWidth) {
685
706
  }
686
707
  }
687
708
 
709
+ /** SPIRAL expansion (opt-in; see the SPIRAL_* constants' comment above for the full design).
710
+ * A deterministic bounded-radius ego walk from the lexical seeds (`scored`), popped
711
+ * fewest-arcs-first via a min-heap keyed (hop ASC, in-graph degree ASC, id ASC), with a
712
+ * degree-quantile hub gate at each expansion step. Emits up to `nodeLimit` newly-reached nodes
713
+ * in pop order, scoring each seed-relative and bounded so a hub can't dominate rank 1.
714
+ * CRITICAL vs beamExpand: it deliberately OMITS the `if (!baseScore.has) continue` guard, so it
715
+ * MAY push modules that had NO lexical match into `scored` — the one path to breaking the lexical
716
+ * ceiling. Mutates `scored` (nudges re-reached matches in place; APPENDS newly-surfaced modules).
717
+ * Pure otherwise (no fs/network); deterministic total ordering throughout. */
718
+ function spiralExpand(graph, scored, { depth = SPIRAL_DEPTH_DEFAULT, q = SPIRAL_Q_DEFAULT, nodeLimit = SPIRAL_NODE_LIMIT_DEFAULT } = {}) {
719
+ if (!scored.length) return;
720
+ const byId = new Map(scored.map((s) => [s.ind.id, s]));
721
+ const seeds = new Set(byId.keys());
722
+ let maxSeed = 0;
723
+ for (const s of scored) maxSeed = Math.max(maxSeed, s.score);
724
+ if (!(maxSeed > 0)) return;
725
+ // Combined undirected adjacency over the expansion kinds (cochange dropped). In-graph degree =
726
+ // neighbour count over these kinds — the "arcs" the frontier orders and the quantile gate reads.
727
+ const adj = adjacencyForKinds(graph, SPIRAL_EXPAND_KINDS);
728
+ const degree = (id) => (adj.get(id)?.size || 0);
729
+ // Binary min-heap over the frontier, keyed (hop ASC, degree ASC, id ASC) — pop the closest,
730
+ // least-connected node first, so expansion fans through the sparse surroundings and fizzles at
731
+ // hubs. The id tiebreak makes the order a deterministic total order (no RNG, no insertion bias).
732
+ const heap = [];
733
+ const less = (a, b) => a.hop !== b.hop ? a.hop < b.hop
734
+ : a.deg !== b.deg ? a.deg < b.deg
735
+ : a.id < b.id;
736
+ const swap = (i, j) => { const t = heap[i]; heap[i] = heap[j]; heap[j] = t; };
737
+ const push = (node) => {
738
+ heap.push(node);
739
+ let i = heap.length - 1;
740
+ while (i > 0) { const p = (i - 1) >> 1; if (less(heap[i], heap[p])) { swap(i, p); i = p; } else break; }
741
+ };
742
+ const pop = () => {
743
+ const top = heap[0];
744
+ const last = heap.pop();
745
+ if (heap.length) {
746
+ heap[0] = last;
747
+ let i = 0;
748
+ for (;;) {
749
+ const l = 2 * i + 1, r = 2 * i + 2; let m = i;
750
+ if (l < heap.length && less(heap[l], heap[m])) m = l;
751
+ if (r < heap.length && less(heap[r], heap[m])) m = r;
752
+ if (m === i) break;
753
+ swap(i, m); i = m;
754
+ }
755
+ }
756
+ return top;
757
+ };
758
+ const visited = new Set(seeds);
759
+ for (const id of seeds) push({ id, hop: 0, deg: degree(id) });
760
+ const defIdx = definesIndex(graph);
761
+ let emitted = 0;
762
+ while (heap.length && emitted < nodeLimit) {
763
+ const node = pop();
764
+ if (!seeds.has(node.id)) {
765
+ // Slot this newly-reached node: seed-relative base, hop-decayed and bounded below maxSeed.
766
+ const emitScore = maxSeed * SPIRAL_EMIT_FRAC * Math.pow(SPIRAL_HOP_DECAY, node.hop - 1);
767
+ const existing = byId.get(node.id);
768
+ if (existing) { // already lexically matched (below-k) → bounded nudge, never a replacement
769
+ existing.score += Math.min(emitScore * SPIRAL_PROX_FRAC, existing.score * SPIRAL_PROX_CAP_FRAC);
770
+ } else { // lexically INVISIBLE → introduce it (the beam structurally cannot)
771
+ const ind = graph.byId?.get?.(node.id);
772
+ if (ind && (ind.class || "") === "Module") {
773
+ const defines = defIdx.get(ind.id) || [];
774
+ const entry = { ind, score: emitScore, defineCount: defines.length, matching: [], density: 0 };
775
+ scored.push(entry);
776
+ byId.set(node.id, entry);
777
+ }
778
+ }
779
+ emitted++;
780
+ }
781
+ if (node.hop >= depth) continue;
782
+ // This step's candidate set = the popped node's unvisited MODULE neighbours; quantile-gate by
783
+ // degree, keeping the lowest-degree ⌊q·n⌋ (drop the densest hubs), never fewer than one.
784
+ const cands = [];
785
+ for (const nid of adj.get(node.id) || []) {
786
+ if (visited.has(nid)) continue;
787
+ const ind = graph.byId?.get?.(nid);
788
+ if (!ind || (ind.class || "") !== "Module") continue; // no phantom (fn-fallback) module ids
789
+ cands.push({ id: nid, deg: degree(nid) });
790
+ }
791
+ if (!cands.length) continue;
792
+ cands.sort((a, b) => a.deg - b.deg || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
793
+ const keep = Math.max(1, Math.floor(q * cands.length)); // lowest-degree q-fraction; never empty
794
+ for (let i = 0; i < keep; i++) {
795
+ const c = cands[i];
796
+ visited.add(c.id);
797
+ push({ id: c.id, hop: node.hop + 1, deg: c.deg });
798
+ }
799
+ }
800
+ }
801
+
688
802
  /** The shared module-ranking core behind renderSearch (text) and searchModulesRanked (path+score).
689
803
  * IDF-weights each query token by rarity across modules (so a whole-problem-statement query is not
690
804
  * swamped by ubiquitous words like template/filter/value), scores path-component + symbol-component
691
805
  * + EXACT-symbol matches, re-ranks with a bounded import-proximity bonus, and breaks ties by
692
806
  * matched-symbol DENSITY (a concrete signal — never ground truth). Pure; deterministic. */
693
807
  function scoreModules(graph, tokens, opts = {}) {
694
- const { demoteNonProd = false, callAdjacency = false, implOfInterface = false, beamSearch = false, proseBoost = false, proseLayers = false, literalMention = false, embedRank = false, rawQuery = "" } = opts;
808
+ const { demoteNonProd = false, callAdjacency = false, implOfInterface = false, beamSearch = false, spiral = false, proseBoost = false, proseLayers = false, literalMention = false, embedRank = false, rawQuery = "" } = opts;
695
809
  const beamWidth = Number.isFinite(opts.beamWidth) && opts.beamWidth > 0 ? opts.beamWidth : 8;
696
810
  const defIdx = definesIndex(graph);
697
811
  // Precompute each module's path components + defined-symbol exact/component sets, once.
@@ -956,6 +1070,14 @@ function scoreModules(graph, tokens, opts = {}) {
956
1070
  }
957
1071
  // §5.15 beam search (opt-in): multi-ply generalization of the single-hop families above.
958
1072
  if (beamSearch && scored.length > 1) beamExpand(graph, scored, beamWidth);
1073
+ // SPIRAL (opt-in): bounded-radius ego walk that MAY introduce lexically-invisible modules — runs
1074
+ // last (after every family has finalised the seed scores) so its seed-relative emit scores and
1075
+ // hub gate read the settled ranking, and before the sort so surfaced nodes slot into it.
1076
+ if (spiral && scored.length) spiralExpand(graph, scored, {
1077
+ depth: Number.isFinite(opts.spiralDepth) && opts.spiralDepth > 0 ? opts.spiralDepth : SPIRAL_DEPTH_DEFAULT,
1078
+ q: Number.isFinite(opts.mostDistinctiveBeams) && opts.mostDistinctiveBeams > 0 ? opts.mostDistinctiveBeams : SPIRAL_Q_DEFAULT,
1079
+ nodeLimit: Number.isFinite(opts.spiralNodeLimit) && opts.spiralNodeLimit > 0 ? opts.spiralNodeLimit : SPIRAL_NODE_LIMIT_DEFAULT,
1080
+ });
959
1081
  // Tie-break: score → matched-symbol DENSITY (concrete, not ground truth) → fewer defines → shorter label.
960
1082
  scored.sort((a, b) => b.score - a.score || b.density - a.density || a.defineCount - b.defineCount || String(a.ind.label).length - String(b.ind.label).length);
961
1083
  return scored;