@polycode-projects/seonix 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.mjs +95 -12
- package/package.json +5 -2
- package/src/ask-vocab.mjs +258 -0
- package/src/ask.mjs +762 -0
- package/src/browser.mjs +769 -0
- package/src/codegraph.mjs +352 -23
- package/src/cs_treesitter.mjs +2 -2
- package/src/extract.mjs +51 -8
- package/src/extract_lang.mjs +17 -2
- package/src/java_javaparser.mjs +54 -0
- package/src/java_treesitter.mjs +216 -0
- package/src/jsts_tsc.mjs +2 -2
- package/src/prose.mjs +145 -0
- package/src/schema-docs.mjs +225 -0
- package/src/server.mjs +35 -4
- package/src/temporal.mjs +559 -0
- package/src/viz.mjs +666 -94
- package/src/walk.mjs +52 -5
package/src/codegraph.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { lookupByProseTokens } from "./prose.mjs";
|
|
2
|
+
|
|
1
3
|
// Pure (no-network, no-fs) query logic over the typed `entities` payload that the
|
|
2
4
|
// deterministic indexer writes to <repo>/.seonix/graph.json (shape produced by
|
|
3
5
|
// src/extract.mjs):
|
|
@@ -47,6 +49,11 @@ export function parseEntities(payload) {
|
|
|
47
49
|
relations,
|
|
48
50
|
truncated,
|
|
49
51
|
generatedAt: payload?.generated_at || null,
|
|
52
|
+
// Second pass (PLAN_PROSE_INDEX.md): word -> [individual ids], passed through
|
|
53
|
+
// byte-identical from the payload so ask.mjs's resolveObject can consult it as a
|
|
54
|
+
// fallback tier without reaching back into the raw payload itself. {} when the
|
|
55
|
+
// build had prose disabled or the payload predates this field.
|
|
56
|
+
proseIndex: payload?.proseIndex || {},
|
|
50
57
|
};
|
|
51
58
|
}
|
|
52
59
|
|
|
@@ -265,16 +272,41 @@ function truncationNote(graph) {
|
|
|
265
272
|
* Diamonds collapse (a node appears once, at its shortest depth); cycles
|
|
266
273
|
* terminate via the visited set. Each dependent carries the via-predicate and
|
|
267
274
|
* the test modules covering it (subjects of tests-kind edges pointing at it).
|
|
275
|
+
*
|
|
276
|
+
* Module-coarse "calls" (`mgx:callsCoarse`, extract.mjs) is deliberately
|
|
277
|
+
* conservative — it only fires when the callee's module is ALREADY in the
|
|
278
|
+
* caller's import list ("coarse, import-backed calls", extract.mjs's own
|
|
279
|
+
* comment), so by construction every "calls" edge is a strict subset of an
|
|
280
|
+
* "imports" edge between the same pair — it never independently extends this
|
|
281
|
+
* closure's reach beyond what "imports" alone already gives it. `callsSymbol`
|
|
282
|
+
* (fn/method-granular, no import-backing requirement — same-module calls,
|
|
283
|
+
* ambiguous-name calls the coarse pass drops) is the richer signal; this
|
|
284
|
+
* closure also folds it in, coarsened to module level on read (never stored),
|
|
285
|
+
* mirroring the technique `adjacencyForKinds`/`BEAM_EDGE_GROUPS` already use
|
|
286
|
+
* for the same reason.
|
|
268
287
|
*/
|
|
269
288
|
export function impactClosure(graph, ind, { maxDepth = 8 } = {}) {
|
|
270
289
|
const dependents = new Map();
|
|
271
290
|
const coveredBy = new Map(); // moduleId → [test labels]
|
|
291
|
+
const addDependent = (objectId, subjectId, subjectLabel, via) => {
|
|
292
|
+
// Self-loop guard: callsSymbol coarsens to module level, so two symbols in
|
|
293
|
+
// the SAME module calling each other must not produce a module pointing at
|
|
294
|
+
// itself (imports/calls edges are already module-to-module and can't self-loop).
|
|
295
|
+
if (!objectId || !subjectId || objectId === subjectId) return;
|
|
296
|
+
if (!dependents.has(objectId)) dependents.set(objectId, []);
|
|
297
|
+
dependents.get(objectId).push({ id: subjectId, label: subjectLabel, via });
|
|
298
|
+
};
|
|
272
299
|
for (const g of graph.relations) {
|
|
273
300
|
const kind = relationKind(g);
|
|
274
301
|
if (kind === "imports" || kind === "calls") {
|
|
302
|
+
for (const e of g.edges) addDependent(e.object, e.subject, e.subjectLabel || e.subject, g.predicate);
|
|
303
|
+
} else if (kind === "callsSymbol") {
|
|
275
304
|
for (const e of g.edges) {
|
|
276
|
-
|
|
277
|
-
|
|
305
|
+
const subjModId = moduleIdOfId(graph, e.subject);
|
|
306
|
+
const objModId = moduleIdOfId(graph, e.object);
|
|
307
|
+
if (!subjModId || !objModId) continue;
|
|
308
|
+
const subjLabel = graph.byId.get(subjModId)?.label || subjModId;
|
|
309
|
+
addDependent(objModId, subjModId, subjLabel, g.predicate);
|
|
278
310
|
}
|
|
279
311
|
} else if (kind === "tests") {
|
|
280
312
|
for (const e of g.edges) {
|
|
@@ -313,7 +345,7 @@ const IMPACT_TESTS_PER_DEP = 3; // covering tests listed per dependent
|
|
|
313
345
|
|
|
314
346
|
export function renderImpact(graph, ind, { maxDepth = 8 } = {}) {
|
|
315
347
|
const levels = impactClosure(graph, ind, { maxDepth });
|
|
316
|
-
const lines = [`Impact of changing ${ind.label} (reverse closure over imports/calls edges):`];
|
|
348
|
+
const lines = [`Impact of changing ${ind.label} (reverse closure over imports/calls edges, module- and function-level):`];
|
|
317
349
|
if (!levels.length) {
|
|
318
350
|
lines.push("no dependents found in the current artifact — nothing imports or calls it (or its edges are not in the extracted graph yet).");
|
|
319
351
|
}
|
|
@@ -338,7 +370,7 @@ export function renderImpact(graph, ind, { maxDepth = 8 } = {}) {
|
|
|
338
370
|
});
|
|
339
371
|
const truncatedStructural = graph.truncated.filter((t) => {
|
|
340
372
|
const kind = relationKind({ predicate: t.predicate });
|
|
341
|
-
return kind === "imports" || kind === "calls" || kind === "tests";
|
|
373
|
+
return kind === "imports" || kind === "calls" || kind === "callsSymbol" || kind === "tests";
|
|
342
374
|
});
|
|
343
375
|
if (truncatedStructural.length) {
|
|
344
376
|
lines.push(
|
|
@@ -376,7 +408,7 @@ function definesIndex(graph) {
|
|
|
376
408
|
*/
|
|
377
409
|
const SEARCH_LIMIT = 10;
|
|
378
410
|
const SEARCH_SYMBOLS_SHOWN = 8;
|
|
379
|
-
//
|
|
411
|
+
// Locate scoring — IDF-weighted, component-aware. The rig queries with the WHOLE problem
|
|
380
412
|
// statement, so ubiquitous tokens (template/filter/value/text) would swamp the score; weight each
|
|
381
413
|
// token by rarity across modules (inverse module-frequency) so the distinctive term decides. Match
|
|
382
414
|
// identifier COMPONENTS (boundary-aware) so "text" hits utils/text.py but NOT "ci<text>". An EXACT
|
|
@@ -389,6 +421,85 @@ const SYM_MATCH_CAP = 4; // only the top-K highest-IDF symbol-COMPONENT hits c
|
|
|
389
421
|
const PROX_FRAC = 0.2; // import-adjacency bonus = this × the strongest matched neighbour …
|
|
390
422
|
const PROX_CAP_FRAC = 0.35; // … capped at this × the module's own score (a nudge — hubs can't run away)
|
|
391
423
|
const isTestLabel = (s) => /(^|\/)tests?\//.test(s) || /(^|\/)test_[^/]*\.py$/.test(s) || /\.tests(\.|$)/.test(s);
|
|
424
|
+
// B016 R1a (opt-in via demoteNonProd): non-production paths — examples, fixtures, sample/demo
|
|
425
|
+
// apps, and test-* harness packages — share path/symbol vocabulary with the production module
|
|
426
|
+
// and shadow it in locate (B015: js-express injected examples/route-middleware/index.js at
|
|
427
|
+
// rank 1; java-gson's TOP2 slot 2 was a test-shrinker fixture). DEMOTED, not excluded: none of
|
|
428
|
+
// the B015 truths live under these paths (checked corpus/instances-*/…/spec.json 2026-07-02),
|
|
429
|
+
// but a future task whose truth IS a test/example file must stay reachable.
|
|
430
|
+
const NONPROD_DEMOTE = 0.15;
|
|
431
|
+
const isNonProdLabel = (s) => /(^|\/)(examples?|fixtures?|samples?|demos?|benchmarks?|test-[^/]+)(\/|$)/.test(s);
|
|
432
|
+
// B016 E1a (opt-in via callAdjacency): resolved-call adjacency, same bounded-nudge shape as the
|
|
433
|
+
// import-proximity bonus. Python graphs carry call edges (django: 993 calls / 23,596 callsSymbol);
|
|
434
|
+
// the syntax-level C#/Java extractors emit ~none today, so this flag is Python-value only.
|
|
435
|
+
const CALL_PROX_FRAC = 0.2;
|
|
436
|
+
const CALL_PROX_CAP_FRAC = 0.35;
|
|
437
|
+
// B016 E1b (opt-in via implOfInterface): boost a module that implements an interface DEFINED
|
|
438
|
+
// in a strongly-matched module (C# IBasketService→BasketService, the rank-4 case). PLAN_B016
|
|
439
|
+
// §6.1 specified an `isAbstract` guard, but that field is never populated by any extractor —
|
|
440
|
+
// verified empirically 2026-07-02 against django/eshoponweb/java-gson .seonix/graph.json: 0
|
|
441
|
+
// individuals carry `isAbstract` in all three. The only real distinguishing signal in the data
|
|
442
|
+
// is C#'s naming convention (interfaces prefixed `I<Uppercase>`, e.g. IBasketService) — and C#'s
|
|
443
|
+
// `inherits` edges point at an UNRESOLVED `ext:<Name>` id rather than the interface's own
|
|
444
|
+
// individual, so the object must be resolved by an exact label match against internal
|
|
445
|
+
// Class-labeled individuals. SCOPED to `.cs` implementer modules only: without that scope, 11 of
|
|
446
|
+
// django's 7,014 inherits edges superficially match `I[A-Z]` (IOBase, IExact, IContains, …ordinary
|
|
447
|
+
// Python class names, not interfaces) and would reintroduce the over-injection E1a already showed
|
|
448
|
+
// on class-heavy Python graphs. Java's `inherits` predicate resolves cleanly to real individuals
|
|
449
|
+
// but carries no tag or naming convention distinguishing interface implementation from concrete
|
|
450
|
+
// inheritance (TypeAdapterFactory IS an interface in Gson, no "I" prefix) — a Java-safe guard does
|
|
451
|
+
// not exist without an extractor change (E1c, deferred). E1b is C#-only until then.
|
|
452
|
+
const IMPL_PROX_FRAC = 0.2;
|
|
453
|
+
const IMPL_PROX_CAP_FRAC = 0.35;
|
|
454
|
+
const isCsModuleLabel = (s) => /\.cs$/i.test(s);
|
|
455
|
+
const looksLikeCsInterface = (label) => /^I[A-Z]/.test(String(label || ""));
|
|
456
|
+
|
|
457
|
+
// PLAN_PROSE_INDEX.md §6 (opt-in via proseBoost, 2026-07-02): a matched module whose lexical
|
|
458
|
+
// score comes only from its path/symbol NAMES misses the case where the query's vocabulary
|
|
459
|
+
// only overlaps a decomposed identifier or a doc-comment elsewhere in that module (e.g. "billing
|
|
460
|
+
// calculation" never appears in `calculateTotalPrice`'s own path, only in its prose tokens).
|
|
461
|
+
// Same bounded-nudge shape/magnitude as the other proximity families — a nudge onto modules that
|
|
462
|
+
// ALREADY matched lexically (never a new zero-match candidate), never a replacement for the
|
|
463
|
+
// lexical score. NOT wired into any bench arm and NOT a shipped default — an available lever
|
|
464
|
+
// only, exactly like §5.15 beam search before it, pending its own gate/benchmark evidence.
|
|
465
|
+
const PROSE_PROX_FRAC = 0.2;
|
|
466
|
+
const PROSE_PROX_CAP_FRAC = 0.35;
|
|
467
|
+
const PROSE_LOOKUP_LIMIT = 50; // bounds lookupByProseTokens' scan; the CAP_FRAC bounds the nudge regardless
|
|
468
|
+
|
|
469
|
+
// PLAN_SEON_TUNING.md §5.15 "discriminative multi-hop expansion" (opt-in via beamSearch):
|
|
470
|
+
// generalizes the R1a/E1a/E1b family's single fixed-type, single-hop nudge into an adaptive,
|
|
471
|
+
// multi-PLY expansion. Terminology follows Wikipedia's "Beam search" and Lowerre & Reddy, "The
|
|
472
|
+
// Harpy Speech Understanding System" (Carnegie-Mellon, the paper that coined "beam search" — no
|
|
473
|
+
// University of Essex 1980s/90s beam-search paper exists; searched 2026-07-02, none found, this
|
|
474
|
+
// is the honest substitute). One hop of expansion = a PLY; the surviving candidate set at a ply =
|
|
475
|
+
// the BEAM; beamWidth (β) caps how many survive; discarding non-survivors = PRUNING.
|
|
476
|
+
//
|
|
477
|
+
// Harpy's own beamwidth was a MARGIN/THRESHOLD relative to the ply's best score ("candidates
|
|
478
|
+
// that fall below a threshold of acceptability are pruned"), not a fixed count — this is a
|
|
479
|
+
// threshold+cap HYBRID (keep everyone within BEAM_MARGIN_FRAC of the ply's best, THEN cap at β),
|
|
480
|
+
// not naive top-k. A fixed-count beam would prematurely discard exactly the kind of weak-then-
|
|
481
|
+
// strong candidate E1b's own motivating case demonstrated: BasketService.cs sat at lexical rank 4
|
|
482
|
+
// and was only promoted by considering impl-of-interface structure beyond the first pass — a
|
|
483
|
+
// hard top-k cut at ply 0 could drop such a candidate before any later ply had a chance to
|
|
484
|
+
// recover it (Russell & Norvig's "local beam search... quickly becomes concentrated in a small
|
|
485
|
+
// region" failure mode, which Wikipedia's article cites for exactly this risk).
|
|
486
|
+
//
|
|
487
|
+
// Successors are generated PER EDGE KIND separately (not pooled then pruned once), so a dense
|
|
488
|
+
// edge type (imports) cannot crowd out a sparse-but-discriminative one (inherits) — each kind's
|
|
489
|
+
// survivors are computed independently, then MERGED (Harpy's own "candidate merging": two states
|
|
490
|
+
// reaching the same successor collapse to one path, keeping the better score). A short overflow
|
|
491
|
+
// list of near-miss pruned candidates is kept as a safety valve: if a ply's beam runs dry, the
|
|
492
|
+
// overflow is reconsidered rather than the walk simply stopping.
|
|
493
|
+
//
|
|
494
|
+
// SAFETY SCOPE: like every proximity family above, this only re-ranks modules that ALREADY
|
|
495
|
+
// matched lexically (present in `scored`) — it never introduces a zero-match candidate, so it
|
|
496
|
+
// cannot regress precision/over-injection the way an unbounded multi-hop walk could.
|
|
497
|
+
const BEAM_MARGIN_FRAC = 0.5; // keep ply candidates scoring >= (ply-best * this), before the cap
|
|
498
|
+
const BEAM_PROX_FRAC = 0.2; // bounded nudge — same shape/magnitude as the other proximity families
|
|
499
|
+
const BEAM_PROX_CAP_FRAC = 0.35;
|
|
500
|
+
const BEAM_OVERFLOW_CAP = 4; // near-miss safety valve size
|
|
501
|
+
const BEAM_PLIES = 2; // hops of expansion
|
|
502
|
+
const BEAM_EDGE_GROUPS = [["imports"], ["calls", "callsSymbol"], ["inherits"], ["cochange"]];
|
|
392
503
|
|
|
393
504
|
/** Split a lowercased path label into boundary components: django/utils/text.py →
|
|
394
505
|
* {django,utils,text,py}. Component equality (not substring) stops "text" matching "ci<text>". */
|
|
@@ -401,12 +512,100 @@ function identComponents(name) {
|
|
|
401
512
|
return new Set(String(name).replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
|
|
402
513
|
}
|
|
403
514
|
|
|
515
|
+
/** For one edge-kind group, the depth-1 successor of `fromId` reachable via any edge in `kinds`,
|
|
516
|
+
* as a Map<moduleId, neighbourModuleId> adjacency (undirected — a module's neighbours via that
|
|
517
|
+
* kind, in either edge direction). Endpoints are mapped to their containing module first (call
|
|
518
|
+
* edges live at function granularity), matching the existing E1a call-adjacency convention. */
|
|
519
|
+
function adjacencyForKinds(graph, kinds) {
|
|
520
|
+
const adj = new Map();
|
|
521
|
+
const link = (a, b) => {
|
|
522
|
+
if (!a || !b || a === b) return;
|
|
523
|
+
if (!adj.has(a)) adj.set(a, new Set());
|
|
524
|
+
if (!adj.has(b)) adj.set(b, new Set());
|
|
525
|
+
adj.get(a).add(b);
|
|
526
|
+
adj.get(b).add(a);
|
|
527
|
+
};
|
|
528
|
+
for (const kind of kinds) {
|
|
529
|
+
for (const e of edgesOfKind(graph, kind)) {
|
|
530
|
+
link(moduleIdOfId(graph, e.subject), moduleIdOfId(graph, e.object));
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return adj;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** Beam-search-style multi-PLY expansion (PLAN_SEON_TUNING.md §5.15; see the BEAM_* constants'
|
|
537
|
+
* comment above for the full design rationale). Mutates `s.score` in place on `scored` entries
|
|
538
|
+
* it boosts — same bounded-nudge shape as the single-hop proximity families, just reachable over
|
|
539
|
+
* more than one hop when a ply's beam survives that far. Pure otherwise (no fs/network). */
|
|
540
|
+
function beamExpand(graph, scored, beamWidth) {
|
|
541
|
+
if (scored.length < 2) return;
|
|
542
|
+
const byId = new Map(scored.map((s) => [s.ind.id, s]));
|
|
543
|
+
const baseScore = new Map(scored.map((s) => [s.ind.id, s.score]));
|
|
544
|
+
|
|
545
|
+
// Margin+cap prune a candidate-score Map down to this ply's beam, returning [survivors, overflow].
|
|
546
|
+
const pruneToBeam = (candidates) => {
|
|
547
|
+
if (!candidates.size) return [[], []];
|
|
548
|
+
let best = 0;
|
|
549
|
+
for (const v of candidates.values()) best = Math.max(best, v);
|
|
550
|
+
const ranked = [...candidates.entries()].sort((a, b) => b[1] - a[1]);
|
|
551
|
+
const survivors = [];
|
|
552
|
+
const overflow = [];
|
|
553
|
+
for (const [id, score] of ranked) {
|
|
554
|
+
if (score >= best * BEAM_MARGIN_FRAC && survivors.length < beamWidth) survivors.push([id, score]);
|
|
555
|
+
else if (overflow.length < BEAM_OVERFLOW_CAP) overflow.push([id, score]);
|
|
556
|
+
}
|
|
557
|
+
return [survivors, overflow];
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
// Ply 0 beam = the current top-scoring already-matched modules (margin+cap over the whole set).
|
|
561
|
+
let [beam, overflow] = pruneToBeam(new Map(scored.map((s) => [s.ind.id, s.score])));
|
|
562
|
+
const boosted = new Set(beam.map(([id]) => id));
|
|
563
|
+
|
|
564
|
+
for (let ply = 0; ply < BEAM_PLIES && beam.length; ply++) {
|
|
565
|
+
// Per-edge-kind successor generation, scored, pruned INDEPENDENTLY per kind (so a dense kind
|
|
566
|
+
// like imports can't crowd out a sparse-but-discriminative one like inherits), then merged.
|
|
567
|
+
const merged = new Map(); // successorId -> best propagated score across all kinds this ply
|
|
568
|
+
const plyOverflow = [];
|
|
569
|
+
for (const kinds of BEAM_EDGE_GROUPS) {
|
|
570
|
+
const adj = adjacencyForKinds(graph, kinds);
|
|
571
|
+
const candidates = new Map();
|
|
572
|
+
for (const [parentId, parentScore] of beam) {
|
|
573
|
+
for (const neighbourId of adj.get(parentId) || []) {
|
|
574
|
+
if (!baseScore.has(neighbourId)) continue; // only re-rank already-matched modules
|
|
575
|
+
candidates.set(neighbourId, Math.max(candidates.get(neighbourId) || 0, parentScore));
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
const [survivors, kindOverflow] = pruneToBeam(candidates);
|
|
579
|
+
for (const [id, score] of survivors) merged.set(id, Math.max(merged.get(id) || 0, score));
|
|
580
|
+
plyOverflow.push(...kindOverflow);
|
|
581
|
+
}
|
|
582
|
+
overflow.push(...plyOverflow);
|
|
583
|
+
// Apply the bounded nudge once per module (first ply it's reached), same shape as the other
|
|
584
|
+
// proximity families — a nudge, never a replacement.
|
|
585
|
+
for (const [id, propagated] of merged) {
|
|
586
|
+
if (boosted.has(id)) continue;
|
|
587
|
+
const s = byId.get(id);
|
|
588
|
+
if (!s) continue;
|
|
589
|
+
s.score += Math.min(propagated * BEAM_PROX_FRAC, s.score * BEAM_PROX_CAP_FRAC);
|
|
590
|
+
boosted.add(id);
|
|
591
|
+
}
|
|
592
|
+
beam = [...merged.entries()];
|
|
593
|
+
// Safety valve: if this ply's beam ran dry, reconsider the near-miss overflow instead of
|
|
594
|
+
// just stopping — cheap insurance against a total pruning failure.
|
|
595
|
+
if (!beam.length && overflow.length) {
|
|
596
|
+
beam = overflow.splice(0, BEAM_OVERFLOW_CAP).filter(([id]) => !boosted.has(id));
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
404
601
|
/** The shared module-ranking core behind renderSearch (text) and searchModulesRanked (path+score).
|
|
405
602
|
* IDF-weights each query token by rarity across modules (so a whole-problem-statement query is not
|
|
406
603
|
* swamped by ubiquitous words like template/filter/value), scores path-component + symbol-component
|
|
407
604
|
* + EXACT-symbol matches, re-ranks with a bounded import-proximity bonus, and breaks ties by
|
|
408
605
|
* matched-symbol DENSITY (a concrete signal — never ground truth). Pure; deterministic. */
|
|
409
|
-
function scoreModules(graph, tokens) {
|
|
606
|
+
function scoreModules(graph, tokens, opts = {}) {
|
|
607
|
+
const { demoteNonProd = false, callAdjacency = false, implOfInterface = false, beamSearch = false, proseBoost = false } = opts;
|
|
608
|
+
const beamWidth = Number.isFinite(opts.beamWidth) && opts.beamWidth > 0 ? opts.beamWidth : 8;
|
|
410
609
|
const defIdx = definesIndex(graph);
|
|
411
610
|
// Precompute each module's path components + defined-symbol exact/component sets, once.
|
|
412
611
|
const modules = [];
|
|
@@ -449,7 +648,8 @@ function scoreModules(graph, tokens) {
|
|
|
449
648
|
for (let i = 0; i < Math.min(compWeights.length, SYM_MATCH_CAP); i++) symScore += compWeights[i] * SYM_W;
|
|
450
649
|
let score = exactScore + pathScore + symScore;
|
|
451
650
|
if (!score) continue;
|
|
452
|
-
if (isTestLabel(m.
|
|
651
|
+
if (demoteNonProd && (isTestLabel(m.labelLc) || isNonProdLabel(m.labelLc))) score *= NONPROD_DEMOTE; // B016 R1a
|
|
652
|
+
else if (isTestLabel(m.labelLc)) score *= 0.4; // source first; tests still discoverable
|
|
453
653
|
const matching = m.defines.filter((d) => { const dl = d.toLowerCase(); const cs = identComponents(d); return tokens.some((t) => dl === t || cs.has(t)); });
|
|
454
654
|
const density = m.defines.length ? matchCount / m.defines.length : 0;
|
|
455
655
|
scored.push({ ind: m.ind, score, defineCount: m.defines.length, matching, density });
|
|
@@ -473,6 +673,86 @@ function scoreModules(graph, tokens) {
|
|
|
473
673
|
s.score += Math.min(bestNeighbor * PROX_FRAC, s.score * PROX_CAP_FRAC);
|
|
474
674
|
}
|
|
475
675
|
}
|
|
676
|
+
// B016 E1a (opt-in): resolved-call adjacency — a matched module CALLED BY (or calling) a
|
|
677
|
+
// stronger-matching module rises with it (initials-filter: defaultfilters.py calls into
|
|
678
|
+
// utils/text.py, whose lexical rank was 8). Call edges live at function level, so endpoints
|
|
679
|
+
// map to their containing modules first. Same bounded-nudge formula as import-proximity;
|
|
680
|
+
// only re-ranks modules that already matched.
|
|
681
|
+
if (callAdjacency && scored.length > 1) {
|
|
682
|
+
const baseById = new Map(scored.map((s) => [s.ind.id, s.score]));
|
|
683
|
+
const adj = new Map();
|
|
684
|
+
for (const kind of ["calls", "callsSymbol"]) {
|
|
685
|
+
for (const e of edgesOfKind(graph, kind)) {
|
|
686
|
+
const sm = moduleIdOfId(graph, e.subject);
|
|
687
|
+
const om = moduleIdOfId(graph, e.object);
|
|
688
|
+
if (!sm || !om || sm === om) continue;
|
|
689
|
+
if (!baseById.has(sm) && !baseById.has(om)) continue;
|
|
690
|
+
if (!adj.has(sm)) adj.set(sm, new Set());
|
|
691
|
+
if (!adj.has(om)) adj.set(om, new Set());
|
|
692
|
+
adj.get(sm).add(om);
|
|
693
|
+
adj.get(om).add(sm);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
for (const s of scored) {
|
|
697
|
+
let bestNeighbor = 0;
|
|
698
|
+
for (const nid of adj.get(s.ind.id) || []) bestNeighbor = Math.max(bestNeighbor, baseById.get(nid) || 0);
|
|
699
|
+
s.score += Math.min(bestNeighbor * CALL_PROX_FRAC, s.score * CALL_PROX_CAP_FRAC);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
// B016 E1b (opt-in): impl-of-interface — a C# module implementing an interface DEFINED in a
|
|
703
|
+
// stronger-matching module rises with it (eshoponweb: IBasketService.cs rank 1, BasketService.cs
|
|
704
|
+
// rank 4). `inherits` edges point the OBJECT at an unresolved `ext:<Name>` id for C#, so resolve
|
|
705
|
+
// by exact label match against internal Class individuals. Only re-ranks modules that already
|
|
706
|
+
// matched, and only when both the implementer module is `.cs` and the base name looks like a C#
|
|
707
|
+
// interface (see the const block above for why — isAbstract does not exist in the data).
|
|
708
|
+
if (implOfInterface && scored.length > 1) {
|
|
709
|
+
const baseById = new Map(scored.map((s) => [s.ind.id, s.score]));
|
|
710
|
+
const classByLabel = new Map();
|
|
711
|
+
for (const ind of graph.individuals) {
|
|
712
|
+
if ((ind.class || "") === "Class" && ind.label) classByLabel.set(String(ind.label), ind);
|
|
713
|
+
}
|
|
714
|
+
for (const s of scored) {
|
|
715
|
+
if (!isCsModuleLabel(s.ind.label)) continue;
|
|
716
|
+
let bestNeighbor = 0;
|
|
717
|
+
for (const e of edgesOfKind(graph, "inherits")) {
|
|
718
|
+
const subjModId = moduleIdOfId(graph, e.subject);
|
|
719
|
+
if (subjModId !== s.ind.id) continue;
|
|
720
|
+
if (!looksLikeCsInterface(e.objectLabel)) continue;
|
|
721
|
+
let ifaceModId = moduleIdOfId(graph, e.object); // resolves real (non-ext:) targets
|
|
722
|
+
if (!ifaceModId) {
|
|
723
|
+
const ifaceInd = classByLabel.get(String(e.objectLabel || ""));
|
|
724
|
+
if (ifaceInd) ifaceModId = moduleIdOf(graph, ifaceInd);
|
|
725
|
+
}
|
|
726
|
+
if (ifaceModId) bestNeighbor = Math.max(bestNeighbor, baseById.get(ifaceModId) || 0);
|
|
727
|
+
}
|
|
728
|
+
s.score += Math.min(bestNeighbor * IMPL_PROX_FRAC, s.score * IMPL_PROX_CAP_FRAC);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
// PLAN_PROSE_INDEX.md §6 (opt-in): lexical boost from decomposed-identifier/doc-comment
|
|
732
|
+
// prose tokens — see the PROSE_PROX_* comment above for the full rationale. One
|
|
733
|
+
// lookupByProseTokens call for the whole query (not per-module), then aggregated into a
|
|
734
|
+
// per-module signal via moduleIdOfId, same as the call-adjacency/impl-of-interface families.
|
|
735
|
+
// Unlike the proximity families above, this signal is absolute per-module (prose-token
|
|
736
|
+
// overlap), not relative to a stronger NEIGHBOUR in `scored` — so it applies even when
|
|
737
|
+
// only one module matched lexically (no ">1" gate needed).
|
|
738
|
+
if (proseBoost && scored.length && graph.proseIndex) {
|
|
739
|
+
const proseHits = lookupByProseTokens(graph.proseIndex, tokens.join(" "), { limit: PROSE_LOOKUP_LIMIT });
|
|
740
|
+
if (proseHits.length) {
|
|
741
|
+
const proseByModule = new Map();
|
|
742
|
+
for (const { id, score } of proseHits) {
|
|
743
|
+
const modId = moduleIdOfId(graph, id);
|
|
744
|
+
if (!modId) continue;
|
|
745
|
+
proseByModule.set(modId, (proseByModule.get(modId) || 0) + score);
|
|
746
|
+
}
|
|
747
|
+
for (const s of scored) {
|
|
748
|
+
const signal = proseByModule.get(s.ind.id) || 0;
|
|
749
|
+
if (!signal) continue;
|
|
750
|
+
s.score += Math.min(signal * PROSE_PROX_FRAC, s.score * PROSE_PROX_CAP_FRAC);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
// §5.15 beam search (opt-in): multi-ply generalization of the single-hop families above.
|
|
755
|
+
if (beamSearch && scored.length > 1) beamExpand(graph, scored, beamWidth);
|
|
476
756
|
// Tie-break: score → matched-symbol DENSITY (concrete, not ground truth) → fewer defines → shorter label.
|
|
477
757
|
scored.sort((a, b) => b.score - a.score || b.density - a.density || a.defineCount - b.defineCount || String(a.ind.label).length - String(b.ind.label).length);
|
|
478
758
|
return scored;
|
|
@@ -481,11 +761,47 @@ function scoreModules(graph, tokens) {
|
|
|
481
761
|
/** TUNING #3: the ranked module list as plain `{path, score}` (highest-first), using the SAME
|
|
482
762
|
* ranking renderSearch uses (path + symbol + exact-symbol + import-proximity). Lets the rig
|
|
483
763
|
* read the score GAP between rank-1 and rank-2 (which the text renderer hides) so it can keep
|
|
484
|
-
* rank-2 only when it is close. Pure; deterministic.
|
|
485
|
-
|
|
764
|
+
* rank-2 only when it is close. Pure; deterministic.
|
|
765
|
+
* NOTE: scoreModules still RANKS (locate always returns modules), but the score-gap top-1
|
|
766
|
+
* SELECTION that consumes this gap is OFF by default in run.mjs/selectModules — it over-injected
|
|
767
|
+
* on some tasks. The shipped default takes the top-2 instead. */
|
|
768
|
+
export function searchModulesRanked(graph, query, opts = {}) {
|
|
486
769
|
const tokens = String(query || "").toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean);
|
|
487
770
|
if (!tokens.length) return [];
|
|
488
|
-
return scoreModules(graph, tokens).map((s) => ({ path: String(s.ind.label), score: s.score }));
|
|
771
|
+
return scoreModules(graph, tokens, opts).map((s) => ({ path: String(s.ind.label), score: s.score }));
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// B016 R1b, promoted to the shipped default (2026-07-02): positive in every measured cell across
|
|
775
|
+
// B016's P1 (tuning task + a genuinely held-out task) and P2 (both eshoponweb tasks; clears the
|
|
776
|
+
// ≥50%-vs-otb bar outright on order-service-total). See PLAN_B016.md §6.9. 0.6 is the exact ratio
|
|
777
|
+
// tested throughout — do not drift it from bench/arms.mjs's arm values or scripts/rank-gate.mjs's
|
|
778
|
+
// --gap default; all three should read this constant.
|
|
779
|
+
export const DEFAULT_SCORE_GAP = 0.6;
|
|
780
|
+
|
|
781
|
+
/** Score-gap-driven module selection: take the top_k ranked hits, then extend the selection to
|
|
782
|
+
* include ranks (top_k)..2 whose score sits within `scoreGapK` of rank 1 — the near-tie case
|
|
783
|
+
* where a second (or third) module is genuinely as relevant as the top hit, not filler. Never
|
|
784
|
+
* resurrects a suppressed (empty) selection: a top_k of 0 stays empty regardless of scoreGapK.
|
|
785
|
+
* Pure — the single source of truth for gap-extension, shared by the CLI/MCP product surface
|
|
786
|
+
* (cli.mjs's query-based `digest`) and the bench rig (bench/run.mjs's selectModules).
|
|
787
|
+
*
|
|
788
|
+
* DELIBERATELY NEUTRAL BY DEFAULT: `scoreGapK` defaults to `null` (gap-extension OFF, plain
|
|
789
|
+
* top-`top_k`) here — the SHIPPED default of `DEFAULT_SCORE_GAP` is a product-surface policy
|
|
790
|
+
* decision, applied explicitly by the caller (cli.mjs's digest query-mode), not baked into this
|
|
791
|
+
* primitive. A library default of "on" would make every future caller who forgets to pass
|
|
792
|
+
* `scoreGapK` silently inherit gap-extension — including future bench arms, breaking the
|
|
793
|
+
* paired-arm "byte-identical when off" comparability this repo's whole measurement methodology
|
|
794
|
+
* depends on. See test/selectRankedModules.test.mjs's "absent scoreGapK is byte-identical to
|
|
795
|
+
* plain top-k" case. */
|
|
796
|
+
export function selectRankedModules(ranked, { top_k = 2, scoreGapK = null } = {}) {
|
|
797
|
+
if (!ranked.length || top_k <= 0) return [];
|
|
798
|
+
const picked = ranked.slice(0, top_k).map((r) => r.path);
|
|
799
|
+
if (scoreGapK && picked.length >= 1 && ranked[0].score > 0) {
|
|
800
|
+
for (const r of ranked.slice(1, 3)) {
|
|
801
|
+
if (r.score / ranked[0].score >= scoreGapK && !picked.includes(r.path)) picked.push(r.path);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return picked;
|
|
489
805
|
}
|
|
490
806
|
|
|
491
807
|
export function renderSearch(graph, query, { limit = SEARCH_LIMIT, kind = "", decorator = "", name = "" } = {}) {
|
|
@@ -521,12 +837,26 @@ export function renderSearch(graph, query, { limit = SEARCH_LIMIT, kind = "", de
|
|
|
521
837
|
// the agent need not Read/Grep. All keep the bounded-output discipline. -------
|
|
522
838
|
|
|
523
839
|
/** All edges whose relation classifies to `kind`, flattened across relation groups. */
|
|
524
|
-
|
|
840
|
+
/** All edges of a classified relation kind (imports/calls/defines/tests/touches/inherits/
|
|
841
|
+
* cochange/reexports/callsSymbol/touchesSymbol/contains — see relationKind/PROP_KIND above),
|
|
842
|
+
* flattened across every raw relation group that classifies to it. Exported for ask.mjs's
|
|
843
|
+
* mechanical NL-query engine (PLAN_MECHANICAL_CHAT.md) to orchestrate rather than duplicate. */
|
|
844
|
+
export function edgesOfKind(graph, kind) {
|
|
525
845
|
const out = [];
|
|
526
846
|
for (const g of graph.relations) if (relationKind(g) === kind) out.push(...g.edges);
|
|
527
847
|
return out;
|
|
528
848
|
}
|
|
529
849
|
|
|
850
|
+
/** moduleIdOf by raw edge-endpoint id: resolves through byId when the individual exists,
|
|
851
|
+
* else falls back to parsing an `fn:<path>#name` id directly (callsSymbol objects may name
|
|
852
|
+
* symbols with no individual of their own). Null if it cannot be mapped. */
|
|
853
|
+
function moduleIdOfId(graph, id) {
|
|
854
|
+
const ind = graph.byId?.get?.(id);
|
|
855
|
+
if (ind) return moduleIdOf(graph, ind);
|
|
856
|
+
const m = String(id || "").match(/^fn:(.+)#/);
|
|
857
|
+
return m ? `mod:${m[1]}` : null;
|
|
858
|
+
}
|
|
859
|
+
|
|
530
860
|
/** The module id an individual belongs to (itself if a Module; via its site span,
|
|
531
861
|
* else parsed from an `fn:<path>#name` id). Null if it cannot be mapped. */
|
|
532
862
|
function moduleIdOf(graph, ind) {
|
|
@@ -870,7 +1200,7 @@ function searchSymbols(graph, tokens, { limit = SEARCH_LIMIT, kind, decFilter, n
|
|
|
870
1200
|
// idea; LocAgent: structured, replacement-shaped output drives tool adoption).
|
|
871
1201
|
|
|
872
1202
|
const CONTEXT_SIBLING_CAP = 8; // Lever 1: the bundle is re-billed every turn — keep a few most-relevant siblings, not all.
|
|
873
|
-
const CLASS_MEMBER_CAP = 16; //
|
|
1203
|
+
const CLASS_MEMBER_CAP = 16; // Class-internal members shown when the anchor is a class/method.
|
|
874
1204
|
const COCHANGE_MID_CAP = 4; // #13: trim the MID bundle's co-change tail (was 8) — re-billed every turn.
|
|
875
1205
|
const CONTEXT_TESTS_CAP = 6; // #13: cap the covering-tests list in the bundle.
|
|
876
1206
|
const INSERTION_REGION_CAP = 40; // #2: contiguous tail lines shown as the "write your new sibling here" region.
|
|
@@ -988,9 +1318,9 @@ export function contextPlan(graph, ind) {
|
|
|
988
1318
|
globals.push({ label: mem.label, value: (mem.attributes || []).find((a) => a.key === "value")?.value || "", site });
|
|
989
1319
|
if (site) insertion = Math.max(insertion, site.end);
|
|
990
1320
|
} else if (cls === "Function" || cls === "Class") {
|
|
991
|
-
//
|
|
992
|
-
//
|
|
993
|
-
//
|
|
1321
|
+
// Carry each sibling's `raises` + one-line doc so a validator-style task sees the
|
|
1322
|
+
// error-contract without reading the body. #3 adds params/returns/callees for
|
|
1323
|
+
// structural-similarity ranking.
|
|
994
1324
|
siblings.push({
|
|
995
1325
|
id: mem.id, label: mem.label, class: cls, site, decorators: decoratorOf(mem),
|
|
996
1326
|
raises: attrVal(mem, "raises"), doc: attrVal(mem, "doc"),
|
|
@@ -1015,15 +1345,15 @@ export function contextPlan(graph, ind) {
|
|
|
1015
1345
|
siblings = rankSiblings(siblings, anchor || { label: ind.label }, structuralTarget);
|
|
1016
1346
|
// Lever 2: when the anchor is a module (no anchor body shown), surface the single
|
|
1017
1347
|
// closest sibling's FULL body as the copy-this exemplar; signatures alone made the
|
|
1018
|
-
// agent fall back to Read
|
|
1348
|
+
// agent fall back to Read. With a function/class anchor its own body suffices.
|
|
1019
1349
|
const exemplar = !anchor ? siblings.find((s) => s.site && s.label !== ind.label) || null : null;
|
|
1020
1350
|
const tests = [...new Set(edgesOfKind(graph, "tests").filter((e) => e.object === modId).map((e) => e.subjectLabel || e.subject))].slice(0, CONTEXT_TESTS_CAP);
|
|
1021
1351
|
const cochange = cochangeNeighbours(graph, modId).slice(0, COCHANGE_MID_CAP);
|
|
1022
1352
|
const exports = edgesOfKind(graph, "reexports").filter((e) => e.subject === modId).map((e) => e.objectLabel || e.object).slice(0, 20);
|
|
1023
|
-
//
|
|
1353
|
+
// The LITERAL __all__ membership (even unresolved) — the public surface a
|
|
1024
1354
|
// new sibling must join to be importable.
|
|
1025
1355
|
const allExports = attrVal(graph.byId.get(modId), "all");
|
|
1026
|
-
//
|
|
1356
|
+
// Class-internal members. When the anchor IS a class (or a method of one),
|
|
1027
1357
|
// the edit often lives inside that class (e.g. add Truncator.lines), so list its
|
|
1028
1358
|
// members with signatures so the agent need not read/grep the class body.
|
|
1029
1359
|
const contains = edgesOfKind(graph, "contains");
|
|
@@ -1121,11 +1451,10 @@ export function sizeBundle(plan, graph, { untuned = false } = {}) {
|
|
|
1121
1451
|
// (c) a large/complex target (long body, many params, or it raises) → MID.
|
|
1122
1452
|
const loc = focal.site ? focal.site.end - focal.site.start + 1 : Infinity;
|
|
1123
1453
|
const arity = countParams(focal.params);
|
|
1124
|
-
// (c) a long/complex focal escalates TINY→MID.
|
|
1125
|
-
// symbol anchor (so a long-exemplar MODULE digest stayed TINY)
|
|
1126
|
-
//
|
|
1127
|
-
//
|
|
1128
|
-
// param is now a no-op for sizing (kept so the seonix-b010 control arm's flag still resolves).
|
|
1454
|
+
// (c) a long/complex focal escalates TINY→MID. Escalation fires on any long focal: gating it on
|
|
1455
|
+
// an explicit symbol anchor (so a long-exemplar MODULE digest stayed TINY) regressed results,
|
|
1456
|
+
// because the trimmed sibling/test tail was load-bearing scaffolding. The `untuned` param is now
|
|
1457
|
+
// a no-op for sizing (kept so the seonix-b010 control arm's flag still resolves).
|
|
1129
1458
|
if (loc > TINY_MAX_LOC || arity > TINY_MAX_ARITY || Boolean(focal.raises)) tier = "MID";
|
|
1130
1459
|
// (d) LARGE — only for an EXPLICIT symbol focus (plan.anchor), where inlining the
|
|
1131
1460
|
// depth-1 callee bodies / the class shape is worth the tokens: a cross-module call from
|
package/src/cs_treesitter.mjs
CHANGED
|
@@ -126,8 +126,8 @@ export async function extractFile(absPath, root) {
|
|
|
126
126
|
imports: [...imports].sort(), defines, calls: collectCalls(tree.rootNode), exports: [...exports].sort() };
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
export async function ingest(root) {
|
|
130
|
-
const files = await walk(root, EXTS);
|
|
129
|
+
export async function ingest(root, { ignore = null } = {}) {
|
|
130
|
+
const files = await walk(root, EXTS, ignore);
|
|
131
131
|
const modules = [];
|
|
132
132
|
const failures = [];
|
|
133
133
|
for (const f of files) {
|