@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 +18 -1
- package/package.json +1 -1
- package/src/ask-vocab.mjs +66 -2
- package/src/ask.mjs +210 -9
- package/src/codegraph.mjs +123 -1
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
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(`
|
|
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;
|