@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/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
- modules.push({ ind, label, labelLc, defines, symSet, symComps });
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 tokens = String(query || "").toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean);
972
+ const raw = String(query || "");
973
+ const tokens = raw.toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean);
770
974
  if (!tokens.length) return [];
771
- return scoreModules(graph, tokens, opts).map((s) => ({ path: String(s.ind.label), score: s.score }));
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
@@ -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
- const cname = fieldText(n, "name");
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
+ }