@polycode-projects/seonix 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.
package/src/ask.mjs CHANGED
@@ -41,6 +41,9 @@ import {
41
41
  CONTRACTIONS, MISSPELLINGS, WRONG_WORDS, G_DROP, FILLER_WORDS,
42
42
  CONTEXT_PRONOUNS, NEGATION_FRAMES, COMMIT_CONTENT_FRAMES, META_MEANING_VERBS,
43
43
  WHERE_MARKERS, MENTION_MARKERS,
44
+ RELATIVE_PRONOUNS, PLACEHOLDER_NOUNS, BOOLEAN_CONNECTIVES, QUALIFIERS,
45
+ AGGREGATE_TRIGGERS, LIST_TRIGGERS, SUPERLATIVE_EXTREMES, EDGE_NOUN_TO_METRIC, ANAPHORA_TRIGGERS,
46
+ MEMBERSHIP_KINDS, CASCADE_NOISE, CASCADE_SYNONYMS, HELP_TRIGGERS,
44
47
  } from "./ask-vocab.mjs";
45
48
  import { lookupByProseTokens } from "./prose.mjs";
46
49
  // The OPTIONAL Node-only wink-nlp adapter (lemma/POS tier). BOUNDARY: the inlined
@@ -588,6 +591,16 @@ export function parseQuery(query, { nlp = undefined } = {}) {
588
591
  if (!raw) return null;
589
592
  const text = applyNegationFrames(normalizeQuery(raw));
590
593
  if (!text) return null;
594
+ // COMPOSITIONAL PARSE PATH (PLAN §5.16 P3) — the new PRIMARY layer: a recursive
595
+ // descent over CLAUSES for the compositional shapes (nested/relative, boolean,
596
+ // qualifiers, aggregates, superlatives, anaphora). It fires ONLY when a
597
+ // compositional MARKER is present and returns null otherwise, so every plain
598
+ // clause falls straight through to the unchanged two-strategy merge below — the
599
+ // whole existing grammar is preserved bit-for-bit. When a marker IS present but
600
+ // the phrase cannot be compiled, it returns an honest {node:"miss"} rather than
601
+ // letting keyword-spot guess at a composition it never expressed.
602
+ const composite = parseComposite(text, adapter);
603
+ if (composite) return composite;
591
604
  const hits = STRATEGIES.map((s) => ({ name: s.name, parsed: s.parse(text, adapter) })).filter((r) => r.parsed);
592
605
  if (hits.length === 0) return null;
593
606
  if (hits.length === 1) return hits[0].parsed;
@@ -596,10 +609,691 @@ export function parseQuery(query, { nlp = undefined } = {}) {
596
609
  return { ambiguousParse: true, candidates: hits.map((h) => h.parsed) };
597
610
  }
598
611
 
612
+ // ============================================================================
613
+ // §compositional grammar (PLAN §5.16 P3) — the step up from ELIZA keyword-
614
+ // spotting to a real recursive-descent grammar. Tokenize -> recursive-descent
615
+ // parse to an AST of nodes -> compile to graph traversal. The AST node shapes
616
+ // (all carry a `node` tag so traverse()/render() can branch without touching the
617
+ // simple-clause path):
618
+ // {node:"clause", clause} — a wrapped simple parse (the leaf)
619
+ // {node:"allOfClass", entityType} — every individual of a class
620
+ // {node:"reverseSet"|"forwardSet", kind, entityType, inner} — nested/relative:
621
+ // the OBJECT (reverse) / SUBJECT (forward) of the outer edge is the id-set
622
+ // produced by evaluating `inner` (another AST) — two-stage traversal.
623
+ // {node:"membership", entityType, term} — "<entity> of/in <term>"
624
+ // {node:"qualifier", filters:[word…], inner} — adjective post-filters on a set
625
+ // {node:"boolean", entityType, atoms:[{op,kind,ast|filters}…]} — set algebra
626
+ // over the SAME subject (and/or/but-not); op ∈ seed/intersection/union/difference
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)
631
+ // {node:"superlative", entityType, metric, metricNoun, extreme} — rank by degree
632
+ // {node:"anaphora", mode, filter} — over ask()'s `prev` id array
633
+ // {node:"miss", reason} — a compositional marker was seen
634
+ // but could not compile: an honest stated miss, never a guess.
635
+ // The grammar COMPOSES the closed vocabulary (ask-vocab.mjs); it never opens it —
636
+ // every leaf still resolves through the existing curated clause parser + tiered
637
+ // resolveObject, so a term it can't resolve is still an honest object-miss.
638
+ // ============================================================================
639
+
640
+ // Depth cap on nesting (PLAN P3: "depth ≥2 nesting; guard against runaway with a
641
+ // sane hop cap and an honest 'too deep to resolve' if exceeded").
642
+ const MAX_COMPOSE_DEPTH = 4;
643
+ // A resolvable-later placeholder object term for the OUTER clause of a nested
644
+ // parse: the outer clause is parsed normally (so its verb/shape/grain classify),
645
+ // then its `object` is discarded and replaced at eval time by the inner set. Chosen
646
+ // to be plainly alphabetic (not a stopword, not vocabulary) so the clause parser
647
+ // treats it as an ordinary object term rather than dropping it.
648
+ const NEST_SENTINEL = "zzinnerset";
649
+ // Filler words dropped at the front of a relative predicate / anaphora filter.
650
+ const PRED_LEAD_SKIP = new Set(["that", "which", "who", "are", "is", "was", "were", "do", "does", "also", "still", "both", "and"]);
651
+ const FRAME_WORDS = new Set(["which", "what", "who", "list", "show", "find", "give", "me", "us", "all"]);
652
+
653
+ const splitWords = (text) => String(text).replace(/\?+\s*$/, "").replace(/,/g, " ").split(/\s+/).filter(Boolean);
654
+ const entityNoun = (w) => (ENTITY_TO_TYPE[w] ? { entityType: ENTITY_TO_TYPE[w], placeholder: false }
655
+ : (PLACEHOLDER_NOUNS.includes(w) ? { entityType: null, placeholder: true } : null));
656
+ const isGerundVerb = (w) => !!VERB_TO_KIND[w] && w.endsWith("ing");
657
+
658
+ /** Run the two existing strategies on a FRAGMENT and return a single simple clause
659
+ * (or null). Deterministic tie-break: on strategy disagreement the anchored parse
660
+ * wins (STRATEGIES[0]) — a fragment fed from the composer is already shape-
661
+ * constrained, so the merge's "surface an ambiguity" behavior isn't wanted here. */
662
+ function parseSimpleClause(text, nlp) {
663
+ const hits = STRATEGIES.map((s) => s.parse(text, nlp)).filter(Boolean);
664
+ if (!hits.length) return null;
665
+ if (hits.length === 1) return hits[0];
666
+ return sameParse(hits[0], hits[1]) ? hits[0] : hits[0];
667
+ }
668
+
669
+ /** Top compositional dispatcher — first marker-matching production wins; a
670
+ * production returns null (not this shape → fall through) or an AST node (which
671
+ * may itself be {node:"miss"} when the marker was present but uncompilable). */
672
+ function parseComposite(text, nlp) {
673
+ const w = splitWords(text);
674
+ const lc = w.map((x) => x.toLowerCase());
675
+ return parseAnaphora(w, lc, nlp)
676
+ || parseAggregate(w, lc, nlp)
677
+ || parseSuperlative(w, lc, nlp)
678
+ || parseList(w, lc, nlp, 0)
679
+ || parseNested(w, lc, nlp, 0)
680
+ || parseRelationalOrQualified(w, lc, nlp, 0);
681
+ }
682
+
683
+ /** A set-producing sub-expression (used for nested inner clauses, boolean branches,
684
+ * and count restrictors): nested first, then the relational/qualifier/boolean
685
+ * parser, then a bare simple clause. Carries `depth` for the nesting cap. */
686
+ function parseSetPhrase(text, nlp, depth) {
687
+ if (depth > MAX_COMPOSE_DEPTH) return { node: "miss", reason: "too deep to resolve" };
688
+ const w = splitWords(text);
689
+ const lc = w.map((x) => x.toLowerCase());
690
+ const nested = parseNested(w, lc, nlp, depth);
691
+ if (nested) return nested;
692
+ const rel = parseRelationalOrQualified(w, lc, nlp, depth);
693
+ if (rel) return rel;
694
+ const clause = parseSimpleClause(text, nlp);
695
+ if (clause) return { node: "clause", clause };
696
+ return null;
697
+ }
698
+
699
+ /** NESTED / RELATIVE (object-position relative clause): "<outer verb> <placeholder
700
+ * |entity> that <inner>" — the noun before "that" is the OBJECT of the outer edge,
701
+ * constrained by the inner clause. Distinguished from a subject-relative ("functions
702
+ * that call X", handled by parseRelationalOrQualified) by requiring a VERB before the
703
+ * relative noun (i.e. the noun is not the leading subject). Returns a reverse/forward
704
+ * Set node, an honest miss (marker present, uncompilable), or null (no object-relative
705
+ * marker → let another production try). */
706
+ function parseNested(w, lc, nlp, depth) {
707
+ for (let r = 1; r < lc.length; r += 1) {
708
+ if (!RELATIVE_PRONOUNS.includes(lc[r])) continue;
709
+ if (r + 1 >= lc.length) continue; // nothing after "that"
710
+ const noun = entityNoun(lc[r - 1]);
711
+ if (!noun) continue; // "that" not preceded by a noun
712
+ const head = w.slice(0, r - 1); // outer clause words, minus the placeholder noun
713
+ if (!head.length) continue; // noun is the leading subject → subject-relative, not this shape
714
+ const outer = parseSimpleClause([...head, NEST_SENTINEL].join(" "), nlp);
715
+ if (!outer || (outer.shape !== "reverse" && outer.shape !== "forward")) continue;
716
+ if (outer.modifier && outer.modifier !== "direct") continue; // no transitive-over-set closure primitive
717
+ // build the inner sub-query: "which <placeholder-noun> <inner-text>" — recurses,
718
+ // so the inner may itself be nested/boolean/qualified (depth ≥2).
719
+ const innerText = `which ${lc[r - 1]} ${w.slice(r + 1).join(" ")}`;
720
+ const inner = parseSetPhrase(innerText, nlp, depth + 1);
721
+ if (!inner || inner.node === "miss") return inner ? { node: "miss", reason: inner.reason || "inner clause didn't parse" } : { node: "miss", reason: "inner clause didn't parse" };
722
+ return { node: outer.shape === "reverse" ? "reverseSet" : "forwardSet", kind: outer.kind, entityType: outer.entityType, inner };
723
+ }
724
+ return null;
725
+ }
726
+
727
+ /** ANAPHORA over the previous result set: "which of those/them <filter>", "how many
728
+ * of those <filter>". Requires "of <pronoun>" (so a bare "those" in a term never
729
+ * fires). Returns a {node:"anaphora"} (mode count|list), a miss (filter present but
730
+ * uncompilable), or null. */
731
+ function parseAnaphora(w, lc, nlp) {
732
+ let p = -1;
733
+ for (let i = 1; i < lc.length; i += 1) {
734
+ if (ANAPHORA_TRIGGERS.includes(lc[i]) && lc[i - 1] === "of") { p = i; break; }
735
+ }
736
+ if (p < 0) return null;
737
+ const head = lc.slice(0, p - 1).join(" ");
738
+ const mode = /^(how many|how much|count)\b/.test(head) ? "count" : "list";
739
+ const filter = parsePredicateFilter(w.slice(p + 1), nlp);
740
+ if (filter === undefined) return { node: "miss", reason: "the follow-up filter didn't parse" };
741
+ return { node: "anaphora", mode, filter };
742
+ }
743
+
744
+ /** Parse a trailing filter (for anaphora, and any "of those that …" tail) into
745
+ * {type:"all"} | {type:"qual", filters} | {type:"clause", clause}. Returns
746
+ * undefined when a non-empty filter cannot be compiled (an honest miss upstream). */
747
+ function parsePredicateFilter(words, nlp) {
748
+ let i = 0;
749
+ const lc = words.map((x) => x.toLowerCase());
750
+ while (i < lc.length && PRED_LEAD_SKIP.has(lc[i])) i += 1;
751
+ const rest = words.slice(i);
752
+ const restLc = lc.slice(i);
753
+ if (!rest.length) return { type: "all" };
754
+ if (restLc.every((x) => QUALIFIERS[x])) return { type: "qual", filters: restLc };
755
+ const clause = parseSimpleClause(`what ${rest.join(" ")}`, nlp);
756
+ if (clause && (clause.shape === "reverse" || clause.shape === "forward") && clause.object) {
757
+ return { type: "clause", clause };
758
+ }
759
+ return undefined;
760
+ }
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
+
774
+ /** AGGREGATE / COUNT: "how many <entity> [<restrictor>]", "count <entity>",
775
+ * "number of <entity> that …". A bare "how many classes" counts the class of
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). */
778
+ function parseAggregate(w, lc, nlp) {
779
+ const trig = AGGREGATE_TRIGGERS.find((t) => lc.slice(0, t.split(" ").length).join(" ") === t);
780
+ if (!trig) return null;
781
+ let i = trig.split(" ").length;
782
+ while (i < lc.length && (lc[i] === "the" || lc[i] === "a" || lc[i] === "all")) i += 1;
783
+ const quals = [];
784
+ while (i < lc.length && QUALIFIERS[lc[i]]) { quals.push(lc[i]); i += 1; }
785
+ const noun = i < lc.length ? entityNoun(lc[i]) : null;
786
+ if (!noun) return { node: "miss", reason: "count needs a known entity kind (functions, classes, modules, …)" };
787
+ const entWord = lc[i];
788
+ i += 1;
789
+ const tail = w.slice(i);
790
+ const tailMeaningful = lc.slice(i).some((t) => !STOPWORDS.has(t) && !AGG_TAIL_FILLER.has(t));
791
+ let base;
792
+ if (tailMeaningful) {
793
+ const setAst = parseSetPhrase(`which ${entWord} ${tail.join(" ")}`, nlp, 1);
794
+ if (!setAst || setAst.node === "miss") return { node: "miss", reason: "the count restrictor didn't parse" };
795
+ base = setAst;
796
+ } else {
797
+ base = { node: "allOfClass", entityType: noun.entityType };
798
+ }
799
+ if (quals.length) base = { node: "qualifier", filters: quals, inner: base };
800
+ return { node: "count", entityType: noun.entityType, base };
801
+ }
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
+
876
+ /** SUPERLATIVE: "which <entity> has the most/fewest <edge-noun>", "the most-connected
877
+ * <entity>", "the largest <entity>". Ranks individuals of <entity> by a degree
878
+ * metric over the classified edge groups. An unrecognized edge noun is an honest
879
+ * miss naming the supported ones. */
880
+ function parseSuperlative(w, lc, nlp) {
881
+ // extreme (single word, or "most connected" two-word)
882
+ let ext = null; let extIdx = -1;
883
+ for (let i = 0; i < lc.length; i += 1) {
884
+ const two = lc.slice(i, i + 2).join(" ");
885
+ if (SUPERLATIVE_EXTREMES[two]) { ext = SUPERLATIVE_EXTREMES[two]; extIdx = i; break; }
886
+ if (SUPERLATIVE_EXTREMES[lc[i]]) { ext = SUPERLATIVE_EXTREMES[lc[i]]; extIdx = i; break; }
887
+ }
888
+ if (!ext) return null;
889
+ // entity noun anywhere (first match, deterministic)
890
+ let entityType; let entWord = null;
891
+ for (const x of lc) { const n = entityNoun(x); if (n && !n.placeholder) { entityType = n.entityType; entWord = x; break; } }
892
+ if (!entWord) return { node: "miss", reason: "a superlative needs an entity kind (module, class, function, …)" };
893
+ // edge noun after the extreme (imports/callers/methods/…)
894
+ let metric = null; let metricNoun = null;
895
+ for (let i = extIdx; i < lc.length; i += 1) {
896
+ if (EDGE_NOUN_TO_METRIC[lc[i]]) { metric = EDGE_NOUN_TO_METRIC[lc[i]]; metricNoun = lc[i]; break; }
897
+ }
898
+ const connectivity = lc.includes("connected") || lc.slice(extIdx, extIdx + 2).join(" ") === "most connected"
899
+ || ["largest", "biggest", "smallest"].includes(lc[extIdx]);
900
+ if (!metric) {
901
+ if (connectivity) { metric = EDGE_NOUN_TO_METRIC.connections; metricNoun = "connections"; }
902
+ else return { node: "miss", reason: "name what to rank by (imports, callers, methods, tests, or connections)" };
903
+ }
904
+ return { node: "superlative", entityType, metric, metricNoun, extreme: ext };
905
+ }
906
+
907
+ /** RELATIONAL / BOOLEAN / QUALIFIER (subject-first): "[which] [<qualifier>…] <entity>
908
+ * [that] <predicate>", where <predicate> is one or more relation clauses joined by
909
+ * and/or/but-not over the SAME subject, a "<of|in> <term>" membership, or empty (a
910
+ * bare qualified class). Fires ONLY on a compositional marker — a leading qualifier,
911
+ * a relative pronoun, a gerund-led predicate, or a membership "of/in" — so a plain
912
+ * reverse query ("which functions call helper") and the bare-template ambiguous case
913
+ * ("which classes extends Base and couples to logging", no marker) both fall through
914
+ * to the existing strategies untouched. Returns an AST node, a miss, or null. */
915
+ function parseRelationalOrQualified(w, lc, nlp, depth) {
916
+ let i = 0;
917
+ while (i < lc.length && FRAME_WORDS.has(lc[i])) i += 1;
918
+ const framed = i > 0;
919
+ const quals = [];
920
+ while (i < lc.length && QUALIFIERS[lc[i]]) { quals.push(lc[i]); i += 1; }
921
+ const noun = i < lc.length ? entityNoun(lc[i]) : null;
922
+ if (!noun) {
923
+ // an unknown adjective sitting in the qualifier slot, right before a known
924
+ // entity noun ("which shiny methods", "static frobnicated functions") — an
925
+ // honest miss that NAMES the supported qualifiers, never a guess (PLAN P3).
926
+ // STOPWORDS are excluded so a normal question auxiliary in that position ("what
927
+ // DID commit X touch", "what WAS in <sha>") is left for the existing parser, not
928
+ // mistaken for an unknown qualifier.
929
+ if ((framed || quals.length) && i + 1 < lc.length && /^[a-z]+$/.test(lc[i])
930
+ && !VERB_TO_KIND[lc[i]] && !STOPWORDS.has(lc[i]) && entityNoun(lc[i + 1])) {
931
+ return { node: "miss", reason: `unknown qualifier "${lc[i]}" — supported: ${Object.keys(QUALIFIERS).join(", ")}` };
932
+ }
933
+ return null; // no subject entity → not this shape
934
+ }
935
+ const entityType = noun.entityType;
936
+ const entWord = lc[i];
937
+ i += 1;
938
+ let predLc = lc.slice(i);
939
+ let predWords = w.slice(i);
940
+ let relFlag = false;
941
+ if (predLc.length && RELATIVE_PRONOUNS.includes(predLc[0])) { relFlag = true; predLc = predLc.slice(1); predWords = predWords.slice(1); }
942
+ const membershipLed = predLc[0] === "of" || predLc[0] === "in";
943
+ const gerundLed = predLc.length > 0 && isGerundVerb(predLc[0]);
944
+ // marker gate — the crux of backward-compat: without one of these, this is not a
945
+ // compositional query and we must NOT hijack it from the existing parser.
946
+ if (!(quals.length || relFlag || membershipLed || gerundLed)) return null;
947
+
948
+ // empty predicate → a bare qualified class ("public methods")
949
+ if (!predWords.length) {
950
+ let base = { node: "allOfClass", entityType };
951
+ if (!quals.length) return { node: "miss", reason: "nothing to filter or traverse" };
952
+ return { node: "qualifier", filters: quals, inner: base };
953
+ }
954
+
955
+ const subjPrefix = noun.placeholder ? "what" : `which ${entWord}`;
956
+ const { branches, ops } = splitBoolean(predLc, predWords);
957
+ // build one atom per branch, borrowing a leading verb phrase across bare branches
958
+ // ("importing X or Y" → the second branch inherits "importing").
959
+ let prevVerb = null;
960
+ const atoms = [];
961
+ for (let b = 0; b < branches.length; b += 1) {
962
+ const bw = branches[b];
963
+ const blc = bw.map((x) => x.toLowerCase());
964
+ const op = b === 0 ? "seed" : ops[b - 1];
965
+ if (bw.length && blc.every((x) => QUALIFIERS[x])) { atoms.push({ op, kind: "qual", filters: blc }); continue; }
966
+ if (blc[0] === "of" || blc[0] === "in") {
967
+ atoms.push({ op, kind: "set", ast: { node: "membership", entityType, term: bw.slice(1).join(" ") } });
968
+ continue;
969
+ }
970
+ let phrase = bw;
971
+ const vh = findPhrase(blc, VERB_TO_KIND);
972
+ if (vh) prevVerb = bw.slice(vh.start, vh.end);
973
+ else if (prevVerb) phrase = [...prevVerb, ...bw];
974
+ // a branch is a single predicate (top-level booleans are already split out), so
975
+ // parse it as nested-or-simple — NOT back through parseSetPhrase, which would
976
+ // re-detect the branch's own gerund/relative lead and recurse on identical text.
977
+ const ast = parseBranchAst(`${subjPrefix} ${phrase.join(" ")}`, nlp, depth + 1);
978
+ if (!ast || ast.node === "miss") return { node: "miss", reason: (ast && ast.reason) || "a clause in the combination didn't parse" };
979
+ atoms.push({ op, kind: "set", ast });
980
+ }
981
+ // the first atom must be a base set, not a bare qualifier (a qualifier needs
982
+ // something to filter). "public methods" already took the empty-predicate path above.
983
+ if (atoms[0].kind !== "set") return { node: "miss", reason: "start with a clause, then combine with and/or/but-not" };
984
+
985
+ let result;
986
+ if (atoms.length === 1) {
987
+ result = atoms[0].ast;
988
+ } else {
989
+ result = { node: "boolean", entityType, atoms };
990
+ }
991
+ if (quals.length) result = { node: "qualifier", filters: quals, inner: result };
992
+ return result;
993
+ }
994
+
995
+ /** Parse a single boolean branch (one predicate over the subject) into a set-AST:
996
+ * nested (the branch has its own object-relative "that") or a plain simple clause.
997
+ * Deliberately does NOT re-enter parseRelationalOrQualified — the branch has no
998
+ * top-level boolean of its own (it was just split off one), so descending there
999
+ * would only re-detect its gerund/relative lead and recurse on the same text. */
1000
+ function parseBranchAst(text, nlp, depth) {
1001
+ if (depth > MAX_COMPOSE_DEPTH) return { node: "miss", reason: "too deep to resolve" };
1002
+ const w = splitWords(text);
1003
+ const lc = w.map((x) => x.toLowerCase());
1004
+ const nested = parseNested(w, lc, nlp, depth);
1005
+ if (nested) return nested;
1006
+ const clause = parseSimpleClause(text, nlp);
1007
+ return clause ? { node: "clause", clause } : null;
1008
+ }
1009
+
1010
+ /** Split a predicate word array on boolean connectives (longest key first, so
1011
+ * "but not" beats a bare "not"). Returns {branches:[[word…]…], ops:[op…]} with
1012
+ * branches.length === ops.length + 1. */
1013
+ function splitBoolean(predLc, predWords) {
1014
+ const conns = Object.keys(BOOLEAN_CONNECTIVES).sort((a, z) => z.split(" ").length - a.split(" ").length);
1015
+ const branches = []; const ops = [];
1016
+ let start = 0; let i = 0;
1017
+ while (i < predLc.length) {
1018
+ let hit = null;
1019
+ for (const c of conns) {
1020
+ const cw = c.split(" ");
1021
+ if (predLc.slice(i, i + cw.length).join(" ") === c) { hit = { c, len: cw.length }; break; }
1022
+ }
1023
+ if (hit && i > start) { // a connective, and not at a branch start (avoid leading "and")
1024
+ branches.push(predWords.slice(start, i));
1025
+ ops.push(BOOLEAN_CONNECTIVES[hit.c]);
1026
+ i += hit.len; start = i;
1027
+ } else if (hit) { i += hit.len; start = i; } // connective at branch start — skip it
1028
+ else i += 1;
1029
+ }
1030
+ branches.push(predWords.slice(start));
1031
+ return { branches, ops };
1032
+ }
1033
+
1034
+ // ---- compositional EVALUATION — compile an AST to a graph traversal, reusing the
1035
+ // same primitives (edgesOfKind, resolveObject, refineToEntities, traverse) the
1036
+ // simple path uses. Pure given (graph, ast, opts). ----
1037
+
1038
+ /** Reverse traversal over a SET of object ids (the nested "callers of {X…}" step) —
1039
+ * mirrors traverse()'s reverse general case (symbol-grain sibling + defines-refine),
1040
+ * but membership-tests e.object against a set instead of a single id. */
1041
+ function reverseOverSet(graph, kind, entityType, objectIds) {
1042
+ const symbolKind = SYMBOL_GRAIN_SIBLING[kind];
1043
+ if (symbolKind && FINE_ENTITY_TYPES.has(entityType)) {
1044
+ const edges = edgesOfKind(graph, symbolKind).filter((e) => objectIds.has(e.object));
1045
+ return uniqueById(edges.map((e) => graph.byId.get(e.subject)).filter((s) => s && s.class === entityType));
1046
+ }
1047
+ const edges = kindsFor(kind).flatMap((k) => edgesOfKind(graph, k)).filter((e) => objectIds.has(e.object));
1048
+ const subjects = uniqueById(edges.map((e) => graph.byId.get(e.subject)).filter(Boolean));
1049
+ if (!entityType || entityType === "Change") return subjects;
1050
+ const direct = subjects.filter((s) => s.class === entityType);
1051
+ if (direct.length) return direct;
1052
+ if (entityType !== "Module" && subjects.some((s) => s.class === "Module")) {
1053
+ return refineToEntities(graph, new Set(subjects.filter((s) => s.class === "Module").map((s) => s.id)), entityType);
1054
+ }
1055
+ return [];
1056
+ }
1057
+
1058
+ /** Forward traversal over a SET of subject ids (the "things {X…} call/define" step). */
1059
+ function forwardOverSet(graph, kind, subjectIds) {
1060
+ const edges = kindsFor(kind).flatMap((k) => edgesOfKind(graph, k)).filter((e) => subjectIds.has(e.subject));
1061
+ return uniqueById(edges.map((e) => graph.byId.get(e.object)).filter(Boolean));
1062
+ }
1063
+
1064
+ function uniqueById(inds) {
1065
+ const seen = new Set(); const out = [];
1066
+ for (const x of inds) if (x && !seen.has(x.id)) { seen.add(x.id); out.push(x); }
1067
+ return out;
1068
+ }
1069
+
1070
+ // Per-graph memo for the qualifier attribute/edge sets (exported symbols, tested
1071
+ // modules, symbol→module map) — computed once, so a qualifier filter over a large
1072
+ // result set stays cheap and deterministic.
1073
+ const qualCache = new WeakMap();
1074
+ function qualSets(graph) {
1075
+ let c = qualCache.get(graph);
1076
+ if (c) return c;
1077
+ const exported = new Set();
1078
+ for (const e of edgesOfKind(graph, "reexports")) {
1079
+ exported.add(String(e.object).toLowerCase());
1080
+ const ind = graph.byId.get(e.object);
1081
+ if (ind) exported.add(String(ind.label).toLowerCase());
1082
+ }
1083
+ const testedModules = new Set(edgesOfKind(graph, "tests").map((e) => e.object));
1084
+ const moduleOfSymbol = new Map();
1085
+ for (const e of edgesOfKind(graph, "defines")) moduleOfSymbol.set(e.object, e.subject);
1086
+ c = { exported, testedModules, moduleOfSymbol };
1087
+ qualCache.set(graph, c);
1088
+ return c;
1089
+ }
1090
+ function moduleIdOf(graph, ind) {
1091
+ if (!ind) return null;
1092
+ if (ind.class === "Module") return ind.id;
1093
+ return qualSets(graph).moduleOfSymbol.get(ind.id) || null;
1094
+ }
1095
+
1096
+ /** Does an individual satisfy one qualifier (spec from QUALIFIERS)? Reads only
1097
+ * attributes/edges the graph already carries — an unpopulated attribute (e.g.
1098
+ * isAbstract) simply yields false, an honest empty rather than an error. */
1099
+ function qualHolds(graph, ind, spec) {
1100
+ if (!spec) return false;
1101
+ switch (spec.via) {
1102
+ case "visibility": {
1103
+ const v = String((ind.attributes || []).find((a) => a.key === "visibility")?.value || "public").toLowerCase();
1104
+ return v === spec.value;
1105
+ }
1106
+ case "attr":
1107
+ return !!(ind.attributes || []).find((a) => a.key === spec.attr)?.value;
1108
+ case "exported": {
1109
+ const ex = qualSets(graph).exported;
1110
+ return ex.has(String(ind.label).toLowerCase()) || ex.has(String(ind.id).toLowerCase());
1111
+ }
1112
+ case "tested": {
1113
+ const mid = moduleIdOf(graph, ind);
1114
+ return (!!mid && qualSets(graph).testedModules.has(mid)) === spec.value;
1115
+ }
1116
+ default: return false;
1117
+ }
1118
+ }
1119
+
1120
+ /** Compile a set-producing AST into an array of individuals. */
1121
+ function evalSet(graph, ast, opts) {
1122
+ switch (ast.node) {
1123
+ case "clause": return traverse(graph, ast.clause, opts).matches || [];
1124
+ case "allOfClass": return graph.individuals.filter((i) => i.class === ast.entityType);
1125
+ case "reverseSet": {
1126
+ const ids = new Set(evalSet(graph, ast.inner, opts).map((i) => i.id));
1127
+ return reverseOverSet(graph, ast.kind, ast.entityType, ids);
1128
+ }
1129
+ case "forwardSet": {
1130
+ const ids = new Set(evalSet(graph, ast.inner, opts).map((i) => i.id));
1131
+ return forwardOverSet(graph, ast.kind, ids);
1132
+ }
1133
+ case "membership": {
1134
+ const r = resolveObject(graph, ast.term);
1135
+ if (!r.match) return [];
1136
+ const ids = new Set([r.match.id]);
1137
+ const objs = uniqueById(MEMBERSHIP_KINDS.flatMap((k) => forwardOverSet(graph, k, ids)));
1138
+ return ast.entityType ? objs.filter((o) => o.class === ast.entityType) : objs;
1139
+ }
1140
+ case "qualifier": {
1141
+ const base = evalSet(graph, ast.inner, opts);
1142
+ return base.filter((ind) => ast.filters.every((f) => qualHolds(graph, ind, QUALIFIERS[f])));
1143
+ }
1144
+ case "boolean": return evalBoolean(graph, ast, opts);
1145
+ case "anaphora": return evalAnaphora(graph, ast, opts).matches;
1146
+ default: return [];
1147
+ }
1148
+ }
1149
+
1150
+ /** Fold a boolean AST left-to-right into a result set. A qualifier atom acts as a
1151
+ * set filter on the accumulator (intersection keeps satisfiers, difference removes
1152
+ * them); a set atom contributes its own id-set for the op. */
1153
+ function evalBoolean(graph, ast, opts) {
1154
+ let acc = [];
1155
+ for (const atom of ast.atoms) {
1156
+ if (atom.op === "seed") { acc = evalSet(graph, atom.ast, opts); continue; }
1157
+ if (atom.kind === "qual") {
1158
+ const holds = (ind) => atom.filters.every((f) => qualHolds(graph, ind, QUALIFIERS[f]));
1159
+ acc = atom.op === "difference" ? acc.filter((i) => !holds(i)) : acc.filter((i) => holds(i));
1160
+ continue;
1161
+ }
1162
+ const oids = new Set(evalSet(graph, atom.ast, opts).map((i) => i.id));
1163
+ if (atom.op === "intersection") acc = acc.filter((i) => oids.has(i.id));
1164
+ else if (atom.op === "difference") acc = acc.filter((i) => !oids.has(i.id));
1165
+ else if (atom.op === "union") {
1166
+ const seen = new Set(acc.map((i) => i.id));
1167
+ for (const other of evalSet(graph, atom.ast, opts)) if (!seen.has(other.id)) { seen.add(other.id); acc.push(other); }
1168
+ }
1169
+ }
1170
+ return acc;
1171
+ }
1172
+
1173
+ /** Anaphora over ask()'s `prev` id array — filter/count the previous answer's ids.
1174
+ * No prev supplied → honest miss (never a guess), like an unresolved pronoun. */
1175
+ function evalAnaphora(graph, ast, opts) {
1176
+ const prev = opts && opts.prev;
1177
+ if (!Array.isArray(prev) || !prev.length) return { compositeMiss: true, reason: "no-prev", matches: [] };
1178
+ let items = prev.map((id) => graph.byId.get(id)).filter(Boolean);
1179
+ const f = ast.filter;
1180
+ if (f && f.type === "qual") {
1181
+ items = items.filter((ind) => f.filters.every((q) => qualHolds(graph, ind, QUALIFIERS[q])));
1182
+ } else if (f && f.type === "clause") {
1183
+ const r = resolveObject(graph, f.clause.object);
1184
+ if (!r.match) items = [];
1185
+ else {
1186
+ // include the symbol-grain sibling so a fn->fn "call" filter tests callsSymbol,
1187
+ // not just the module-coarse "calls" edge (mirrors traverse()'s reverse path).
1188
+ const sib = SYMBOL_GRAIN_SIBLING[f.clause.kind];
1189
+ const kinds = [...kindsFor(f.clause.kind), ...(sib ? [sib] : [])];
1190
+ const ok = new Set(kinds.flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.object === r.match.id).map((e) => e.subject));
1191
+ items = items.filter((ind) => ok.has(ind.id));
1192
+ }
1193
+ }
1194
+ // a count over a prior set names the entity kind when the survivors share a class.
1195
+ const common = items.length && items.every((x) => x.class === items[0].class) ? items[0].class : null;
1196
+ if (ast.mode === "count") return { compositeKind: "count", count: items.length, entityType: common, matches: [] };
1197
+ return { compositeKind: "set", matches: items, entityType: common };
1198
+ }
1199
+
1200
+ // Structural kinds counted for "most-connected" (total degree). Symbol-grain and
1201
+ // commit-history kinds are excluded so "connections" reads as the code-structure
1202
+ // degree a developer means, not every recorded touch.
1203
+ const DEGREE_KINDS = ["imports", "calls", "callsSymbol", "inherits", "contains", "tests"];
1204
+ /** Degree of an individual under a superlative metric ({kind, dir, sibling?, filter?}). */
1205
+ function degreeMetric(graph, ind, metric) {
1206
+ const kinds = metric.kind === "*" ? DEGREE_KINDS : [metric.kind, ...(metric.sibling ? [metric.sibling] : [])];
1207
+ let n = 0;
1208
+ for (const k of kinds) for (const e of edgesOfKind(graph, k)) {
1209
+ const out = e.subject === ind.id; const inc = e.object === ind.id;
1210
+ if (metric.dir === "out" && out) {
1211
+ if (metric.filter) { const o = graph.byId.get(e.object); if (!o || o.class !== metric.filter) continue; }
1212
+ n += 1;
1213
+ } else if (metric.dir === "in" && inc) n += 1;
1214
+ else if (metric.dir === "both" && (out || inc)) n += 1;
1215
+ }
1216
+ return n;
1217
+ }
1218
+ function evalSuperlative(graph, ast) {
1219
+ const pool = graph.individuals.filter((i) => i.class === ast.entityType);
1220
+ const scored = pool.map((ind) => ({ ind, score: degreeMetric(graph, ind, ast.metric) }))
1221
+ .sort((a, z) => (ast.extreme === "most" ? z.score - a.score : a.score - z.score));
1222
+ if (!scored.length) return { compositeKind: "superlative", entityType: ast.entityType, matches: [] };
1223
+ const best = scored[0].score;
1224
+ const winners = scored.filter((s) => s.score === best).map((s) => s.ind);
1225
+ return { compositeKind: "superlative", entityType: ast.entityType, metricNoun: ast.metricNoun, extreme: ast.extreme, score: best, matches: winners };
1226
+ }
1227
+
1228
+ /** Compile any compositional AST to a result object traverse() returns for the
1229
+ * simple path — {matches, …} plus compositeKind/compositeMiss flags render() reads. */
1230
+ export function evalComposite(graph, ast, opts = {}) {
1231
+ if (ast.node === "miss") return { compositeMiss: true, reason: ast.reason || null, matches: [] };
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 };
1234
+ if (ast.node === "superlative") return evalSuperlative(graph, ast);
1235
+ if (ast.node === "anaphora") return evalAnaphora(graph, ast, opts);
1236
+ return { compositeKind: "set", matches: evalSet(graph, ast, opts), entityType: ast.entityType || null };
1237
+ }
1238
+
1239
+ // ---- compositional RENDER — templated, same "honest miss vs cited hit" discipline
1240
+ // as renderCore. ----
1241
+
1242
+ const compositeList = (matches) => listJoin(matches.slice(0, OVERFLOW_CAP)
1243
+ .map((m) => (["Function", "Method"].includes(m.class) ? `${m.label}()` : m.label)))
1244
+ + (matches.length > OVERFLOW_CAP ? `, …and ${matches.length - OVERFLOW_CAP} more` : "");
1245
+
1246
+ /** A compositional worked example for the rephrase hint (§honest miss now shows a
1247
+ * compositional phrasing too). */
1248
+ export function compositionalHint() {
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"';
1250
+ }
1251
+
1252
+ function renderComposite(parsed, result) {
1253
+ if (result.compositeMiss) {
1254
+ if (result.reason === "no-prev") {
1255
+ return { content: `"those"/"them" needs a previous answer to refer to — ask a listing question first, then follow up.`, miss: true, ambiguous: false };
1256
+ }
1257
+ return { content: `couldn't compile this compositional question${result.reason ? ` (${result.reason})` : ""}. ${compositionalHint()}.`, miss: true, ambiguous: false };
1258
+ }
1259
+ if (result.compositeKind === "count") {
1260
+ const noun = result.entityType ? nounFor(result.entityType, result.count) : (result.count === 1 ? "result" : "results");
1261
+ return { content: `${result.count} ${noun}.`, miss: false, ambiguous: false, matches: [] };
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
+ }
1276
+ if (result.compositeKind === "superlative") {
1277
+ if (!result.matches.length) return { content: `no ${nounFor(result.entityType, 2)} to rank in this index.`, miss: true, ambiguous: false };
1278
+ const lead = result.extreme === "most" ? "the most" : "the fewest";
1279
+ const tie = result.matches.length > 1 ? ` (${result.matches.length}-way tie)` : "";
1280
+ return {
1281
+ content: `${compositeList(result.matches)} — ${lead} ${result.metricNoun} (${result.score})${tie}.`,
1282
+ miss: false, ambiguous: false, matches: result.matches,
1283
+ };
1284
+ }
1285
+ // set-producing
1286
+ if (!result.matches.length) {
1287
+ return { content: `nothing in the index matches that${result.entityType ? ` (${nounFor(result.entityType, 2)})` : ""}.`, miss: true, ambiguous: false, matches: [] };
1288
+ }
1289
+ return { content: `${compositeList(result.matches)}.`, miss: false, ambiguous: false, matches: result.matches };
1290
+ }
1291
+
599
1292
  /** The rephrase hint shown on a grammar miss — generated from the SAME tables the parser
600
1293
  * uses, so it can never suggest a phrasing the grammar doesn't actually support (§6.3). */
