@polycode-projects/the-mechanical-code-talker 1.9.2 → 1.10.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.
Files changed (80) hide show
  1. package/README.md +441 -202
  2. package/bin/tmct.mjs +126 -1
  3. package/package.json +4 -2
  4. package/src/answer-variants.mjs +8 -36
  5. package/src/ask-browser-entry.mjs +5 -23
  6. package/src/ask-browser.bundle.js +1 -2
  7. package/src/ask-nlp.mjs +9 -23
  8. package/src/ask-vocab.mjs +139 -589
  9. package/src/ask.mjs +627 -1729
  10. package/src/chat.mjs +1684 -2872
  11. package/src/cli-args.mjs +14 -28
  12. package/src/codegraph.mjs +236 -644
  13. package/src/completions/complete.mjs +18 -62
  14. package/src/completions/graph-adapter.mjs +14 -60
  15. package/src/completions/group.mjs +12 -68
  16. package/src/completions/infer.mjs +38 -126
  17. package/src/completions/prune.mjs +17 -70
  18. package/src/completions/rank.mjs +16 -69
  19. package/src/completions/search.mjs +8 -31
  20. package/src/concept.mjs +32 -88
  21. package/src/conformance.mjs +11 -15
  22. package/src/corpus/conceptnet.mjs +31 -89
  23. package/src/corpus/templates.mjs +19 -45
  24. package/src/corpus/unknown-ingest.mjs +31 -92
  25. package/src/embed.mjs +10 -22
  26. package/src/extensions.mjs +50 -154
  27. package/src/finish.mjs +35 -91
  28. package/src/grammar/ace.mjs +16 -40
  29. package/src/grammar/assert.mjs +1 -1
  30. package/src/grammar/lexicon-core.json +1 -1
  31. package/src/grammar/lexicon.mjs +9 -27
  32. package/src/graph-merge.mjs +2 -3
  33. package/src/hash.mjs +6 -14
  34. package/src/index.mjs +6 -10
  35. package/src/init.mjs +38 -125
  36. package/src/interpret/fuzzy.mjs +10 -29
  37. package/src/interpret/merge.mjs +9 -27
  38. package/src/interpret/normalize.mjs +137 -585
  39. package/src/interpret/pipeline.mjs +23 -71
  40. package/src/interpret/strategies/ace.mjs +7 -31
  41. package/src/interpret/strategies/constructions.mjs +14 -41
  42. package/src/interpret/strategies/grammar.mjs +21 -60
  43. package/src/interpret/strategies/keywords.mjs +42 -131
  44. package/src/interpret/strategies/noise-strip.mjs +18 -89
  45. package/src/memory/bias.mjs +11 -54
  46. package/src/memory/blocks.mjs +18 -69
  47. package/src/memory/core.mjs +171 -591
  48. package/src/memory/fold.mjs +0 -0
  49. package/src/memory/inspect.mjs +7 -25
  50. package/src/memory/shacl.mjs +10 -39
  51. package/src/memory/trust.mjs +26 -127
  52. package/src/memory-ask-browser-entry.mjs +7 -30
  53. package/src/memory-ask-browser.bundle.js +1 -1
  54. package/src/paraphrase.mjs +20 -53
  55. package/src/planning.mjs +15 -157
  56. package/src/prose-nlp.mjs +4 -17
  57. package/src/prose.mjs +19 -67
  58. package/src/providers/bootstrap.mjs +1 -2
  59. package/src/providers/fixture.mjs +1 -2
  60. package/src/providers/graph-service.mjs +28 -59
  61. package/src/repository-interface.mjs +6 -8
  62. package/src/router/drive.mjs +183 -0
  63. package/src/router/goal-reasoner.mjs +66 -231
  64. package/src/router/guardrail.mjs +20 -58
  65. package/src/router/planner.mjs +15 -46
  66. package/src/router/registry.mjs +13 -43
  67. package/src/router/resolver.mjs +46 -131
  68. package/src/router/results.mjs +231 -0
  69. package/src/schema-docs.mjs +10 -27
  70. package/src/server-http.mjs +10 -19
  71. package/src/server.mjs +22 -28
  72. package/src/sessions.mjs +15 -30
  73. package/src/source-slice.mjs +5 -7
  74. package/src/source.mjs +10 -20
  75. package/src/syllogise.mjs +187 -575
  76. package/src/telemetry.mjs +3 -3
  77. package/src/toml-config.mjs +4 -4
  78. package/src/tui/app.mjs +9 -19
  79. package/src/viz.mjs +66 -123
  80. package/src/wink-model.mjs +10 -24
package/src/codegraph.mjs CHANGED
@@ -1,8 +1,5 @@
1
1
  import { lookupByProseTokens, proseLayerHits } from "./prose.mjs";
2
2
  import { cosine } from "./embed.mjs";
3
- // Single-sourced predicate strings (memory/core.mjs owns these constants) — no
4
- // circular-import risk: core.mjs imports trust.mjs/shacl.mjs/planning.mjs, never
5
- // codegraph.mjs, in either direction.
6
3
  import { CREATED_AT_PROP, UPDATED_AT_PROP, provenanceTagToSource } from "./memory/core.mjs";
7
4
 
8
5
  // Pure (no-network, no-fs) query logic over the typed `entities` payload that the
@@ -16,15 +13,6 @@ import { CREATED_AT_PROP, UPDATED_AT_PROP, provenanceTagToSource } from "./memor
16
13
  // individuals: [{id, label, class, derived_from: [ref], mentions: [{id, count}],
17
14
  // attributes?: [{prop, key, value}]}],
18
15
  // }
19
- //
20
- // Ported ≈verbatim from marginalia seon-mcp/src/codegraph.mjs (the shipped,
21
- // tested typed-edge query layer). The only edits: provenance/attestation wording
22
- // is code-graph-generic (git:<sha> / file:line refs, not memory-node prose), and
23
- // a renderSearch() is added for the local, deterministic tmct_search.
24
- //
25
- // Edge inventory is read DYNAMICALLY from the payload (predicate verb + the closed
26
- // `prop` token like "mg:imports"); only the kind-classifier for the impact closure
27
- // hardcodes the relation set.
28
16
 
29
17
  // ---- payload parsing ---------------------------------------------------------
30
18
 
@@ -54,10 +42,7 @@ export function parseEntities(payload) {
54
42
  relations,
55
43
  truncated,
56
44
  generatedAt: payload?.generated_at || null,
57
- // Second pass (PLAN_PROSE_INDEX.md): word -> [individual ids], passed through
58
- // byte-identical from the payload so ask.mjs's resolveObject can consult it as a
59
- // fallback tier without reaching back into the raw payload itself. {} when the
60
- // build had prose disabled or the payload predates this field.
45
+ // word -> [individual ids]; {} when prose was disabled at build time
61
46
  proseIndex: payload?.proseIndex || {},
62
47
  };
63
48
  }
