@polycode-projects/seonix 0.3.0 → 0.4.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/README.md +93 -23
- package/bin/cli.mjs +9 -3
- package/package.json +1 -1
- package/src/ask-vocab.mjs +198 -2
- package/src/ask.mjs +824 -8
- package/src/browser.mjs +87 -13
- package/src/chat.mjs +644 -47
- package/src/codegraph.mjs +55 -2
- package/src/nlp-bundle.mjs +120 -0
- package/src/prose.mjs +42 -0
- package/src/temporal.mjs +70 -0
- package/src/timeline.mjs +51 -8
- package/src/viz.mjs +88 -19
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, 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,586 @@ 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:"superlative", entityType, metric, metricNoun, extreme} — rank by degree
|
|
629
|
+
// {node:"anaphora", mode, filter} — over ask()'s `prev` id array
|
|
630
|
+
// {node:"miss", reason} — a compositional marker was seen
|
|
631
|
+
// but could not compile: an honest stated miss, never a guess.
|
|
632
|
+
// The grammar COMPOSES the closed vocabulary (ask-vocab.mjs); it never opens it —
|
|
633
|
+
// every leaf still resolves through the existing curated clause parser + tiered
|
|
634
|
+
// resolveObject, so a term it can't resolve is still an honest object-miss.
|
|
635
|
+
// ============================================================================
|
|
636
|
+
|
|
637
|
+
// Depth cap on nesting (PLAN P3: "depth ≥2 nesting; guard against runaway with a
|
|
638
|
+
// sane hop cap and an honest 'too deep to resolve' if exceeded").
|
|
639
|
+
const MAX_COMPOSE_DEPTH = 4;
|
|
640
|
+
// A resolvable-later placeholder object term for the OUTER clause of a nested
|
|
641
|
+
// parse: the outer clause is parsed normally (so its verb/shape/grain classify),
|
|
642
|
+
// then its `object` is discarded and replaced at eval time by the inner set. Chosen
|
|
643
|
+
// to be plainly alphabetic (not a stopword, not vocabulary) so the clause parser
|
|
644
|
+
// treats it as an ordinary object term rather than dropping it.
|
|
645
|
+
const NEST_SENTINEL = "zzinnerset";
|
|
646
|
+
// Filler words dropped at the front of a relative predicate / anaphora filter.
|
|
647
|
+
const PRED_LEAD_SKIP = new Set(["that", "which", "who", "are", "is", "was", "were", "do", "does", "also", "still", "both", "and"]);
|
|
648
|
+
const FRAME_WORDS = new Set(["which", "what", "who", "list", "show", "find", "give", "me", "us", "all"]);
|
|
649
|
+
|
|
650
|
+
const splitWords = (text) => String(text).replace(/\?+\s*$/, "").replace(/,/g, " ").split(/\s+/).filter(Boolean);
|
|
651
|
+
const entityNoun = (w) => (ENTITY_TO_TYPE[w] ? { entityType: ENTITY_TO_TYPE[w], placeholder: false }
|
|
652
|
+
: (PLACEHOLDER_NOUNS.includes(w) ? { entityType: null, placeholder: true } : null));
|
|
653
|
+
const isGerundVerb = (w) => !!VERB_TO_KIND[w] && w.endsWith("ing");
|
|
654
|
+
|
|
655
|
+
/** Run the two existing strategies on a FRAGMENT and return a single simple clause
|
|
656
|
+
* (or null). Deterministic tie-break: on strategy disagreement the anchored parse
|
|
657
|
+
* wins (STRATEGIES[0]) — a fragment fed from the composer is already shape-
|
|
658
|
+
* constrained, so the merge's "surface an ambiguity" behavior isn't wanted here. */
|
|
659
|
+
function parseSimpleClause(text, nlp) {
|
|
660
|
+
const hits = STRATEGIES.map((s) => s.parse(text, nlp)).filter(Boolean);
|
|
661
|
+
if (!hits.length) return null;
|
|
662
|
+
if (hits.length === 1) return hits[0];
|
|
663
|
+
return sameParse(hits[0], hits[1]) ? hits[0] : hits[0];
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** Top compositional dispatcher — first marker-matching production wins; a
|
|
667
|
+
* production returns null (not this shape → fall through) or an AST node (which
|
|
668
|
+
* may itself be {node:"miss"} when the marker was present but uncompilable). */
|
|
669
|
+
function parseComposite(text, nlp) {
|
|
670
|
+
const w = splitWords(text);
|
|
671
|
+
const lc = w.map((x) => x.toLowerCase());
|
|
672
|
+
return parseAnaphora(w, lc, nlp)
|
|
673
|
+
|| parseAggregate(w, lc, nlp)
|
|
674
|
+
|| parseSuperlative(w, lc, nlp)
|
|
675
|
+
|| parseNested(w, lc, nlp, 0)
|
|
676
|
+
|| parseRelationalOrQualified(w, lc, nlp, 0);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** A set-producing sub-expression (used for nested inner clauses, boolean branches,
|
|
680
|
+
* and count restrictors): nested first, then the relational/qualifier/boolean
|
|
681
|
+
* parser, then a bare simple clause. Carries `depth` for the nesting cap. */
|
|
682
|
+
function parseSetPhrase(text, nlp, depth) {
|
|
683
|
+
if (depth > MAX_COMPOSE_DEPTH) return { node: "miss", reason: "too deep to resolve" };
|
|
684
|
+
const w = splitWords(text);
|
|
685
|
+
const lc = w.map((x) => x.toLowerCase());
|
|
686
|
+
const nested = parseNested(w, lc, nlp, depth);
|
|
687
|
+
if (nested) return nested;
|
|
688
|
+
const rel = parseRelationalOrQualified(w, lc, nlp, depth);
|
|
689
|
+
if (rel) return rel;
|
|
690
|
+
const clause = parseSimpleClause(text, nlp);
|
|
691
|
+
if (clause) return { node: "clause", clause };
|
|
692
|
+
return null;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** NESTED / RELATIVE (object-position relative clause): "<outer verb> <placeholder
|
|
696
|
+
* |entity> that <inner>" — the noun before "that" is the OBJECT of the outer edge,
|
|
697
|
+
* constrained by the inner clause. Distinguished from a subject-relative ("functions
|
|
698
|
+
* that call X", handled by parseRelationalOrQualified) by requiring a VERB before the
|
|
699
|
+
* relative noun (i.e. the noun is not the leading subject). Returns a reverse/forward
|
|
700
|
+
* Set node, an honest miss (marker present, uncompilable), or null (no object-relative
|
|
701
|
+
* marker → let another production try). */
|
|
702
|
+
function parseNested(w, lc, nlp, depth) {
|
|
703
|
+
for (let r = 1; r < lc.length; r += 1) {
|
|
704
|
+
if (!RELATIVE_PRONOUNS.includes(lc[r])) continue;
|
|
705
|
+
if (r + 1 >= lc.length) continue; // nothing after "that"
|
|
706
|
+
const noun = entityNoun(lc[r - 1]);
|
|
707
|
+
if (!noun) continue; // "that" not preceded by a noun
|
|
708
|
+
const head = w.slice(0, r - 1); // outer clause words, minus the placeholder noun
|
|
709
|
+
if (!head.length) continue; // noun is the leading subject → subject-relative, not this shape
|
|
710
|
+
const outer = parseSimpleClause([...head, NEST_SENTINEL].join(" "), nlp);
|
|
711
|
+
if (!outer || (outer.shape !== "reverse" && outer.shape !== "forward")) continue;
|
|
712
|
+
if (outer.modifier && outer.modifier !== "direct") continue; // no transitive-over-set closure primitive
|
|
713
|
+
// build the inner sub-query: "which <placeholder-noun> <inner-text>" — recurses,
|
|
714
|
+
// so the inner may itself be nested/boolean/qualified (depth ≥2).
|
|
715
|
+
const innerText = `which ${lc[r - 1]} ${w.slice(r + 1).join(" ")}`;
|
|
716
|
+
const inner = parseSetPhrase(innerText, nlp, depth + 1);
|
|
717
|
+
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" };
|
|
718
|
+
return { node: outer.shape === "reverse" ? "reverseSet" : "forwardSet", kind: outer.kind, entityType: outer.entityType, inner };
|
|
719
|
+
}
|
|
720
|
+
return null;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** ANAPHORA over the previous result set: "which of those/them <filter>", "how many
|
|
724
|
+
* of those <filter>". Requires "of <pronoun>" (so a bare "those" in a term never
|
|
725
|
+
* fires). Returns a {node:"anaphora"} (mode count|list), a miss (filter present but
|
|
726
|
+
* uncompilable), or null. */
|
|
727
|
+
function parseAnaphora(w, lc, nlp) {
|
|
728
|
+
let p = -1;
|
|
729
|
+
for (let i = 1; i < lc.length; i += 1) {
|
|
730
|
+
if (ANAPHORA_TRIGGERS.includes(lc[i]) && lc[i - 1] === "of") { p = i; break; }
|
|
731
|
+
}
|
|
732
|
+
if (p < 0) return null;
|
|
733
|
+
const head = lc.slice(0, p - 1).join(" ");
|
|
734
|
+
const mode = /^(how many|how much|count)\b/.test(head) ? "count" : "list";
|
|
735
|
+
const filter = parsePredicateFilter(w.slice(p + 1), nlp);
|
|
736
|
+
if (filter === undefined) return { node: "miss", reason: "the follow-up filter didn't parse" };
|
|
737
|
+
return { node: "anaphora", mode, filter };
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** Parse a trailing filter (for anaphora, and any "of those that …" tail) into
|
|
741
|
+
* {type:"all"} | {type:"qual", filters} | {type:"clause", clause}. Returns
|
|
742
|
+
* undefined when a non-empty filter cannot be compiled (an honest miss upstream). */
|
|
743
|
+
function parsePredicateFilter(words, nlp) {
|
|
744
|
+
let i = 0;
|
|
745
|
+
const lc = words.map((x) => x.toLowerCase());
|
|
746
|
+
while (i < lc.length && PRED_LEAD_SKIP.has(lc[i])) i += 1;
|
|
747
|
+
const rest = words.slice(i);
|
|
748
|
+
const restLc = lc.slice(i);
|
|
749
|
+
if (!rest.length) return { type: "all" };
|
|
750
|
+
if (restLc.every((x) => QUALIFIERS[x])) return { type: "qual", filters: restLc };
|
|
751
|
+
const clause = parseSimpleClause(`what ${rest.join(" ")}`, nlp);
|
|
752
|
+
if (clause && (clause.shape === "reverse" || clause.shape === "forward") && clause.object) {
|
|
753
|
+
return { type: "clause", clause };
|
|
754
|
+
}
|
|
755
|
+
return undefined;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** AGGREGATE / COUNT: "how many <entity> [<restrictor>]", "count <entity>",
|
|
759
|
+
* "number of <entity> that …". A bare "how many classes" counts the class of
|
|
760
|
+
* individuals; a restrictor tail counts a clause's result set. */
|
|
761
|
+
function parseAggregate(w, lc, nlp) {
|
|
762
|
+
const trig = AGGREGATE_TRIGGERS.find((t) => lc.slice(0, t.split(" ").length).join(" ") === t);
|
|
763
|
+
if (!trig) return null;
|
|
764
|
+
let i = trig.split(" ").length;
|
|
765
|
+
while (i < lc.length && (lc[i] === "the" || lc[i] === "a" || lc[i] === "all")) i += 1;
|
|
766
|
+
const quals = [];
|
|
767
|
+
while (i < lc.length && QUALIFIERS[lc[i]]) { quals.push(lc[i]); i += 1; }
|
|
768
|
+
const noun = i < lc.length ? entityNoun(lc[i]) : null;
|
|
769
|
+
if (!noun) return { node: "miss", reason: "count needs a known entity kind (functions, classes, modules, …)" };
|
|
770
|
+
const entWord = lc[i];
|
|
771
|
+
i += 1;
|
|
772
|
+
const tail = w.slice(i);
|
|
773
|
+
let base;
|
|
774
|
+
if (tail.length) {
|
|
775
|
+
const setAst = parseSetPhrase(`which ${entWord} ${tail.join(" ")}`, nlp, 1);
|
|
776
|
+
if (!setAst || setAst.node === "miss") return { node: "miss", reason: "the count restrictor didn't parse" };
|
|
777
|
+
base = setAst;
|
|
778
|
+
} else {
|
|
779
|
+
base = { node: "allOfClass", entityType: noun.entityType };
|
|
780
|
+
}
|
|
781
|
+
if (quals.length) base = { node: "qualifier", filters: quals, inner: base };
|
|
782
|
+
return { node: "count", entityType: noun.entityType, base };
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** SUPERLATIVE: "which <entity> has the most/fewest <edge-noun>", "the most-connected
|
|
786
|
+
* <entity>", "the largest <entity>". Ranks individuals of <entity> by a degree
|
|
787
|
+
* metric over the classified edge groups. An unrecognized edge noun is an honest
|
|
788
|
+
* miss naming the supported ones. */
|
|
789
|
+
function parseSuperlative(w, lc, nlp) {
|
|
790
|
+
// extreme (single word, or "most connected" two-word)
|
|
791
|
+
let ext = null; let extIdx = -1;
|
|
792
|
+
for (let i = 0; i < lc.length; i += 1) {
|
|
793
|
+
const two = lc.slice(i, i + 2).join(" ");
|
|
794
|
+
if (SUPERLATIVE_EXTREMES[two]) { ext = SUPERLATIVE_EXTREMES[two]; extIdx = i; break; }
|
|
795
|
+
if (SUPERLATIVE_EXTREMES[lc[i]]) { ext = SUPERLATIVE_EXTREMES[lc[i]]; extIdx = i; break; }
|
|
796
|
+
}
|
|
797
|
+
if (!ext) return null;
|
|
798
|
+
// entity noun anywhere (first match, deterministic)
|
|
799
|
+
let entityType; let entWord = null;
|
|
800
|
+
for (const x of lc) { const n = entityNoun(x); if (n && !n.placeholder) { entityType = n.entityType; entWord = x; break; } }
|
|
801
|
+
if (!entWord) return { node: "miss", reason: "a superlative needs an entity kind (module, class, function, …)" };
|
|
802
|
+
// edge noun after the extreme (imports/callers/methods/…)
|
|
803
|
+
let metric = null; let metricNoun = null;
|
|
804
|
+
for (let i = extIdx; i < lc.length; i += 1) {
|
|
805
|
+
if (EDGE_NOUN_TO_METRIC[lc[i]]) { metric = EDGE_NOUN_TO_METRIC[lc[i]]; metricNoun = lc[i]; break; }
|
|
806
|
+
}
|
|
807
|
+
const connectivity = lc.includes("connected") || lc.slice(extIdx, extIdx + 2).join(" ") === "most connected"
|
|
808
|
+
|| ["largest", "biggest", "smallest"].includes(lc[extIdx]);
|
|
809
|
+
if (!metric) {
|
|
810
|
+
if (connectivity) { metric = EDGE_NOUN_TO_METRIC.connections; metricNoun = "connections"; }
|
|
811
|
+
else return { node: "miss", reason: "name what to rank by (imports, callers, methods, tests, or connections)" };
|
|
812
|
+
}
|
|
813
|
+
return { node: "superlative", entityType, metric, metricNoun, extreme: ext };
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/** RELATIONAL / BOOLEAN / QUALIFIER (subject-first): "[which] [<qualifier>…] <entity>
|
|
817
|
+
* [that] <predicate>", where <predicate> is one or more relation clauses joined by
|
|
818
|
+
* and/or/but-not over the SAME subject, a "<of|in> <term>" membership, or empty (a
|
|
819
|
+
* bare qualified class). Fires ONLY on a compositional marker — a leading qualifier,
|
|
820
|
+
* a relative pronoun, a gerund-led predicate, or a membership "of/in" — so a plain
|
|
821
|
+
* reverse query ("which functions call helper") and the bare-template ambiguous case
|
|
822
|
+
* ("which classes extends Base and couples to logging", no marker) both fall through
|
|
823
|
+
* to the existing strategies untouched. Returns an AST node, a miss, or null. */
|
|
824
|
+
function parseRelationalOrQualified(w, lc, nlp, depth) {
|
|
825
|
+
let i = 0;
|
|
826
|
+
while (i < lc.length && FRAME_WORDS.has(lc[i])) i += 1;
|
|
827
|
+
const framed = i > 0;
|
|
828
|
+
const quals = [];
|
|
829
|
+
while (i < lc.length && QUALIFIERS[lc[i]]) { quals.push(lc[i]); i += 1; }
|
|
830
|
+
const noun = i < lc.length ? entityNoun(lc[i]) : null;
|
|
831
|
+
if (!noun) {
|
|
832
|
+
// an unknown adjective sitting in the qualifier slot, right before a known
|
|
833
|
+
// entity noun ("which shiny methods", "static frobnicated functions") — an
|
|
834
|
+
// honest miss that NAMES the supported qualifiers, never a guess (PLAN P3).
|
|
835
|
+
// STOPWORDS are excluded so a normal question auxiliary in that position ("what
|
|
836
|
+
// DID commit X touch", "what WAS in <sha>") is left for the existing parser, not
|
|
837
|
+
// mistaken for an unknown qualifier.
|
|
838
|
+
if ((framed || quals.length) && i + 1 < lc.length && /^[a-z]+$/.test(lc[i])
|
|
839
|
+
&& !VERB_TO_KIND[lc[i]] && !STOPWORDS.has(lc[i]) && entityNoun(lc[i + 1])) {
|
|
840
|
+
return { node: "miss", reason: `unknown qualifier "${lc[i]}" — supported: ${Object.keys(QUALIFIERS).join(", ")}` };
|
|
841
|
+
}
|
|
842
|
+
return null; // no subject entity → not this shape
|
|
843
|
+
}
|
|
844
|
+
const entityType = noun.entityType;
|
|
845
|
+
const entWord = lc[i];
|
|
846
|
+
i += 1;
|
|
847
|
+
let predLc = lc.slice(i);
|
|
848
|
+
let predWords = w.slice(i);
|
|
849
|
+
let relFlag = false;
|
|
850
|
+
if (predLc.length && RELATIVE_PRONOUNS.includes(predLc[0])) { relFlag = true; predLc = predLc.slice(1); predWords = predWords.slice(1); }
|
|
851
|
+
const membershipLed = predLc[0] === "of" || predLc[0] === "in";
|
|
852
|
+
const gerundLed = predLc.length > 0 && isGerundVerb(predLc[0]);
|
|
853
|
+
// marker gate — the crux of backward-compat: without one of these, this is not a
|
|
854
|
+
// compositional query and we must NOT hijack it from the existing parser.
|
|
855
|
+
if (!(quals.length || relFlag || membershipLed || gerundLed)) return null;
|
|
856
|
+
|
|
857
|
+
// empty predicate → a bare qualified class ("public methods")
|
|
858
|
+
if (!predWords.length) {
|
|
859
|
+
let base = { node: "allOfClass", entityType };
|
|
860
|
+
if (!quals.length) return { node: "miss", reason: "nothing to filter or traverse" };
|
|
861
|
+
return { node: "qualifier", filters: quals, inner: base };
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const subjPrefix = noun.placeholder ? "what" : `which ${entWord}`;
|
|
865
|
+
const { branches, ops } = splitBoolean(predLc, predWords);
|
|
866
|
+
// build one atom per branch, borrowing a leading verb phrase across bare branches
|
|
867
|
+
// ("importing X or Y" → the second branch inherits "importing").
|
|
868
|
+
let prevVerb = null;
|
|
869
|
+
const atoms = [];
|
|
870
|
+
for (let b = 0; b < branches.length; b += 1) {
|
|
871
|
+
const bw = branches[b];
|
|
872
|
+
const blc = bw.map((x) => x.toLowerCase());
|
|
873
|
+
const op = b === 0 ? "seed" : ops[b - 1];
|
|
874
|
+
if (bw.length && blc.every((x) => QUALIFIERS[x])) { atoms.push({ op, kind: "qual", filters: blc }); continue; }
|
|
875
|
+
if (blc[0] === "of" || blc[0] === "in") {
|
|
876
|
+
atoms.push({ op, kind: "set", ast: { node: "membership", entityType, term: bw.slice(1).join(" ") } });
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
879
|
+
let phrase = bw;
|
|
880
|
+
const vh = findPhrase(blc, VERB_TO_KIND);
|
|
881
|
+
if (vh) prevVerb = bw.slice(vh.start, vh.end);
|
|
882
|
+
else if (prevVerb) phrase = [...prevVerb, ...bw];
|
|
883
|
+
// a branch is a single predicate (top-level booleans are already split out), so
|
|
884
|
+
// parse it as nested-or-simple — NOT back through parseSetPhrase, which would
|
|
885
|
+
// re-detect the branch's own gerund/relative lead and recurse on identical text.
|
|
886
|
+
const ast = parseBranchAst(`${subjPrefix} ${phrase.join(" ")}`, nlp, depth + 1);
|
|
887
|
+
if (!ast || ast.node === "miss") return { node: "miss", reason: (ast && ast.reason) || "a clause in the combination didn't parse" };
|
|
888
|
+
atoms.push({ op, kind: "set", ast });
|
|
889
|
+
}
|
|
890
|
+
// the first atom must be a base set, not a bare qualifier (a qualifier needs
|
|
891
|
+
// something to filter). "public methods" already took the empty-predicate path above.
|
|
892
|
+
if (atoms[0].kind !== "set") return { node: "miss", reason: "start with a clause, then combine with and/or/but-not" };
|
|
893
|
+
|
|
894
|
+
let result;
|
|
895
|
+
if (atoms.length === 1) {
|
|
896
|
+
result = atoms[0].ast;
|
|
897
|
+
} else {
|
|
898
|
+
result = { node: "boolean", entityType, atoms };
|
|
899
|
+
}
|
|
900
|
+
if (quals.length) result = { node: "qualifier", filters: quals, inner: result };
|
|
901
|
+
return result;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/** Parse a single boolean branch (one predicate over the subject) into a set-AST:
|
|
905
|
+
* nested (the branch has its own object-relative "that") or a plain simple clause.
|
|
906
|
+
* Deliberately does NOT re-enter parseRelationalOrQualified — the branch has no
|
|
907
|
+
* top-level boolean of its own (it was just split off one), so descending there
|
|
908
|
+
* would only re-detect its gerund/relative lead and recurse on the same text. */
|
|
909
|
+
function parseBranchAst(text, nlp, depth) {
|
|
910
|
+
if (depth > MAX_COMPOSE_DEPTH) return { node: "miss", reason: "too deep to resolve" };
|
|
911
|
+
const w = splitWords(text);
|
|
912
|
+
const lc = w.map((x) => x.toLowerCase());
|
|
913
|
+
const nested = parseNested(w, lc, nlp, depth);
|
|
914
|
+
if (nested) return nested;
|
|
915
|
+
const clause = parseSimpleClause(text, nlp);
|
|
916
|
+
return clause ? { node: "clause", clause } : null;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/** Split a predicate word array on boolean connectives (longest key first, so
|
|
920
|
+
* "but not" beats a bare "not"). Returns {branches:[[word…]…], ops:[op…]} with
|
|
921
|
+
* branches.length === ops.length + 1. */
|
|
922
|
+
function splitBoolean(predLc, predWords) {
|
|
923
|
+
const conns = Object.keys(BOOLEAN_CONNECTIVES).sort((a, z) => z.split(" ").length - a.split(" ").length);
|
|
924
|
+
const branches = []; const ops = [];
|
|
925
|
+
let start = 0; let i = 0;
|
|
926
|
+
while (i < predLc.length) {
|
|
927
|
+
let hit = null;
|
|
928
|
+
for (const c of conns) {
|
|
929
|
+
const cw = c.split(" ");
|
|
930
|
+
if (predLc.slice(i, i + cw.length).join(" ") === c) { hit = { c, len: cw.length }; break; }
|
|
931
|
+
}
|
|
932
|
+
if (hit && i > start) { // a connective, and not at a branch start (avoid leading "and")
|
|
933
|
+
branches.push(predWords.slice(start, i));
|
|
934
|
+
ops.push(BOOLEAN_CONNECTIVES[hit.c]);
|
|
935
|
+
i += hit.len; start = i;
|
|
936
|
+
} else if (hit) { i += hit.len; start = i; } // connective at branch start — skip it
|
|
937
|
+
else i += 1;
|
|
938
|
+
}
|
|
939
|
+
branches.push(predWords.slice(start));
|
|
940
|
+
return { branches, ops };
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// ---- compositional EVALUATION — compile an AST to a graph traversal, reusing the
|
|
944
|
+
// same primitives (edgesOfKind, resolveObject, refineToEntities, traverse) the
|
|
945
|
+
// simple path uses. Pure given (graph, ast, opts). ----
|
|
946
|
+
|
|
947
|
+
/** Reverse traversal over a SET of object ids (the nested "callers of {X…}" step) —
|
|
948
|
+
* mirrors traverse()'s reverse general case (symbol-grain sibling + defines-refine),
|
|
949
|
+
* but membership-tests e.object against a set instead of a single id. */
|
|
950
|
+
function reverseOverSet(graph, kind, entityType, objectIds) {
|
|
951
|
+
const symbolKind = SYMBOL_GRAIN_SIBLING[kind];
|
|
952
|
+
if (symbolKind && FINE_ENTITY_TYPES.has(entityType)) {
|
|
953
|
+
const edges = edgesOfKind(graph, symbolKind).filter((e) => objectIds.has(e.object));
|
|
954
|
+
return uniqueById(edges.map((e) => graph.byId.get(e.subject)).filter((s) => s && s.class === entityType));
|
|
955
|
+
}
|
|
956
|
+
const edges = kindsFor(kind).flatMap((k) => edgesOfKind(graph, k)).filter((e) => objectIds.has(e.object));
|
|
957
|
+
const subjects = uniqueById(edges.map((e) => graph.byId.get(e.subject)).filter(Boolean));
|
|
958
|
+
if (!entityType || entityType === "Change") return subjects;
|
|
959
|
+
const direct = subjects.filter((s) => s.class === entityType);
|
|
960
|
+
if (direct.length) return direct;
|
|
961
|
+
if (entityType !== "Module" && subjects.some((s) => s.class === "Module")) {
|
|
962
|
+
return refineToEntities(graph, new Set(subjects.filter((s) => s.class === "Module").map((s) => s.id)), entityType);
|
|
963
|
+
}
|
|
964
|
+
return [];
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/** Forward traversal over a SET of subject ids (the "things {X…} call/define" step). */
|
|
968
|
+
function forwardOverSet(graph, kind, subjectIds) {
|
|
969
|
+
const edges = kindsFor(kind).flatMap((k) => edgesOfKind(graph, k)).filter((e) => subjectIds.has(e.subject));
|
|
970
|
+
return uniqueById(edges.map((e) => graph.byId.get(e.object)).filter(Boolean));
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function uniqueById(inds) {
|
|
974
|
+
const seen = new Set(); const out = [];
|
|
975
|
+
for (const x of inds) if (x && !seen.has(x.id)) { seen.add(x.id); out.push(x); }
|
|
976
|
+
return out;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// Per-graph memo for the qualifier attribute/edge sets (exported symbols, tested
|
|
980
|
+
// modules, symbol→module map) — computed once, so a qualifier filter over a large
|
|
981
|
+
// result set stays cheap and deterministic.
|
|
982
|
+
const qualCache = new WeakMap();
|
|
983
|
+
function qualSets(graph) {
|
|
984
|
+
let c = qualCache.get(graph);
|
|
985
|
+
if (c) return c;
|
|
986
|
+
const exported = new Set();
|
|
987
|
+
for (const e of edgesOfKind(graph, "reexports")) {
|
|
988
|
+
exported.add(String(e.object).toLowerCase());
|
|
989
|
+
const ind = graph.byId.get(e.object);
|
|
990
|
+
if (ind) exported.add(String(ind.label).toLowerCase());
|
|
991
|
+
}
|
|
992
|
+
const testedModules = new Set(edgesOfKind(graph, "tests").map((e) => e.object));
|
|
993
|
+
const moduleOfSymbol = new Map();
|
|
994
|
+
for (const e of edgesOfKind(graph, "defines")) moduleOfSymbol.set(e.object, e.subject);
|
|
995
|
+
c = { exported, testedModules, moduleOfSymbol };
|
|
996
|
+
qualCache.set(graph, c);
|
|
997
|
+
return c;
|
|
998
|
+
}
|
|
999
|
+
function moduleIdOf(graph, ind) {
|
|
1000
|
+
if (!ind) return null;
|
|
1001
|
+
if (ind.class === "Module") return ind.id;
|
|
1002
|
+
return qualSets(graph).moduleOfSymbol.get(ind.id) || null;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/** Does an individual satisfy one qualifier (spec from QUALIFIERS)? Reads only
|
|
1006
|
+
* attributes/edges the graph already carries — an unpopulated attribute (e.g.
|
|
1007
|
+
* isAbstract) simply yields false, an honest empty rather than an error. */
|
|
1008
|
+
function qualHolds(graph, ind, spec) {
|
|
1009
|
+
if (!spec) return false;
|
|
1010
|
+
switch (spec.via) {
|
|
1011
|
+
case "visibility": {
|
|
1012
|
+
const v = String((ind.attributes || []).find((a) => a.key === "visibility")?.value || "public").toLowerCase();
|
|
1013
|
+
return v === spec.value;
|
|
1014
|
+
}
|
|
1015
|
+
case "attr":
|
|
1016
|
+
return !!(ind.attributes || []).find((a) => a.key === spec.attr)?.value;
|
|
1017
|
+
case "exported": {
|
|
1018
|
+
const ex = qualSets(graph).exported;
|
|
1019
|
+
return ex.has(String(ind.label).toLowerCase()) || ex.has(String(ind.id).toLowerCase());
|
|
1020
|
+
}
|
|
1021
|
+
case "tested": {
|
|
1022
|
+
const mid = moduleIdOf(graph, ind);
|
|
1023
|
+
return (!!mid && qualSets(graph).testedModules.has(mid)) === spec.value;
|
|
1024
|
+
}
|
|
1025
|
+
default: return false;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/** Compile a set-producing AST into an array of individuals. */
|
|
1030
|
+
function evalSet(graph, ast, opts) {
|
|
1031
|
+
switch (ast.node) {
|
|
1032
|
+
case "clause": return traverse(graph, ast.clause, opts).matches || [];
|
|
1033
|
+
case "allOfClass": return graph.individuals.filter((i) => i.class === ast.entityType);
|
|
1034
|
+
case "reverseSet": {
|
|
1035
|
+
const ids = new Set(evalSet(graph, ast.inner, opts).map((i) => i.id));
|
|
1036
|
+
return reverseOverSet(graph, ast.kind, ast.entityType, ids);
|
|
1037
|
+
}
|
|
1038
|
+
case "forwardSet": {
|
|
1039
|
+
const ids = new Set(evalSet(graph, ast.inner, opts).map((i) => i.id));
|
|
1040
|
+
return forwardOverSet(graph, ast.kind, ids);
|
|
1041
|
+
}
|
|
1042
|
+
case "membership": {
|
|
1043
|
+
const r = resolveObject(graph, ast.term);
|
|
1044
|
+
if (!r.match) return [];
|
|
1045
|
+
const ids = new Set([r.match.id]);
|
|
1046
|
+
const objs = uniqueById(MEMBERSHIP_KINDS.flatMap((k) => forwardOverSet(graph, k, ids)));
|
|
1047
|
+
return ast.entityType ? objs.filter((o) => o.class === ast.entityType) : objs;
|
|
1048
|
+
}
|
|
1049
|
+
case "qualifier": {
|
|
1050
|
+
const base = evalSet(graph, ast.inner, opts);
|
|
1051
|
+
return base.filter((ind) => ast.filters.every((f) => qualHolds(graph, ind, QUALIFIERS[f])));
|
|
1052
|
+
}
|
|
1053
|
+
case "boolean": return evalBoolean(graph, ast, opts);
|
|
1054
|
+
case "anaphora": return evalAnaphora(graph, ast, opts).matches;
|
|
1055
|
+
default: return [];
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/** Fold a boolean AST left-to-right into a result set. A qualifier atom acts as a
|
|
1060
|
+
* set filter on the accumulator (intersection keeps satisfiers, difference removes
|
|
1061
|
+
* them); a set atom contributes its own id-set for the op. */
|
|
1062
|
+
function evalBoolean(graph, ast, opts) {
|
|
1063
|
+
let acc = [];
|
|
1064
|
+
for (const atom of ast.atoms) {
|
|
1065
|
+
if (atom.op === "seed") { acc = evalSet(graph, atom.ast, opts); continue; }
|
|
1066
|
+
if (atom.kind === "qual") {
|
|
1067
|
+
const holds = (ind) => atom.filters.every((f) => qualHolds(graph, ind, QUALIFIERS[f]));
|
|
1068
|
+
acc = atom.op === "difference" ? acc.filter((i) => !holds(i)) : acc.filter((i) => holds(i));
|
|
1069
|
+
continue;
|
|
1070
|
+
}
|
|
1071
|
+
const oids = new Set(evalSet(graph, atom.ast, opts).map((i) => i.id));
|
|
1072
|
+
if (atom.op === "intersection") acc = acc.filter((i) => oids.has(i.id));
|
|
1073
|
+
else if (atom.op === "difference") acc = acc.filter((i) => !oids.has(i.id));
|
|
1074
|
+
else if (atom.op === "union") {
|
|
1075
|
+
const seen = new Set(acc.map((i) => i.id));
|
|
1076
|
+
for (const other of evalSet(graph, atom.ast, opts)) if (!seen.has(other.id)) { seen.add(other.id); acc.push(other); }
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
return acc;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/** Anaphora over ask()'s `prev` id array — filter/count the previous answer's ids.
|
|
1083
|
+
* No prev supplied → honest miss (never a guess), like an unresolved pronoun. */
|
|
1084
|
+
function evalAnaphora(graph, ast, opts) {
|
|
1085
|
+
const prev = opts && opts.prev;
|
|
1086
|
+
if (!Array.isArray(prev) || !prev.length) return { compositeMiss: true, reason: "no-prev", matches: [] };
|
|
1087
|
+
let items = prev.map((id) => graph.byId.get(id)).filter(Boolean);
|
|
1088
|
+
const f = ast.filter;
|
|
1089
|
+
if (f && f.type === "qual") {
|
|
1090
|
+
items = items.filter((ind) => f.filters.every((q) => qualHolds(graph, ind, QUALIFIERS[q])));
|
|
1091
|
+
} else if (f && f.type === "clause") {
|
|
1092
|
+
const r = resolveObject(graph, f.clause.object);
|
|
1093
|
+
if (!r.match) items = [];
|
|
1094
|
+
else {
|
|
1095
|
+
// include the symbol-grain sibling so a fn->fn "call" filter tests callsSymbol,
|
|
1096
|
+
// not just the module-coarse "calls" edge (mirrors traverse()'s reverse path).
|
|
1097
|
+
const sib = SYMBOL_GRAIN_SIBLING[f.clause.kind];
|
|
1098
|
+
const kinds = [...kindsFor(f.clause.kind), ...(sib ? [sib] : [])];
|
|
1099
|
+
const ok = new Set(kinds.flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.object === r.match.id).map((e) => e.subject));
|
|
1100
|
+
items = items.filter((ind) => ok.has(ind.id));
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
// a count over a prior set names the entity kind when the survivors share a class.
|
|
1104
|
+
const common = items.length && items.every((x) => x.class === items[0].class) ? items[0].class : null;
|
|
1105
|
+
if (ast.mode === "count") return { compositeKind: "count", count: items.length, entityType: common, matches: [] };
|
|
1106
|
+
return { compositeKind: "set", matches: items, entityType: common };
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// Structural kinds counted for "most-connected" (total degree). Symbol-grain and
|
|
1110
|
+
// commit-history kinds are excluded so "connections" reads as the code-structure
|
|
1111
|
+
// degree a developer means, not every recorded touch.
|
|
1112
|
+
const DEGREE_KINDS = ["imports", "calls", "callsSymbol", "inherits", "contains", "tests"];
|
|
1113
|
+
/** Degree of an individual under a superlative metric ({kind, dir, sibling?, filter?}). */
|
|
1114
|
+
function degreeMetric(graph, ind, metric) {
|
|
1115
|
+
const kinds = metric.kind === "*" ? DEGREE_KINDS : [metric.kind, ...(metric.sibling ? [metric.sibling] : [])];
|
|
1116
|
+
let n = 0;
|
|
1117
|
+
for (const k of kinds) for (const e of edgesOfKind(graph, k)) {
|
|
1118
|
+
const out = e.subject === ind.id; const inc = e.object === ind.id;
|
|
1119
|
+
if (metric.dir === "out" && out) {
|
|
1120
|
+
if (metric.filter) { const o = graph.byId.get(e.object); if (!o || o.class !== metric.filter) continue; }
|
|
1121
|
+
n += 1;
|
|
1122
|
+
} else if (metric.dir === "in" && inc) n += 1;
|
|
1123
|
+
else if (metric.dir === "both" && (out || inc)) n += 1;
|
|
1124
|
+
}
|
|
1125
|
+
return n;
|
|
1126
|
+
}
|
|
1127
|
+
function evalSuperlative(graph, ast) {
|
|
1128
|
+
const pool = graph.individuals.filter((i) => i.class === ast.entityType);
|
|
1129
|
+
const scored = pool.map((ind) => ({ ind, score: degreeMetric(graph, ind, ast.metric) }))
|
|
1130
|
+
.sort((a, z) => (ast.extreme === "most" ? z.score - a.score : a.score - z.score));
|
|
1131
|
+
if (!scored.length) return { compositeKind: "superlative", entityType: ast.entityType, matches: [] };
|
|
1132
|
+
const best = scored[0].score;
|
|
1133
|
+
const winners = scored.filter((s) => s.score === best).map((s) => s.ind);
|
|
1134
|
+
return { compositeKind: "superlative", entityType: ast.entityType, metricNoun: ast.metricNoun, extreme: ast.extreme, score: best, matches: winners };
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/** Compile any compositional AST to a result object traverse() returns for the
|
|
1138
|
+
* simple path — {matches, …} plus compositeKind/compositeMiss flags render() reads. */
|
|
1139
|
+
export function evalComposite(graph, ast, opts = {}) {
|
|
1140
|
+
if (ast.node === "miss") return { compositeMiss: true, reason: ast.reason || null, matches: [] };
|
|
1141
|
+
if (ast.node === "count") return { compositeKind: "count", count: evalSet(graph, ast.base, opts).length, entityType: ast.entityType, matches: [] };
|
|
1142
|
+
if (ast.node === "superlative") return evalSuperlative(graph, ast);
|
|
1143
|
+
if (ast.node === "anaphora") return evalAnaphora(graph, ast, opts);
|
|
1144
|
+
return { compositeKind: "set", matches: evalSet(graph, ast, opts), entityType: ast.entityType || null };
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// ---- compositional RENDER — templated, same "honest miss vs cited hit" discipline
|
|
1148
|
+
// as renderCore. ----
|
|
1149
|
+
|
|
1150
|
+
const compositeList = (matches) => listJoin(matches.slice(0, OVERFLOW_CAP)
|
|
1151
|
+
.map((m) => (["Function", "Method"].includes(m.class) ? `${m.label}()` : m.label)))
|
|
1152
|
+
+ (matches.length > OVERFLOW_CAP ? `, …and ${matches.length - OVERFLOW_CAP} more` : "");
|
|
1153
|
+
|
|
1154
|
+
/** A compositional worked example for the rephrase hint (§honest miss now shows a
|
|
1155
|
+
* compositional phrasing too). */
|
|
1156
|
+
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"';
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function renderComposite(parsed, result) {
|
|
1161
|
+
if (result.compositeMiss) {
|
|
1162
|
+
if (result.reason === "no-prev") {
|
|
1163
|
+
return { content: `"those"/"them" needs a previous answer to refer to — ask a listing question first, then follow up.`, miss: true, ambiguous: false };
|
|
1164
|
+
}
|
|
1165
|
+
return { content: `couldn't compile this compositional question${result.reason ? ` (${result.reason})` : ""}. ${compositionalHint()}.`, miss: true, ambiguous: false };
|
|
1166
|
+
}
|
|
1167
|
+
if (result.compositeKind === "count") {
|
|
1168
|
+
const noun = result.entityType ? nounFor(result.entityType, result.count) : (result.count === 1 ? "result" : "results");
|
|
1169
|
+
return { content: `${result.count} ${noun}.`, miss: false, ambiguous: false, matches: [] };
|
|
1170
|
+
}
|
|
1171
|
+
if (result.compositeKind === "superlative") {
|
|
1172
|
+
if (!result.matches.length) return { content: `no ${nounFor(result.entityType, 2)} to rank in this index.`, miss: true, ambiguous: false };
|
|
1173
|
+
const lead = result.extreme === "most" ? "the most" : "the fewest";
|
|
1174
|
+
const tie = result.matches.length > 1 ? ` (${result.matches.length}-way tie)` : "";
|
|
1175
|
+
return {
|
|
1176
|
+
content: `${compositeList(result.matches)} — ${lead} ${result.metricNoun} (${result.score})${tie}.`,
|
|
1177
|
+
miss: false, ambiguous: false, matches: result.matches,
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
// set-producing
|
|
1181
|
+
if (!result.matches.length) {
|
|
1182
|
+
return { content: `nothing in the index matches that${result.entityType ? ` (${nounFor(result.entityType, 2)})` : ""}.`, miss: true, ambiguous: false, matches: [] };
|
|
1183
|
+
}
|
|
1184
|
+
return { content: `${compositeList(result.matches)}.`, miss: false, ambiguous: false, matches: result.matches };
|
|
1185
|
+
}
|
|
1186
|
+
|
|
599
1187
|
/** The rephrase hint shown on a grammar miss — generated from the SAME tables the parser
|
|
600
1188
|
* uses, so it can never suggest a phrasing the grammar doesn't actually support (§6.3). */
|
|
601
1189
|
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)'
|
|
1190
|
+
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). '
|
|
1191
|
+
+ compositionalHint();
|
|
603
1192
|
}
|
|
604
1193
|
|
|
605
1194
|
// ---- §4 object-term resolution — mechanical, no embeddings, tiered, stop at first hit ----
|
|
@@ -888,8 +1477,13 @@ const TRANSITIVE_MAX_DEPTH = 8;
|
|
|
888
1477
|
* needed one. Returns {matches, objMatch, candidates, traversal, ambiguous,
|
|
889
1478
|
* answer?, unresolvedPronoun?} — `answer` only set for the "ask" shape.
|
|
890
1479
|
* `matches` is always an array of individuals (or edge records for "ask"). */
|
|
891
|
-
export function traverse(graph, parsed, { contextId = null } = {}) {
|
|
892
|
-
if (!parsed
|
|
1480
|
+
export function traverse(graph, parsed, { contextId = null, prev = null } = {}) {
|
|
1481
|
+
if (!parsed) return { matches: [], objMatch: null, candidates: [], traversal: null, ambiguous: false };
|
|
1482
|
+
// compositional AST (PLAN §5.16 P3) — the new grammar's nodes carry a `node` tag;
|
|
1483
|
+
// everything else (simple clauses, ambiguousParse) flows through the original path
|
|
1484
|
+
// below completely unchanged.
|
|
1485
|
+
if (parsed.node) return evalComposite(graph, parsed, { contextId, prev });
|
|
1486
|
+
if (parsed.ambiguousParse) return { matches: [], objMatch: null, candidates: [], traversal: null, ambiguous: false };
|
|
893
1487
|
const { shape, kind, entityType } = parsed;
|
|
894
1488
|
|
|
895
1489
|
// meta: a question about the graph's OWN vocabulary ("what does cochange mean", "what
|
|
@@ -1155,6 +1749,7 @@ function renderCore(parsed, result) {
|
|
|
1155
1749
|
if (!parsed) {
|
|
1156
1750
|
return { content: `couldn't parse this as a graph question. Try: ${rephraseHint()}`, miss: true, ambiguous: false };
|
|
1157
1751
|
}
|
|
1752
|
+
if (parsed.node) return renderComposite(parsed, result);
|
|
1158
1753
|
if (parsed.ambiguousParse) {
|
|
1159
1754
|
const options = parsed.candidates.map((p, i) => `${i + 1}) ${describeParse(p)}`).join(" or ");
|
|
1160
1755
|
return {
|
|
@@ -1348,6 +1943,192 @@ function renderCore(parsed, result) {
|
|
|
1348
1943
|
return { content: clauses.join(" and ") + extra + ".", miss: false, ambiguous: false, matches: result.matches };
|
|
1349
1944
|
}
|
|
1350
1945
|
|
|
1946
|
+
// ============================================================================
|
|
1947
|
+
// §progressive-relaxation cascade (SHRDLU in a code graph, with a Zork parser's
|
|
1948
|
+
// forgiveness) — a controlled loop that wraps the WHOLE existing parse and runs
|
|
1949
|
+
// ONLY when the direct parse of the normalized query would MISS. A clean direct hit
|
|
1950
|
+
// never enters the cascade (it stays instant and exact); the cascade only ever DROPS
|
|
1951
|
+
// noise/unmatched words or NORMALISES a near-canonical word to the closed vocabulary,
|
|
1952
|
+
// re-attempting the full parse (compositional + templates + keyword-spot) after each
|
|
1953
|
+
// transform, and bottoms out in the SAME honest miss + rephrase hint the engine
|
|
1954
|
+
// already returned — never inventing a term or guessing an entity. Deterministic:
|
|
1955
|
+
// same input → same cascade path. All of it is plain JS over the already-imported
|
|
1956
|
+
// tables + resolveObject/parseQuery, so it survives the viewer bundle's import strip.
|
|
1957
|
+
// ============================================================================
|
|
1958
|
+
|
|
1959
|
+
const wordsOf = (arr) => arr.flatMap((p) => String(p).toLowerCase().split(" "));
|
|
1960
|
+
|
|
1961
|
+
/** Every token the CLOSED grammar gives QUERY MEANING to — relation verbs, entity
|
|
1962
|
+
* nouns, modifiers, qualifiers, aggregate/superlative triggers, edge-degree nouns,
|
|
1963
|
+
* boolean connectives, placeholder nouns, anaphora/meta/where/mention markers,
|
|
1964
|
+
* relative pronouns, and the small synonym keys. The noise-strip pass will NEVER
|
|
1965
|
+
* remove one of these, and the drop-unmatched pass always keeps them: they carry the
|
|
1966
|
+
* intent, only the packaging around them is negotiable. */
|
|
1967
|
+
const CONTENT_VOCAB = new Set([
|
|
1968
|
+
...wordsOf(Object.keys(VERB_TO_KIND)), ...wordsOf(Object.keys(ENTITY_TO_TYPE)),
|
|
1969
|
+
...wordsOf(Object.keys(MODIFIER_TO_KIND)), ...wordsOf(Object.keys(QUALIFIERS)),
|
|
1970
|
+
...wordsOf(AGGREGATE_TRIGGERS), ...wordsOf(Object.keys(SUPERLATIVE_EXTREMES)),
|
|
1971
|
+
...wordsOf(Object.keys(EDGE_NOUN_TO_METRIC)), ...wordsOf(Object.keys(BOOLEAN_CONNECTIVES)),
|
|
1972
|
+
...wordsOf(PLACEHOLDER_NOUNS), ...wordsOf(ANAPHORA_TRIGGERS), ...wordsOf(META_MEANING_VERBS),
|
|
1973
|
+
...wordsOf(WHERE_MARKERS), ...wordsOf(MENTION_MARKERS), ...wordsOf(RELATIVE_PRONOUNS),
|
|
1974
|
+
...wordsOf(Object.keys(CASCADE_SYNONYMS)),
|
|
1975
|
+
]);
|
|
1976
|
+
|
|
1977
|
+
/** Structural scaffolding words — question words, articles-in-questions, frame verbs,
|
|
1978
|
+
* and context pronouns. Not "content", but they hold a sentence together, so the
|
|
1979
|
+
* drop-unmatched pass keeps them (dropping "what"/"of" would corrupt the grammar);
|
|
1980
|
+
* the noise-strip pass may still remove the few of these that are ALSO curated noise
|
|
1981
|
+
* ("the"/"a"/"show"/"me") — the two sets overlap on purpose. */
|
|
1982
|
+
const STRUCTURAL_WORDS = new Set([...STOPWORDS, ...FRAME_WORDS, ...CONTEXT_PRONOUNS]);
|
|
1983
|
+
const CASCADE_NOISE_SET = new Set(wordsOf(CASCADE_NOISE));
|
|
1984
|
+
|
|
1985
|
+
/** Is `parsed` a genuinely ANSWERABLE query — one that both parsed AND (for the simple
|
|
1986
|
+
* clauses) resolves its named term(s) to a graph entity? A composite non-miss node,
|
|
1987
|
+
* an ambiguous parse, and a meta/mentions surface all count; an unresolved-context
|
|
1988
|
+
* pronoun is its OWN specific honest miss (kept, not relaxed). Returns:
|
|
1989
|
+
* true — a real, executable answer (even if it later renders an empty set / "No")
|
|
1990
|
+
* "ambiguous"/"pronoun" — a specific outcome to keep, distinct from relaxable
|
|
1991
|
+
* false — no parse at all, a compositional {node:"miss"}, or an unresolved term
|
|
1992
|
+
* ask() starts the cascade ONLY on `false`, and accepts a relaxed attempt ONLY on the
|
|
1993
|
+
* strict `true` (so the cascade can never "rescue" a query into another kind of miss). */
|
|
1994
|
+
function answerable(graph, parsed, contextId) {
|
|
1995
|
+
if (!parsed) return false;
|
|
1996
|
+
if (parsed.ambiguousParse) return "ambiguous";
|
|
1997
|
+
if (parsed.node) return parsed.node !== "miss";
|
|
1998
|
+
if (parsed.shape === "meta" || parsed.shape === "mentions") return true;
|
|
1999
|
+
const o = resolveTermOrContext(graph, parsed.object, contextId);
|
|
2000
|
+
if (o.unresolvedPronoun) return "pronoun";
|
|
2001
|
+
if (!o.match) return false;
|
|
2002
|
+
if (parsed.shape === "ask") {
|
|
2003
|
+
const s = resolveTermOrContext(graph, parsed.subject, contextId);
|
|
2004
|
+
if (s.unresolvedPronoun) return "pronoun";
|
|
2005
|
+
return s.match ? true : false;
|
|
2006
|
+
}
|
|
2007
|
+
return true;
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
/** Whole-query help/orientation request → show the hint directly (never the relaxation
|
|
2011
|
+
* loop, never a pretend answer). Matches only when the ENTIRE normalized query is a
|
|
2012
|
+
* curated HELP_TRIGGER, so a symbol named "help" in a real question is untouched. */
|
|
2013
|
+
function isHelpRequest(query) {
|
|
2014
|
+
const q = String(query || "").trim().toLowerCase().replace(/[?.!\s]+$/, "");
|
|
2015
|
+
return HELP_TRIGGERS.includes(q);
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
/** The relaxation cascade. Given a query whose direct parse MISSED, walk three
|
|
2019
|
+
* increasingly-permissive layers, re-attempting the full parse after each transform,
|
|
2020
|
+
* and return the FIRST attempt that yields an answerable parse (with a trace of what
|
|
2021
|
+
* it did) — or null if none does (caller then falls back to the original honest miss):
|
|
2022
|
+
* 1. NOISE-STRIP — remove one curated noise token (leftmost) at a time, never one
|
|
2023
|
+
* that is content vocab or resolves to an entity, re-parsing each
|
|
2024
|
+
* time, until a parse answers or no noise tokens remain.
|
|
2025
|
+
* 2. DROP-UNMATCHED — drop the plain-lowercase words that are NEITHER grammar NOR a
|
|
2026
|
+
* resolvable entity (an unknown "frobnicate"); identifier-shaped
|
|
2027
|
+
* tokens (dotted files, shas, CamelCase) are never dropped —
|
|
2028
|
+
* they are content that may honestly fail to resolve.
|
|
2029
|
+
* 3. SYNONYM — rewrite surviving near-canonical words to the closed vocab.
|
|
2030
|
+
* Bounded (one token removed per noise iteration; hard guard) and deterministic. */
|
|
2031
|
+
export function relaxParse(graph, query, { nlp = undefined, contextId = null, prev = null } = {}) {
|
|
2032
|
+
const from = applyNegationFrames(normalizeQuery(String(query || "")));
|
|
2033
|
+
let tokens = splitWords(from);
|
|
2034
|
+
if (!tokens.length) return null;
|
|
2035
|
+
const dropped = [];
|
|
2036
|
+
const steps = [];
|
|
2037
|
+
|
|
2038
|
+
// Two literal-resolution guards (never the fuzzy/prose tiers, whose loose near-
|
|
2039
|
+
// matches would let a noise word masquerade as an entity):
|
|
2040
|
+
// · resolvesExact — EXACT label/id or ext: match only (tier ≤ 2). Used to protect a
|
|
2041
|
+
// curated NOISE word from being stripped: only a token that literally NAMES an
|
|
2042
|
+
// entity ("a module called `the`") is safe-listed. A mere substring coincidence
|
|
2043
|
+
// ("me" ⊂ "Method", tier 3) must NOT block stripping a genuine filler word.
|
|
2044
|
+
// · resolvesLiteral — exact/ext/substring/component (tier ≤ 3). Used to KEEP an
|
|
2045
|
+
// identifier-shaped content token ("logging" ⊂ "src/logging.mjs") through the
|
|
2046
|
+
// drop-unmatched pass, and to hold a synonym rewrite off a real entity name.
|
|
2047
|
+
const resolvesExact = (t) => {
|
|
2048
|
+
const r = resolveObject(graph, t);
|
|
2049
|
+
return !!r.match && r.tier != null && r.tier <= 2;
|
|
2050
|
+
};
|
|
2051
|
+
const resolvesLiteral = (t) => {
|
|
2052
|
+
const r = resolveObject(graph, t);
|
|
2053
|
+
return !!r.match && r.tier != null && r.tier <= 3;
|
|
2054
|
+
};
|
|
2055
|
+
// Does a term string carry at least one REAL word — one the grammar doesn't already
|
|
2056
|
+
// own as vocabulary/scaffolding? Guards against a relaxation that drops the actual
|
|
2057
|
+
// asked term and lets a bare marker slide into its place ("where is [X] defined" →
|
|
2058
|
+
// "where is defined", "defined" is a WHERE_MARKER, never the thing being located).
|
|
2059
|
+
const hasRealTerm = (s) => splitWords(String(s || "")).some((w) => {
|
|
2060
|
+
const lc = w.toLowerCase();
|
|
2061
|
+
return !CONTENT_VOCAB.has(lc) && !STRUCTURAL_WORDS.has(lc);
|
|
2062
|
+
});
|
|
2063
|
+
// Accept a relaxed attempt ONLY if it is a genuinely answerable parse (terms resolve)
|
|
2064
|
+
// AND it renders a REAL positive answer — never another empty/miss (relaxation earns a
|
|
2065
|
+
// win only by turning a miss into an answer, never a differently-worded miss) — and
|
|
2066
|
+
// never by promoting a bare marker to the asked term.
|
|
2067
|
+
const TERM_SHAPES = new Set(["reverse", "forward", "where", "when", "ask"]);
|
|
2068
|
+
const attempt = (toks) => {
|
|
2069
|
+
const text = toks.join(" ");
|
|
2070
|
+
const p = parseQuery(text, { nlp });
|
|
2071
|
+
if (answerable(graph, p, contextId) !== true) return null;
|
|
2072
|
+
if (p && !p.node && TERM_SHAPES.has(p.shape)) {
|
|
2073
|
+
if (p.object != null && !hasRealTerm(p.object)) return null;
|
|
2074
|
+
if (p.shape === "ask" && p.subject != null && !hasRealTerm(p.subject)) return null;
|
|
2075
|
+
}
|
|
2076
|
+
const rendered = render(p, traverse(graph, p, { contextId, prev }));
|
|
2077
|
+
return rendered.miss ? null : { parsed: p, text };
|
|
2078
|
+
};
|
|
2079
|
+
const done = (hit) => ({ parsed: hit.parsed, from, to: hit.text, dropped: [...dropped], steps });
|
|
2080
|
+
|
|
2081
|
+
// Layer 1 — NOISE-STRIP (one lowest-value token at a time)
|
|
2082
|
+
let guard = 0;
|
|
2083
|
+
const hardCap = Math.max(tokens.length, 1) + 12;
|
|
2084
|
+
for (; guard < hardCap; guard += 1) {
|
|
2085
|
+
let idx = -1;
|
|
2086
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
2087
|
+
const lc = tokens[i].toLowerCase();
|
|
2088
|
+
if (CASCADE_NOISE_SET.has(lc) && !CONTENT_VOCAB.has(lc) && !resolvesExact(tokens[i])) { idx = i; break; }
|
|
2089
|
+
}
|
|
2090
|
+
if (idx < 0) break;
|
|
2091
|
+
const removed = tokens[idx];
|
|
2092
|
+
tokens = tokens.filter((_, i) => i !== idx);
|
|
2093
|
+
dropped.push(removed);
|
|
2094
|
+
steps.push(`strip noise "${removed}" → "${tokens.join(" ")}"`);
|
|
2095
|
+
const hit = attempt(tokens);
|
|
2096
|
+
if (hit) return done(hit);
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
// Layer 2 — DROP-UNMATCHED (plain-lowercase unknowns beside the real terms)
|
|
2100
|
+
const survivors = [];
|
|
2101
|
+
const nowDropped = [];
|
|
2102
|
+
for (const t of tokens) {
|
|
2103
|
+
const lc = t.toLowerCase();
|
|
2104
|
+
const plain = /^[a-z]+$/.test(lc);
|
|
2105
|
+
const keep = !plain || CONTENT_VOCAB.has(lc) || STRUCTURAL_WORDS.has(lc) || resolvesLiteral(t);
|
|
2106
|
+
if (keep) survivors.push(t); else nowDropped.push(t);
|
|
2107
|
+
}
|
|
2108
|
+
if (nowDropped.length && survivors.length) {
|
|
2109
|
+
tokens = survivors;
|
|
2110
|
+
dropped.push(...nowDropped);
|
|
2111
|
+
steps.push(`drop unmatched ${JSON.stringify(nowDropped)} → "${tokens.join(" ")}"`);
|
|
2112
|
+
const hit = attempt(tokens);
|
|
2113
|
+
if (hit) return done(hit);
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
// Layer 3 — SYNONYM-NORMALISE the survivors onto the canonical vocabulary
|
|
2117
|
+
let changed = false;
|
|
2118
|
+
const normed = tokens.map((t) => {
|
|
2119
|
+
const lc = t.toLowerCase();
|
|
2120
|
+
if (CASCADE_SYNONYMS[lc] && !resolvesLiteral(t)) { changed = true; return CASCADE_SYNONYMS[lc]; }
|
|
2121
|
+
return t;
|
|
2122
|
+
});
|
|
2123
|
+
if (changed) {
|
|
2124
|
+
steps.push(`normalise synonyms → "${normed.join(" ")}"`);
|
|
2125
|
+
const hit = attempt(normed);
|
|
2126
|
+
if (hit) return done(hit);
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
return null; // exhausted — the honest bottom of the cascade (caller keeps the original miss)
|
|
2130
|
+
}
|
|
2131
|
+
|
|
1351
2132
|
// ---- orchestration — the seonix_ask entry point (§6.3: parse -> resolve -> traverse -> render) ----
|
|
1352
2133
|
|
|
1353
2134
|
/** Answer a free-text question over the graph, mechanically. `opts.contextId`
|
|
@@ -1356,15 +2137,45 @@ function renderCore(parsed, result) {
|
|
|
1356
2137
|
* a pronoun then produces an honest miss rather than a guess. `opts.nlp`
|
|
1357
2138
|
* overrides the lemma/POS adapter (see parseQuery) — leave it undefined and
|
|
1358
2139
|
* a Node process picks up wink automatically while the inlined viewer stays
|
|
1359
|
-
* adapter-less by construction.
|
|
2140
|
+
* adapter-less by construction. `opts.prev` is the id array of the LAST answer's
|
|
2141
|
+
* matches — thread it from a chat loop so a follow-up anaphora question ("which of
|
|
2142
|
+
* those are tested", "how many of them call X") filters/counts the prior result
|
|
2143
|
+
* set; omit it and anaphora questions produce an honest "needs a previous answer"
|
|
2144
|
+
* miss. Returns the full {content, seonix_ask:
|
|
1360
2145
|
* {mechanical,parsed,matches,traversal,miss,ambiguous,candidates?}} envelope
|
|
1361
2146
|
* §6.2 specifies. Zero generative model calls. */
|
|
1362
|
-
export function ask(graph, query, { contextId = null, nlp = undefined } = {}) {
|
|
1363
|
-
|
|
1364
|
-
|
|
2147
|
+
export function ask(graph, query, { contextId = null, nlp = undefined, prev = null } = {}) {
|
|
2148
|
+
// Explicit help/orientation request → the rephrase hint directly (the honest bottom
|
|
2149
|
+
// of the cascade, reached on demand), never a pretend answer or a relaxation attempt.
|
|
2150
|
+
if (isHelpRequest(query)) {
|
|
2151
|
+
return {
|
|
2152
|
+
content: rephraseHint(),
|
|
2153
|
+
seonix_ask: {
|
|
2154
|
+
mechanical: true, parsed: null, matches: [], traversal: null,
|
|
2155
|
+
miss: true, ambiguous: false, matchedVia: null, help: true, relaxed: null,
|
|
2156
|
+
},
|
|
2157
|
+
};
|
|
2158
|
+
}
|
|
2159
|
+
const direct = parseQuery(query, { nlp });
|
|
2160
|
+
// The relaxation cascade fires ONLY when the DIRECT parse would miss (no parse, a
|
|
2161
|
+
// compositional {node:"miss"}, or an unresolved named term) — a clean hit, an
|
|
2162
|
+
// ambiguous parse, an unresolved-pronoun miss, and a real-but-empty answer all keep
|
|
2163
|
+
// the direct parse untouched (a hit stays instant and exact).
|
|
2164
|
+
let parsed = direct;
|
|
2165
|
+
let relaxed = null;
|
|
2166
|
+
if (answerable(graph, direct, contextId) === false) {
|
|
2167
|
+
const r = relaxParse(graph, query, { nlp, contextId, prev });
|
|
2168
|
+
if (r) { parsed = r.parsed; relaxed = { from: r.from, to: r.to, dropped: r.dropped, steps: r.steps }; }
|
|
2169
|
+
}
|
|
2170
|
+
const result = traverse(graph, parsed, { contextId, prev });
|
|
1365
2171
|
const rendered = render(parsed, result);
|
|
2172
|
+
// If relaxation materially rewrote the query and produced a real answer, note it
|
|
2173
|
+
// lightly (terse, honest) so the reader knows how the question was read.
|
|
2174
|
+
const content = (relaxed && !rendered.miss && relaxed.to !== relaxed.from)
|
|
2175
|
+
? `read as "${relaxed.to}" — ${rendered.content}`
|
|
2176
|
+
: rendered.content;
|
|
1366
2177
|
return {
|
|
1367
|
-
content
|
|
2178
|
+
content,
|
|
1368
2179
|
seonix_ask: {
|
|
1369
2180
|
mechanical: true,
|
|
1370
2181
|
parsed: (parsed && !parsed.ambiguousParse) ? parsed : null,
|
|
@@ -1374,6 +2185,11 @@ export function ask(graph, query, { contextId = null, nlp = undefined } = {}) {
|
|
|
1374
2185
|
traversal: result.traversal || null,
|
|
1375
2186
|
miss: !!rendered.miss,
|
|
1376
2187
|
ambiguous: !!rendered.ambiguous,
|
|
2188
|
+
// The relaxation trace: null when the direct parse was used as-is (a clean hit or
|
|
2189
|
+
// an honest miss the cascade couldn't/shouldn't rescue), else what the cascade
|
|
2190
|
+
// dropped/normalised to reach an answer. A caller can assert relaxed===null to
|
|
2191
|
+
// prove the cascade never touched a direct hit.
|
|
2192
|
+
relaxed,
|
|
1377
2193
|
// Confidence provenance: "prose" when resolveObject fell through to the tier-4
|
|
1378
2194
|
// prose-index fallback (PLAN_PROSE_INDEX.md §6 — matched what the symbol talks
|
|
1379
2195
|
// about, not its name); "fuzzy" when the tier-5 bounded-edit-distance pass
|