601
1294
  export function rephraseHint() {
602
- return '"which <functions|classes|modules> <imports|calls|uses|inherits from|tests|touched> <name>" or "what does <name> <import|call|export>" or "what uses <name>" or "where is <name> defined" / "where is <name> mentioned" or "when did <name> change" or "which changes touch commit <sha>"/"what did commit <sha> touch" (a commit\'s own changes) or plainly "what calls this" (about a selected node) or "what does <term> mean"/"what is a <ClassName>" (about the graph\'s own vocabulary)';
1295
+ return '"which <functions|classes|modules> <imports|calls|uses|inherits from|tests|touched> <name>" or "what does <name> <import|call|export>" or "what uses <name>" or "where is <name> defined" / "where is <name> mentioned" or "when did <name> change" or "which changes touch commit <sha>"/"what did commit <sha> touch" (a commit\'s own changes) or plainly "what calls this" (about a selected node) or "what does <term> mean"/"what is a <ClassName>" (about the graph\'s own vocabulary). '
1296
+ + compositionalHint();
603
1297
  }
604
1298
 
605
1299
  // ---- §4 object-term resolution — mechanical, no embeddings, tiered, stop at first hit ----
@@ -888,8 +1582,13 @@ const TRANSITIVE_MAX_DEPTH = 8;
888
1582
  * needed one. Returns {matches, objMatch, candidates, traversal, ambiguous,
889
1583
  * answer?, unresolvedPronoun?} — `answer` only set for the "ask" shape.
