@polycode-projects/seonix 0.2.1 → 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 +204 -25
- package/bin/cli.mjs +78 -18
- package/package.json +5 -3
- package/roslyn/Program.cs +79 -11
- package/src/ask-nlp.mjs +73 -0
- package/src/ask-vocab.mjs +370 -5
- package/src/ask.mjs +1523 -83
- package/src/browser.mjs +99 -19
- package/src/chat.mjs +785 -0
- package/src/codegraph.mjs +213 -5
- package/src/cs_treesitter.mjs +57 -34
- package/src/embed.mjs +191 -0
- package/src/extract.mjs +220 -39
- package/src/java_treesitter.mjs +82 -55
- package/src/jsts_tsc.mjs +51 -4
- package/src/nlp-bundle.mjs +120 -0
- package/src/prose-nlp.mjs +52 -0
- package/src/prose.mjs +42 -0
- package/src/schema-docs.mjs +29 -0
- package/src/server.mjs +27 -4
- package/src/sessions.mjs +206 -0
- package/src/temporal.mjs +70 -0
- package/src/timeline.mjs +160 -0
- package/src/viz.mjs +273 -83
package/src/codegraph.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { lookupByProseTokens } from "./prose.mjs";
|
|
1
|
+
import { lookupByProseTokens, proseLayerHits } from "./prose.mjs";
|
|
2
|
+
import { cosine } from "./embed.mjs";
|
|
2
3
|
|
|
3
4
|
// Pure (no-network, no-fs) query logic over the typed `entities` payload that the
|
|
4
5
|
// deterministic indexer writes to <repo>/.seonix/graph.json (shape produced by
|
|
@@ -466,6 +467,67 @@ const PROSE_PROX_FRAC = 0.2;
|
|
|
466
467
|
const PROSE_PROX_CAP_FRAC = 0.35;
|
|
467
468
|
const PROSE_LOOKUP_LIMIT = 50; // bounds lookupByProseTokens' scan; the CAP_FRAC bounds the nudge regardless
|
|
468
469
|
|
|
470
|
+
// Layered prose normalisation (opt-in via proseLayers, 2026-07-02): the prose index now carries
|
|
471
|
+
// NORMALISED layers (spell-corrected / canonical-schema-term / stem / lemma) under
|
|
472
|
+
// proseIndex["seonix:layers"] (built by the prose pre-pass; consumed read-only via prose.mjs's
|
|
473
|
+
// proseLayerHits). Today the locate scorer matches query tokens against a module's path/symbol
|
|
474
|
+
// text VERBATIM, so a task-text word that only reaches a module via its stem/lemma/canonical form
|
|
475
|
+
// scores nothing. With the flag on, a query token that does NOT already match a module lexically,
|
|
476
|
+
// but DOES resolve to one of that module's individuals through a normalised layer, contributes a
|
|
477
|
+
// bounded, DISCOUNTED signal — weaker evidence than a verbatim match by construction (halved, then
|
|
478
|
+
// the shared FRAC/CAP nudge), and, like every proximity family, it only re-ranks modules ALREADY
|
|
479
|
+
// in `scored` — it never invents a zero-match candidate and never overrides an exact hit. NOT a
|
|
480
|
+
// shipped default and NOT wired into any bench arm — an available lever pending its own gate
|
|
481
|
+
// evidence, exactly like proseBoost/beamSearch before it.
|
|
482
|
+
const PROSE_LAYER_FRAC = 0.2; // bounded nudge — same shape/magnitude as the other proximity families …
|
|
483
|
+
const PROSE_LAYER_CAP_FRAC = 0.35; // … capped at this × the module's own base score (a nudge; hubs can't run away)
|
|
484
|
+
const PROSE_LAYER_DISCOUNT = 0.5; // a normalised-layer hit is WEAKER evidence than an exact/component token
|
|
485
|
+
// match — halved before the FRAC/CAP nudge, so a layer hit can never rival
|
|
486
|
+
// a verbatim lexical match (the "a miss beats a guess" discipline).
|
|
487
|
+
|
|
488
|
+
// PLAN_SEON_TUNING.md §7.5 finding 1 / §7.6(5a) (opt-in via literalMention, 2026-07-02): the query
|
|
489
|
+
// tokenizer split(/[^a-z0-9_]+/) DESTROYS a literal dotted module reference present verbatim in
|
|
490
|
+
// task text — "django.utils.http" scatters into {django,utils,http}, tokens so common across
|
|
491
|
+
// 2,931 modules that utils/http.py ranked 41 on B016's domain-filter — while every Module
|
|
492
|
+
// individual carries an unread `dotted` attribute. The lever scans the RAW query (threaded through
|
|
493
|
+
// as opts.rawQuery by searchModulesRanked) for whole, boundary-checked occurrences of each
|
|
494
|
+
// module's `dotted` name and repo-relative path (label). Boundary rule: a match flanked by an
|
|
495
|
+
// identifier/dotted/path continuation char ([a-z0-9_./]) does not count — which is also
|
|
496
|
+
// longest-match-wins for free: a package __init__'s dotted prefix ("django.utils" inside
|
|
497
|
+
// "django.utils.http") is followed by ".", so only the full module's own name fires (the two
|
|
498
|
+
// __init__.py prefix artifacts the 2026-07-02 review flagged). Specificity floor: a candidate
|
|
499
|
+
// with fewer than LIT_MIN_COMPONENTS dot/slash components never fires (a bare "utils" — or
|
|
500
|
+
// "django.utils" — must not). A hit adds a bounded BASE-score component weighted like the
|
|
501
|
+
// exact-symbol channel (LIT_W = EXACT_W per component IDF, top-LIT_COMP_CAP components like
|
|
502
|
+
// SYM_MATCH_CAP), then capped at LIT_CAP_FRAC × the strongest base score — the FRAC/CAP shape of
|
|
503
|
+
// the proximity families, anchored to the query's own best lexical evidence: a verbatim mention
|
|
504
|
+
// can lift a module INTO the top ranks but can never become an unbounded override. Applied
|
|
505
|
+
// BEFORE the proximity families so a mentioned module also donates adjacency like any other
|
|
506
|
+
// strong match. Only modules that already matched lexically are eligible (a mentioned module
|
|
507
|
+
// always is — its path components are query tokens by construction), preserving the levers'
|
|
508
|
+
// shared no-new-candidates safety scope.
|
|
509
|
+
const LIT_W = EXACT_W; // per-component weight — a verbatim module mention is the strongest locate signal
|
|
510
|
+
const LIT_MIN_COMPONENTS = 3; // "django.utils.http" fires; "django.utils"/"utils" never do
|
|
511
|
+
const LIT_COMP_CAP = 4; // like SYM_MATCH_CAP: only the top-K highest-IDF components accrue
|
|
512
|
+
const LIT_FRAC = 1.0; // bonus = min(litWeight × this, maxBase × LIT_CAP_FRAC)
|
|
513
|
+
const LIT_CAP_FRAC = 0.9; // … so a mention approaches — never dwarfs — the best lexical score
|
|
514
|
+
|
|
515
|
+
// PLAN_SEON_TUNING.md §7.6(5b) (opt-in via embedRank + an injected embedder, 2026-07-02): static-
|
|
516
|
+
// embedding re-rank — the deterministic "near-LLM" lever. The caller loads embed.mjs's
|
|
517
|
+
// potion-base-8M table (loadEmbedder(); null when the one-time-fetch weights are absent) and
|
|
518
|
+
// passes it as opts.embedder, keeping this module pure (no fs here; the flag no-ops with a
|
|
519
|
+
// one-time stderr note when the embedder is missing, so CI never needs the 30 MB artifact).
|
|
520
|
+
// Per-module text = path components + defined symbol names + doc first-lines — all read from the
|
|
521
|
+
// graph, never from source — embedded lazily and cached per process (EMB_CACHE, WeakMap-keyed on
|
|
522
|
+
// the graph). Cosine(query, module) becomes the same bounded FRAC/CAP nudge as the proximity
|
|
523
|
+
// families: only re-ranks modules that ALREADY matched lexically, never introduces a candidate.
|
|
524
|
+
const EMB_FRAC = 0.2;
|
|
525
|
+
const EMB_CAP_FRAC = 0.35;
|
|
526
|
+
const EMB_TEXT_SYMBOL_CAP = 64; // bound the per-module text: top defines …
|
|
527
|
+
const EMB_TEXT_DOC_CAP = 12; // … and doc first-lines (a giant module can't grow an unbounded text)
|
|
528
|
+
const EMB_CACHE = new WeakMap(); // graph -> { embedder, texts, vecs: Map<moduleId, Float32Array> }
|
|
529
|
+
let embedWarned = false;
|
|
530
|
+
|
|
469
531
|
// PLAN_SEON_TUNING.md §5.15 "discriminative multi-hop expansion" (opt-in via beamSearch):
|
|
470
532
|
// generalizes the R1a/E1a/E1b family's single fixed-type, single-hop nudge into an adaptive,
|
|
471
533
|
// multi-PLY expansion. Terminology follows Wikipedia's "Beam search" and Lowerre & Reddy, "The
|
|
@@ -501,6 +563,31 @@ const BEAM_OVERFLOW_CAP = 4; // near-miss safety valve size
|
|
|
501
563
|
const BEAM_PLIES = 2; // hops of expansion
|
|
502
564
|
const BEAM_EDGE_GROUPS = [["imports"], ["calls", "callsSymbol"], ["inherits"], ["cochange"]];
|
|
503
565
|
|
|
566
|
+
/** embedRank: per-module embeddable text — path components + defined symbol names + doc
|
|
567
|
+
* first-lines, ALL already in the graph (never re-reads source), bounded by the EMB_TEXT_*
|
|
568
|
+
* caps. Built once per graph and cached alongside the vectors in EMB_CACHE. */
|
|
569
|
+
function moduleEmbedTexts(graph) {
|
|
570
|
+
const texts = new Map(); // moduleId -> text
|
|
571
|
+
const defIdx = definesIndex(graph);
|
|
572
|
+
const docs = new Map(); // moduleId -> [doc first-lines]
|
|
573
|
+
for (const ind of graph.individuals) {
|
|
574
|
+
const doc = (ind.attributes || []).find((a) => a.key === "doc")?.value;
|
|
575
|
+
if (!doc) continue;
|
|
576
|
+
const modId = (ind.class || "") === "Module" ? ind.id : moduleIdOf(graph, ind);
|
|
577
|
+
if (!modId) continue;
|
|
578
|
+
let arr = docs.get(modId);
|
|
579
|
+
if (!arr) docs.set(modId, (arr = []));
|
|
580
|
+
if (arr.length < EMB_TEXT_DOC_CAP) arr.push(String(doc).split("\n")[0]);
|
|
581
|
+
}
|
|
582
|
+
for (const ind of graph.individuals) {
|
|
583
|
+
if ((ind.class || "") !== "Module") continue;
|
|
584
|
+
const parts = String(ind.label).split(/[^a-zA-Z0-9_]+/).filter(Boolean);
|
|
585
|
+
const syms = (defIdx.get(ind.id) || []).slice(0, EMB_TEXT_SYMBOL_CAP);
|
|
586
|
+
texts.set(ind.id, [...parts, ...syms, ...(docs.get(ind.id) || [])].join(" "));
|
|
587
|
+
}
|
|
588
|
+
return texts;
|
|
589
|
+
}
|
|
590
|
+
|
|
504
591
|
/** Split a lowercased path label into boundary components: django/utils/text.py →
|
|
505
592
|
* {django,utils,text,py}. Component equality (not substring) stops "text" matching "ci<text>". */
|
|
506
593
|
function pathComponents(labelLc) {
|
|
@@ -604,7 +691,7 @@ function beamExpand(graph, scored, beamWidth) {
|
|
|
604
691
|
* + EXACT-symbol matches, re-ranks with a bounded import-proximity bonus, and breaks ties by
|
|
605
692
|
* matched-symbol DENSITY (a concrete signal — never ground truth). Pure; deterministic. */
|
|
606
693
|
function scoreModules(graph, tokens, opts = {}) {
|
|
607
|
-
const { demoteNonProd = false, callAdjacency = false, implOfInterface = false, beamSearch = false, proseBoost = false } = opts;
|
|
694
|
+
const { demoteNonProd = false, callAdjacency = false, implOfInterface = false, beamSearch = false, proseBoost = false, proseLayers = false, literalMention = false, embedRank = false, rawQuery = "" } = opts;
|
|
608
695
|
const beamWidth = Number.isFinite(opts.beamWidth) && opts.beamWidth > 0 ? opts.beamWidth : 8;
|
|
609
696
|
const defIdx = definesIndex(graph);
|
|
610
697
|
// Precompute each module's path components + defined-symbol exact/component sets, once.
|
|
@@ -617,7 +704,12 @@ function scoreModules(graph, tokens, opts = {}) {
|
|
|
617
704
|
const symSet = new Set(defines.map((d) => d.toLowerCase())); // exact symbol names
|
|
618
705
|
const symComps = new Set();
|
|
619
706
|
for (const d of defines) for (const c of identComponents(d)) symComps.add(c);
|
|
620
|
-
|
|
707
|
+
// literalMention only: the Module's `dotted` attribute (mgx:dotted) — the verbatim form a
|
|
708
|
+
// task statement uses ("django.utils.http"); "" when absent. Gated so OFF does zero work.
|
|
709
|
+
const dotted = literalMention
|
|
710
|
+
? String((ind.attributes || []).find((a) => a.key === "dotted")?.value || "").toLowerCase()
|
|
711
|
+
: "";
|
|
712
|
+
modules.push({ ind, label, labelLc, defines, symSet, symComps, dotted });
|
|
621
713
|
}
|
|
622
714
|
const N = modules.length || 1;
|
|
623
715
|
// Inverse module-frequency: a token in many modules carries little locating signal; a rare one
|
|
@@ -654,6 +746,50 @@ function scoreModules(graph, tokens, opts = {}) {
|
|
|
654
746
|
const density = m.defines.length ? matchCount / m.defines.length : 0;
|
|
655
747
|
scored.push({ ind: m.ind, score, defineCount: m.defines.length, matching, density });
|
|
656
748
|
}
|
|
749
|
+
// §7.5/§7.6(5a) literalMention (opt-in): verbatim dotted-name/path mentions in the RAW query —
|
|
750
|
+
// see the LIT_* constants' comment above for the full design. Runs before the proximity
|
|
751
|
+
// families so a mentioned module donates adjacency like any other strong match.
|
|
752
|
+
if (literalMention && rawQuery && scored.length) {
|
|
753
|
+
const rawLc = String(rawQuery).toLowerCase();
|
|
754
|
+
const continues = (ch) => ch != null && /[a-z0-9_./]/.test(ch);
|
|
755
|
+
// Whole, boundary-checked occurrence of `cand` in the raw query (see boundary rule above).
|
|
756
|
+
const mentioned = (cand) => {
|
|
757
|
+
for (let i = rawLc.indexOf(cand); i !== -1; i = rawLc.indexOf(cand, i + 1)) {
|
|
758
|
+
if (!continues(rawLc[i - 1]) && !continues(rawLc[i + cand.length])) return true;
|
|
759
|
+
}
|
|
760
|
+
return false;
|
|
761
|
+
};
|
|
762
|
+
// IDF for a candidate's components: normally already in the map (they are query tokens by
|
|
763
|
+
// construction when tokens came from this same raw query); computed-and-cached otherwise
|
|
764
|
+
// (a caller passing mismatched tokens/rawQuery must not crash or skew).
|
|
765
|
+
const idfOf = (t) => {
|
|
766
|
+
if (!idf.has(t)) {
|
|
767
|
+
let df = 0;
|
|
768
|
+
for (const m of modules) if (m.labelLc.includes(t) || m.symComps.has(t) || m.symSet.has(t)) df++;
|
|
769
|
+
idf.set(t, Math.log(1 + N / (1 + df)));
|
|
770
|
+
}
|
|
771
|
+
return idf.get(t);
|
|
772
|
+
};
|
|
773
|
+
const byModId = new Map(modules.map((m) => [m.ind.id, m]));
|
|
774
|
+
let maxBase = 0;
|
|
775
|
+
for (const s of scored) maxBase = Math.max(maxBase, s.score);
|
|
776
|
+
for (const s of scored) {
|
|
777
|
+
const m = byModId.get(s.ind.id);
|
|
778
|
+
if (!m) continue;
|
|
779
|
+
let litWeight = 0; // best single matched candidate (dotted vs path share components anyway)
|
|
780
|
+
for (const cand of new Set([m.dotted, m.labelLc])) {
|
|
781
|
+
if (!cand) continue;
|
|
782
|
+
if (cand.split(/[./]+/).filter(Boolean).length < LIT_MIN_COMPONENTS) continue; // specificity floor
|
|
783
|
+
if (!mentioned(cand)) continue;
|
|
784
|
+
// IDF-weight the candidate's tokens (same tokenizer as the query), highest first.
|
|
785
|
+
const weights = [...new Set(cand.split(/[^a-z0-9_]+/).filter(Boolean))].map(idfOf).sort((a, b) => b - a);
|
|
786
|
+
let w = 0;
|
|
787
|
+
for (let i = 0; i < Math.min(weights.length, LIT_COMP_CAP); i++) w += weights[i] * LIT_W;
|
|
788
|
+
litWeight = Math.max(litWeight, w);
|
|
789
|
+
}
|
|
790
|
+
if (litWeight) s.score += Math.min(litWeight * LIT_FRAC, maxBase * LIT_CAP_FRAC);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
657
793
|
// Import-graph proximity (rescaled): a matched module that imports / is imported by a
|
|
658
794
|
// STRONGER-matching module gets a bonus proportional to that neighbour, so a genuine 2nd module
|
|
659
795
|
// (truncatelines' text.py) rises with its sibling. Only re-ranks modules that ALREADY matched.
|
|
@@ -751,6 +887,73 @@ function scoreModules(graph, tokens, opts = {}) {
|
|
|
751
887
|
}
|
|
752
888
|
}
|
|
753
889
|
}
|
|
890
|
+
// Layered prose normalisation (opt-in): a query token that did NOT match a module lexically but
|
|
891
|
+
// resolves to one of its individuals through a NORMALISED prose layer (stem/lemma/canonical/spell)
|
|
892
|
+
// adds a bounded, discounted signal — see the PROSE_LAYER_* comment above. One proseLayerHits call
|
|
893
|
+
// per DISTINCT query token, ids folded to their containing module via moduleIdOfId (same as the
|
|
894
|
+
// proseBoost/call-adjacency families). Only tokens NOT already matching a module lexically count
|
|
895
|
+
// for that module (a layer hit is purely ADDITIVE evidence for otherwise-missed words — never
|
|
896
|
+
// double-counting a token the base score already saw), weighted by the token's own IDF (so a
|
|
897
|
+
// ubiquitous word contributes almost nothing) and halved (PROSE_LAYER_DISCOUNT: weaker than a
|
|
898
|
+
// verbatim match), then the shared FRAC/CAP nudge. Only re-ranks modules already in `scored`.
|
|
899
|
+
if (proseLayers && scored.length && graph.proseIndex) {
|
|
900
|
+
const scoredById = new Map(scored.map((s) => [s.ind.id, s]));
|
|
901
|
+
const modById = new Map(modules.map((m) => [m.ind.id, m]));
|
|
902
|
+
const layerSignal = new Map(); // moduleId -> accumulated discounted, IDF-weighted layer signal
|
|
903
|
+
for (const t of new Set(tokens)) {
|
|
904
|
+
const w = idf.get(t) || 0;
|
|
905
|
+
if (!w) continue;
|
|
906
|
+
const { ids } = proseLayerHits(graph.proseIndex, t);
|
|
907
|
+
if (!ids.length) continue;
|
|
908
|
+
const hitMods = new Set();
|
|
909
|
+
for (const id of ids) {
|
|
910
|
+
const modId = moduleIdOfId(graph, id);
|
|
911
|
+
if (!modId || hitMods.has(modId)) continue;
|
|
912
|
+
hitMods.add(modId);
|
|
913
|
+
if (!scoredById.has(modId)) continue; // never a new zero-match candidate
|
|
914
|
+
const m = modById.get(modId);
|
|
915
|
+
if (m && (m.symSet.has(t) || m.symComps.has(t) || m.labelLc.includes(t))) continue; // already matched lexically → not additive
|
|
916
|
+
layerSignal.set(modId, (layerSignal.get(modId) || 0) + w * PROSE_LAYER_DISCOUNT);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
for (const s of scored) {
|
|
920
|
+
const signal = layerSignal.get(s.ind.id) || 0;
|
|
921
|
+
if (!signal) continue;
|
|
922
|
+
s.score += Math.min(signal * PROSE_LAYER_FRAC, s.score * PROSE_LAYER_CAP_FRAC);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
// §7.6(5b) embedRank (opt-in): static-embedding cosine re-rank — see the EMB_* constants'
|
|
926
|
+
// comment above. The embedder is INJECTED (opts.embedder, from embed.mjs's loadEmbedder) so
|
|
927
|
+
// this module stays fs-free; absent embedder → no-op with a one-time stderr note, never a
|
|
928
|
+
// failure (the 30 MB weights are a local opt-in fetch, not a test/CI dependency).
|
|
929
|
+
if (embedRank) {
|
|
930
|
+
if (!opts.embedder) {
|
|
931
|
+
if (!embedWarned) {
|
|
932
|
+
embedWarned = true;
|
|
933
|
+
process.stderr.write("seonix: embedRank requested but no embedder available (weights not fetched? see `npm run refs:embeddings`) — flag is a no-op\n");
|
|
934
|
+
}
|
|
935
|
+
} else if (scored.length) {
|
|
936
|
+
const embedder = opts.embedder;
|
|
937
|
+
let cache = EMB_CACHE.get(graph);
|
|
938
|
+
if (!cache || cache.embedder !== embedder) {
|
|
939
|
+
cache = { embedder, texts: moduleEmbedTexts(graph), vecs: new Map() };
|
|
940
|
+
EMB_CACHE.set(graph, cache);
|
|
941
|
+
}
|
|
942
|
+
const qv = embedder.embed(rawQuery || tokens.join(" "));
|
|
943
|
+
let maxBase = 0;
|
|
944
|
+
for (const s of scored) maxBase = Math.max(maxBase, s.score);
|
|
945
|
+
for (const s of scored) {
|
|
946
|
+
let v = cache.vecs.get(s.ind.id);
|
|
947
|
+
if (!v) {
|
|
948
|
+
v = embedder.embed(cache.texts.get(s.ind.id) || String(s.ind.label));
|
|
949
|
+
cache.vecs.set(s.ind.id, v);
|
|
950
|
+
}
|
|
951
|
+
const sim = Math.max(0, cosine(qv, v)); // negative similarity never penalises
|
|
952
|
+
if (!sim) continue;
|
|
953
|
+
s.score += Math.min(sim * maxBase * EMB_FRAC, s.score * EMB_CAP_FRAC);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
}
|
|
754
957
|
// §5.15 beam search (opt-in): multi-ply generalization of the single-hop families above.
|
|
755
958
|
if (beamSearch && scored.length > 1) beamExpand(graph, scored, beamWidth);
|
|
756
959
|
// Tie-break: score → matched-symbol DENSITY (concrete, not ground truth) → fewer defines → shorter label.
|
|
@@ -766,9 +969,14 @@ function scoreModules(graph, tokens, opts = {}) {
|
|
|
766
969
|
* SELECTION that consumes this gap is OFF by default in run.mjs/selectModules — it over-injected
|
|
767
970
|
* on some tasks. The shipped default takes the top-2 instead. */
|
|
768
971
|
export function searchModulesRanked(graph, query, opts = {}) {
|
|
769
|
-
const
|
|
972
|
+
const raw = String(query || "");
|
|
973
|
+
const tokens = raw.toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean);
|
|
770
974
|
if (!tokens.length) return [];
|
|
771
|
-
|
|
975
|
+
// literalMention needs the query BEFORE tokenization (the tokenizer destroys the dotted refs
|
|
976
|
+
// it matches on) and embedRank embeds the raw phrasing; threaded only when a flag that
|
|
977
|
+
// consumes it is on, so the OFF path is provably unchanged.
|
|
978
|
+
const effOpts = (opts.literalMention || opts.embedRank) ? { ...opts, rawQuery: raw } : opts;
|
|
979
|
+
return scoreModules(graph, tokens, effOpts).map((s) => ({ path: String(s.ind.label), score: s.score }));
|
|
772
980
|
}
|
|
773
981
|
|
|
774
982
|
// B016 R1b, promoted to the shipped default (2026-07-02): positive in every measured cell across
|
package/src/cs_treesitter.mjs
CHANGED
|
@@ -32,6 +32,7 @@ const endLine = (n) => n.endPosition.row + 1;
|
|
|
32
32
|
const fieldText = (n, f) => n.childForFieldName(f)?.text || "";
|
|
33
33
|
|
|
34
34
|
const TYPE_DECLS = new Set(["class_declaration", "interface_declaration", "struct_declaration", "record_declaration", "enum_declaration"]);
|
|
35
|
+
const SUBKIND = { interface_declaration: "interface", struct_declaration: "struct", record_declaration: "record", enum_declaration: "enum" };
|
|
35
36
|
|
|
36
37
|
function collectCalls(node) {
|
|
37
38
|
const out = new Set();
|
|
@@ -41,6 +42,12 @@ function collectCalls(node) {
|
|
|
41
42
|
if (n.type === "invocation_expression") {
|
|
42
43
|
const fn = n.childForFieldName("function");
|
|
43
44
|
if (fn) out.add(fn.text.replace(/\s+/g, "").slice(0, 80));
|
|
45
|
+
} else if (n.type === "object_creation_expression") {
|
|
46
|
+
// `new X(...)` → bare "X" (generics/qualifiers stripped); implicit `new(...)`
|
|
47
|
+
// is a different node type and stays excluded (matches the Roslyn backend).
|
|
48
|
+
const t = n.childForFieldName("type");
|
|
49
|
+
const nm = t ? t.text.replace(/\s+/g, "").replace(/<.*$/s, "").split(".").pop() : "";
|
|
50
|
+
if (nm) out.add(nm.slice(0, 80));
|
|
44
51
|
}
|
|
45
52
|
for (let i = 0; i < n.namedChildCount; i++) stack.push(n.namedChild(i));
|
|
46
53
|
}
|
|
@@ -58,6 +65,50 @@ function modifiersText(node) {
|
|
|
58
65
|
}
|
|
59
66
|
const visFrom = (mods) => mods.includes("private") ? "private" : mods.includes("protected") ? "protected" : "";
|
|
60
67
|
|
|
68
|
+
/** Emit one type declaration (and, recursively, its nested types as Outer.Inner —
|
|
69
|
+
* matching the Roslyn backend's qualified names). kind stays "class"; the flavour
|
|
70
|
+
* (interface/struct/record/enum) lands in `subkind`. */
|
|
71
|
+
function emitType(n, prefix, defines, exports) {
|
|
72
|
+
const simple = fieldText(n, "name");
|
|
73
|
+
if (!simple) return;
|
|
74
|
+
const cname = prefix ? `${prefix}.${simple}` : simple;
|
|
75
|
+
const mods = modifiersText(n);
|
|
76
|
+
const bases = [];
|
|
77
|
+
const baseList = n.childForFieldName("bases") || n.namedChildren.find((c) => c.type === "base_list");
|
|
78
|
+
if (baseList) for (let i = 0; i < baseList.namedChildCount; i++) bases.push(baseList.namedChild(i).text.slice(0, 80));
|
|
79
|
+
defines.push({ name: cname, kind: "class", lineno: line(n), end_lineno: endLine(n), bases, decorators: [],
|
|
80
|
+
...(SUBKIND[n.type] ? { subkind: SUBKIND[n.type] } : {}),
|
|
81
|
+
...(visFrom(mods) ? { visibility: visFrom(mods) } : {}) });
|
|
82
|
+
if (mods.includes("public")) exports.add(cname);
|
|
83
|
+
const body = n.childForFieldName("body");
|
|
84
|
+
if (!body) return;
|
|
85
|
+
for (let i = 0; i < body.namedChildCount; i++) {
|
|
86
|
+
const mem = body.namedChild(i);
|
|
87
|
+
if (TYPE_DECLS.has(mem.type)) {
|
|
88
|
+
emitType(mem, cname, defines, exports); // nested type → Outer.Inner
|
|
89
|
+
} else if (mem.type === "enum_member_declaration") {
|
|
90
|
+
const mn = fieldText(mem, "name") || mem.text;
|
|
91
|
+
if (mn) defines.push({ name: `${cname}.${mn}`, kind: "attribute", lineno: line(mem), end_lineno: endLine(mem), decorators: [], is_constant: true });
|
|
92
|
+
} else if (mem.type === "method_declaration" || mem.type === "constructor_declaration" || mem.type === "local_function_statement") {
|
|
93
|
+
const mn = fieldText(mem, "name") || cname;
|
|
94
|
+
const mmods = modifiersText(mem);
|
|
95
|
+
const params = (mem.childForFieldName("parameters")?.text || "").replace(/\s+/g, " ").replace(/^\(|\)$/g, "").slice(0, 160);
|
|
96
|
+
const returns = (mem.childForFieldName("returns") || mem.childForFieldName("type"))?.text?.slice(0, 80) || "";
|
|
97
|
+
defines.push({ name: `${cname}.${mn}`, kind: "method", lineno: line(mem), end_lineno: endLine(mem),
|
|
98
|
+
decorators: [], params, returns, calls: collectCalls(mem),
|
|
99
|
+
...(mmods.includes("static") ? { is_static: true } : {}),
|
|
100
|
+
...(visFrom(mmods) ? { visibility: visFrom(mmods) } : {}) });
|
|
101
|
+
} else if (mem.type === "property_declaration") {
|
|
102
|
+
const mn = fieldText(mem, "name");
|
|
103
|
+
if (mn) defines.push({ name: `${cname}.${mn}`, kind: "attribute", lineno: line(mem), end_lineno: endLine(mem), decorators: [] });
|
|
104
|
+
} else if (mem.type === "field_declaration") {
|
|
105
|
+
const v = mem.descendantsOfType?.("variable_declarator")?.[0];
|
|
106
|
+
const mn = v ? fieldText(v, "name") || v.text : "";
|
|
107
|
+
if (mn) defines.push({ name: `${cname}.${mn}`, kind: "attribute", lineno: line(mem), end_lineno: endLine(mem), decorators: [] });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
61
112
|
export async function extractFile(absPath, root) {
|
|
62
113
|
const text = await readFile(absPath, "utf8").catch(() => null);
|
|
63
114
|
const path = relPath(root, absPath);
|
|
@@ -84,41 +135,9 @@ export async function extractFile(absPath, root) {
|
|
|
84
135
|
} else if ((n.type === "namespace_declaration" || n.type === "file_scoped_namespace_declaration") && !primaryNs) {
|
|
85
136
|
primaryNs = fieldText(n, "name");
|
|
86
137
|
} else if (TYPE_DECLS.has(n.type)) {
|
|
87
|
-
|
|
88
|
-
if (cname) {
|
|
89
|
-
const mods = modifiersText(n);
|
|
90
|
-
const bases = [];
|
|
91
|
-
const baseList = n.childForFieldName("bases") || n.namedChildren.find((c) => c.type === "base_list");
|
|
92
|
-
if (baseList) for (let i = 0; i < baseList.namedChildCount; i++) bases.push(baseList.namedChild(i).text.slice(0, 80));
|
|
93
|
-
defines.push({ name: cname, kind: "class", lineno: line(n), end_lineno: endLine(n), bases, decorators: [],
|
|
94
|
-
...(visFrom(mods) ? { visibility: visFrom(mods) } : {}) });
|
|
95
|
-
if (mods.includes("public")) exports.add(cname);
|
|
96
|
-
const body = n.childForFieldName("body");
|
|
97
|
-
if (body) {
|
|
98
|
-
for (let i = 0; i < body.namedChildCount; i++) {
|
|
99
|
-
const mem = body.namedChild(i);
|
|
100
|
-
if (mem.type === "method_declaration" || mem.type === "constructor_declaration" || mem.type === "local_function_statement") {
|
|
101
|
-
const mn = fieldText(mem, "name") || cname;
|
|
102
|
-
const mmods = modifiersText(mem);
|
|
103
|
-
const params = (mem.childForFieldName("parameters")?.text || "").replace(/\s+/g, " ").replace(/^\(|\)$/g, "").slice(0, 160);
|
|
104
|
-
const returns = (mem.childForFieldName("returns") || mem.childForFieldName("type"))?.text?.slice(0, 80) || "";
|
|
105
|
-
defines.push({ name: `${cname}.${mn}`, kind: "method", lineno: line(mem), end_lineno: endLine(mem),
|
|
106
|
-
decorators: [], params, returns, calls: collectCalls(mem),
|
|
107
|
-
...(mmods.includes("static") ? { is_static: true } : {}),
|
|
108
|
-
...(visFrom(mmods) ? { visibility: visFrom(mmods) } : {}) });
|
|
109
|
-
} else if (mem.type === "property_declaration") {
|
|
110
|
-
const mn = fieldText(mem, "name");
|
|
111
|
-
if (mn) defines.push({ name: `${cname}.${mn}`, kind: "attribute", lineno: line(mem), end_lineno: endLine(mem), decorators: [] });
|
|
112
|
-
} else if (mem.type === "field_declaration") {
|
|
113
|
-
const v = mem.descendantsOfType?.("variable_declarator")?.[0];
|
|
114
|
-
const mn = v ? fieldText(v, "name") || v.text : "";
|
|
115
|
-
if (mn) defines.push({ name: `${cname}.${mn}`, kind: "attribute", lineno: line(mem), end_lineno: endLine(mem), decorators: [] });
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
// don't descend into the type body again for nested types beyond members
|
|
120
|
-
}
|
|
138
|
+
emitType(n, "", defines, exports);
|
|
121
139
|
}
|
|
140
|
+
// descend, but not back into a type body (emitType recurses for nested types)
|
|
122
141
|
if (!TYPE_DECLS.has(n.type)) for (let i = 0; i < n.namedChildCount; i++) stack.push(n.namedChild(i));
|
|
123
142
|
}
|
|
124
143
|
|
|
@@ -137,4 +156,8 @@ export async function ingest(root, { ignore = null } = {}) {
|
|
|
137
156
|
return { modules, failures, fileCount: files.length };
|
|
138
157
|
}
|
|
139
158
|
|
|
159
|
+
export function toolAvailable() {
|
|
160
|
+
try { loadTreeSitter(); return true; } catch { return false; }
|
|
161
|
+
}
|
|
162
|
+
|
|
140
163
|
export const meta = { id: "treesitter-cs", language: "c#", lib: "tree-sitter-c-sharp", exts: EXTS };
|
package/src/embed.mjs
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// embed.mjs — deterministic static-embedding lookup (model2vec potion-base-8M).
|
|
2
|
+
// PLAN_SEON_TUNING.md §7.6(5b), 2026-07-02 library-leverage review.
|
|
3
|
+
//
|
|
4
|
+
// The honest "near-LLM" locate lever: a STATIC per-token embedding table (model2vec's
|
|
5
|
+
// potion-base-8M, MIT — 29,528 WordPiece subwords × 256 fp32 dims, ~30 MB) read straight
|
|
6
|
+
// from its safetensors export, mean-pooled and L2-normalised. No ONNX runtime, no model
|
|
7
|
+
// calls, no network after the one-time fetch — pure table lookup + float arithmetic, so
|
|
8
|
+
// the same text always embeds to the same vector ($0, deterministic, offline).
|
|
9
|
+
//
|
|
10
|
+
// Dependency choice (documented per the review): the safetensors format is an 8-byte LE
|
|
11
|
+
// header length + JSON header + raw little-endian tensor bytes, and the tokenizer is a
|
|
12
|
+
// plain Bert-style WordPiece (tokenizer.json: BertNormalizer lowercase + BertPreTokenizer
|
|
13
|
+
// + greedy longest-match with "##" continuations) — both are small enough to hand-roll
|
|
14
|
+
// with node built-ins, so neither the @yarflam/potion-base-8m fallback package nor an HF
|
|
15
|
+
// tokenizer dependency is taken. Numerical intent follows model2vec's own encode (subword
|
|
16
|
+
// ids WITHOUT the [CLS]/[SEP] template, mean pool, normalize per config.json) — exact
|
|
17
|
+
// float parity with the Python lib is not claimed; determinism and rank usefulness are.
|
|
18
|
+
//
|
|
19
|
+
// The weights are NEVER committed and NEVER in the npm package: they live in the
|
|
20
|
+
// gitignored vendor/ tree (fetched by scripts/fetch-embeddings.mjs, `npm run
|
|
21
|
+
// refs:embeddings`, SHA-256-pinned like the repo's other binary artefacts). Everything
|
|
22
|
+
// here degrades gracefully — loadEmbedder() returns null when the weights dir is absent,
|
|
23
|
+
// and callers (codegraph.mjs's opt-in embedRank, scripts/rank-gate.mjs) no-op with a
|
|
24
|
+
// clear note instead of failing, so CI/tests never require the 30 MB download.
|
|
25
|
+
|
|
26
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
27
|
+
import { join, dirname } from "node:path";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
|
|
30
|
+
const MODEL_FILE = "model.safetensors";
|
|
31
|
+
const TOKENIZER_FILE = "tokenizer.json";
|
|
32
|
+
const CONFIG_FILE = "config.json";
|
|
33
|
+
|
|
34
|
+
/** Default artifact dir: $SEONIX_EMBED_DIR, else <repo>/vendor/embeddings/potion-base-8M
|
|
35
|
+
* (gitignored via vendor/ — the location scripts/fetch-embeddings.mjs writes). */
|
|
36
|
+
export function defaultEmbeddingsDir() {
|
|
37
|
+
if (process.env.SEONIX_EMBED_DIR) return process.env.SEONIX_EMBED_DIR;
|
|
38
|
+
const here = dirname(fileURLToPath(import.meta.url)); // packages/seonix/src
|
|
39
|
+
return join(here, "..", "..", "..", "vendor", "embeddings", "potion-base-8M");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---- safetensors (hand-rolled: u64le header length + JSON header + raw tensors) -------
|
|
43
|
+
|
|
44
|
+
/** Read the single 2-D F32 embedding tensor from a safetensors file →
|
|
45
|
+
* { matrix: Float32Array (row-major), rows, dim }. model2vec exports exactly one
|
|
46
|
+
* tensor named "embeddings"; any lone 2-D F32 tensor is accepted for test fixtures. */
|
|
47
|
+
function readSafetensors(file) {
|
|
48
|
+
const buf = readFileSync(file);
|
|
49
|
+
const headerLen = Number(buf.readBigUInt64LE(0));
|
|
50
|
+
const header = JSON.parse(buf.subarray(8, 8 + headerLen).toString("utf8"));
|
|
51
|
+
const name = header.embeddings
|
|
52
|
+
? "embeddings"
|
|
53
|
+
: Object.keys(header).find((k) => k !== "__metadata__" && header[k]?.shape?.length === 2);
|
|
54
|
+
const t = name && header[name];
|
|
55
|
+
if (!t) throw new Error(`no 2-D tensor in ${file}`);
|
|
56
|
+
if (t.dtype !== "F32") throw new Error(`unsupported dtype ${t.dtype} in ${file} (only F32)`);
|
|
57
|
+
const [rows, dim] = t.shape;
|
|
58
|
+
const [start, end] = t.data_offsets;
|
|
59
|
+
const bytes = buf.subarray(8 + headerLen + start, 8 + headerLen + end);
|
|
60
|
+
if (bytes.byteLength !== rows * dim * 4) throw new Error(`tensor size mismatch in ${file}`);
|
|
61
|
+
// Copy into a fresh ArrayBuffer: the slice's byteOffset inside the file buffer is not
|
|
62
|
+
// guaranteed 4-byte aligned, and Float32Array requires alignment.
|
|
63
|
+
const matrix = new Float32Array(rows * dim);
|
|
64
|
+
new Uint8Array(matrix.buffer).set(bytes);
|
|
65
|
+
return { matrix, rows, dim };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---- Bert-style WordPiece tokenizer (from tokenizer.json) -----------------------------
|
|
69
|
+
|
|
70
|
+
const isPunct = (ch) => {
|
|
71
|
+
const c = ch.codePointAt(0);
|
|
72
|
+
// ASCII punctuation ranges (Bert treats these as standalone tokens) + general unicode P/S.
|
|
73
|
+
return (c >= 33 && c <= 47) || (c >= 58 && c <= 64) || (c >= 91 && c <= 96) || (c >= 123 && c <= 126) ||
|
|
74
|
+
/[\p{P}\p{S}]/u.test(ch);
|
|
75
|
+
};
|
|
76
|
+
const isCjk = (ch) => {
|
|
77
|
+
const c = ch.codePointAt(0);
|
|
78
|
+
return (c >= 0x4e00 && c <= 0x9fff) || (c >= 0x3400 && c <= 0x4dbf) ||
|
|
79
|
+
(c >= 0xf900 && c <= 0xfaff) || (c >= 0x20000 && c <= 0x2ffff);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function makeTokenizer(spec) {
|
|
83
|
+
if (spec?.model?.type !== "WordPiece") {
|
|
84
|
+
throw new Error(`unsupported tokenizer model "${spec?.model?.type}" (only WordPiece)`);
|
|
85
|
+
}
|
|
86
|
+
const vocab = spec.model.vocab; // token -> id
|
|
87
|
+
const contPrefix = spec.model.continuing_subword_prefix ?? "##";
|
|
88
|
+
const maxChars = spec.model.max_input_chars_per_word ?? 100;
|
|
89
|
+
const unkId = vocab[spec.model.unk_token] ?? null;
|
|
90
|
+
const lowercase = spec.normalizer?.lowercase !== false;
|
|
91
|
+
|
|
92
|
+
// BertNormalizer: clean control chars, pad CJK, lowercase (+ strip accents when lowercasing).
|
|
93
|
+
const normalize = (text) => {
|
|
94
|
+
let s = String(text).replace(/[\u0000\ufffd]/g, "").replace(/[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, " ");
|
|
95
|
+
s = [...s].map((ch) => (isCjk(ch) ? ` ${ch} ` : ch)).join("");
|
|
96
|
+
if (lowercase) s = s.toLowerCase().normalize("NFD").replace(/\p{Mn}/gu, "");
|
|
97
|
+
return s;
|
|
98
|
+
};
|
|
99
|
+
// BertPreTokenizer: split on whitespace; every punctuation char is its own word.
|
|
100
|
+
const preTokenize = (s) => {
|
|
101
|
+
const words = [];
|
|
102
|
+
for (const chunk of s.split(/\s+/)) {
|
|
103
|
+
if (!chunk) continue;
|
|
104
|
+
let cur = "";
|
|
105
|
+
for (const ch of chunk) {
|
|
106
|
+
if (isPunct(ch)) {
|
|
107
|
+
if (cur) { words.push(cur); cur = ""; }
|
|
108
|
+
words.push(ch);
|
|
109
|
+
} else cur += ch;
|
|
110
|
+
}
|
|
111
|
+
if (cur) words.push(cur);
|
|
112
|
+
}
|
|
113
|
+
return words;
|
|
114
|
+
};
|
|
115
|
+
// Greedy longest-match WordPiece per word; a word with no valid segmentation → [UNK].
|
|
116
|
+
const wordPiece = (word) => {
|
|
117
|
+
if (word.length > maxChars) return unkId == null ? [] : [unkId];
|
|
118
|
+
const ids = [];
|
|
119
|
+
let start = 0;
|
|
120
|
+
while (start < word.length) {
|
|
121
|
+
let end = word.length;
|
|
122
|
+
let id = null;
|
|
123
|
+
while (end > start) {
|
|
124
|
+
const piece = (start > 0 ? contPrefix : "") + word.slice(start, end);
|
|
125
|
+
if (vocab[piece] !== undefined) { id = vocab[piece]; break; }
|
|
126
|
+
end--;
|
|
127
|
+
}
|
|
128
|
+
if (id == null) return unkId == null ? [] : [unkId];
|
|
129
|
+
ids.push(id);
|
|
130
|
+
start = end;
|
|
131
|
+
}
|
|
132
|
+
return ids;
|
|
133
|
+
};
|
|
134
|
+
// No [CLS]/[SEP] template — model2vec pools content subwords only.
|
|
135
|
+
return (text) => preTokenize(normalize(text)).flatMap(wordPiece);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---- embedder ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
const LOADED = new Map(); // dir -> embedder | null (per-process cache; weights load once)
|
|
141
|
+
|
|
142
|
+
/** Load tokenizer + embedding matrix from `dir` (default: defaultEmbeddingsDir()).
|
|
143
|
+
* Returns null — silently — when the artifacts are absent (the one-time
|
|
144
|
+
* `npm run refs:embeddings` fetch has not run): callers no-op, never fail.
|
|
145
|
+
* The returned embedder is { dim, dir, embed(text) → L2-normalised Float32Array }. */
|
|
146
|
+
export function loadEmbedder({ dir = defaultEmbeddingsDir() } = {}) {
|
|
147
|
+
if (LOADED.has(dir)) return LOADED.get(dir);
|
|
148
|
+
const modelFile = join(dir, MODEL_FILE);
|
|
149
|
+
const tokFile = join(dir, TOKENIZER_FILE);
|
|
150
|
+
if (!existsSync(modelFile) || !existsSync(tokFile)) {
|
|
151
|
+
LOADED.set(dir, null);
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
const { matrix, rows, dim } = readSafetensors(modelFile);
|
|
155
|
+
const tokenize = makeTokenizer(JSON.parse(readFileSync(tokFile, "utf8")));
|
|
156
|
+
let cfg = {};
|
|
157
|
+
try { cfg = JSON.parse(readFileSync(join(dir, CONFIG_FILE), "utf8")); } catch { /* optional */ }
|
|
158
|
+
const doNormalize = cfg.normalize !== false;
|
|
159
|
+
|
|
160
|
+
const embed = (text) => {
|
|
161
|
+
const ids = tokenize(text);
|
|
162
|
+
const v = new Float32Array(dim);
|
|
163
|
+
if (!ids.length) return v; // zero vector: cosine 0 against everything
|
|
164
|
+
for (const id of ids) {
|
|
165
|
+
if (id < 0 || id >= rows) continue;
|
|
166
|
+
const off = id * dim;
|
|
167
|
+
for (let j = 0; j < dim; j++) v[j] += matrix[off + j];
|
|
168
|
+
}
|
|
169
|
+
for (let j = 0; j < dim; j++) v[j] /= ids.length; // mean pool
|
|
170
|
+
if (doNormalize) {
|
|
171
|
+
let norm = 0;
|
|
172
|
+
for (let j = 0; j < dim; j++) norm += v[j] * v[j];
|
|
173
|
+
norm = Math.sqrt(norm);
|
|
174
|
+
if (norm > 0) for (let j = 0; j < dim; j++) v[j] /= norm;
|
|
175
|
+
}
|
|
176
|
+
return v;
|
|
177
|
+
};
|
|
178
|
+
const embedder = { dim, dir, embed };
|
|
179
|
+
LOADED.set(dir, embedder);
|
|
180
|
+
return embedder;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Cosine similarity. Over L2-normalised vectors this is just the dot product, but the
|
|
184
|
+
* full form is kept so unnormalised test fixtures behave. 0 when either vector is zero. */
|
|
185
|
+
export function cosine(a, b) {
|
|
186
|
+
let dot = 0, na = 0, nb = 0;
|
|
187
|
+
const n = Math.min(a.length, b.length);
|
|
188
|
+
for (let i = 0; i < n; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
|
|
189
|
+
if (na === 0 || nb === 0) return 0;
|
|
190
|
+
return dot / (Math.sqrt(na) * Math.sqrt(nb));
|
|
191
|
+
}
|