@polycode-projects/seonix 0.1.0 → 0.2.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,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
- if (!dependents.has(e.object)) dependents.set(e.object, []);
277
- dependents.get(e.object).push({ id: e.subject, label: e.subjectLabel || e.subject, via: g.predicate });
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
- // B012 locate scoring — IDF-weighted, component-aware. The rig queries with the WHOLE problem
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.label.toLowerCase())) score *= 0.4; // source first; tests still discoverable
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
- export function searchModulesRanked(graph, query) {
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
- function edgesOfKind(graph, kind) {
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; // B007 gap (a): class-internal members shown when the anchor is a class/method.
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
- // B007 gap (c): carry each sibling's `raises` + one-line doc so a validator-style
992
- // task sees the error-contract without reading the body. #3 adds params/returns/callees
993
- // for structural-similarity ranking.
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 (B006). With a function/class anchor its own body suffices.
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
- // B007 gap (b): the LITERAL __all__ membership (even unresolved) — the public surface a
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
- // B007 gap (a): class-internal members. When the anchor IS a class (or a method of one),
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. TUNING #1 (B011) gated this on an explicit
1125
- // symbol anchor (so a long-exemplar MODULE digest stayed TINY); BENCHMARK_011 proved that
1126
- // trim REGRESSED every model (the trimmed sibling/test tail was load-bearing scaffolding), so
1127
- // B012 REVERTS it escalation fires on any long focal, == the B010 digest. The `untuned`
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
@@ -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) {