890
1584
  * `matches` is always an array of individuals (or edge records for "ask"). */
891
- export function traverse(graph, parsed, { contextId = null } = {}) {
892
- if (!parsed || parsed.ambiguousParse) return { matches: [], objMatch: null, candidates: [], traversal: null, ambiguous: false };
1585
+ export function traverse(graph, parsed, { contextId = null, prev = null } = {}) {
1586
+ if (!parsed) return { matches: [], objMatch: null, candidates: [], traversal: null, ambiguous: false };
1587
+ // compositional AST (PLAN §5.16 P3) — the new grammar's nodes carry a `node` tag;
1588
+ // everything else (simple clauses, ambiguousParse) flows through the original path
1589
+ // below completely unchanged.
1590
+ if (parsed.node) return evalComposite(graph, parsed, { contextId, prev });
1591
+ if (parsed.ambiguousParse) return { matches: [], objMatch: null, candidates: [], traversal: null, ambiguous: false };
893
1592
  const { shape, kind, entityType } = parsed;
894
1593
 
895
1594
  // meta: a question about the graph's OWN vocabulary ("what does cochange mean", "what
@@ -1155,6 +1854,7 @@ function renderCore(parsed, result) {
1155
1854
  if (!parsed) {
1156
1855
  return { content: `couldn't parse this as a graph question. Try: ${rephraseHint()}`, miss: true, ambiguous: false };
1157
1856
  }
1857
+ if (parsed.node) return renderComposite(parsed, result);
1158
1858
  if (parsed.ambiguousParse) {
1159
1859
  const options = parsed.candidates.map((p, i) => `${i + 1}) ${describeParse(p)}`).join(" or ");
1160
1860
  return {
@@ -1348,6 +2048,288 @@ function renderCore(parsed, result) {
1348
2048
  return { content: clauses.join(" and ") + extra + ".", miss: false, ambiguous: false, matches: result.matches };
1349
2049
  }
1350
2050
 
2051
+ // ============================================================================
2052
+ // §progressive-relaxation cascade (SHRDLU in a code graph, with a Zork parser's
2053
+ // forgiveness) — a controlled loop that wraps the WHOLE existing parse and runs
2054
+ // ONLY when the direct parse of the normalized query would MISS. A clean direct hit
2055
+ // never enters the cascade (it stays instant and exact); the cascade only ever DROPS
2056
+ // noise/unmatched words or NORMALISES a near-canonical word to the closed vocabulary,
2057
+ // re-attempting the full parse (compositional + templates + keyword-spot) after each
2058
+ // transform, and bottoms out in the SAME honest miss + rephrase hint the engine
2059
+ // already returned — never inventing a term or guessing an entity. Deterministic:
2060
+ // same input → same cascade path. All of it is plain JS over the already-imported
2061
+ // tables + resolveObject/parseQuery, so it survives the viewer bundle's import strip.
2062
+ // ============================================================================
2063
+
2064
+ const wordsOf = (arr) => arr.flatMap((p) => String(p).toLowerCase().split(" "));
2065
+
2066
+ /** Every token the CLOSED grammar gives QUERY MEANING to — relation verbs, entity
2067
+ * nouns, modifiers, qualifiers, aggregate/superlative triggers, edge-degree nouns,
2068
+ * boolean connectives, placeholder nouns, anaphora/meta/where/mention markers,
2069
+ * relative pronouns, and the small synonym keys. The noise-strip pass will NEVER
2070
+ * remove one of these, and the drop-unmatched pass always keeps them: they carry the
2071
+ * intent, only the packaging around them is negotiable. */
2072
+ const CONTENT_VOCAB = new Set([
2073
+ ...wordsOf(Object.keys(VERB_TO_KIND)), ...wordsOf(Object.keys(ENTITY_TO_TYPE)),
2074
+ ...wordsOf(Object.keys(MODIFIER_TO_KIND)), ...wordsOf(Object.keys(QUALIFIERS)),
2075
+ ...wordsOf(AGGREGATE_TRIGGERS), ...wordsOf(Object.keys(SUPERLATIVE_EXTREMES)),
2076
+ ...wordsOf(Object.keys(EDGE_NOUN_TO_METRIC)), ...wordsOf(Object.keys(BOOLEAN_CONNECTIVES)),
2077
+ ...wordsOf(PLACEHOLDER_NOUNS), ...wordsOf(ANAPHORA_TRIGGERS), ...wordsOf(META_MEANING_VERBS),
2078
+ ...wordsOf(WHERE_MARKERS), ...wordsOf(MENTION_MARKERS), ...wordsOf(RELATIVE_PRONOUNS),
2079
+ ...wordsOf(Object.keys(CASCADE_SYNONYMS)),
2080
+ ]);
2081
+
2082
+ /** Structural scaffolding words — question words, articles-in-questions, frame verbs,
2083
+ * and context pronouns. Not "content", but they hold a sentence together, so the
2084
+ * drop-unmatched pass keeps them (dropping "what"/"of" would corrupt the grammar);
2085
+ * the noise-strip pass may still remove the few of these that are ALSO curated noise
2086
+ * ("the"/"a"/"show"/"me") — the two sets overlap on purpose. */
2087
+ const STRUCTURAL_WORDS = new Set([...STOPWORDS, ...FRAME_WORDS, ...CONTEXT_PRONOUNS]);
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
+ }
2132
+
2133
+ /** Is `parsed` a genuinely ANSWERABLE query — one that both parsed AND (for the simple
2134
+ * clauses) resolves its named term(s) to a graph entity? A composite non-miss node,
2135
+ * an ambiguous parse, and a meta/mentions surface all count; an unresolved-context
2136
+ * pronoun is its OWN specific honest miss (kept, not relaxed). Returns:
2137
+ * true — a real, executable answer (even if it later renders an empty set / "No")
2138
+ * "ambiguous"/"pronoun" — a specific outcome to keep, distinct from relaxable
2139
+ * false — no parse at all, a compositional {node:"miss"}, or an unresolved term
2140
+ * ask() starts the cascade ONLY on `false`, and accepts a relaxed attempt ONLY on the
2141
+ * strict `true` (so the cascade can never "rescue" a query into another kind of miss). */
2142
+ function answerable(graph, parsed, contextId) {
2143
+ if (!parsed) return false;
2144
+ if (parsed.ambiguousParse) return "ambiguous";
2145
+ if (parsed.node) return parsed.node !== "miss";
2146
+ if (parsed.shape === "meta" || parsed.shape === "mentions") return true;
2147
+ const o = resolveTermOrContext(graph, parsed.object, contextId);
2148
+ if (o.unresolvedPronoun) return "pronoun";
2149
+ if (!o.match) return false;
2150
+ if (parsed.shape === "ask") {
2151
+ const s = resolveTermOrContext(graph, parsed.subject, contextId);
2152
+ if (s.unresolvedPronoun) return "pronoun";
2153
+ return s.match ? true : false;
2154
+ }
2155
+ return true;
2156
+ }
2157
+
2158
+ /** Whole-query help/orientation request → show the hint directly (never the relaxation
2159
+ * loop, never a pretend answer). Matches only when the ENTIRE normalized query is a
2160
+ * curated HELP_TRIGGER, so a symbol named "help" in a real question is untouched. */
2161
+ function isHelpRequest(query) {
2162
+ const q = String(query || "").trim().toLowerCase().replace(/[?.!\s]+$/, "");
2163
+ return HELP_TRIGGERS.includes(q);
2164
+ }
2165
+
2166
+ /** The relaxation cascade. Given a query whose direct parse MISSED, walk three
2167
+ * increasingly-permissive layers, re-attempting the full parse after each transform,
2168
+ * and return the FIRST attempt that yields an answerable parse (with a trace of what
2169
+ * it did) — or null if none does (caller then falls back to the original honest miss):
2170
+ * 1. NOISE-STRIP — remove one curated noise token (leftmost) at a time, never one
2171
+ * that is content vocab or resolves to an entity, re-parsing each
2172
+ * time, until a parse answers or no noise tokens remain.
2173
+ * 2. DROP-UNMATCHED — drop the plain-lowercase words that are NEITHER grammar NOR a
2174
+ * resolvable entity (an unknown "frobnicate"); identifier-shaped
2175
+ * tokens (dotted files, shas, CamelCase) are never dropped —
2176
+ * they are content that may honestly fail to resolve.
2177
+ * 3. SYNONYM — rewrite surviving near-canonical words to the closed vocab.
2178
+ * Bounded (one token removed per noise iteration; hard guard) and deterministic. */
2179
+ export function relaxParse(graph, query, { nlp = undefined, contextId = null, prev = null } = {}) {
2180
+ const from = applyNegationFrames(normalizeQuery(String(query || "")));
2181
+ let tokens = splitWords(from);
2182
+ if (!tokens.length) return null;
2183
+ const dropped = [];
2184
+ const steps = [];
2185
+
2186
+ // Two literal-resolution guards (never the fuzzy/prose tiers, whose loose near-
2187
+ // matches would let a noise word masquerade as an entity):
2188
+ // · resolvesExact — EXACT label/id or ext: match only (tier ≤ 2). Used to protect a
2189
+ // curated NOISE word from being stripped: only a token that literally NAMES an
2190
+ // entity ("a module called `the`") is safe-listed. A mere substring coincidence
2191
+ // ("me" ⊂ "Method", tier 3) must NOT block stripping a genuine filler word.
2192
+ // · resolvesLiteral — exact/ext/substring/component (tier ≤ 3). Used to KEEP an
2193
+ // identifier-shaped content token ("logging" ⊂ "src/logging.mjs") through the
2194
+ // drop-unmatched pass, and to hold a synonym rewrite off a real entity name.
2195
+ const resolvesExact = (t) => {
2196
+ const r = resolveObject(graph, t);
2197
+ return !!r.match && r.tier != null && r.tier <= 2;
2198
+ };
2199
+ const resolvesLiteral = (t) => {
2200
+ const r = resolveObject(graph, t);
2201
+ return !!r.match && r.tier != null && r.tier <= 3;
2202
+ };
2203
+ // Does a term string carry at least one REAL word — one the grammar doesn't already
2204
+ // own as vocabulary/scaffolding? Guards against a relaxation that drops the actual
2205
+ // asked term and lets a bare marker slide into its place ("where is [X] defined" →
2206
+ // "where is defined", "defined" is a WHERE_MARKER, never the thing being located).
2207
+ const hasRealTerm = (s) => splitWords(String(s || "")).some((w) => {
2208
+ const lc = w.toLowerCase();
2209
+ return !CONTENT_VOCAB.has(lc) && !STRUCTURAL_WORDS.has(lc);
2210
+ });
2211
+ // Accept a relaxed attempt ONLY if it is a genuinely answerable parse (terms resolve)
2212
+ // AND it renders a REAL positive answer — never another empty/miss (relaxation earns a
2213
+ // win only by turning a miss into an answer, never a differently-worded miss) — and
2214
+ // never by promoting a bare marker to the asked term.
2215
+ const TERM_SHAPES = new Set(["reverse", "forward", "where", "when", "ask"]);
2216
+ const attempt = (toks) => {
2217
+ const text = toks.join(" ");
2218
+ const p = parseQuery(text, { nlp });
2219
+ if (answerable(graph, p, contextId) !== true) return null;
2220
+ if (p && !p.node && TERM_SHAPES.has(p.shape)) {
2221
+ if (p.object != null && !hasRealTerm(p.object)) return null;
2222
+ if (p.shape === "ask" && p.subject != null && !hasRealTerm(p.subject)) return null;
2223
+ }
2224
+ const rendered = render(p, traverse(graph, p, { contextId, prev }));
2225
+ return rendered.miss ? null : { parsed: p, text };
2226
+ };
2227
+ const done = (hit) => ({ parsed: hit.parsed, from, to: hit.text, dropped: [...dropped], steps });
2228
+
2229
+ // Layer 1 — NOISE-STRIP (one lowest-value token at a time)
2230
+ let guard = 0;
2231
+ const hardCap = Math.max(tokens.length, 1) + 12;
2232
+ for (; guard < hardCap; guard += 1) {
2233
+ let idx = -1;
2234
+ for (let i = 0; i < tokens.length; i += 1) {
2235
+ const lc = tokens[i].toLowerCase();
2236
+ if (CASCADE_NOISE_SET.has(lc) && !CONTENT_VOCAB.has(lc) && !resolvesExact(tokens[i])) { idx = i; break; }
2237
+ }
2238
+ if (idx < 0) break;
2239
+ const removed = tokens[idx];
2240
+ tokens = tokens.filter((_, i) => i !== idx);
2241
+ dropped.push(removed);
2242
+ steps.push(`strip noise "${removed}" → "${tokens.join(" ")}"`);
2243
+ const hit = attempt(tokens);
2244
+ if (hit) return done(hit);
2245
+ }
2246
+
2247
+ // Layer 2 — DROP-UNMATCHED (plain-lowercase unknowns beside the real terms)
2248
+ const survivors = [];
2249
+ const nowDropped = [];
2250
+ const corrected = [];
2251
+ for (const t of tokens) {
2252
+ const lc = t.toLowerCase();
2253
+ const plain = /^[a-z]+$/.test(lc);
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) {
2268
+ tokens = survivors;
2269
+ dropped.push(...nowDropped);
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(" ")}"`);
2272
+ const hit = attempt(tokens);
2273
+ if (hit) return done(hit);
2274
+ }
2275
+
2276
+ // Layer 3 — SYNONYM-NORMALISE the survivors onto the canonical vocabulary
2277
+ let changed = false;
2278
+ const normed = tokens.map((t) => {
2279
+ const lc = t.toLowerCase();
2280
+ if (CASCADE_SYNONYMS[lc] && !resolvesLiteral(t)) { changed = true; return CASCADE_SYNONYMS[lc]; }
2281
+ return t;
2282
+ });
2283
+ if (changed) {
2284
+ steps.push(`normalise synonyms → "${normed.join(" ")}"`);
2285
+ const hit = attempt(normed);
2286
+ if (hit) return done(hit);
2287
+ }
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
+
2330
+ return null; // exhausted — the honest bottom of the cascade (caller keeps the original miss)
2331
+ }
2332
+
1351
2333
  // ---- orchestration — the seonix_ask entry point (§6.3: parse -> resolve -> traverse -> render) ----
1352
2334
 
1353
2335
  /** Answer a free-text question over the graph, mechanically. `opts.contextId`
@@ -1356,15 +2338,45 @@ function renderCore(parsed, result) {
1356
2338
  * a pronoun then produces an honest miss rather than a guess. `opts.nlp`
1357
2339
  * overrides the lemma/POS adapter (see parseQuery) — leave it undefined and
1358
2340
  * a Node process picks up wink automatically while the inlined viewer stays
1359
- * adapter-less by construction. Returns the full {content, seonix_ask:
2341
+ * adapter-less by construction. `opts.prev` is the id array of the LAST answer's
2342
+ * matches — thread it from a chat loop so a follow-up anaphora question ("which of
2343
+ * those are tested", "how many of them call X") filters/counts the prior result
2344
+ * set; omit it and anaphora questions produce an honest "needs a previous answer"
2345
+ * miss. Returns the full {content, seonix_ask:
1360
2346
  * {mechanical,parsed,matches,traversal,miss,ambiguous,candidates?}} envelope
1361
2347
  * §6.2 specifies. Zero generative model calls. */
1362
- export function ask(graph, query, { contextId = null, nlp = undefined } = {}) {
1363
- const parsed = parseQuery(query, { nlp });
1364
- const result = traverse(graph, parsed, { contextId });
2348
+ export function ask(graph, query, { contextId = null, nlp = undefined, prev = null } = {}) {
2349
+ // Explicit help/orientation request → the rephrase hint directly (the honest bottom
2350
+ // of the cascade, reached on demand), never a pretend answer or a relaxation attempt.
2351
+ if (isHelpRequest(query)) {
2352
+ return {
2353
+ content: rephraseHint(),
2354
+ seonix_ask: {
2355
+ mechanical: true, parsed: null, matches: [], traversal: null,
2356
+ miss: true, ambiguous: false, matchedVia: null, help: true, relaxed: null,
2357
+ },
2358
+ };
2359
+ }
2360
+ const direct = parseQuery(query, { nlp });
2361
+ // The relaxation cascade fires ONLY when the DIRECT parse would miss (no parse, a
2362
+ // compositional {node:"miss"}, or an unresolved named term) — a clean hit, an
2363
+ // ambiguous parse, an unresolved-pronoun miss, and a real-but-empty answer all keep
2364
+ // the direct parse untouched (a hit stays instant and exact).
2365
+ let parsed = direct;
2366
+ let relaxed = null;
2367
+ if (answerable(graph, direct, contextId) === false) {
2368
+ const r = relaxParse(graph, query, { nlp, contextId, prev });
2369
+ if (r) { parsed = r.parsed; relaxed = { from: r.from, to: r.to, dropped: r.dropped, steps: r.steps }; }
2370
+ }
2371
+ const result = traverse(graph, parsed, { contextId, prev });
1365
2372
  const rendered = render(parsed, result);
2373
+ // If relaxation materially rewrote the query and produced a real answer, note it
2374
+ // lightly (terse, honest) so the reader knows how the question was read.
2375
+ const content = (relaxed && !rendered.miss && relaxed.to !== relaxed.from)
2376
+ ? `read as "${relaxed.to}" — ${rendered.content}`
2377
+ : rendered.content;
1366
2378
  return {
1367
- content: rendered.content,
2379
+ content,
1368
2380
  seonix_ask: {
1369
2381
  mechanical: true,
1370
2382
  parsed: (parsed && !parsed.ambiguousParse) ? parsed : null,
@@ -1374,6 +2386,11 @@ export function ask(graph, query, { contextId = null, nlp = undefined } = {}) {
1374
2386
  traversal: result.traversal || null,
1375
2387
  miss: !!rendered.miss,
1376
2388
  ambiguous: !!rendered.ambiguous,
2389
+ // The relaxation trace: null when the direct parse was used as-is (a clean hit or
2390
+ // an honest miss the cascade couldn't/shouldn't rescue), else what the cascade
2391
+ // dropped/normalised to reach an answer. A caller can assert relaxed===null to
2392
+ // prove the cascade never touched a direct hit.
2393
+ relaxed,
1377
2394
  // Confidence provenance: "prose" when resolveObject fell through to the tier-4
1378
2395
  // prose-index fallback (PLAN_PROSE_INDEX.md §6 — matched what the symbol talks
1379
2396
  // about, not its name); "fuzzy" when the tier-5 bounded-edit-distance pass