@@ -80,9 +65,7 @@ const PROP_KIND = {
80
65
  "seon:hassupertype": "inherits",
81
66
  "mgx:changecoupledwith": "cochange",
82
67
  "mgx:reexports": "reexports",
83
- // fine-grained symbol-level edges (Commit→symbol history, fn→fn in-repo calls).
84
- // These stay SEPARATE kinds from the module-coarse "touches"/"calls" so the impact
85
- // closure (module-coarse) is unchanged.
68
+ // symbol-level edges stay separate kinds so the module-coarse impact closure is unchanged
86
69
  "mgx:touchessymbol": "touchesSymbol",
87
70
  "mgx:callssymbol": "callsSymbol",
88
71
  // legacy tokens (pre-realign graphs) — kept so a stale artifact still classifies
@@ -95,20 +78,12 @@ const PROP_KIND = {
95
78
  "mg:defines": "defines",
96
79
  "mg:tests": "tests",
97
80
  "mg:touches": "touches",
98
- // memory-graph predicates (src/memory/core.mjs, src/sessions.mjs) each maps to
99
- // itself as its own kind name (no module-rollup abbreviation needed, unlike
100
- // imports/calls) so adjacencyForKinds/edgesOfKind can walk the memory graph too.
81
+ // memory-graph predicates map to themselves so adjacencyForKinds/edgesOfKind can walk them too
101
82
  "mgx:saidinsession": "saidInSession",
102
83
  "mgx:inreplyto": "inReplyTo",
103
84
  "mgx:statedby": "statedBy",
104
85
  "mgx:canonicalisedfrom": "canonicalisedFrom",
105
- // PLAN_VIZ_MEMORY.md Bug 2 fix: the two FIXED structural link kinds
106
- // deriveFactTermGraph (below) synthesizes on every Fact — Fact -> its own
107
- // subject/object Term individual. Without these a walk seeded on a Fact (the
108
- // default mostRecentIndividual seed right after a teach turn) could never
109
- // reach the term graph at all. Distinct from the per-predicate kinds below
110
- // (an open-ended, DYNAMIC set — see relationKind's "factrel:" branch), these
111
- // two are fixed and few, so a plain PROP_KIND row is the simplest fit.
86
+ // structural links deriveFactTermGraph synthesizes on every Fact (Fact -> its own subject/object Term)
112
87
  "mgx:factsubjectterm": "factSubjectTerm",
113
88
  "mgx:factobjectterm": "factObjectTerm",
114
89
  };
@@ -116,18 +91,10 @@ const PROP_KIND = {
116
91
  export function relationKind(group) {
117
92
  const prop = String(group?.prop || "").toLowerCase();
118
93
  if (PROP_KIND[prop]) return PROP_KIND[prop];
119
- // PLAN_VIZ_MEMORY.md Bug 2 fix: deriveFactTermGraph's per-predicate relation
120
- // groups (Term -> Term, one group per DISTINCT fact predicate actually
121
- // present in the data — there is no fixed vocabulary to enumerate here: a
122
- // freshly taught "mgx:<verb>" predicate (generalVerbTeach) must classify
123
- // automatically, never requiring a PROP_KIND edit per predicate). Those
124
- // groups self-namespace their `prop` as `factrel:<predicate>` specifically
125
- // so they can self-classify here, verbatim, with zero collision risk against
126
- // any real code-graph or memory-graph prop token (none use this prefix).
94
+ // deriveFactTermGraph namespaces taught predicates as "factrel:<predicate>"
127
95
  if (prop.startsWith("factrel:")) return group.predicate || null;
128
96
  const pred = String(group?.predicate || "").toLowerCase();
129
- // symbol-granular fallbacks first, so a near-miss token name still classifies to the
130
- // fine-grained kind rather than collapsing to module-coarse calls/touches.
97
+ // symbol-granular fallbacks first, so a near-miss still classifies fine-grained
131
98
  if (/symbol/.test(pred)) {
132
99
  if (/\b(call|invoke)/.test(pred)) return "callsSymbol";
133
100
  if (/(touch|chang|modif)/.test(pred)) return "touchesSymbol";
@@ -157,9 +124,6 @@ function basename(p) {
157
124
  return parts[parts.length - 1];
158
125
  }
159
126
 
160
- // Attestation: a ref prefixed `git:` (a commit that touched the entity) counts as
161
- // one mention, so better-attested (more-churned) entities rank/render ahead of
162
- // untouched ones even before per-node mention counts exist.
163
127
  const isProvRef = (r) => /^(git|turn):/.test(String(r || ""));
164
128
 
165
129
  export function turnRefCount(ind) {
@@ -240,9 +204,7 @@ function relLabel(g) {
240
204
  return g.prop ? `${g.predicate} [${g.prop}]` : g.predicate;
241
205
  }
242
206
 
243
- // Bounded list rendering token efficiency is the whole point of the graph, so
244
- // hub entities must never dump hundreds of edges. Show the first `n`, then a
245
- // "+K more" tail with the true count.
207
+ // Show the first `n` items, then a "+K more" tail with the true count.
246
208
  function capJoin(items, n, sep = ", ") {
247
209
  if (items.length <= n) return items.join(sep);
248
210
  return items.slice(0, n).join(sep) + `, +${items.length - n} more`;
@@ -295,23 +257,17 @@ function truncationNote(graph) {
295
257
  return `note: partial edge lists for: ${list}. Counts are complete; the lists are not.`;
296
258
  }
297
259
 
298
- // ---- compare (scoped v1, HANDOVER.md 2026-07-12 "no comparison capability" item) ----
260
+ // ---- compare ----
299
261
 
300
- /** One side-by-side row for a single classified relation (predicate/prop pair),
301
- * in the given direction reuses edgesFor/relLabel/capJoin verbatim (the SAME
302
- * classified relation groups and edge-cap discipline renderDescribe reads), just
303
- * paired up instead of listed independently per entity. `field` picks the
304
- * correct edge endpoint for the direction (`out` reads the OBJECT end,
305
- * `incoming` reads the SUBJECT end) — edgesFor's own out/incoming split. */
262
+ /** One side-by-side row for a classified relation. `field` picks the edge
263
+ * endpoint for the direction ("out" reads object, "incoming" reads subject). */
306
264
  function compareRow(prefix, key, aEdges, bEdges, labelA, labelB, field) {
307
265
  const fmt = (edges) => (edges.length ? capJoin(edges.map((e) => e[`${field}Label`] || e[field]), DESCRIBE_EDGE_CAP) : "none");
308
266
  return ` ${prefix}${key}: ${labelA} (${aEdges.length}) -> ${fmt(aEdges)}; ${labelB} (${bEdges.length}) -> ${fmt(bEdges)}`;
309
267
  }
310
268
 
311
- /** predicate-label -> {group, aEdges, bEdges}, built from BOTH sides' edge
312
- * groups for one direction (out or incoming) — a plain union-by-key merge, no
313
- * new graph query: every group/edges pair here is exactly what edgesFor already
314
- * returned for each individual separately. */
269
+ /** predicate-label -> {group, aEdges, bEdges}, a union-by-key merge of both
270
+ * sides' edge groups for one direction. */
315
271
  function pairByPredicate(aGroups, bGroups) {
316
272
  const byPred = new Map();
317
273
  for (const { group, edges } of aGroups) byPred.set(relLabel(group), { group, aEdges: edges, bEdges: [] });
@@ -323,16 +279,8 @@ function pairByPredicate(aGroups, bGroups) {
323
279
  return byPred;
324
280
  }
325
281
 
326
- /** Compact, honest side-by-side comparison of two SAME-KIND individuals
327
- * the scoped-down v1 comparison capability (HANDOVER.md 2026-07-12): reuses
328
- * the exact edgesFor/relLabel/capJoin machinery renderDescribe already reads
329
- * (same classified relation groups, same DESCRIBE_EDGE_CAP discipline), just
330
- * rendered as a paired diff instead of two independent one-entity reports —
331
- * no new graph traversal, only a new presentation over data describe already
332
- * surfaces. Deliberately refuses (returns null) rather than forcing a
333
- * comparison across mismatched kinds or the same individual twice — the
334
- * caller (chat.mjs's compare lane) renders its own honest message for those
335
- * cases instead of an empty/degenerate report. */
282
+ /** Side-by-side comparison of two same-kind individuals. Returns null for
283
+ * mismatched kinds or comparing an individual to itself. */
336
284
  export function renderCompare(graph, indA, indB) {
337
285
  if (!indA || !indB || indA.id === indB.id) return null;
338
286
  const klass = indA.class || "Entity";
@@ -355,8 +303,7 @@ export function renderCompare(graph, indA, indB) {
355
303
  }
356
304
  }
357
305
 
358
- // Attribute diff same key-union approach, but only MISMATCHES are worth
359
- // surfacing (a shared attribute value isn't a "difference").
306
+ // only mismatches are worth surfacing as a "difference"
360
307
  const attrsA = new Map((indA.attributes || []).map((x) => [x.key, x.value]));
361
308
  const attrsB = new Map((indB.attributes || []).map((x) => [x.key, x.value]));
362
309
  const attrKeys = new Set([...attrsA.keys(), ...attrsB.keys()]);
@@ -372,31 +319,16 @@ export function renderCompare(graph, indA, indB) {
372
319
 
373
320
  // ---- impact (transitive reverse closure over imports/calls) ---------------------
374
321
 
375
- /**
376
- * BFS the REVERSE of imports/calls edges from `ind` "what would break".
377
- * Diamonds collapse (a node appears once, at its shortest depth); cycles
378
- * terminate via the visited set. Each dependent carries the via-predicate and
379
- * the test modules covering it (subjects of tests-kind edges pointing at it).
380
- *
381
- * Module-coarse "calls" (`mgx:callsCoarse`, graph-build.mjs) is deliberately
382
- * conservative — it only fires when the callee's module is ALREADY in the
383
- * caller's import list ("coarse, import-backed calls", graph-build.mjs's own
384
- * comment), so by construction every "calls" edge is a strict subset of an
385
- * "imports" edge between the same pair — it never independently extends this
386
- * closure's reach beyond what "imports" alone already gives it. `callsSymbol`
387
- * (fn/method-granular, no import-backing requirement — same-module calls,
388
- * ambiguous-name calls the coarse pass drops) is the richer signal; this
389
- * closure also folds it in, coarsened to module level on read (never stored),
390
- * mirroring the technique `adjacencyForKinds`/`BEAM_EDGE_GROUPS` already use
391
- * for the same reason.
392
- */
322
+ /** BFS the reverse of imports/calls edges from `ind` — "what would break".
323
+ * Diamonds collapse to shortest depth; cycles terminate via the visited set.
324
+ * `callsSymbol` is folded in too, coarsened to module level on read: it has
325
+ * no import-backing requirement, so it can reach dependents "calls" alone would miss. */
393
326
  export function impactClosure(graph, ind, { maxDepth = 8 } = {}) {
394
327
  const dependents = new Map();
395
328
  const coveredBy = new Map(); // moduleId → [test labels]
396
329
  const addDependent = (objectId, subjectId, subjectLabel, via) => {
397
- // Self-loop guard: callsSymbol coarsens to module level, so two symbols in
398
- // the SAME module calling each other must not produce a module pointing at
399
- // itself (imports/calls edges are already module-to-module and can't self-loop).
330
+ // callsSymbol coarsens to module level, so two symbols in the same module
331
+ // calling each other must not produce a module pointing at itself
400
332
  if (!objectId || !subjectId || objectId === subjectId) return;
401
333
  if (!dependents.has(objectId)) dependents.set(objectId, []);
402
334
  dependents.get(objectId).push({ id: subjectId, label: subjectLabel, via });
@@ -513,190 +445,94 @@ function definesIndex(graph) {
513
445
  */
514
446
  export const SEARCH_LIMIT = 10;
515
447
  const SEARCH_SYMBOLS_SHOWN = 8;
516
- // Locate scoring IDF-weighted, component-aware. The rig queries with the WHOLE problem
517
- // statement, so ubiquitous tokens (template/filter/value/text) would swamp the score; weight each
518
- // token by rarity across modules (inverse module-frequency) so the distinctive term decides. Match
519
- // identifier COMPONENTS (boundary-aware) so "text" hits utils/text.py but NOT "ci<text>". An EXACT
520
- // defined-symbol-name hit is the strongest "the code lives here" signal. Deterministic; no models.
521
- const PATH_W = 3; // token == a path component (django/utils/<text>.py)
448
+ // Locate scoring is IDF-weighted and component-aware: weight each token by
449
+ // rarity across modules so a distinctive term decides over ubiquitous ones,
450
+ // and match identifier components (boundary-aware) so "text" hits
451
+ // utils/text.py but not "ci<text>". Deterministic; no models.
452
+ const PATH_W = 3; // token == a path component
522
453
  const SYM_W = 2; // token == a component of a defined symbol name
523
- const EXACT_W = 5; // token == a whole defined symbol name (strongest locate signal)
524
- const SYM_MATCH_CAP = 4; // only the top-K highest-IDF symbol-COMPONENT hits count, so a giant
525
- // bag-of-symbols module (e.g. db/backends features) can't accrete noise
526
- const PROX_FRAC = 0.2; // import-adjacency bonus = this × the strongest matched neighbour …
527
- const PROX_CAP_FRAC = 0.35; // … capped at this × the module's own score (a nudge — hubs can't run away)
454
+ const EXACT_W = 5; // token == a whole defined symbol name
455
+ const SYM_MATCH_CAP = 4; // only the top-K highest-IDF symbol-component hits count
456
+ const PROX_FRAC = 0.2; // import-adjacency bonus = this × the strongest matched neighbour
457
+ const PROX_CAP_FRAC = 0.35; // capped at this × the module's own score
528
458
  const isTestLabel = (s) => /(^|\/)tests?\//.test(s) || /(^|\/)test_[^/]*\.py$/.test(s) || /\.tests(\.|$)/.test(s);
529
- // B016 R1a (opt-in via demoteNonProd): non-production paths examples, fixtures, sample/demo
530
- // apps, and test-* harness packages share path/symbol vocabulary with the production module
531
- // and shadow it in locate (B015: js-express injected examples/route-middleware/index.js at
532
- // rank 1; java-gson's TOP2 slot 2 was a test-shrinker fixture). DEMOTED, not excluded: none of
533
- // the B015 truths live under these paths (checked corpus/instances-*/…/spec.json 2026-07-02),
534
- // but a future task whose truth IS a test/example file must stay reachable.
459
+ // opt-in via demoteNonProd: demote (not exclude) example/fixture/sample/demo/test-*
460
+ // paths, which share vocabulary with production modules and would otherwise shadow them
535
461
  const NONPROD_DEMOTE = 0.15;
536
462
  const isNonProdLabel = (s) => /(^|\/)(examples?|fixtures?|samples?|demos?|benchmarks?|test-[^/]+)(\/|$)/.test(s);
537
- // B016 E1a (opt-in via callAdjacency): resolved-call adjacency, same bounded-nudge shape as the
538
- // import-proximity bonus. Python graphs carry call edges (django: 993 calls / 23,596 callsSymbol);
539
- // the syntax-level C#/Java extractors emit ~none today, so this flag is Python-value only.
463
+ // opt-in via callAdjacency: same bounded-nudge shape as import-proximity, Python-value
464
+ // only today (C#/Java extractors emit ~no call edges)
540
465
  const CALL_PROX_FRAC = 0.2;
541
466
  const CALL_PROX_CAP_FRAC = 0.35;
542
- // B016 E1b (opt-in via implOfInterface): boost a module that implements an interface DEFINED
543
- // in a strongly-matched module (C# IBasketService→BasketService, the rank-4 case). PLAN_B016
544
- // §6.1 specified an `isAbstract` guard, but that field is never populated by any extractor —
545
- // verified empirically 2026-07-02 against django/eshoponweb/java-gson .tmct/graph.json: 0
546
- // individuals carry `isAbstract` in all three. The only real distinguishing signal in the data
547
- // is C#'s naming convention (interfaces prefixed `I<Uppercase>`, e.g. IBasketService) — and C#'s
548
- // `inherits` edges point at an UNRESOLVED `ext:<Name>` id rather than the interface's own
549
- // individual, so the object must be resolved by an exact label match against internal
550
- // Class-labeled individuals. SCOPED to `.cs` implementer modules only: without that scope, 11 of
551
- // django's 7,014 inherits edges superficially match `I[A-Z]` (IOBase, IExact, IContains, …ordinary
552
- // Python class names, not interfaces) and would reintroduce the over-injection E1a already showed
553
- // on class-heavy Python graphs. Java's `inherits` predicate resolves cleanly to real individuals
554
- // but carries no tag or naming convention distinguishing interface implementation from concrete
555
- // inheritance (TypeAdapterFactory IS an interface in Gson, no "I" prefix) — a Java-safe guard does
556
- // not exist without an extractor change (E1c, deferred). E1b is C#-only until then.
467
+ // opt-in via implOfInterface: boost a module implementing an interface in a strongly-
468
+ // matched module. C#-only: interfaces are detected by the `I<Uppercase>` naming
469
+ // convention against unresolved `ext:<Name>` inherits targets; Java's inherits edges
470
+ // carry no equivalent signal to distinguish interface implementation from inheritance.
557
471
  const IMPL_PROX_FRAC = 0.2;
558
472
  const IMPL_PROX_CAP_FRAC = 0.35;
559
473
  const isCsModuleLabel = (s) => /\.cs$/i.test(s);
560
474
  const looksLikeCsInterface = (label) => /^I[A-Z]/.test(String(label || ""));
561
475
 
562
- // PLAN_PROSE_INDEX.md §6 (opt-in via proseBoost, 2026-07-02): a matched module whose lexical
563
- // score comes only from its path/symbol NAMES misses the case where the query's vocabulary
564
- // only overlaps a decomposed identifier or a doc-comment elsewhere in that module (e.g. "billing
565
- // calculation" never appears in `calculateTotalPrice`'s own path, only in its prose tokens).
566
- // Same bounded-nudge shape/magnitude as the other proximity families — a nudge onto modules that
567
- // ALREADY matched lexically (never a new zero-match candidate), never a replacement for the
568
- // lexical score. NOT wired into any bench arm and NOT a shipped default — an available lever
569
- // only, exactly like §5.15 beam search before it, pending its own gate/benchmark evidence.
476
+ // opt-in via proseBoost: bounded nudge for modules whose match comes from a
477
+ // decomposed identifier or doc-comment elsewhere in the module, not its path/symbol names
570
478
  const PROSE_PROX_FRAC = 0.2;
571
479
  const PROSE_PROX_CAP_FRAC = 0.35;
572
- const PROSE_LOOKUP_LIMIT = 50; // bounds lookupByProseTokens' scan; the CAP_FRAC bounds the nudge regardless
573
-
574
- // Layered prose normalisation (opt-in via proseLayers, 2026-07-02): the prose index now carries
575
- // NORMALISED layers (spell-corrected / canonical-schema-term / stem / lemma) under
576
- // proseIndex["tmct:layers"] (built by the prose pre-pass; consumed read-only via prose.mjs's
577
- // proseLayerHits). Today the locate scorer matches query tokens against a module's path/symbol
578
- // text VERBATIM, so a task-text word that only reaches a module via its stem/lemma/canonical form
579
- // scores nothing. With the flag on, a query token that does NOT already match a module lexically,
580
- // but DOES resolve to one of that module's individuals through a normalised layer, contributes a
581
- // bounded, DISCOUNTED signal weaker evidence than a verbatim match by construction (halved, then
582
- // the shared FRAC/CAP nudge), and, like every proximity family, it only re-ranks modules ALREADY
583
- // in `scored` it never invents a zero-match candidate and never overrides an exact hit. NOT a
584
- // shipped default and NOT wired into any bench arm — an available lever pending its own gate
585
- // evidence, exactly like proseBoost/beamSearch before it.
586
- const PROSE_LAYER_FRAC = 0.2; // bounded nudge — same shape/magnitude as the other proximity families …
587
- const PROSE_LAYER_CAP_FRAC = 0.35; // … capped at this × the module's own base score (a nudge; hubs can't run away)
588
- const PROSE_LAYER_DISCOUNT = 0.5; // a normalised-layer hit is WEAKER evidence than an exact/component token
589
- // match — halved before the FRAC/CAP nudge, so a layer hit can never rival
590
- // a verbatim lexical match (the "a miss beats a guess" discipline).
591
-
592
- // PLAN_SEON_TUNING.md §7.5 finding 1 / §7.6(5a) (opt-in via literalMention, 2026-07-02): the query
593
- // tokenizer split(/[^a-z0-9_]+/) DESTROYS a literal dotted module reference present verbatim in
594
- // task text — "django.utils.http" scatters into {django,utils,http}, tokens so common across
595
- // 2,931 modules that utils/http.py ranked 41 on B016's domain-filter — while every Module
596
- // individual carries an unread `dotted` attribute. The lever scans the RAW query (threaded through
597
- // as opts.rawQuery by searchModulesRanked) for whole, boundary-checked occurrences of each
598
- // module's `dotted` name and repo-relative path (label). Boundary rule: a match flanked by an
599
- // identifier/dotted/path continuation char ([a-z0-9_./]) does not count — which is also
600
- // longest-match-wins for free: a package __init__'s dotted prefix ("django.utils" inside
601
- // "django.utils.http") is followed by ".", so only the full module's own name fires (the two
602
- // __init__.py prefix artifacts the 2026-07-02 review flagged). Specificity floor: a candidate
603
- // with fewer than LIT_MIN_COMPONENTS dot/slash components never fires (a bare "utils" — or
604
- // "django.utils" — must not). A hit adds a bounded BASE-score component weighted like the
605
- // exact-symbol channel (LIT_W = EXACT_W per component IDF, top-LIT_COMP_CAP components like
606
- // SYM_MATCH_CAP), then capped at LIT_CAP_FRAC × the strongest base score — the FRAC/CAP shape of
607
- // the proximity families, anchored to the query's own best lexical evidence: a verbatim mention
608
- // can lift a module INTO the top ranks but can never become an unbounded override. Applied
609
- // BEFORE the proximity families so a mentioned module also donates adjacency like any other
610
- // strong match. Only modules that already matched lexically are eligible (a mentioned module
611
- // always is — its path components are query tokens by construction), preserving the levers'
612
- // shared no-new-candidates safety scope.
613
- const LIT_W = EXACT_W; // per-component weight — a verbatim module mention is the strongest locate signal
614
- const LIT_MIN_COMPONENTS = 3; // "django.utils.http" fires; "django.utils"/"utils" never do
615
- const LIT_COMP_CAP = 4; // like SYM_MATCH_CAP: only the top-K highest-IDF components accrue
616
- const LIT_FRAC = 1.0; // bonus = min(litWeight × this, maxBase × LIT_CAP_FRAC)
617
- const LIT_CAP_FRAC = 0.9; // … so a mention approaches — never dwarfs — the best lexical score
618
-
619
- // PLAN_SEON_TUNING.md §7.6(5b) (opt-in via embedRank + an injected embedder, 2026-07-02): static-
620
- // embedding re-rank — the deterministic "near-LLM" lever. The caller loads embed.mjs's
621
- // potion-base-8M table (loadEmbedder(); null when the one-time-fetch weights are absent) and
622
- // passes it as opts.embedder, keeping this module pure (no fs here; the flag no-ops with a
623
- // one-time stderr note when the embedder is missing, so CI never needs the 30 MB artifact).
624
- // Per-module text = path components + defined symbol names + doc first-lines — all read from the
625
- // graph, never from source — embedded lazily and cached per process (EMB_CACHE, WeakMap-keyed on
626
- // the graph). Cosine(query, module) becomes the same bounded FRAC/CAP nudge as the proximity
627
- // families: only re-ranks modules that ALREADY matched lexically, never introduces a candidate.
480
+ const PROSE_LOOKUP_LIMIT = 50;
481
+
482
+ // opt-in via proseLayers: lets a query token that only matches via a normalised
483
+ // layer (stem/lemma/spell-corrected/canonical) still contribute, discounted (halved)
484
+ // relative to a verbatim match, via prose.mjs's proseLayerHits
485
+ const PROSE_LAYER_FRAC = 0.2;
486
+ const PROSE_LAYER_CAP_FRAC = 0.35;
487
+ const PROSE_LAYER_DISCOUNT = 0.5;
488
+
489
+ // opt-in via literalMention: recovers a literal dotted module reference the query
490
+ // tokenizer would otherwise scatter ("django.utils.http" -> {django,utils,http}),
491
+ // by scanning the raw query for whole, boundary-checked module dotted-name/path hits
492
+ const LIT_W = EXACT_W;
493
+ const LIT_MIN_COMPONENTS = 3; // "django.utils.http" fires; "utils" alone never does
494
+ const LIT_COMP_CAP = 4;
495
+ const LIT_FRAC = 1.0;
496
+ const LIT_CAP_FRAC = 0.9;
497
+
498
+ // opt-in via embedRank + an injected embedder: static-embedding re-rank over
499
+ // path/symbol/doc text read from the graph (never source), cached per-process in EMB_CACHE
628
500
  const EMB_FRAC = 0.2;
629
501
  const EMB_CAP_FRAC = 0.35;
630
- const EMB_TEXT_SYMBOL_CAP = 64; // bound the per-module text: top defines …
631
- const EMB_TEXT_DOC_CAP = 12; // … and doc first-lines (a giant module can't grow an unbounded text)
502
+ const EMB_TEXT_SYMBOL_CAP = 64;
503
+ const EMB_TEXT_DOC_CAP = 12;
632
504
  const EMB_CACHE = new WeakMap(); // graph -> { embedder, texts, vecs: Map<moduleId, Float32Array> }
633
505
  let embedWarned = false;
634
506
 
635
- // PLAN_SEON_TUNING.md §5.15 "discriminative multi-hop expansion" (opt-in via beamSearch):
636
- // generalizes the R1a/E1a/E1b family's single fixed-type, single-hop nudge into an adaptive,
637
- // multi-PLY expansion. Terminology follows Wikipedia's "Beam search" and Lowerre & Reddy, "The
638
- // Harpy Speech Understanding System" (Carnegie-Mellon, the paper that coined "beam search" no
639
- // University of Essex 1980s/90s beam-search paper exists; searched 2026-07-02, none found, this
640
- // is the honest substitute). One hop of expansion = a PLY; the surviving candidate set at a ply =
641
- // the BEAM; beamWidth (β) caps how many survive; discarding non-survivors = PRUNING.
642
- //
643
- // Harpy's own beamwidth was a MARGIN/THRESHOLD relative to the ply's best score ("candidates
644
- // that fall below a threshold of acceptability are pruned"), not a fixed count — this is a
645
- // threshold+cap HYBRID (keep everyone within BEAM_MARGIN_FRAC of the ply's best, THEN cap at β),
646
- // not naive top-k. A fixed-count beam would prematurely discard exactly the kind of weak-then-
647
- // strong candidate E1b's own motivating case demonstrated: BasketService.cs sat at lexical rank 4
648
- // and was only promoted by considering impl-of-interface structure beyond the first pass — a
649
- // hard top-k cut at ply 0 could drop such a candidate before any later ply had a chance to
650
- // recover it (Russell & Norvig's "local beam search... quickly becomes concentrated in a small
651
- // region" failure mode, which Wikipedia's article cites for exactly this risk).
652
- //
653
- // Successors are generated PER EDGE KIND separately (not pooled then pruned once), so a dense
654
- // edge type (imports) cannot crowd out a sparse-but-discriminative one (inherits) — each kind's
655
- // survivors are computed independently, then MERGED (Harpy's own "candidate merging": two states
656
- // reaching the same successor collapse to one path, keeping the better score). A short overflow
657
- // list of near-miss pruned candidates is kept as a safety valve: if a ply's beam runs dry, the
658
- // overflow is reconsidered rather than the walk simply stopping.
659
- //
660
- // SAFETY SCOPE: like every proximity family above, this only re-ranks modules that ALREADY
661
- // matched lexically (present in `scored`) — it never introduces a zero-match candidate, so it
662
- // cannot regress precision/over-injection the way an unbounded multi-hop walk could.
663
- const BEAM_MARGIN_FRAC = 0.5; // keep ply candidates scoring >= (ply-best * this), before the cap
664
- const BEAM_PROX_FRAC = 0.2; // bounded nudge — same shape/magnitude as the other proximity families
507
+ // opt-in via beamSearch: multi-ply adaptive expansion of the proximity nudge above.
508
+ // Beam width is a margin relative to each ply's best score (not a fixed count), so a
509
+ // weak-then-strong candidate isn't prematurely discarded. Successors are generated
510
+ // and pruned per edge kind independently so a dense kind (imports) can't crowd out a
511
+ // sparse one (inherits). Only re-ranks modules already present in `scored`.
512
+ const BEAM_MARGIN_FRAC = 0.5;
513
+ const BEAM_PROX_FRAC = 0.2;
665
514
  const BEAM_PROX_CAP_FRAC = 0.35;
666
515
  const BEAM_OVERFLOW_CAP = 4; // near-miss safety valve size
667
- const BEAM_PLIES = 2; // hops of expansion
516
+ const BEAM_PLIES = 2;
668
517
  const BEAM_EDGE_GROUPS = [["imports"], ["calls", "callsSymbol"], ["inherits"], ["cochange"]];
669
518
 
670
- // ---- SPIRAL expansion (opt-in, default off; BEAM_RESEARCH.md's "fix #2/#3" made concrete) ------
671
- // Deterministic bounded-radius ego walk from the lexical seeds, ordered fewest-arcs-first, with a
672
- // degree-quantile hub gate. UNLIKE beamExpand it MAY introduce modules that had no lexical match
673
- // (it walks the graph from the seeds), so it can in principle lift a lexically-invisible truth into
674
- // top-k — the whole point. cochange is dropped (temporal-coupling noise; see the research synthesis).
675
- // • spiralDepth — max hop radius from the seeds (bounded ego expansion). Default 3.
676
- // • mostDistinctiveBeams — degree-quantile gate q∈(0,1]: at each expansion step keep only the
677
- // lowest-degree ⌊q·n⌋ candidates (drop the top (1−q) hubs); q=1.0 keeps
678
- // all. Never empties the frontier (keeps ≥1 — the least-connected).
679
- // • spiralNodeLimit — emit budget: how many newly-reached nodes the spiral surfaces. Held at
680
- // 12 (MID-tier digest breadth, a KNOWN-DOABLE token budget) — a fixed
681
- // budget, NOT a recall dial.
519
+ // ---- spiral expansion (opt-in, default off): a deterministic bounded-radius ego
520
+ // walk from the lexical seeds, ordered fewest-arcs-first with a degree-quantile hub
521
+ // gate. Unlike beamExpand it may introduce modules with no lexical match at all. ----
682
522
  const SPIRAL_DEPTH_DEFAULT = 3;
683
523
  const SPIRAL_NODE_LIMIT_DEFAULT = 12;
684
- const SPIRAL_Q_DEFAULT = 0.9; // mild hub pruning (drop only the densest 10%) the centre point
524
+ const SPIRAL_Q_DEFAULT = 0.9; // keep only the least-connected 90% at each step
685
525
  const SPIRAL_EXPAND_KINDS = ["imports", "calls", "callsSymbol", "inherits"]; // cochange dropped
686
- // The memory graph's real edge-kind inventory (traced via every objectProperties.push/.find
687
- // site in src/memory/*.mjs and src/sessions.mjs)the `kinds` a memory-graph spiralExpand call
688
- // passes so it walks Session/Fact/Source/Utterance individuals rather than code-graph Modules.
689
- // NOTE: mgx:asksAbout (src/sessions.mjs) is deliberately EXCLUDED — that predicate lives in the
690
- // CODE graph (Session ↔ code entities a chat turn resolved/answered), not the memory graph.
526
+ // The memory graph's edge-kind inventory a memory-graph spiralExpand call walks
527
+ // instead of code-graph Modules. mgx:asksAbout is excluded it lives in the code graph.
691
528
  export const MEMORY_SPIRAL_EXPAND_KINDS = ["saidInSession", "inReplyTo", "statedBy", "canonicalisedFrom"];
692
- const SPIRAL_EMIT_FRAC = 0.5; // a newly-surfaced node's base score = maxSeed × this …
693
- const SPIRAL_HOP_DECAY = 0.6; // … decayed by this per hop from the seeds (bounded < maxSeed, so a walked-in node never dominates rank 1)
694
- const SPIRAL_PROX_FRAC = 0.2; // an ALREADY-matched module the spiral re-reaches gets a bounded nudge …
695
- const SPIRAL_PROX_CAP_FRAC = 0.35; // … capped at this × its own score (same shape as every other proximity family)
696
-
697
- /** embedRank: per-module embeddable text path components + defined symbol names + doc
698
- * first-lines, ALL already in the graph (never re-reads source), bounded by the EMB_TEXT_*
699
- * caps. Built once per graph and cached alongside the vectors in EMB_CACHE. */
529
+ const SPIRAL_EMIT_FRAC = 0.5;
530
+ const SPIRAL_HOP_DECAY = 0.6;
531
+ const SPIRAL_PROX_FRAC = 0.2;
532
+ const SPIRAL_PROX_CAP_FRAC = 0.35;
533
+
534
+ /** embedRank: per-module embeddable text from path components + defined symbol
535
+ * names + doc first-lines, cached alongside the vectors in EMB_CACHE. */
700
536
  function moduleEmbedTexts(graph) {
701
537
  const texts = new Map(); // moduleId -> text
702
538
  const defIdx = definesIndex(graph);
@@ -730,14 +566,9 @@ function identComponents(name) {
730
566
  return new Set(String(name).replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
731
567
  }
732
568
 
733
- /** For one edge-kind group, the depth-1 successor of `fromId` reachable via any edge in `kinds`,
734
- * as a Map<moduleId, neighbourModuleId> adjacency (undirected a module's neighbours via that
735
- * kind, in either edge direction). Endpoints are mapped to their containing module first (call
736
- * edges live at function granularity), matching the existing E1a call-adjacency convention.
737
- * `idNormalizer` (default null) lets a caller fold edge endpoints some OTHER way — the memory
738
- * graph has no "containing module" concept, so a memory-graph caller passes `(id) => id` to walk
739
- * its raw individual ids unchanged. Defaulting to null (rather than `moduleIdOfId` directly)
740
- * keeps the sole existing caller (`adjacencyForKinds(graph, kinds)` in beamExpand) byte-identical. */
569
+ /** Undirected adjacency (Map<id, Set<id>>) over any edge in `kinds`. Endpoints
570
+ * are folded to their containing module by default; `idNormalizer` lets a
571
+ * caller (e.g. the memory graph, which has no "module" concept) walk raw ids instead. */
741
572
  export function adjacencyForKinds(graph, kinds, idNormalizer = null) {
742
573
  const norm = idNormalizer || ((id) => moduleIdOfId(graph, id));
743
574
  const adj = new Map();
@@ -756,16 +587,14 @@ export function adjacencyForKinds(graph, kinds, idNormalizer = null) {
756
587
  return adj;
757
588
  }
758
589
 
759
- /** Beam-search-style multi-PLY expansion (PLAN_SEON_TUNING.md §5.15; see the BEAM_* constants'
760
- * comment above for the full design rationale). Mutates `s.score` in place on `scored` entries
761
- * it boosts — same bounded-nudge shape as the single-hop proximity families, just reachable over
762
- * more than one hop when a ply's beam survives that far. Pure otherwise (no fs/network). */
590
+ /** Beam-search-style multi-ply expansion (see BEAM_* constants above). Mutates
591
+ * `s.score` in place on boosted `scored` entries. */
763
592
  function beamExpand(graph, scored, beamWidth) {
764
593
  if (scored.length < 2) return;
765
594
  const byId = new Map(scored.map((s) => [s.ind.id, s]));
766
595
  const baseScore = new Map(scored.map((s) => [s.ind.id, s.score]));
767
596
 
768
- // Margin+cap prune a candidate-score Map down to this ply's beam, returning [survivors, overflow].
597
+ // Margin+cap prune a candidate-score Map down to this ply's beam, returning [survivors, overflow]
769
598
  const pruneToBeam = (candidates) => {
770
599
  if (!candidates.size) return [[], []];
771
600
  let best = 0;
@@ -780,13 +609,12 @@ function beamExpand(graph, scored, beamWidth) {
780
609
  return [survivors, overflow];
781
610
  };
782
611
 
783
- // Ply 0 beam = the current top-scoring already-matched modules (margin+cap over the whole set).
612
+ // Ply 0 beam = the current top-scoring already-matched modules
784
613
  let [beam, overflow] = pruneToBeam(new Map(scored.map((s) => [s.ind.id, s.score])));
785
614
  const boosted = new Set(beam.map(([id]) => id));
786
615
 
787
616
  for (let ply = 0; ply < BEAM_PLIES && beam.length; ply++) {
788
- // Per-edge-kind successor generation, scored, pruned INDEPENDENTLY per kind (so a dense kind
789
- // like imports can't crowd out a sparse-but-discriminative one like inherits), then merged.
617
+ // per-edge-kind successor generation, scored/pruned independently, then merged
790
618
  const merged = new Map(); // successorId -> best propagated score across all kinds this ply
791
619
  const plyOverflow = [];
792
620
  for (const kinds of BEAM_EDGE_GROUPS) {
@@ -803,8 +631,6 @@ function beamExpand(graph, scored, beamWidth) {
803
631
  plyOverflow.push(...kindOverflow);
804
632
  }
805
633
  overflow.push(...plyOverflow);
806
- // Apply the bounded nudge once per module (first ply it's reached), same shape as the other
807
- // proximity families — a nudge, never a replacement.
808
634
  for (const [id, propagated] of merged) {
809
635
  if (boosted.has(id)) continue;
810
636
  const s = byId.get(id);
@@ -813,57 +639,21 @@ function beamExpand(graph, scored, beamWidth) {
813
639
  boosted.add(id);
814
640
  }
815
641
  beam = [...merged.entries()];
816
- // Safety valve: if this ply's beam ran dry, reconsider the near-miss overflow instead of
817
- // just stopping — cheap insurance against a total pruning failure.
642
+ // if this ply's beam ran dry, reconsider the near-miss overflow instead of stopping
818
643
  if (!beam.length && overflow.length) {
819
644
  beam = overflow.splice(0, BEAM_OVERFLOW_CAP).filter(([id]) => !boosted.has(id));
820
645
  }
821
646
  }
822
647
  }
823
648
 
824
- /** SPIRAL expansion (opt-in; see the SPIRAL_* constants' comment above for the full design).
825
- * A deterministic bounded-radius ego walk from the lexical seeds (`scored`, or an explicit
826
- * `seeds` override see below), popped fewest-arcs-first via a min-heap keyed (hop ASC,
827
- * in-graph degree ASC, id ASC), with a degree-quantile hub gate at each expansion step. Emits
828
- * up to `nodeLimit` newly-reached nodes in pop order, scoring each seed-relative and bounded so
829
- * a hub can't dominate rank 1.
830
- * CRITICAL vs beamExpand: it deliberately OMITS the `if (!baseScore.has) continue` guard, so it
831
- * MAY push modules that had NO lexical match into `scored` the one path to breaking the lexical
832
- * ceiling. Mutates `scored` (nudges re-reached matches in place; APPENDS newly-surfaced modules)
833
- * when the score-nudge machinery is active. Pure otherwise (no fs/network); deterministic total
834
- * ordering throughout.
835
- *
836
- * Generalised (2026-07-11) past its original code-graph-only, `scored`-only shape so a pure
837
- * graph-visualisation walk (no lexical match list at all) can reuse the exact same traversal:
838
- * - `scored` is now OPTIONAL (default `[]`) — a bare walk with no ranking machinery.
839
- * - `kinds` (default `SPIRAL_EXPAND_KINDS`) — the edge-kind set to walk; a memory-graph caller
840
- * passes `MEMORY_SPIRAL_EXPAND_KINDS`.
841
- * - `classPredicate` (default `(ind) => (ind.class || "") === "Module"`) — replaces the two
842
- * hardcoded `"Module"` checks below, so a memory-graph caller can pass `() => true` (every
843
- * class walkable) or any other individual filter.
844
- * - `idNormalizer` (default `null`) — threaded straight into the internal `adjacencyForKinds`
845
- * call; a memory-graph caller passes `(id) => id` (no module-folding).
846
- * - `seeds` (default derived from `scored`, as before) — an explicit id iterable, so a caller
847
- * with no `scored` list at all (e.g. `mostRecentIndividual`'s single seed) can still drive
848
- * the walk.
849
- * - `hubDegree` (default `Infinity`, PLAN_VIZ_MEMORY.md's page-size strategy — seonix's own
850
- * third cap, default 40 there): stop expanding THROUGH a node with MORE than this many
851
- * in-graph neighbours over `kinds` — the node itself is still popped/emitted normally (still
852
- * shown), it just contributes no candidates for the NEXT hop. Distinct from `q` (a relative,
853
- * per-step quantile gate that always keeps at least one candidate) and from `nodeLimit` (a
854
- * total emit budget): `hubDegree` is an absolute per-node gate that can drop a hub's entire
855
- * fan-out to zero, so an ultra-common hypernym ("thing", "entity" — reachable from thousands
856
- * of IsA facts) can't swallow the whole node budget in one hop. `Infinity` (no gate) keeps
857
- * every existing caller byte-identical. EXEMPTS the seed(s) (hop 0) themselves — a walk
858
- * started directly ON a hub (e.g. `tmct viz --term tree` where "tree" is a 1,972-fact
859
- * ConceptNet hub, measured live this session) still shows that hub's own immediate
860
- * neighbourhood; only a hub reached MID-walk (hop > 0) has its own further fan-out gated.
861
- * The score-nudge machinery (mutating `scored`/introducing newly-surfaced individuals into it)
862
- * is gated behind `scored.length > 0 && maxSeed > 0` — the exact condition the original early
863
- * return checked — so an empty `scored` degrades gracefully into a pure walk rather than erroring.
864
- * Returns `[{id, hop}]` for every node the walk actually pops (seeds included, at hop 0) — this
865
- * used to return `undefined`; safe, since the sole caller (`scoreModules`) already discards the
866
- * return value (confirmed by inspection, not assumed). */
649
+ /** Deterministic bounded-radius ego walk from the lexical seeds (`scored`, or
650
+ * an explicit `seeds` override), popped fewest-arcs-first with a degree-
651
+ * quantile hub gate. Unlike beamExpand, it may push modules with no lexical
652
+ * match into `scored`. `kinds`/`classPredicate`/`idNormalizer` let a
653
+ * memory-graph caller reuse the same walk over its own individuals/edges;
654
+ * `hubDegree` (default Infinity) caps a node's own fan-out so a hypernym hub
655
+ * can't swallow the whole emit budget, but never blocks a walk started
656
+ * directly on that hub. Returns `[{id, hop}]` for every popped node. */
867
657
  export function spiralExpand(graph, scored = [], {
868
658
  depth = SPIRAL_DEPTH_DEFAULT,
869
659
  q = SPIRAL_Q_DEFAULT,
@@ -943,19 +733,10 @@ export function spiralExpand(graph, scored = [], {
943
733
  emitted++;
944
734
  }
945
735
  if (node.hop >= depth) continue;
946
- // hubDegree gate: a node above the cap is still shown (already emitted above) but never
947
- // expanded THROUGH its own neighbours contribute nothing to the next hop. EXEMPTS hop 0
948
- // (a seed) deliberately: measured live against a real init:xl-scale corpus this session,
949
- // `tmct viz --term tree` (a 1,972-fact hub term) with the gate applied unconditionally
950
- // returned a single, useless lone node — seeding directly ON a term the user explicitly
951
- // asked to centre on must always show ITS OWN immediate neighbourhood, or the whole
952
- // `--term`/click-to-recentre feature is pointless on exactly the popular, interesting terms
953
- // it exists for. A hub only reached mid-walk (hop > 0) still gates normally — this only
954
- // changes the walk's own STARTING point(s), not general hub suppression elsewhere.
736
+ // exempts hop 0: seeding directly on a hub term must still show its own
737
+ // immediate neighbourhood, or --term/click-to-recentre is pointless on exactly
738
+ // the popular terms it exists for
955
739
  if (node.hop > 0 && degree(node.id) > hubDegree) continue;
956
- // This step's candidate set = the popped node's unvisited neighbours matching classPredicate;
957
- // quantile-gate by degree, keeping the lowest-degree ⌊q·n⌋ (drop the densest hubs), never
958
- // fewer than one.
959
740
  const cands = [];
960
741
  for (const nid of adj.get(node.id) || []) {
961
742
  if (visited.has(nid)) continue;
@@ -975,13 +756,9 @@ export function spiralExpand(graph, scored = [], {
975
756
  return results;
976
757
  }
977
758
 
978
- /** The individual with the most recent `createdAtProp` attribute — item 1's ("Traversal") seed
979
- * default: "sort memory-graph individuals by mgx:createdAt descending, seed from the single most
980
- * recent." Deterministic tie-break by id (lowest id wins) when two individuals share the exact
981
- * same timestamp — same total-order convention `spiralExpand`'s own heap uses. Null when no
982
- * individual carries the attribute at all (empty graph, or a graph that predates timestamps).
983
- * ISO-8601 timestamps compare correctly as plain strings (same zero-padded width throughout this
984
- * codebase), so no Date parsing is needed. */
759
+ /** The individual with the most recent `createdAtProp` attribute (ties break
760
+ * on lowest id); null if none carry the attribute. ISO-8601 timestamps
761
+ * compare correctly as plain strings, so no Date parsing is needed. */
985
762
  export function mostRecentIndividual(graph, createdAtProp = CREATED_AT_PROP) {
986
763
  let best = null; // { ind, v }
987
764
  for (const ind of graph?.individuals || []) {
@@ -992,11 +769,9 @@ export function mostRecentIndividual(graph, createdAtProp = CREATED_AT_PROP) {
992
769
  return best ? best.ind : null;
993
770
  }
994
771
 
995
- /** The shared module-ranking core behind renderSearch (text) and searchModulesRanked (path+score).
996
- * IDF-weights each query token by rarity across modules (so a whole-problem-statement query is not
997
- * swamped by ubiquitous words like template/filter/value), scores path-component + symbol-component
998
- * + EXACT-symbol matches, re-ranks with a bounded import-proximity bonus, and breaks ties by
999
- * matched-symbol DENSITY (a concrete signal — never ground truth). Pure; deterministic. */
772
+ /** Shared module-ranking core behind renderSearch and searchModulesRanked.
773
+ * IDF-weights each query token, scores path/symbol/exact-symbol matches, and
774
+ * re-ranks with a bounded import-proximity bonus. Pure; deterministic. */
1000
775
  function scoreModules(graph, tokens, opts = {}) {
1001
776
  const { demoteNonProd = false, callAdjacency = false, implOfInterface = false, beamSearch = false, spiral = false, proseBoost = false, proseLayers = false, literalMention = false, embedRank = false, rawQuery = "" } = opts;
1002
777
  const beamWidth = Number.isFinite(opts.beamWidth) && opts.beamWidth > 0 ? opts.beamWidth : 8;
@@ -1011,19 +786,13 @@ function scoreModules(graph, tokens, opts = {}) {
1011
786
  const symSet = new Set(defines.map((d) => d.toLowerCase())); // exact symbol names
1012
787
  const symComps = new Set();
1013
788
  for (const d of defines) for (const c of identComponents(d)) symComps.add(c);
1014
- // literalMention only: the Module's `dotted` attribute (mgx:dotted) — the verbatim form a
1015
- // task statement uses ("django.utils.http"); "" when absent. Gated so OFF does zero work.
1016
789
  const dotted = literalMention
1017
790
  ? String((ind.attributes || []).find((a) => a.key === "dotted")?.value || "").toLowerCase()
1018
791
  : "";
1019
792
  modules.push({ ind, label, labelLc, defines, symSet, symComps, dotted });
1020
793
  }
1021
794
  const N = modules.length || 1;
1022
- // Inverse module-frequency: a token in many modules carries little locating signal; a rare one
1023
- // decides. df = modules where the token appears in the path (substring — keeps "filter" matching
1024
- // "defaultfilters"), as a symbol component, or as an exact symbol name. A loose path substring like
1025
- // "text" that hits many modules therefore earns a low weight, so "ci<text>" can't beat utils/text.py.
1026
- // idf = log(1 + N/(1+df)) → ~0 for ubiquitous tokens, large for rare ones.
795
+ // idf = log(1 + N/(1+df)): near-zero for ubiquitous tokens, large for rare ones
1027
796
  const idf = new Map();
1028
797
  for (const t of tokens) {
1029
798
  if (idf.has(t)) continue;
@@ -1047,28 +816,22 @@ function scoreModules(graph, tokens, opts = {}) {
1047
816
  for (let i = 0; i < Math.min(compWeights.length, SYM_MATCH_CAP); i++) symScore += compWeights[i] * SYM_W;
1048
817
  let score = exactScore + pathScore + symScore;
1049
818
  if (!score) continue;
1050
- if (demoteNonProd && (isTestLabel(m.labelLc) || isNonProdLabel(m.labelLc))) score *= NONPROD_DEMOTE; // B016 R1a
819
+ if (demoteNonProd && (isTestLabel(m.labelLc) || isNonProdLabel(m.labelLc))) score *= NONPROD_DEMOTE;
1051
820
  else if (isTestLabel(m.labelLc)) score *= 0.4; // source first; tests still discoverable
1052
821
  const matching = m.defines.filter((d) => { const dl = d.toLowerCase(); const cs = identComponents(d); return tokens.some((t) => dl === t || cs.has(t)); });
1053
822
  const density = m.defines.length ? matchCount / m.defines.length : 0;
1054
823
  scored.push({ ind: m.ind, score, defineCount: m.defines.length, matching, density });
1055
824
  }
1056
- // §7.5/§7.6(5a) literalMention (opt-in): verbatim dotted-name/path mentions in the RAW query
1057
- // see the LIT_* constants' comment above for the full design. Runs before the proximity
1058
- // families so a mentioned module donates adjacency like any other strong match.
825
+ // literalMention: runs before the proximity families so a mentioned module donates adjacency too
1059
826
  if (literalMention && rawQuery && scored.length) {
1060
827
  const rawLc = String(rawQuery).toLowerCase();
1061
828
  const continues = (ch) => ch != null && /[a-z0-9_./]/.test(ch);
1062
- // Whole, boundary-checked occurrence of `cand` in the raw query (see boundary rule above).
1063
829
  const mentioned = (cand) => {
1064
830
  for (let i = rawLc.indexOf(cand); i !== -1; i = rawLc.indexOf(cand, i + 1)) {
1065
831
  if (!continues(rawLc[i - 1]) && !continues(rawLc[i + cand.length])) return true;
1066
832
  }
1067
833
  return false;
1068
834
  };
1069
- // IDF for a candidate's components: normally already in the map (they are query tokens by
1070
- // construction when tokens came from this same raw query); computed-and-cached otherwise
1071
- // (a caller passing mismatched tokens/rawQuery must not crash or skew).
1072
835
  const idfOf = (t) => {
1073
836
  if (!idf.has(t)) {
1074
837
  let df = 0;
@@ -1086,9 +849,8 @@ function scoreModules(graph, tokens, opts = {}) {
1086
849
  let litWeight = 0; // best single matched candidate (dotted vs path share components anyway)
1087
850
  for (const cand of new Set([m.dotted, m.labelLc])) {
1088
851
  if (!cand) continue;
1089
- if (cand.split(/[./]+/).filter(Boolean).length < LIT_MIN_COMPONENTS) continue; // specificity floor
852
+ if (cand.split(/[./]+/).filter(Boolean).length < LIT_MIN_COMPONENTS) continue;
1090
853
  if (!mentioned(cand)) continue;
1091
- // IDF-weight the candidate's tokens (same tokenizer as the query), highest first.
1092
854
  const weights = [...new Set(cand.split(/[^a-z0-9_]+/).filter(Boolean))].map(idfOf).sort((a, b) => b - a);
1093
855
  let w = 0;
1094
856
  for (let i = 0; i < Math.min(weights.length, LIT_COMP_CAP); i++) w += weights[i] * LIT_W;
@@ -1097,9 +859,7 @@ function scoreModules(graph, tokens, opts = {}) {
1097
859
  if (litWeight) s.score += Math.min(litWeight * LIT_FRAC, maxBase * LIT_CAP_FRAC);
1098
860
  }
1099
861
  }
1100
- // Import-graph proximity (rescaled): a matched module that imports / is imported by a
1101
- // STRONGER-matching module gets a bonus proportional to that neighbour, so a genuine 2nd module
1102
- // (truncatelines' text.py) rises with its sibling. Only re-ranks modules that ALREADY matched.
862
+ // import-graph proximity: a matched module importing/imported-by a stronger match rises with it
1103
863
  if (scored.length > 1) {
1104
864
  const baseById = new Map(scored.map((s) => [s.ind.id, s.score]));
1105
865
  const adj = new Map();
@@ -1116,11 +876,7 @@ function scoreModules(graph, tokens, opts = {}) {
1116
876
  s.score += Math.min(bestNeighbor * PROX_FRAC, s.score * PROX_CAP_FRAC);
1117
877
  }
1118
878
  }
1119
- // B016 E1a (opt-in): resolved-call adjacency a matched module CALLED BY (or calling) a
1120
- // stronger-matching module rises with it (initials-filter: defaultfilters.py calls into
1121
- // utils/text.py, whose lexical rank was 8). Call edges live at function level, so endpoints
1122
- // map to their containing modules first. Same bounded-nudge formula as import-proximity;
1123
- // only re-ranks modules that already matched.
879
+ // callAdjacency: same formula as import-proximity, over resolved call edges (folded to modules)
1124
880
  if (callAdjacency && scored.length > 1) {
1125
881
  const baseById = new Map(scored.map((s) => [s.ind.id, s.score]));
1126
882
  const adj = new Map();
@@ -1142,12 +898,7 @@ function scoreModules(graph, tokens, opts = {}) {
1142
898
  s.score += Math.min(bestNeighbor * CALL_PROX_FRAC, s.score * CALL_PROX_CAP_FRAC);
1143
899
  }
1144
900
  }
1145
- // B016 E1b (opt-in): impl-of-interface — a C# module implementing an interface DEFINED in a
1146
- // stronger-matching module rises with it (eshoponweb: IBasketService.cs rank 1, BasketService.cs
1147
- // rank 4). `inherits` edges point the OBJECT at an unresolved `ext:<Name>` id for C#, so resolve
1148
- // by exact label match against internal Class individuals. Only re-ranks modules that already
1149
- // matched, and only when both the implementer module is `.cs` and the base name looks like a C#
1150
- // interface (see the const block above for why — isAbstract does not exist in the data).
901
+ // implOfInterface: a C# module implementing an interface rises with the interface's own match
1151
902
  if (implOfInterface && scored.length > 1) {
1152
903
  const baseById = new Map(scored.map((s) => [s.ind.id, s.score]));
1153
904
  const classByLabel = new Map();
@@ -1171,13 +922,7 @@ function scoreModules(graph, tokens, opts = {}) {
1171
922
  s.score += Math.min(bestNeighbor * IMPL_PROX_FRAC, s.score * IMPL_PROX_CAP_FRAC);
1172
923
  }
1173
924
  }
1174
- // PLAN_PROSE_INDEX.md §6 (opt-in): lexical boost from decomposed-identifier/doc-comment
1175
- // prose tokens — see the PROSE_PROX_* comment above for the full rationale. One
1176
- // lookupByProseTokens call for the whole query (not per-module), then aggregated into a
1177
- // per-module signal via moduleIdOfId, same as the call-adjacency/impl-of-interface families.
1178
- // Unlike the proximity families above, this signal is absolute per-module (prose-token
1179
- // overlap), not relative to a stronger NEIGHBOUR in `scored` — so it applies even when
1180
- // only one module matched lexically (no ">1" gate needed).
925
+ // proseBoost: absolute per-module prose-token overlap, so it applies with only one match too
1181
926
  if (proseBoost && scored.length && graph.proseIndex) {
1182
927
  const proseHits = lookupByProseTokens(graph.proseIndex, tokens.join(" "), { limit: PROSE_LOOKUP_LIMIT });
1183
928
  if (proseHits.length) {
@@ -1194,15 +939,8 @@ function scoreModules(graph, tokens, opts = {}) {
1194
939
  }
1195
940
  }
1196
941
  }
1197
- // Layered prose normalisation (opt-in): a query token that did NOT match a module lexically but
1198
- // resolves to one of its individuals through a NORMALISED prose layer (stem/lemma/canonical/spell)
1199
- // adds a bounded, discounted signal — see the PROSE_LAYER_* comment above. One proseLayerHits call
1200
- // per DISTINCT query token, ids folded to their containing module via moduleIdOfId (same as the
1201
- // proseBoost/call-adjacency families). Only tokens NOT already matching a module lexically count
1202
- // for that module (a layer hit is purely ADDITIVE evidence for otherwise-missed words — never
1203
- // double-counting a token the base score already saw), weighted by the token's own IDF (so a
1204
- // ubiquitous word contributes almost nothing) and halved (PROSE_LAYER_DISCOUNT: weaker than a
1205
- // verbatim match), then the shared FRAC/CAP nudge. Only re-ranks modules already in `scored`.
942
+ // proseLayers: a token that only matches via a normalised layer adds discounted, additive
943
+ // evidence never double-counted against a token the base score already saw
1206
944
  if (proseLayers && scored.length && graph.proseIndex) {
1207
945
  const scoredById = new Map(scored.map((s) => [s.ind.id, s]));
1208
946
  const modById = new Map(modules.map((m) => [m.ind.id, m]));
@@ -1217,9 +955,9 @@ function scoreModules(graph, tokens, opts = {}) {
1217
955
  const modId = moduleIdOfId(graph, id);
1218
956
  if (!modId || hitMods.has(modId)) continue;
1219
957
  hitMods.add(modId);
1220
- if (!scoredById.has(modId)) continue; // never a new zero-match candidate
958
+ if (!scoredById.has(modId)) continue; // never a new zero-match candidate
1221
959
  const m = modById.get(modId);
1222
- if (m && (m.symSet.has(t) || m.symComps.has(t) || m.labelLc.includes(t))) continue; // already matched lexically → not additive
960
+ if (m && (m.symSet.has(t) || m.symComps.has(t) || m.labelLc.includes(t))) continue; // already matched lexically
1223
961
  layerSignal.set(modId, (layerSignal.get(modId) || 0) + w * PROSE_LAYER_DISCOUNT);
1224
962
  }
1225
963
  }
@@ -1229,10 +967,8 @@ function scoreModules(graph, tokens, opts = {}) {
1229
967
  s.score += Math.min(signal * PROSE_LAYER_FRAC, s.score * PROSE_LAYER_CAP_FRAC);
1230
968
  }
1231
969
  }
1232
- // §7.6(5b) embedRank (opt-in): static-embedding cosine re-rank see the EMB_* constants'
1233
- // comment above. The embedder is INJECTED (opts.embedder, from embed.mjs's loadEmbedder) so
1234
- // this module stays fs-free; absent embedder → no-op with a one-time stderr note, never a
1235
- // failure (the 30 MB weights are a local opt-in fetch, not a test/CI dependency).
970
+ // embedRank: the embedder is injected so this module stays fs-free; absent -> a one-time
971
+ // stderr note, never a failure
1236
972
  if (embedRank) {
1237
973
  if (!opts.embedder) {
1238
974
  if (!embedWarned) {
@@ -1261,9 +997,9 @@ function scoreModules(graph, tokens, opts = {}) {
1261
997
  }
1262
998
  }
1263
999
  }
1264
- // §5.15 beam search (opt-in): multi-ply generalization of the single-hop families above.
1000
+ // beamSearch (opt-in): multi-ply generalization of the single-hop families above.
1265
1001
  if (beamSearch && scored.length > 1) beamExpand(graph, scored, beamWidth);
1266
- // SPIRAL (opt-in): bounded-radius ego walk that MAY introduce lexically-invisible modules — runs
1002
+ // SPIRAL (opt-in): bounded-radius ego walk that may introduce lexically-invisible modules — runs
1267
1003
  // last (after every family has finalised the seed scores) so its seed-relative emit scores and
1268
1004
  // hub gate read the settled ranking, and before the sort so surfaced nodes slot into it.
1269
1005
  if (spiral && scored.length) spiralExpand(graph, scored, {
@@ -1276,12 +1012,12 @@ function scoreModules(graph, tokens, opts = {}) {
1276
1012
  return scored;
1277
1013
  }
1278
1014
 
1279
- /** TUNING #3: the ranked module list as plain `{path, score}` (highest-first), using the SAME
1015
+ /** The ranked module list as plain `{path, score}` (highest-first), using the same
1280
1016
  * ranking renderSearch uses (path + symbol + exact-symbol + import-proximity). Lets the rig
1281
- * read the score GAP between rank-1 and rank-2 (which the text renderer hides) so it can keep
1017
+ * read the score gap between rank-1 and rank-2 (which the text renderer hides) so it can keep
1282
1018
  * rank-2 only when it is close. Pure; deterministic.
1283
- * NOTE: scoreModules still RANKS (locate always returns modules), but the score-gap top-1
1284
- * SELECTION that consumes this gap is OFF by default in run.mjs/selectModules — it over-injected
1019
+ * Note: scoreModules still ranks (locate always returns modules), but the score-gap top-1
1020
+ * selection that consumes this gap is off by default in run.mjs/selectModules — it over-injected
1285
1021
  * on some tasks. The shipped default takes the top-2 instead. */
1286
1022
  export function searchModulesRanked(graph, query, opts = {}) {
1287
1023
  const raw = String(query || "");
@@ -1294,28 +1030,15 @@ export function searchModulesRanked(graph, query, opts = {}) {
1294
1030
  return scoreModules(graph, tokens, effOpts).map((s) => ({ path: String(s.ind.label), score: s.score }));
1295
1031
  }
1296
1032
 
1297
- // B016 R1b, promoted to the shipped default (2026-07-02): positive in every measured cell across
1298
- // B016's P1 (tuning task + a genuinely held-out task) and P2 (both eshoponweb tasks; clears the
1299
- // ≥50%-vs-otb bar outright on order-service-total). See PLAN_B016.md §6.9. 0.6 is the exact ratio
1300
- // tested throughout — do not drift it from bench/arms.mjs's arm values or scripts/rank-gate.mjs's
1301
- // --gap default; all three should read this constant.
1033
+ // 0.6 is the exact ratio tested throughout do not drift it from bench/arms.mjs's arm values
1034
+ // or scripts/rank-gate.mjs's --gap default; all three should read this constant.
1302
1035
  export const DEFAULT_SCORE_GAP = 0.6;
1303
1036
 
1304
- /** Score-gap-driven module selection: take the top_k ranked hits, then extend the selection to
1305
- * include ranks (top_k)..2 whose score sits within `scoreGapK` of rank 1 — the near-tie case
1306
- * where a second (or third) module is genuinely as relevant as the top hit, not filler. Never
1307
- * resurrects a suppressed (empty) selection: a top_k of 0 stays empty regardless of scoreGapK.
1308
- * Pure the single source of truth for gap-extension, shared by the CLI product surface
1309
- * (cli.mjs's query-based `digest`) and the bench rig (bench/run.mjs's selectModules).
1310
- *
1311
- * DELIBERATELY NEUTRAL BY DEFAULT: `scoreGapK` defaults to `null` (gap-extension OFF, plain
1312
- * top-`top_k`) here — the SHIPPED default of `DEFAULT_SCORE_GAP` is a product-surface policy
1313
- * decision, applied explicitly by the caller (cli.mjs's digest query-mode), not baked into this
1314
- * primitive. A library default of "on" would make every future caller who forgets to pass
1315
- * `scoreGapK` silently inherit gap-extension — including future bench arms, breaking the
1316
- * paired-arm "byte-identical when off" comparability this repo's whole measurement methodology
1317
- * depends on. See test/selectRankedModules.test.mjs's "absent scoreGapK is byte-identical to
1318
- * plain top-k" case. */
1037
+ /** Take the top_k ranked hits, then extend to ranks (top_k)..2 whose score
1038
+ * sits within `scoreGapK` of rank 1 — the near-tie case where another module
1039
+ * is genuinely as relevant as the top hit. `scoreGapK` defaults to null
1040
+ * (off): the shipped `DEFAULT_SCORE_GAP` is a caller-applied policy, not a
1041
+ * library default, so bench arms stay byte-identical when the flag is off. */
1319
1042
  export function selectRankedModules(ranked, { top_k = 2, scoreGapK = null } = {}) {
1320
1043
  if (!ranked.length || top_k <= 0) return [];
1321
1044
  const picked = ranked.slice(0, top_k).map((r) => r.path);
@@ -1355,39 +1078,24 @@ export function renderSearch(graph, query, { limit = SEARCH_LIMIT, kind = "", de
1355
1078
  return lines.join("\n");
1356
1079
  }
1357
1080
 
1358
- // ---- §9 read-replacing tools (members / inheritance / architecture / coverage /
1081
+ // ---- read-replacing tools (members / inheritance / architecture / coverage /
1359
1082
  // history / call neighbours). Each answers ONE question in one compact call so
1360
1083
  // the agent need not Read/Grep. All keep the bounded-output discipline. -------
1361
1084
 
1362
- /** Per-graph, per-kind memo for edgesOfKind's own flattened scan WeakMap<graph,
1363
- * Map<kind, edge[]>>, mirroring qualCache's (ask.mjs) established per-graph-object
1364
- * caching convention: a loaded graph's `relations` are never mutated in place after
1365
- * parseEntities builds it (every refresh constructs a NEW graph object), so caching
1366
- * keyed on graph object identity is correctness-safe for a graph's whole lifetime —
1367
- * same invariant qualCache already relies on in production. edgesOfKind is called
1368
- * repeatedly on the SAME (graph, kind) pair across a single query's traversal
1369
- * (evalSet/traverse/adjacencyForKinds/renderArchitecture/… all re-derive it), and at
1370
- * monorepo scale (tens of thousands of modules) that repeated O(relations) scan is a
1371
- * real latency/GC cost — this collapses every call after the first to an O(1) lookup. */
1085
+ // Per-graph, per-kind memo: a loaded graph's `relations` are never mutated in
1086
+ // place (a refresh builds a new graph object), so caching on graph identity
1087
+ // is correctness-safe and collapses repeated O(relations) scans to O(1).
1372
1088
  const edgesOfKindCache = new WeakMap();
1373
1089
 
1374
- /** All edges whose relation classifies to `kind`, flattened across relation groups. */
1375
- /** All edges of a classified relation kind (imports/calls/defines/tests/touches/inherits/
1376
- * cochange/reexports/callsSymbol/touchesSymbol/contains — see relationKind/PROP_KIND above),
1377
- * flattened across every raw relation group that classifies to it. Exported for ask.mjs's
1378
- * mechanical NL-query engine (PLAN_MECHANICAL_CHAT.md) to orchestrate rather than duplicate.
1379
- * Memoized per (graph, kind) — see edgesOfKindCache's own doc above (perf lever, HANDOVER
1380
- * follow-up #8: latency/GC on monorepo-scale graphs, not a correctness fix — the earlier
1381
- * stack-overflow bug below is already fixed and unrelated). */
1090
+ /** All edges of a classified relation kind, flattened across every raw
1091
+ * relation group that classifies to it. Memoized per (graph, kind). */
1382
1092
  export function edgesOfKind(graph, kind) {
1383
1093
  let byKind = edgesOfKindCache.get(graph);
1384
1094
  if (!byKind) { byKind = new Map(); edgesOfKindCache.set(graph, byKind); }
1385
1095
  const cached = byKind.get(kind);
1386
1096
  if (cached) return cached;
1387
1097
  const out = [];
1388
- // Plain-loop append, NOT out.push(...g.edges): argument spread materialises every
1389
- // element as a call argument and overflows the stack past ~100k edges (live report:
1390
- // 27,770-module repo, "list modules in <dir>" → "Maximum call stack size exceeded").
1098
+ // plain-loop append, not spread: argument spread overflows the stack past ~100k edges
1391
1099
  for (const g of graph.relations) {
1392
1100
  if (relationKind(g) !== kind) continue;
1393
1101
  for (const e of g.edges) out.push(e);
@@ -1396,15 +1104,9 @@ export function edgesOfKind(graph, kind) {
1396
1104
  return out;
1397
1105
  }
1398
1106
 
1399
- /** A node's "last touched" moment, DERIVED rather than stored (PLAN_VIZ.md §2): the node's own
1400
- * `updatedAtProp`/`createdAtProp` attribute, or the max `createdAt` over every edge (in
1401
- * `graph.relations`, ACROSS every kind, not just classified ones) touching it as either
1402
- * subject or object — whichever is newer. `""` when nothing carries a timestamp at all. Compares
1403
- * ISO-8601 strings directly (correct for same-width zero-padded timestamps, no Date parsing).
1404
- * Tolerates edges with no `createdAt` field (pre-dating `upsertEdge`'s own stamp, or written by
1405
- * a path that bypasses `upsertEdge` entirely) by simply skipping them, never throwing. Operates
1406
- * on the shared parsed-graph shape (`graph.relations`/`graph.individuals`), not memory-specific —
1407
- * same reasoning `edgesOfKind`/`moduleIdOf` already document. */
1107
+ /** A node's "last touched" moment: its own updatedAt/createdAt attribute, or
1108
+ * the max `createdAt` over every edge touching it, whichever is newer. ""
1109
+ * when nothing carries a timestamp. Skips edges with no `createdAt` rather than throwing. */
1408
1110
  export function derivedUpdatedAt(graph, ind, { createdAtProp = CREATED_AT_PROP, updatedAtProp = UPDATED_AT_PROP } = {}) {
1409
1111
  if (!ind) return "";
1410
1112
  const attrs = ind.attributes || [];
@@ -1421,17 +1123,10 @@ export function derivedUpdatedAt(graph, ind, { createdAtProp = CREATED_AT_PROP,
1421
1123
  return best;
1422
1124
  }
1423
1125
 
1424
- /** Turn a `spiralExpand` walk (`[{id, hop}]`) into the `{nodes, edges}` shape
1425
- * `tmct viz` renders — pure, no I/O, shared verbatim between the CLI
1426
- * (`src/viz.mjs`'s `computeVizGraph`) and the browser bundle's client-side
1427
- * re-walk/recentre (PLAN_BREADTH_FIRST_NLU.md §5 follow-on, operator
1428
- * directive 2026-07-11) so both paths render byte-identically from the same
1429
- * logic, never two hand-maintained copies. `nodes` enrich each walked id with
1430
- * its real label/class/timestamps (`derivedUpdatedAt`, above); `edges` are
1431
- * every relation-group edge connecting two walked nodes (not just the kinds
1432
- * the walk itself traversed through — an incidental edge between two reached
1433
- * nodes still renders), de-duped on (subject, object, predicate) across
1434
- * relation groups. */
1126
+ /** Turn a `spiralExpand` walk into the `{nodes, edges}` shape `tmct viz`
1127
+ * renders, shared between the CLI and the browser bundle's client-side
1128
+ * re-walk. `edges` includes any relation-group edge connecting two walked
1129
+ * nodes, not just kinds the walk itself traversed, de-duped on (subject, object, predicate). */
1435
1130
  export function buildVizNodesAndEdges(graph, walked, { createdAtProp = CREATED_AT_PROP, updatedAtProp = UPDATED_AT_PROP } = {}) {
1436
1131
  const nodeIds = new Set(walked.map((w) => w.id));
1437
1132
  const nodes = walked.map(({ id, hop }) => {
@@ -1457,46 +1152,17 @@ export function buildVizNodesAndEdges(graph, walked, { createdAtProp = CREATED_A
1457
1152
  return { nodes, edges };
1458
1153
  }
1459
1154
 
1460
- // PLAN_VIZ_MEMORY.md Bug 2 fix: the FACT_CLASS string, mirrored here rather than
1461
- // imported (memory/core.mjs's own FACT_CLASS export is a plain "Fact" literal —
1462
- // importing one more binding across this already-imported module isn't worth
1463
- // it for a single string every reader of this file can eyeball is exactly what
1464
- // memory/core.mjs's own appendFact writes).
1465
1155
  const MEMORY_FACT_CLASS = "Fact";
1466
1156
  const MEMORY_TERM_CLASS = "Term";
1467
1157
 
1468
- /** Derive a TERM-relation VIEW of a memory graph's reified Facts (Bug 2 fix,
1469
- * PLAN_VIZ_MEMORY.md) — never mutates `graph`, never persisted, viz-only.
1470
- *
1471
- * A Fact individual stores its subject/predicate/object as plain normalized
1472
- * STRING attributes (`rdf:subject`/`rdf:predicate`/`rdf:object`,
1473
- * memory/core.mjs's appendFact) there is no individual node for "dog" at
1474
- * all. So the real subject->predicate->object concept structure is
1475
- * structurally invisible to any walk over `graph.relations` as it stands:
1476
- * every edge there connects two INDIVIDUAL ids (Utterance/Session/Fact/
1477
- * Source), never a concept term. This function materializes the missing
1478
- * structure as a NEW, derived graph:
1479
- * - one synthetic `Term` individual per distinct normalized subject/object
1480
- * string (id `term:<t>`, label `t`);
1481
- * - one synthetic relation group per DISTINCT fact predicate actually
1482
- * present in the data (Term -> Term, `subject`/`object` = the two terms'
1483
- * ids) — no hardcoded predicate vocabulary: a freshly taught "mgx:<verb>"
1484
- * predicate (generalVerbTeach) becomes walkable automatically, the same
1485
- * turn it's asserted;
1486
- * - two FIXED structural link groups, `factSubjectTerm`/`factObjectTerm`
1487
- * (Fact -> its own subject/object Term) — without these a walk seeded on
1488
- * a Fact (the default `mostRecentIndividual` seed right after a teach
1489
- * turn) could never reach the term graph in the first place; a `--term`
1490
- * seed reaches the SAME facts via the same links, in reverse.
1491
- * Returns `{ graph: <augmented graph>, factRelationKinds: [<predicate>, …] }`
1492
- * — `factRelationKinds` is exactly the dynamic `kinds` list a caller passes
1493
- * to `spiralExpand` for the "concept relation" walk (see MEMORY_SPIRAL_EXPAND_KINDS
1494
- * for the sibling "provenance/meta" kinds list). A graph with no Fact
1495
- * individuals (or a code graph passed in by mistake) is a safe no-op:
1496
- * `{ graph, factRelationKinds: [] }`, the SAME graph object, unchanged.
1497
- * Pure; deterministic (Map iteration order = insertion order = first-seen
1498
- * order over `graph.individuals`, so re-running on the same input is
1499
- * byte-identical). */
1158
+ /** Derive a term-relation view of a memory graph's reified Facts (never
1159
+ * mutates `graph`, viz-only). A Fact stores subject/predicate/object as
1160
+ * plain string attributes, so the concept structure is invisible to a walk
1161
+ * over `graph.relations` as-is; this synthesizes one Term individual per
1162
+ * distinct subject/object string, one relation group per distinct fact
1163
+ * predicate, and two fixed Fact->Term link groups so a walk seeded on a Fact
1164
+ * can reach the term graph at all. Returns `{ graph: <augmented graph>,
1165
+ * factRelationKinds: [<predicate>, …] }`; a graph with no Facts is a no-op. */
1500
1166
  export function deriveFactTermGraph(graph) {
1501
1167
  const termById = new Map(); // term:<t> -> individual
1502
1168
  const groupByPredicate = new Map(); // predicate -> relation group
@@ -1520,10 +1186,6 @@ export function deriveFactTermGraph(graph) {
1520
1186
  const objectTermId = ensureTerm(o);
1521
1187
  let group = groupByPredicate.get(p);
1522
1188
  if (!group) {
1523
- // `factrel:` namespace: relationKind's own dedicated branch self-classifies
1524
- // any group with this prefix to its raw predicate, verbatim — see that
1525
- // function's comment for why (an open-ended, dynamically-discovered kind
1526
- // set with no PROP_KIND row to add per predicate).
1527
1189
  group = { predicate: p, prop: `factrel:${p}`, count: 0, edges: [] };
1528
1190
  groupByPredicate.set(p, group);
1529
1191
  }
@@ -1551,28 +1213,16 @@ export function deriveFactTermGraph(graph) {
1551
1213
  };
1552
1214
  }
1553
1215
 
1554
- /** The two FIXED structural link kinds `deriveFactTermGraph` always emits
1555
- * (Fact -> its own subject/object Term) see that function's own doc for
1556
- * why they're needed at all. Bundled into the "relation" (concept) walk, not
1557
- * the "meta" (provenance) one: a user who toggles to meta-only still gets
1558
- * today's exact byte-identical provenance-only view (see viz.mjs's edge-kind
1559
- * toggle). */
1216
+ /** The two fixed structural link kinds `deriveFactTermGraph` always emits.
1217
+ * Bundled into the "relation" (concept) walk, not "meta" (provenance), so a
1218
+ * user toggling to meta-only still gets the provenance-only view. */
1560
1219
  export const MEMORY_FACT_LINK_KINDS = ["factSubjectTerm", "factObjectTerm"];
1561
1220
 
1562
- /** Bug 2 fix: the combined kinds list a memory-graph walk actually uses, for a
1563
- * given edge-kind MODE — "meta" (today's exact provenance-only walk, kept as
1564
- * a filterable toggle, never deleted), "relation" (the NEW concept view
1565
- * Bug 2's fix), or "both" (the default: meta AND relation kinds together, so
1566
- * a click on a recently-taught Fact reaches its own concept neighbourhood the
1567
- * same turn). `factRelationKinds` is the dynamic per-predicate list
1568
- * `deriveFactTermGraph` discovered in THIS graph — there is no fixed
1569
- * vocabulary, a freshly taught predicate is walkable the same turn it's
1570
- * asserted. Lives here (not viz.mjs) and is re-exported through
1571
- * ask-browser-entry.mjs specifically so BOTH the CLI's own generation-time
1572
- * walk (viz.mjs's computeVizGraph) AND the browser bundle's client-side
1573
- * re-walk (a recentre or an edge-kind-toggle change) combine kinds via the
1574
- * SAME function — viz.mjs itself can't be bundled for the browser (it does
1575
- * real fs I/O), so this had to live in the shared, browser-safe module. */
1221
+ /** The combined kinds list a memory-graph walk uses for a given edge-kind
1222
+ * mode: "meta" (provenance-only), "relation" (concept view), or "both"
1223
+ * (default). Lives here, not viz.mjs, so the CLI and the browser bundle's
1224
+ * client-side re-walk share the same function (viz.mjs does real fs I/O and
1225
+ * can't be bundled for the browser). */
1576
1226
  export function edgeKindsFor(mode, factRelationKinds) {
1577
1227
  const relationKinds = [...factRelationKinds, ...MEMORY_FACT_LINK_KINDS];
1578
1228
  if (mode === "meta") return [...MEMORY_SPIRAL_EXPAND_KINDS];
@@ -1580,7 +1230,7 @@ export function edgeKindsFor(mode, factRelationKinds) {
1580
1230
  return [...MEMORY_SPIRAL_EXPAND_KINDS, ...relationKinds]; // "both" (default)
1581
1231
  }
1582
1232
 
1583
- const LEGEND_MAX_BUCKETS = 20; // seonix precedent, PLAN_VIZ_MEMORY.md: too many chips to be usable
1233
+ const LEGEND_MAX_BUCKETS = 20; // too many chips to be usable
1584
1234
  const LEGEND_MIN_BUCKETS = 2; // nothing to filter with only one bucket
1585
1235
  const LEGEND_COLLAPSE_TOP_N = 15; // "top 15 by count, rest grouped as Other" — stays under the max
1586
1236
 
@@ -1603,8 +1253,7 @@ function normalizedEntropy(buckets) {
1603
1253
 
1604
1254
  /** Collapse a raw {value,count} bucket list down to at most LEGEND_MAX_BUCKETS
1605
1255
  * entries: keep the top LEGEND_COLLAPSE_TOP_N by count, fold the rest into a
1606
- * single "Other" bucket the plan's own "may need a top-15-by-count, rest
1607
- * grouped as Other" escape hatch, applied generically (not predicate-only) so
1256
+ * single "Other" bucket, applied generically (not predicate-only) so
1608
1257
  * any dimension that happens to be high-cardinality degrades the same way.
1609
1258
  * A no-op (returns `buckets` unchanged, same array) when already <= the cap.
1610
1259
  * Exported (not just used internally by pickLegendDimension) so the browser
@@ -1652,15 +1301,9 @@ function provenanceBucketLabel(rawTag) {
1652
1301
  return src.kind;
1653
1302
  }
1654
1303
 
1655
- /** A single walked NODE's bucket value under one legend dimension — the
1656
- * per-node counterpart to `pickLegendDimension`'s aggregate bucket counts,
1657
- * exported so both the CLI's own legend computation AND the browser bundle's
1658
- * client-side dimension-switcher (a user flipping from "split by predicate"
1659
- * to "split by trust source" without regenerating the page,
1660
- * PLAN_VIZ_MEMORY.md's Controls section) filter/color by the SAME derivation,
1661
- * never a second hand-rolled copy. `"class"` reads every node; `"predicate"`/
1662
- * `"provenance"` only ever return non-null for a Fact-class node (any other
1663
- * class simply has no predicate/provenance of its own to bucket on). */
1304
+ /** A single walked node's bucket value under one legend dimension. `"class"`
1305
+ * reads every node; `"predicate"`/`"provenance"` only return non-null for a
1306
+ * Fact-class node. */
1664
1307
  export function legendValueFor(graph, node, dimension) {
1665
1308
  if (dimension === "class") return node?.class || "(none)";
1666
1309
  if (!node || node.class !== MEMORY_FACT_CLASS) return null;
@@ -1670,31 +1313,12 @@ export function legendValueFor(graph, node, dimension) {
1670
1313
  return null;
1671
1314
  }
1672
1315
 
1673
- /** Auto-pick the filter/legend dimension at generation time (PLAN_VIZ_MEMORY.md
1674
- * "Auto-picking the filter/legend dimension" section full algorithm/
1675
- * rationale there). seonix hardcodes its legend dimension (a small, near-
1676
- * uniform set of code-graph classes) tmct's memory graph does NOT have that
1677
- * property: `class` is `{Fact, Session, Source, Utterance, Term}` and once
1678
- * real data is seeded, Fact dominates so heavily that a class-based legend
1679
- * filters almost nothing. This scores three candidate dimensions by
1680
- * normalized Shannon entropy over their bucket-size distribution (rewards an
1681
- * even-ish split, penalizes one dominant bucket) and picks the best-scoring
1682
- * QUALIFYING one (`LEGEND_MIN_BUCKETS <= k <= LEGEND_MAX_BUCKETS`, after a
1683
- * top-15+Other collapse for anything over the cap) as the PRIMARY legend:
1684
- * 1. `class` — every walked node's own `.class`.
1685
- * 2. `predicate` — every walked Fact node's `rdf:predicate` attribute (a
1686
- * relation-shaped split: "show me only IsA facts").
1687
- * 3. `provenance` — every walked Fact node's provenance prefix, collapsed
1688
- * (see provenanceBucketLabel) — a TRUST-shaped split.
1689
- * Pure, one pass over the already-walked `nodes` (no new graph traversal) —
1690
- * computed ONCE at generation time and embedded into the page's JSON, never
1691
- * recomputed client-side. `graph` supplies the per-Fact attribute lookups
1692
- * `nodes` itself doesn't carry (predicate/provenance are Fact ATTRIBUTES,
1693
- * not part of the {id,hop,label,class,createdAt,updatedAt} viz node shape).
1694
- * Returns `{ primary, dimensions: { class, predicate, provenance } }`, each
1695
- * entry `{ score, qualifies, buckets: [{value, count}] }`. When nothing
1696
- * qualifies (e.g. a tiny 1-2-node walk), `primary` falls back to `"class"` —
1697
- * today's behavior — so the legend is never simply empty. */
1316
+ /** Auto-pick the filter/legend dimension: since Fact dominates class-based
1317
+ * legends once real memory-graph data is seeded, this scores class/predicate/
1318
+ * provenance by normalized Shannon entropy over their bucket distribution
1319
+ * and picks the best-scoring qualifying one (LEGEND_MIN_BUCKETS..MAX_BUCKETS).
1320
+ * Falls back to "class" when nothing qualifies. Returns `{ primary,
1321
+ * dimensions: { class, predicate, provenance } }`. */
1698
1322
  export function pickLegendDimension(graph, nodes) {
1699
1323
  const classBuckets = bucketCounts((nodes || []).map((n) => legendValueFor(graph, n, "class")));
1700
1324
  const predicateBuckets = bucketCounts((nodes || []).map((n) => legendValueFor(graph, n, "predicate")));
@@ -1940,11 +1564,7 @@ export function renderHistory(graph, ind) {
1940
1564
  }
1941
1565
 
1942
1566
  /** Modules that call into the target's module (one hop over `calls`). */
1943
- // Symbol-grain classes whose call graph lives on the fn/method-precise `callsSymbol`
1944
- // edge, not the module-coarse `calls`. When the resolved target IS one of these, callers/
1945
- // callees must read the SYMBOL node's own edges — mapping it to its enclosing module (the
1946
- // old behaviour) both mislabels the answer with `mod:<path>` and scans the wrong edge set,
1947
- // so "Widget.render --callsSymbol--> fnAlpha" was reported as "no recorded callers".
1567
+ // classes whose call graph lives on the fn/method-precise callsSymbol edge, not module-coarse calls
1948
1568
  const CALL_SYMBOL_CLASSES = new Set(["Function", "Method"]);
1949
1569
 
1950
1570
  export function renderCallers(graph, ind) {
@@ -2061,11 +1681,10 @@ export function renderClassHistory(graph, ind) {
2061
1681
  return renderSymbolHistory(graph, ind);
2062
1682
  }
2063
1683
 
2064
- // ---- author identity (0.8.2 WS4): the Commit "author" attribute answered as a
2065
- // person "who is <Name>", "what did <Name> touch". Author is an ATTRIBUTE
2066
- // (key "author"/mgx:commitAuthor), never an individual, so these read the
2067
- // attribute off every Commit and aggregate. All renderers return null on an
2068
- // unknown name — the chat lane falls through to the ordinary honest miss. ----
1684
+ // ---- author identity: the Commit "author" attribute answered as a person
1685
+ // ("who is <Name>", "what did <Name> touch"). Author is an attribute, never
1686
+ // an individual, so these renderers read it off every Commit and aggregate.
1687
+ // All return null on an unknown name; the chat lane falls through to an honest miss. ----
2069
1688
 
2070
1689
  const AUTHOR_TOUCH_CAP = 15;
2071
1690
  const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
@@ -2167,13 +1786,9 @@ export function renderCommitAuthor(graph, sha) {
2167
1786
 
2168
1787
  const SYMBOL_CLASSES = { function: "Function", class: "Class", method: "Method", attribute: "Attribute" };
2169
1788
 
2170
- /** The structured scorer behind searchSymbols (kind=function/class/method/attribute, with
2171
- * name/decorator filters): filters graph individuals to `kind`'s class, scores by token
2172
- * substring hits (or 1 for an empty query, matching everything of that kind), and sorts
2173
- * score desc, tie-broken by SHORTER label first (matches searchSymbols's original inline
2174
- * sort exactly). An unrecognised `kind` yields an empty ranked list — callers that need to
2175
- * distinguish "unknown kind" from "kind valid, nothing matched" check SYMBOL_CLASSES
2176
- * themselves (see searchSymbols below). Pure; deterministic.
1789
+ /** The structured scorer behind searchSymbols: filters to `kind`'s class,
1790
+ * scores by token substring hits, sorts score desc then shorter label first.
1791
+ * An unrecognised `kind` yields an empty ranked list.
2177
1792
  * @returns {Array<{ind: object, score: number}>} */
2178
1793
  export function scoreSymbolsRanked(graph, tokens, { kind, decFilter = "", nameRe = null } = {}) {
2179
1794
  const targetClass = SYMBOL_CLASSES[kind];
@@ -2205,24 +1820,22 @@ function searchSymbols(graph, tokens, { limit = SEARCH_LIMIT, kind, decFilter, n
2205
1820
  return lines.join("\n");
2206
1821
  }
2207
1822
 
2208
- // ---- tmct_context: a one-shot "edit bundle" plan (pure; the server adds the file
2209
- // reads). Returns everything needed to add-a-sibling to a module in ONE call,
2210
- // so the agent need not searchdescribesnippet→read×N (RepoGraph ego-network
2211
- // idea; LocAgent: structured, replacement-shaped output drives tool adoption).
2212
-
2213
- const CONTEXT_SIBLING_CAP = 8; // Lever 1: the bundle is re-billed every turn — keep a few most-relevant siblings, not all.
2214
- const CLASS_MEMBER_CAP = 16; // Class-internal members shown when the anchor is a class/method.
2215
- const COCHANGE_MID_CAP = 4; // #13: trim the MID bundle's co-change tail (was 8) — re-billed every turn.
2216
- const CONTEXT_TESTS_CAP = 6; // #13: cap the covering-tests list in the bundle.
2217
- const INSERTION_REGION_CAP = 40; // #2: contiguous tail lines shown as the "write your new sibling here" region.
2218
- // #6 task-size thresholds (named, next to the caps above). B1/B6: widened so the COMMON
2219
- // "add a small sibling util / register a filter" task lands at the lean TINY default (a 1-2
2220
- // param helper with a short body), and only genuinely bigger edits top up to MID/LARGE.
2221
- const TINY_MAX_LOC = 12; // TINY: exemplar/anchor body ≤ this many lines …
2222
- const TINY_MAX_ARITY = 2; // … AND ≤ this many params (value, arg) …
2223
- const LARGE_CLASS_MEMBERS = 8; // LARGE: anchor is a method of a class with ≥ this many members ("big class").
2224
- const INLINE_CALLEE_CAP = 3; // LARGE: inline at most this many depth-1 in-repo callee bodies …
2225
- const INLINE_CALLEE_LOC = 120; // … up to this many total lines.
1823
+ // ---- tmct_context: a one-shot "edit bundle" plan (pure; the server adds the
1824
+ // file reads). Returns everything needed to add a sibling to a module in one
1825
+ // call, so the agent need not search->describe->snippet->read x N. ----
1826
+
1827
+ const CONTEXT_SIBLING_CAP = 8; // the bundle is re-billed every turn — keep a few most-relevant siblings, not all
1828
+ const CLASS_MEMBER_CAP = 16; // class-internal members shown when the anchor is a class/method
1829
+ const COCHANGE_MID_CAP = 4;
1830
+ const CONTEXT_TESTS_CAP = 6;
1831
+ const INSERTION_REGION_CAP = 40; // contiguous tail lines shown as the "write your new sibling here" region
1832
+ // task-size thresholds, widened so a common "add a small sibling util" task
1833
+ // lands at the lean TINY default and only genuinely bigger edits top up
1834
+ const TINY_MAX_LOC = 12;
1835
+ const TINY_MAX_ARITY = 2;
1836
+ const LARGE_CLASS_MEMBERS = 8;
1837
+ const INLINE_CALLEE_CAP = 3;
1838
+ const INLINE_CALLEE_LOC = 120;
2226
1839
 
2227
1840
  const splitDecs = (s) => String(s || "").split(",").map((x) => x.trim()).filter(Boolean);
2228
1841
  const tokenize = (s) =>
@@ -2276,10 +1889,9 @@ function structuralScore(s, target) {
2276
1889
  return score + Math.min(shared, 4) * 2;
2277
1890
  }
2278
1891
 
2279
- /** Lever 1: rank siblings by relevance to the anchor so the lean bundle shows the ones
2280
- * worth copying shared decorator (the module's registration pattern, e.g.
2281
- * @register.filter) > name-affinity (shared tokens) > nearest source position. Pure;
2282
- * mutates a transient `_score` only. */
1892
+ /** Rank siblings by relevance to the anchor: shared decorator (the module's
1893
+ * registration pattern) > name-affinity (shared tokens) > nearest source
1894
+ * position. Mutates a transient `_score` only. */
2283
1895
  function rankSiblings(siblings, { decorators: anchorDecorators = "", label: anchorLabel = "", site: anchorSite = null } = {}, structuralTarget = null) {
2284
1896
  const decCount = new Map();
2285
1897
  for (const s of siblings) for (const d of splitDecs(s.decorators)) decCount.set(d, (decCount.get(d) || 0) + 1);
@@ -2293,9 +1905,7 @@ function rankSiblings(siblings, { decorators: anchorDecorators = "", label: anch
2293
1905
  for (const s of siblings) {
2294
1906
  const decMatch = splitDecs(s.decorators).some((d) => targetDecs.has(d)) ? 1 : 0;
2295
1907
  const nameAff = tokenize(s.label).filter((t) => anchorTokens.has(t)).length;
2296
- // #3: structural affinity (param-count / has-returns / has-raises / shared in-repo
2297
- // callees) sits BELOW name-affinity (max 16 < 50) — a tiebreaker within a name tier.
2298
- const struct = structuralScore(s, structuralTarget);
1908
+ const struct = structuralScore(s, structuralTarget); // tiebreaker within a name tier
2299
1909
  const pos = anchorStart != null && s.site ? 1 / (1 + Math.abs(s.site.start - anchorStart)) : 0;
2300
1910
  s._score = decMatch * 1000 + nameAff * 50 + struct + pos;
2301
1911
  }
@@ -2310,8 +1920,7 @@ export function contextPlan(graph, ind) {
2310
1920
  const modId = moduleIdOf(graph, ind);
2311
1921
  const moduleLabel = graph.byId.get(modId)?.label || String(modId || "").replace(/^mod:/, "");
2312
1922
  const defEdges = edgesOfKind(graph, "defines").filter((e) => e.subject === modId);
2313
- // #3: index fn→fn in-repo callees once, so siblings/anchor carry their callee set for
2314
- // structural ranking and the sizeBundle cross-module-call check.
1923
+ // index fn→fn in-repo callees once, so siblings/anchor carry their callee set
2315
1924
  const calleeMap = new Map();
2316
1925
  for (const e of edgesOfKind(graph, "callsSymbol")) {
2317
1926
  if (!calleeMap.has(e.subject)) calleeMap.set(e.subject, new Set());
@@ -2329,9 +1938,8 @@ export function contextPlan(graph, ind) {
2329
1938
  globals.push({ label: mem.label, value: (mem.attributes || []).find((a) => a.key === "value")?.value || "", site });
2330
1939
  if (site) insertion = Math.max(insertion, site.end);
2331
1940
  } else if (cls === "Function" || cls === "Class") {
2332
- // Carry each sibling's `raises` + one-line doc so a validator-style task sees the
2333
- // error-contract without reading the body. #3 adds params/returns/callees for
2334
- // structural-similarity ranking.
1941
+ // raises + one-line doc so a validator-style task sees the error-contract
1942
+ // without reading the body; params/returns/callees feed structural-similarity ranking
2335
1943
  siblings.push({
2336
1944
  id: mem.id, label: mem.label, class: cls, site, decorators: decoratorOf(mem),
2337
1945
  raises: attrVal(mem, "raises"), doc: attrVal(mem, "doc"),
@@ -2350,13 +1958,12 @@ export function contextPlan(graph, ind) {
2350
1958
  }
2351
1959
  : null;
2352
1960
  const totalSiblings = siblings.length;
2353
- // #3: the structural target the exemplar should resemble the anchor's own shape when
2354
- // there is one, else the dominant pattern across siblings (module anchor case).
1961
+ // the structural target the exemplar should resemble: the anchor's own
1962
+ // shape, or the dominant pattern across siblings for a module anchor
2355
1963
  const structuralTarget = anchor ? profileOf(anchor) : dominantProfile(siblings);
2356
1964
  siblings = rankSiblings(siblings, anchor || { label: ind.label }, structuralTarget);
2357
- // Lever 2: when the anchor is a module (no anchor body shown), surface the single
2358
- // closest sibling's FULL body as the copy-this exemplar; signatures alone made the
2359
- // agent fall back to Read. With a function/class anchor its own body suffices.
1965
+ // when the anchor is a module, surface the closest sibling's full body as
1966
+ // the copy-this exemplar signatures alone made the agent fall back to Read
2360
1967
  const exemplar = !anchor ? siblings.find((s) => s.site && s.label !== ind.label) || null : null;
2361
1968
  const tests = [...new Set(edgesOfKind(graph, "tests").filter((e) => e.object === modId).map((e) => e.subjectLabel || e.subject))].slice(0, CONTEXT_TESTS_CAP);
2362
1969
  const cochange = cochangeNeighbours(graph, modId).slice(0, COCHANGE_MID_CAP);
@@ -2388,16 +1995,15 @@ export function contextPlan(graph, ind) {
2388
1995
  }).slice(0, CLASS_MEMBER_CAP);
2389
1996
  classMembers = { className: owner?.label || String(classOwnerId).replace(/^fn:.*#/, ""), members, total: contains.filter((e) => e.subject === classOwnerId).length };
2390
1997
  }
2391
- // #2: contiguous insertion region from the LAST top-level definition (sibling/global,
2392
- // or the exemplar, which is a sibling) through end-of-module. We give the start line here;
2393
- // the server extends `end` to the real end-of-file (capped) using the lines it reads.
1998
+ // contiguous insertion region from the last top-level definition through
1999
+ // end-of-module; the server extends `end` to the real end-of-file
2394
2000
  let lastTop = null;
2395
2001
  for (const s of [...siblings, ...globals]) {
2396
2002
  if (s.site && (!lastTop || s.site.start > lastTop.start)) lastTop = s.site;
2397
2003
  }
2398
2004
  const insertionRegion = lastTop ? { start: lastTop.start, end: lastTop.end } : null;
2399
- // #6: the focal symbol (anchor when present, else the module's exemplar) drives both the
2400
- // call hint and the LARGE-tier inlined-callee bodies.
2005
+ // the focal symbol (anchor, else the module's exemplar) drives the call
2006
+ // hint and the LARGE-tier inlined-callee bodies
2401
2007
  const focal = anchor || exemplar;
2402
2008
  const focalInd = focal?.id ? graph.byId.get(focal.id) : null;
2403
2009
  const callHintStr = focalInd ? callHint(graph, focalInd) : "";
@@ -2417,7 +2023,7 @@ export function contextPlan(graph, ind) {
2417
2023
  };
2418
2024
  }
2419
2025
 
2420
- // ---- #6 task-size-adaptive bundle (TINY / MID / LARGE) ---------------------------
2026
+ // ---- task-size-adaptive bundle (TINY / MID / LARGE) ------------------------------
2421
2027
 
2422
2028
  /** Which bundle sections a tier emits. TINY is genuinely minimal (header + one short
2423
2029
  * exemplar body + registration + insertion region + __all__); MID is the full bundle;
@@ -2432,10 +2038,8 @@ export function bundleMask(tier) {
2432
2038
  return all; // MID
2433
2039
  }
2434
2040
 
2435
- /** B2: a TRIMMED mask for SECONDARY (related-but-not-primary) digest modules keep the cheap,
2436
- * cache-stable signal (registration globals, ranked sibling SIGNATURES, the insertion region,
2437
- * __all__) but drop the expensive bodies (anchor/exemplar/inlined callees) and the variable
2438
- * tails (tests/cochange/re-exports/class members). Pure. */
2041
+ /** A trimmed mask for secondary (related-but-not-primary) digest modules: keep
2042
+ * the cheap, cache-stable signal but drop expensive bodies and variable tails. */
2439
2043
  export function trimBundleMask(mask) {
2440
2044
  return {
2441
2045
  ...mask,
@@ -2445,11 +2049,10 @@ export function trimBundleMask(mask) {
2445
2049
  };
2446
2050
  }
2447
2051
 
2448
- /** Classify a context plan by task size and return {tier, mask, topup}. B1/B6: lean by
2449
- * default — START at TINY and escalate ("top-up") one tier ONLY when the lean bundle would
2450
- * omit something the edit demonstrably needs (no exemplar body, a class/method edit, or a
2451
- * large/complex target → MID; a cross-module call or a big-class method → LARGE). `topup`
2452
- * records whether auto-sizing escalated above TINY (surfaced in the digest header). Pure. */
2052
+ /** Classify a context plan by task size, {tier, mask, topup}. Lean by default:
2053
+ * start at TINY and escalate one tier only when the lean bundle would omit
2054
+ * something the edit demonstrably needs. `topup` records whether auto-sizing
2055
+ * escalated above TINY. */
2453
2056
  export function sizeBundle(plan, graph, { untuned = false } = {}) {
2454
2057
  const focal = plan.anchor || plan.exemplar;
2455
2058
  let tier = "TINY";
@@ -2462,17 +2065,11 @@ export function sizeBundle(plan, graph, { untuned = false } = {}) {
2462
2065
  // (c) a large/complex target (long body, many params, or it raises) → MID.
2463
2066
  const loc = focal.site ? focal.site.end - focal.site.start + 1 : Infinity;
2464
2067
  const arity = countParams(focal.params);
2465
- // (c) a long/complex focal escalates TINY→MID. Escalation fires on any long focal: gating it on
2466
- // an explicit symbol anchor (so a long-exemplar MODULE digest stayed TINY) regressed results,
2467
- // because the trimmed sibling/test tail was load-bearing scaffolding. The `untuned` param is now
2468
- // a no-op for sizing (kept so the tmct-b010 control arm's flag still resolves).
2068
+ // `untuned` is a no-op for sizing now (kept so a legacy control-arm flag still resolves)
2469
2069
  if (loc > TINY_MAX_LOC || arity > TINY_MAX_ARITY || Boolean(focal.raises)) tier = "MID";
2470
- // (d) LARGE only for an EXPLICIT symbol focus (plan.anchor), where inlining the
2471
- // depth-1 callee bodies / the class shape is worth the tokens: a cross-module call from
2472
- // the anchor, OR an anchor that is a method of a big class. When the focal is merely a
2473
- // module-EXEMPLAR (the digest/module-anchor case), a cross-module call does NOT force
2474
- // LARGE — the exemplar body already shows the call, and MID's signatures suffice; this
2475
- // keeps the common "register a filter" module bundle lean.
2070
+ // LARGE only for an explicit symbol focus: a cross-module call from the
2071
+ // anchor, or an anchor that's a method of a big class. A module-exemplar
2072
+ // focal never forces LARGE MID's signatures suffice and keeps it lean.
2476
2073
  let crossModule = false;
2477
2074
  if (plan.anchor) {
2478
2075
  for (const cid of focal.callees || []) {
@@ -2490,10 +2087,9 @@ export function sizeBundle(plan, graph, { untuned = false } = {}) {
2490
2087
  return { tier, mask: bundleMask(tier), topup: tier !== "TINY" };
2491
2088
  }
2492
2089
 
2493
- /** B2: order SECONDARY digest modules by relevance to the PRIMARY (first) module import
2494
- * adjacency (either direction, incl. coarse calls) outranks change-coupling weight; ties keep
2495
- * the caller's input order (stable, deterministic). Returns the candidate labels reordered.
2496
- * Pure — no fs. Falls back to the input order when the primary can't be mapped to a module. */
2090
+ /** Order secondary digest modules by relevance to the primary module: import
2091
+ * adjacency outranks change-coupling weight; ties keep input order. Falls
2092
+ * back to input order when the primary can't be mapped to a module. */
2497
2093
  export function rankModulesByProximity(graph, primaryLabel, candidateLabels) {
2498
2094
  const moduleIdFor = (label) => {
2499
2095
  const { match } = resolveSymbol(graph, label);
@@ -2523,7 +2119,7 @@ export function rankModulesByProximity(graph, primaryLabel, candidateLabels) {
2523
2119
  .map((s) => s.label);
2524
2120
  }
2525
2121
 
2526
- // ---- #7 tmct_context_more: only the sections a TINY/MID bundle omits ------------
2122
+ // ---- tmct_context_more: only the sections a TINY/MID bundle omits ---------------
2527
2123
 
2528
2124
  /** Render ONLY the bundle sections a lean bundle omits (sibling list / class members /
2529
2125
  * re-exports / __all__ / tests / cochange) for a symbol's module. Pure (no fs). */
@@ -2560,14 +2156,10 @@ export function renderContextMore(plan) {
2560
2156
 
2561
2157
  // ---- Repository Interface: graph-only context() bundle (no fs) -----------------
2562
2158
 
2563
- /** Render a graph-only edit bundle for `plan` — every contextPlan section EXCEPT the
2564
- * fs-dependent anchor/exemplar/inlined-callee body text (registration globals, class
2565
- * members, ranked siblings, __all__, re-exports, the insertion point, covering tests,
2566
- * co-change neighbours), gated by `mask` (see bundleMask/sizeBundle). Pure — no fs.
2567
- * Used by graph-service.mjs's context() service so a graph-only provider (no working
2568
- * tree) can still return a real HIT instead of an NO_SOURCE miss — see PLAN item 2d /
2569
- * INTERFACE_VERSION 1.1.0. A source-capable provider layers the body sections on top
2570
- * (it has fs access this module deliberately does not). */
2159
+ /** Render a graph-only edit bundle: every contextPlan section except the
2160
+ * fs-dependent body text, gated by `mask`. Lets a graph-only provider (no
2161
+ * working tree) return a real hit instead of a NO_SOURCE miss; a
2162
+ * source-capable provider layers the body sections on top. */
2571
2163
  export function renderGraphOnlyBundle(plan, mask) {
2572
2164
  const out = [
2573
2165
  `Edit context for ${plan.moduleLabel} (graph-only bundle — siblings/registration/tests are real graph truth; ` +
@@ -2612,7 +2204,7 @@ export function renderGraphOnlyBundle(plan, mask) {
2612
2204
  return out.join("\n");
2613
2205
  }
2614
2206
 
2615
- // ---- #7 cold-tool catalog (written to <repo>/.tmct/TOOLS.md by the index step) --
2207
+ // ---- cold-tool catalog (written to <repo>/.tmct/TOOLS.md by the index step) -----
2616
2208
 
2617
2209
  /** Markdown catalog of the COLD tools (everything except the hot catalog tools): each
2618
2210
  * with a one-line purpose and the exact Bash invocation via the CLI `cli <tool>` route.