sigmap 8.20.0 → 8.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,32 @@ Format: [Semantic Versioning](https://semver.org/)
10
10
 
11
11
  ---
12
12
 
13
+ ## [8.22.0] — 2026-07-28
14
+
15
+ Minor release — **"Hard Corpus" (v8.22)**: the benchmark corpus gains a no-leakage hard split with a deterministic leakage gate, and per-repo-size buckets stop tiny repos from flattering the average. The headline retrieval number gets harder — and honest.
16
+
17
+ ### Added
18
+ - **Hard-split corpus + leakage gate + size buckets (#505, PR #506):** new `src/eval/corpus.js` — a task "leaks" when its BM25-tokenized query shares a stemmed token with the tokenized basenames of its expected files; `validateTasks` flags leaky `split: "hard"` tasks as violations, and `sizeBucket` groups repos at 200/1000 scanned files (tertiles of the 43-repo corpus). New CI gate `scripts/validate-task-corpus.mjs` (exit 1 on hard-split leakage). `loadTasks` carries the optional `split` field (default `easy`). `benchmark:honest` now reports hit@5/MRR per split and per size bucket — buckets use files scanned on disk, not the budget-capped context index. 15 hand-authored hard tasks (express, flask, axios, fastify, gin), all leak-free. 8 new integration tests (128 test files).
19
+ - **MiniMax LLM-ablation provider (PR #504)** — thanks @octo-patch: `MINIMAX_API_KEY` support in `scripts/run-llm-ablation.mjs` (OpenAI-compatible endpoint, default model MiniMax-M3, `MINIMAX_BASE_URL` override) plus a pricing entry and tests.
20
+
21
+ ### Changed
22
+ - **Headline honesty, again:** the leakage gate measured that **90 of 110 pre-existing easy tasks leak filename tokens**, and the new hard split scores **33.3% hit@5 vs the grep baseline's 53.3%** — with leakage removed, grep currently wins; that measured vocabulary-mismatch ceiling is what B2 (repo-mined expansion, v9.0) exists to attack. Overall corpus (125 tasks): 72.8% hit@5, honest lift 1.63× (+28pt).
23
+
24
+ ---
25
+
26
+ ## [8.21.0] — 2026-07-19
27
+
28
+ Minor release — **"Semantic Bridge II" (v8.21)**: doc-comment hints reach Go, Rust, and Java, and the import graph gains a principled centrality prior for ranking — flag-gated and measured.
29
+
30
+ ### Added
31
+ - **Go/Rust/Java doc-comment hints (#501, PR #502):** `buildDocHints` in the Go extractor (godoc `//` blocks above top-level `func`/`type`, compiler directives `//go:`/`nolint` skipped), the Rust extractor (`///` blocks above `pub fn`/`struct`/`enum`/`trait` and impl methods, `#[attr]` lines between doc and declaration tolerated), and the Java extractor (Javadoc on type declarations **and** public/protected members; tag-only blocks produce no hint). First prose sentence, 60-char cap, appended after the line anchor as ` # <hint>` — byte-format identical to the Python/JS/TS hints. Hints are mined from the original source since `extract()` strips comments before matching; undocumented signatures are byte-identical to before.
32
+ - **Centrality rank blend (#501, PR #502, opt-in `retrieval.centralityBlend`):** new `src/graph/centrality.js` — zero-dependency power iteration over the forward import graph (damping 0.85, 20 iterations, sorted nodes, dangling mass redistributed; deterministic), max-normalized to (0,1]. `rank()` blends `0.3 × centrality` as a small additive prior onto **positively-scored files only** (`signals.centrality`) — a tie-breaker among matches, never a way to surface non-matches. Wired like `callGraphBoost`: MCP `query_context` + CLI `ask`/`--query`, all non-fatal. New A/B measure gate `scripts/run-centrality-blend-benchmark.mjs` (`npm run benchmark:centrality-blend`).
33
+
34
+ ### Changed
35
+ - **Measured and gated off by default:** the centrality A/B over 90 tasks / 18 repos scored both arms at 77.8% hit@5 (+0 tasks) — non-regressing but neutral on the lexical-favoring corpus, so `retrieval.centralityBlend` ships **off** per the measure gate; the v8.22 hard-split corpus (A3) is the next chance to show a real delta. 11 new integration tests (127 test files).
36
+
37
+ ---
38
+
13
39
  ## [8.20.0] — 2026-07-19
14
40
 
15
41
  Minor release — **"Semantic Bridge I" (v8.20)**: the JS/TS extractors gain the same doc-comment hints Python has carried for releases, and the cross-session stores get a single inspect/prune surface.
package/README.md CHANGED
@@ -59,10 +59,10 @@ That map is exactly what agentic grep is worst at: reproducible, auditable conte
59
59
 
60
60
  **Proof it pays off** (full benchmark below):
61
61
  <!--SM:whyMetrics-->
62
- - **85.6% hit@5** — right file in top 5 results (vs 42.7% single-shot grep baseline — 2.00× lift)
62
+ - **82.2% hit@5** — right file in top 5 results (vs 44.8% single-shot grep baseline — 1.59× lift)
63
63
  - **96.8% token reduction** — average across 21 real repos
64
- - **67.8% task-success proxy** — modeled from retrieval tiers, not measured LLM sessions
65
- - **1.47 prompts per task** — down from 2.84 (48.4% fewer retries, modeled)
64
+ - **64.8% task-success proxy** — modeled from retrieval tiers, not measured LLM sessions
65
+ - **1.53 prompts per task** — down from 2.84 (46.1% fewer retries, modeled)
66
66
  <!--/SM:whyMetrics-->
67
67
  - **<!--SM:languages-->33<!--/SM:languages--> languages supported** — TypeScript, Python, Go, Rust, Java, R, and more
68
68
  - **No vendor lock-in** — works with any AI assistant or local LLM
@@ -98,7 +98,7 @@ sigmap verify answer.md --report # standalone red/amber/green HTML report
98
98
  | Without SigMap | With SigMap |
99
99
  |---|---|
100
100
  | ❌ Non-reproducible agent guesses | ✅ Deterministic map — same input, same output, every time |
101
- | ❌ "Trust me" AI answers | ✅ Grounded — right file in context <!--SM:hitWhole-->86%<!--/SM:hitWhole--> of the time, every symbol on a real line anchor |
101
+ | ❌ "Trust me" AI answers | ✅ Grounded — right file in context <!--SM:hitWhole-->82%<!--/SM:hitWhole--> of the time, every symbol on a real line anchor |
102
102
  | ❌ Embeddings / vector DB required | ✅ Zero deps, no infra, fully offline |
103
103
 
104
104
  ---
@@ -122,13 +122,13 @@ Ask → Rank → Context → Validate → Judge → Learn
122
122
 
123
123
  <!--SM:benchmarkBlock-->
124
124
  ```
125
- Benchmark : sigmap-v8.20-main (21 repositories, including R language)
126
- Date : 2026-07-19
125
+ Benchmark : sigmap-v8.22-main (21 repositories, including R language)
126
+ Date : 2026-07-27
127
127
 
128
- Hit@5 : 85.6% (grep-agent baseline 42.7% — 2.00× lift)
128
+ Hit@5 : 82.2% (grep-agent baseline 44.8% — 1.59× lift)
129
129
  Token reduction: 96.8% (across 21 repos)
130
- Prompt reduction : 48.4% (2.84 → 1.47 prompts per task, modeled)
131
- Task success : 67.8% (proxy — modeled from retrieval tiers)
130
+ Prompt reduction : 46.1% (2.84 → 1.53 prompts per task, modeled)
131
+ Task success : 64.8% (proxy — modeled from retrieval tiers)
132
132
  Repos tested : 21 (JavaScript, Python, Go, Rust, Java, R, C++, C#, Dart, Swift, Ruby, PHP, Scala, Kotlin, and more)
133
133
  ```
134
134
  <!--/SM:benchmarkBlock-->
package/gen-context.js CHANGED
@@ -1533,6 +1533,8 @@ __factories["./src/config/defaults"] = function(module, exports) {
1533
1533
  recencyBoost: 1.5,
1534
1534
  // Boost files call-graph-connected to query matches (opt-in, measure-gated)
1535
1535
  callGraphBoost: false,
1536
+ // Blend import-graph centrality into ranking as a small prior (opt-in, measure-gated)
1537
+ centralityBlend: false,
1536
1538
  // Append route pseudo-signatures to the rankable index (opt-in, measure-gated)
1537
1539
  surfaceEnrichment: false,
1538
1540
  },
@@ -4099,6 +4101,93 @@ __factories["./src/eval/analyzer"] = function(module, exports) {
4099
4101
 
4100
4102
  };
4101
4103
 
4104
+ // ── ./src/eval/corpus ──
4105
+ __factories["./src/eval/corpus"] = function(module, exports) {
4106
+
4107
+ /**
4108
+ * Task-corpus hygiene (A3, v8.22 "Hard Corpus").
4109
+ *
4110
+ * A benchmark query "leaks" when it shares a token with the basenames of its
4111
+ * expected files — hit@5 then partly measures filename matching, not
4112
+ * retrieval. The criterion is deterministic and reuses the production
4113
+ * tokenizer (identifier splitting + stemming from src/retrieval/bm25.js), so
4114
+ * "payments" leaks against payment.js and "InterceptorManager" leaks against
4115
+ * "interceptor manager" the same way the ranker would see them.
4116
+ *
4117
+ * Tasks carry an optional `split` field: 'hard' tasks MUST be leak-free
4118
+ * (validateTasks reports them as violations); 'easy' tasks (the default) may
4119
+ * leak — that is what makes them easy.
4120
+ *
4121
+ * Size buckets group repos by indexed file count so large repos stop being
4122
+ * averaged away by tiny ones. Thresholds are the rough tertiles of the
4123
+ * current benchmarks/repos corpus (43 repos, 27–3450 source files).
4124
+ */
4125
+
4126
+ const { tokenize } = __require('./src/retrieval/bm25');
4127
+
4128
+ const BUCKET_LIMITS = { small: 200, medium: 1000 }; // files; large = above medium
4129
+
4130
+ /**
4131
+ * Stemmed tokens of a file path's basename (extension stripped).
4132
+ * @param {string} filePath
4133
+ * @returns {string[]}
4134
+ */
4135
+ function basenameTokens(filePath) {
4136
+ const base = String(filePath).split('/').pop() || '';
4137
+ return tokenize(base.replace(/\.[^.]*$/, ''));
4138
+ }
4139
+
4140
+ /**
4141
+ * Leaked tokens between a query and its expected files' basenames.
4142
+ * @param {string} query
4143
+ * @param {string[]} expectedFiles
4144
+ * @returns {{ leaked: string[], clean: boolean }}
4145
+ */
4146
+ function queryLeakage(query, expectedFiles) {
4147
+ const qToks = new Set(tokenize(query));
4148
+ const leaked = new Set();
4149
+ for (const f of expectedFiles || []) {
4150
+ for (const t of basenameTokens(f)) {
4151
+ if (qToks.has(t)) leaked.add(t);
4152
+ }
4153
+ }
4154
+ return { leaked: [...leaked].sort(), clean: leaked.size === 0 };
4155
+ }
4156
+
4157
+ /**
4158
+ * Validate a task list: every task gets a leakage result; hard-split tasks
4159
+ * that leak are violations.
4160
+ * @param {Array<{id?:string, query:string, expected_files?:string[], split?:string}>} tasks
4161
+ * @returns {{ results: object[], hardViolations: object[] }}
4162
+ */
4163
+ function validateTasks(tasks) {
4164
+ const results = [];
4165
+ const hardViolations = [];
4166
+ for (const t of tasks || []) {
4167
+ const split = t.split === 'hard' ? 'hard' : 'easy';
4168
+ const { leaked, clean } = queryLeakage(t.query, t.expected_files);
4169
+ const row = { id: t.id || '?', split, leaked, clean };
4170
+ results.push(row);
4171
+ if (split === 'hard' && !clean) hardViolations.push(row);
4172
+ }
4173
+ return { results, hardViolations };
4174
+ }
4175
+
4176
+ /**
4177
+ * Size bucket for a repo by indexed file count.
4178
+ * @param {number} fileCount
4179
+ * @returns {'small'|'medium'|'large'}
4180
+ */
4181
+ function sizeBucket(fileCount) {
4182
+ if (fileCount < BUCKET_LIMITS.small) return 'small';
4183
+ if (fileCount <= BUCKET_LIMITS.medium) return 'medium';
4184
+ return 'large';
4185
+ }
4186
+
4187
+ module.exports = { basenameTokens, queryLeakage, validateTasks, sizeBucket, BUCKET_LIMITS };
4188
+
4189
+ };
4190
+
4102
4191
  // ── ./src/eval/llm-ablation ──
4103
4192
  __factories["./src/eval/llm-ablation"] = function(module, exports) {
4104
4193
 
@@ -4398,7 +4487,7 @@ __factories["./src/eval/runner"] = function(module, exports) {
4398
4487
 
4399
4488
  /**
4400
4489
  * Load tasks from a JSONL file.
4401
- * Each line: { id, query, expected_files, repo }
4490
+ * Each line: { id, query, expected_files, repo, split? ('easy'|'hard') }
4402
4491
  * Invalid or blank lines are silently skipped.
4403
4492
  * @param {string} tasksFile - absolute or relative path
4404
4493
  * @returns {Array<{id:string, query:string, expected:string[], repo:string}>}
@@ -4418,6 +4507,7 @@ __factories["./src/eval/runner"] = function(module, exports) {
4418
4507
  query: obj.query,
4419
4508
  expected: obj.expected_files,
4420
4509
  repo: obj.repo || '.',
4510
+ split: obj.split === 'hard' ? 'hard' : 'easy',
4421
4511
  });
4422
4512
  }
4423
4513
  } catch {
@@ -5928,6 +6018,10 @@ __factories["./src/extractors/go"] = function(module, exports) {
5928
6018
  function extract(src) {
5929
6019
  if (!src || typeof src !== 'string') return [];
5930
6020
  const sigs = [];
6021
+ const docHints = buildDocHints(src);
6022
+ // Append the godoc hint after the anchor as ` # <hint>` — same convention
6023
+ // as the Python/JS extractors' doc hints.
6024
+ const hinted = (sig, name) => (docHints.has(name) ? `${sig} # ${docHints.get(name)}` : sig);
5931
6025
 
5932
6026
  const stripped = src
5933
6027
  .replace(/\/\/.*$/gm, '')
@@ -5939,14 +6033,14 @@ __factories["./src/extractors/go"] = function(module, exports) {
5939
6033
  // Structs
5940
6034
  for (const m of stripped.matchAll(/^type\s+(\w+)\s+struct\s*\{/gm)) {
5941
6035
  const end = blockEndIdx(m.index + m[0].length);
5942
- sigs.push(withAnchor(`type ${m[1]} struct`, lineAt(stripped, m.index), lineAt(stripped, end)));
6036
+ sigs.push(hinted(withAnchor(`type ${m[1]} struct`, lineAt(stripped, m.index), lineAt(stripped, end)), m[1]));
5943
6037
  }
5944
6038
 
5945
6039
  // Interfaces
5946
6040
  for (const m of stripped.matchAll(/^type\s+(\w+)\s+interface\s*\{/gm)) {
5947
6041
  const bodyStart = m.index + m[0].length;
5948
6042
  const block = extractBlock(stripped, bodyStart);
5949
- sigs.push(withAnchor(`type ${m[1]} interface`, lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)));
6043
+ sigs.push(hinted(withAnchor(`type ${m[1]} interface`, lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)), m[1]));
5950
6044
  for (const meth of extractInterfaceMethods(block)) {
5951
6045
  sigs.push(withAnchor(` ${meth.text}`, lineAt(stripped, bodyStart + meth.declIdx), lineAt(stripped, bodyStart + meth.endIdx)));
5952
6046
  }
@@ -5958,7 +6052,7 @@ __factories["./src/extractors/go"] = function(module, exports) {
5958
6052
  const retType = m[4] ? m[4].trim().replace(/\s+/g, ' ') : '';
5959
6053
  const retStr = retType ? ` → ${retType.slice(0, 30)}` : '';
5960
6054
  const end = blockEndIdx(m.index + m[0].length);
5961
- sigs.push(withAnchor(`func ${receiver}${m[2]}(${normalizeParams(m[3])})${retStr}`, lineAt(stripped, m.index), lineAt(stripped, end)));
6055
+ sigs.push(hinted(withAnchor(`func ${receiver}${m[2]}(${normalizeParams(m[3])})${retStr}`, lineAt(stripped, m.index), lineAt(stripped, end)), m[2]));
5962
6056
  }
5963
6057
 
5964
6058
  return sigs.slice(0, 25);
@@ -5994,6 +6088,30 @@ __factories["./src/extractors/go"] = function(module, exports) {
5994
6088
  return params.trim().replace(/\s+/g, ' ');
5995
6089
  }
5996
6090
 
6091
+ // Godoc: the `//` comment block directly above a top-level func/type/method
6092
+ // declaration → first prose sentence, 60-char cap. Runs on the ORIGINAL src
6093
+ // (extract strips comments before matching). Compiler directives (`//go:...`)
6094
+ // carry no prose and are skipped.
6095
+ function buildDocHints(src) {
6096
+ const hints = new Map();
6097
+ const re = /((?:^\/\/[^\n]*\n)+)(?:func\s+(?:\(\w+\s+[\w*]+\)\s+)?(\w+)\s*\(|type\s+(\w+)\s+(?:struct|interface)\b)/gm;
6098
+ for (const m of src.matchAll(re)) {
6099
+ const name = m[2] || m[3];
6100
+ const hint = firstDocSentence(m[1]);
6101
+ if (hint && !hints.has(name)) hints.set(name, hint);
6102
+ }
6103
+ return hints;
6104
+ }
6105
+
6106
+ // First non-directive prose line of a `//` block → first sentence, 60-char cap.
6107
+ function firstDocSentence(block) {
6108
+ const line = String(block).split('\n')
6109
+ .map((l) => l.replace(/^\/\/\s?/, '').trim())
6110
+ .find((l) => l && !l.startsWith('go:') && !l.startsWith('nolint'));
6111
+ if (!line) return '';
6112
+ return line.split(/[.!?]/)[0].trim().slice(0, 60);
6113
+ }
6114
+
5997
6115
  module.exports = { extract };
5998
6116
 
5999
6117
  };
@@ -6126,6 +6244,10 @@ __factories["./src/extractors/java"] = function(module, exports) {
6126
6244
  function extract(src) {
6127
6245
  if (!src || typeof src !== 'string') return [];
6128
6246
  const sigs = [];
6247
+ const docHints = buildDocHints(src);
6248
+ // Append the Javadoc hint after the anchor as ` # <hint>` — same convention
6249
+ // as the Python/JS extractors' doc hints.
6250
+ const hinted = (sig, name) => (docHints.has(name) ? `${sig} # ${docHints.get(name)}` : sig);
6129
6251
 
6130
6252
  const stripped = src
6131
6253
  .replace(/\/\/.*$/gm, '')
@@ -6136,9 +6258,9 @@ __factories["./src/extractors/java"] = function(module, exports) {
6136
6258
  for (const m of stripped.matchAll(typeRegex)) {
6137
6259
  const bodyStart = m.index + m[0].length;
6138
6260
  const block = extractBlock(stripped, bodyStart);
6139
- sigs.push(withAnchor(`${m[1]} ${m[2]}`, lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)));
6261
+ sigs.push(hinted(withAnchor(`${m[1]} ${m[2]}`, lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)), m[2]));
6140
6262
  for (const meth of extractMembers(block)) {
6141
- sigs.push(withAnchor(` ${meth.text}`, lineAt(stripped, bodyStart + meth.declIdx), lineAt(stripped, bodyStart + meth.endIdx)));
6263
+ sigs.push(hinted(withAnchor(` ${meth.text}`, lineAt(stripped, bodyStart + meth.declIdx), lineAt(stripped, bodyStart + meth.endIdx)), meth.name));
6142
6264
  }
6143
6265
  }
6144
6266
 
@@ -6165,6 +6287,7 @@ __factories["./src/extractors/java"] = function(module, exports) {
6165
6287
  const retStr = ret ? ` → ${ret}` : '';
6166
6288
  members.push({
6167
6289
  text: `${m[2]}(${normalizeParams(m[3])})${retStr}`,
6290
+ name: m[2],
6168
6291
  declIdx: m.index + (m[0].length - m[0].trimStart().length),
6169
6292
  endIdx: m.index + m[0].length,
6170
6293
  });
@@ -6182,6 +6305,36 @@ __factories["./src/extractors/java"] = function(module, exports) {
6182
6305
  return type.trim().replace(/\s+/g, ' ').slice(0, 30);
6183
6306
  }
6184
6307
 
6308
+ // Javadoc: the `/** ... */` block directly above a type or public/protected
6309
+ // member declaration → first prose sentence, 60-char cap. Runs on the
6310
+ // ORIGINAL src (extract strips comments before matching). Annotation lines
6311
+ // (`@Override` etc.) between the doc block and the declaration are tolerated.
6312
+ // Body may not contain `*/` so a failed adjacency check can't expand across
6313
+ // code to the next comment block and misattribute the hint.
6314
+ function buildDocHints(src) {
6315
+ const hints = new Map();
6316
+ const patterns = [
6317
+ /\/\*\*((?:[^*]|\*(?!\/))*)\*\/\s*(?:@\w+(?:\([^)]*\))?\s*)*(?:public\s+|protected\s+)?(?:abstract\s+|final\s+)?(?:class|interface|enum)\s+(\w+)/g,
6318
+ /\/\*\*((?:[^*]|\*(?!\/))*)\*\/\s*(?:@\w+(?:\([^)]*\))?\s*)*(?:public|protected)\s+(?:static\s+)?(?:final\s+)?(?:synchronized\s+)?(?:<[^>]+>\s+)?[\w<>\[\], ?.]+\s+(\w+)\s*\(/g,
6319
+ ];
6320
+ for (const re of patterns) {
6321
+ for (const m of src.matchAll(re)) {
6322
+ const hint = firstDocSentence(m[1]);
6323
+ if (hint && !hints.has(m[2])) hints.set(m[2], hint);
6324
+ }
6325
+ }
6326
+ return hints;
6327
+ }
6328
+
6329
+ // First non-tag prose line of a Javadoc body → first sentence, 60-char cap.
6330
+ function firstDocSentence(body) {
6331
+ const line = String(body).split('\n')
6332
+ .map((l) => l.replace(/^\s*\*\s?/, '').trim())
6333
+ .find((l) => l && !l.startsWith('@'));
6334
+ if (!line) return '';
6335
+ return line.split(/[.!?]/)[0].trim().slice(0, 60);
6336
+ }
6337
+
6185
6338
  module.exports = { extract };
6186
6339
 
6187
6340
  };
@@ -7680,6 +7833,10 @@ __factories["./src/extractors/rust"] = function(module, exports) {
7680
7833
  function extract(src) {
7681
7834
  if (!src || typeof src !== 'string') return [];
7682
7835
  const sigs = [];
7836
+ const docHints = buildDocHints(src);
7837
+ // Append the doc-comment hint after the anchor as ` # <hint>` — same
7838
+ // convention as the Python/JS extractors' doc hints.
7839
+ const hinted = (sig, name) => (docHints.has(name) ? `${sig} # ${docHints.get(name)}` : sig);
7683
7840
 
7684
7841
  const stripped = src
7685
7842
  .replace(/\/\/.*$/gm, '')
@@ -7701,19 +7858,19 @@ __factories["./src/extractors/rust"] = function(module, exports) {
7701
7858
  // Structs
7702
7859
  for (const m of stripped.matchAll(/^pub\s+struct\s+(\w+)(?:<[^{]*>)?/gm)) {
7703
7860
  const [s, e] = rangeFor(m.index, m.index + m[0].length);
7704
- sigs.push(withAnchor(`pub struct ${m[1]}`, s, e));
7861
+ sigs.push(hinted(withAnchor(`pub struct ${m[1]}`, s, e), m[1]));
7705
7862
  }
7706
7863
 
7707
7864
  // Enums
7708
7865
  for (const m of stripped.matchAll(/^pub\s+enum\s+(\w+)(?:<[^{]*>)?/gm)) {
7709
7866
  const [s, e] = rangeFor(m.index, m.index + m[0].length);
7710
- sigs.push(withAnchor(`pub enum ${m[1]}`, s, e));
7867
+ sigs.push(hinted(withAnchor(`pub enum ${m[1]}`, s, e), m[1]));
7711
7868
  }
7712
7869
 
7713
7870
  // Traits
7714
7871
  for (const m of stripped.matchAll(/^pub\s+trait\s+(\w+)(?:<[^{]*>)?/gm)) {
7715
7872
  const [s, e] = rangeFor(m.index, m.index + m[0].length);
7716
- sigs.push(withAnchor(`pub trait ${m[1]}`, s, e));
7873
+ sigs.push(hinted(withAnchor(`pub trait ${m[1]}`, s, e), m[1]));
7717
7874
  }
7718
7875
 
7719
7876
  // impl blocks
@@ -7722,7 +7879,7 @@ __factories["./src/extractors/rust"] = function(module, exports) {
7722
7879
  const block = extractBlock(stripped, bodyStart);
7723
7880
  sigs.push(withAnchor(`impl ${m[1]}`, lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)));
7724
7881
  for (const fn of extractMethods(block)) {
7725
- sigs.push(withAnchor(` ${fn.text}`, lineAt(stripped, bodyStart + fn.declIdx), lineAt(stripped, bodyStart + fn.endIdx)));
7882
+ sigs.push(hinted(withAnchor(` ${fn.text}`, lineAt(stripped, bodyStart + fn.declIdx), lineAt(stripped, bodyStart + fn.endIdx)), fn.name));
7726
7883
  }
7727
7884
  }
7728
7885
 
@@ -7731,7 +7888,7 @@ __factories["./src/extractors/rust"] = function(module, exports) {
7731
7888
  const asyncKw = m[0].includes('async') ? 'async ' : '';
7732
7889
  const retStr = extractReturnType(m[3]);
7733
7890
  const [s, e] = rangeFor(m.index, m.index + m[0].length);
7734
- sigs.push(withAnchor(`pub ${asyncKw}fn ${m[1]}(${normalizeParams(m[2])})${retStr}`, s, e));
7891
+ sigs.push(hinted(withAnchor(`pub ${asyncKw}fn ${m[1]}(${normalizeParams(m[2])})${retStr}`, s, e), m[1]));
7735
7892
  }
7736
7893
 
7737
7894
  return sigs.slice(0, 25);
@@ -7755,6 +7912,7 @@ __factories["./src/extractors/rust"] = function(module, exports) {
7755
7912
  const retStr = extractReturnType(m[3]);
7756
7913
  methods.push({
7757
7914
  text: `pub ${asyncKw}fn ${m[1]}(${normalizeParams(m[2])})${retStr}`,
7915
+ name: m[1],
7758
7916
  declIdx: m.index + (m[0].length - m[0].trimStart().length),
7759
7917
  endIdx: m.index + m[0].length,
7760
7918
  });
@@ -7775,6 +7933,29 @@ __factories["./src/extractors/rust"] = function(module, exports) {
7775
7933
  return ` → ${rt.length > 30 ? rt.slice(0, 27) + '...' : rt}`;
7776
7934
  }
7777
7935
 
7936
+ // Rustdoc: the `///` block directly above a declaration → first prose
7937
+ // sentence, 60-char cap. Runs on the ORIGINAL src (extract strips comments
7938
+ // before matching). Attribute lines (`#[...]`) between the doc block and the
7939
+ // declaration are tolerated.
7940
+ function buildDocHints(src) {
7941
+ const hints = new Map();
7942
+ const re = /((?:^[ \t]*\/\/\/[^\n]*\n)+)(?:[ \t]*#\[[^\n]*\n)*[ \t]*pub(?:\s+async)?\s+(?:fn|struct|enum|trait)\s+(\w+)/gm;
7943
+ for (const m of src.matchAll(re)) {
7944
+ const hint = firstDocSentence(m[1]);
7945
+ if (hint && !hints.has(m[2])) hints.set(m[2], hint);
7946
+ }
7947
+ return hints;
7948
+ }
7949
+
7950
+ // First prose line of a `///` block → first sentence, 60-char cap.
7951
+ function firstDocSentence(block) {
7952
+ const line = String(block).split('\n')
7953
+ .map((l) => l.replace(/^[ \t]*\/\/\/\s?/, '').trim())
7954
+ .find((l) => l);
7955
+ if (!line) return '';
7956
+ return line.split(/[.!?]/)[0].trim().slice(0, 60);
7957
+ }
7958
+
7778
7959
  module.exports = { extract };
7779
7960
 
7780
7961
  };
@@ -11806,6 +11987,71 @@ __factories["./src/graph/call-graph"] = function(module, exports) {
11806
11987
 
11807
11988
  };
11808
11989
 
11990
+ // ── ./src/graph/centrality ──
11991
+ __factories["./src/graph/centrality"] = function(module, exports) {
11992
+
11993
+ /**
11994
+ * Zero-dependency import-graph centrality (Semantic Bridge II, B3).
11995
+ *
11996
+ * Power iteration over the forward dependency graph: rank flows from each
11997
+ * importer to the files it imports, so heavily-referenced files accumulate
11998
+ * centrality and one-off helpers do not. Deterministic — fixed damping,
11999
+ * fixed iteration count, nodes processed in sorted order.
12000
+ *
12001
+ * The result feeds the opt-in `retrieval.centralityBlend` ranking prior
12002
+ * (see src/retrieval/ranker.js) — a principled deepening of the existing
12003
+ * graph-boost idea, not a replacement for query relevance.
12004
+ */
12005
+
12006
+ const DAMPING = 0.85;
12007
+ const ITERATIONS = 20;
12008
+
12009
+ /**
12010
+ * Compute a normalized centrality score for every file in a dependency graph.
12011
+ *
12012
+ * @param {{ forward: Map<string, string[]> }} graph - forward dependency graph
12013
+ * (file → files it imports), as built by src/graph/builder.js
12014
+ * @returns {Map<string, number>} file → centrality in (0, 1], max-normalized;
12015
+ * empty Map when the graph is missing or empty
12016
+ */
12017
+ function computeCentrality(graph) {
12018
+ if (!graph || !(graph.forward instanceof Map) || graph.forward.size === 0) return new Map();
12019
+
12020
+ const nodes = new Set(graph.forward.keys());
12021
+ for (const deps of graph.forward.values()) {
12022
+ for (const dep of deps || []) nodes.add(dep);
12023
+ }
12024
+ const nodeList = [...nodes].sort();
12025
+ const n = nodeList.length;
12026
+ const indexOf = new Map(nodeList.map((file, i) => [file, i]));
12027
+ const outLinks = nodeList.map((file) =>
12028
+ (graph.forward.get(file) || []).map((dep) => indexOf.get(dep)).filter((i) => i !== undefined));
12029
+
12030
+ let ranks = new Array(n).fill(1 / n);
12031
+ for (let iter = 0; iter < ITERATIONS; iter++) {
12032
+ const next = new Array(n).fill((1 - DAMPING) / n);
12033
+ let dangling = 0;
12034
+ for (let i = 0; i < n; i++) {
12035
+ if (outLinks[i].length === 0) { dangling += ranks[i]; continue; }
12036
+ const share = (DAMPING * ranks[i]) / outLinks[i].length;
12037
+ for (const j of outLinks[i]) next[j] += share;
12038
+ }
12039
+ // Dangling mass (files that import nothing) is redistributed uniformly.
12040
+ const danglingShare = (DAMPING * dangling) / n;
12041
+ for (let i = 0; i < n; i++) next[i] += danglingShare;
12042
+ ranks = next;
12043
+ }
12044
+
12045
+ const max = Math.max(...ranks) || 1;
12046
+ const result = new Map();
12047
+ for (let i = 0; i < n; i++) result.set(nodeList[i], ranks[i] / max);
12048
+ return result;
12049
+ }
12050
+
12051
+ module.exports = { computeCentrality, DAMPING, ITERATIONS };
12052
+
12053
+ };
12054
+
11809
12055
  // ── ./src/graph/impact ──
11810
12056
  __factories["./src/graph/impact"] = function(module, exports) {
11811
12057
 
@@ -13963,8 +14209,9 @@ __factories["./src/mcp/handlers"] = function(module, exports) {
13963
14209
  // Build dependency graph for neighbor boost — non-fatal if it fails
13964
14210
  let graph = null;
13965
14211
  try { graph = buildFromCwd(cwd); } catch (_) {}
13966
- // Opt-in call-graph neighbor boost + surface enrichment — non-fatal
14212
+ // Opt-in call-graph neighbor boost + surface enrichment + centrality blend — non-fatal
13967
14213
  let callGraph = null;
14214
+ let centrality = null;
13968
14215
  try {
13969
14216
  const { loadConfig } = __require('./src/config/loader');
13970
14217
  const retrieval = loadConfig(cwd).retrieval;
@@ -13974,8 +14221,11 @@ __factories["./src/mcp/handlers"] = function(module, exports) {
13974
14221
  if (retrieval && retrieval.surfaceEnrichment) {
13975
14222
  __require('./src/retrieval/enrich-from-maps').enrichWithSurfaces(index, cwd);
13976
14223
  }
14224
+ if (retrieval && retrieval.centralityBlend && graph) {
14225
+ centrality = __require('./src/graph/centrality').computeCentrality(graph);
14226
+ }
13977
14227
  } catch (_) {}
13978
- const results = rank(args.query, index, { topK, cwd, graph, callGraph });
14228
+ const results = rank(args.query, index, { topK, cwd, graph, callGraph, centrality });
13979
14229
  return formatRankTable(results, args.query);
13980
14230
  } catch (err) {
13981
14231
  return `_query_context failed: ${err.message}_`;
@@ -14688,7 +14938,7 @@ __factories["./src/mcp/server"] = function(module, exports) {
14688
14938
 
14689
14939
  const SERVER_INFO = {
14690
14940
  name: 'sigmap',
14691
- version: '8.20.0',
14941
+ version: '8.22.0',
14692
14942
  description: 'SigMap MCP server — code signatures on demand',
14693
14943
  };
14694
14944
 
@@ -15818,6 +16068,9 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
15818
16068
  callHop: 0.30, // call-graph file neighbor (opt-in retrieval.callGraphBoost)
15819
16069
  };
15820
16070
 
16071
+ // Max additive prior for import-graph centrality (opt-in retrieval.centralityBlend)
16072
+ const CENTRALITY_BLEND_WEIGHT = 0.3;
16073
+
15821
16074
  // Intent-specific weight adjustments
15822
16075
  const INTENT_WEIGHTS = {
15823
16076
  search: DEFAULT_WEIGHTS,
@@ -15950,6 +16203,8 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
15950
16203
  * @param {{ forward: Map<string,string[]> }} [opts.graph] - dependency graph for neighbor boost
15951
16204
  * @param {{ forward: Map<string,string[]> }} [opts.callGraph] - file-level call-graph edges
15952
16205
  * (from buildCallFileGraph) for the opt-in call-neighbor boost
16206
+ * @param {Map<string,number>} [opts.centrality] - absolute file → normalized
16207
+ * centrality (from computeCentrality) for the opt-in centrality blend
15953
16208
  * @returns {{ file: string, score: number, sigs: string[], tokens: number, intent: string, signals: object }[]}
15954
16209
  */
15955
16210
  function rank(query, sigIndex, opts) {
@@ -16098,6 +16353,26 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
16098
16353
  }
16099
16354
  }
16100
16355
 
16356
+ // Centrality blend (opt-in via retrieval.centralityBlend): a small additive
16357
+ // prior from import-graph centrality so heavily-referenced files rank above
16358
+ // one-off helpers on ambiguous queries. Applied only to positively-scored
16359
+ // files — a tie-breaker among matches, never a way to surface non-matches.
16360
+ const centrality = (opts && opts.centrality instanceof Map && opts.centrality.size > 0) ? opts.centrality : null;
16361
+ if (centrality && cwd) {
16362
+ const path = require('path');
16363
+ for (const entry of scored) {
16364
+ if (entry.score <= 0) continue;
16365
+ const abs = path.resolve(cwd, entry.file);
16366
+ // The graph builder lowercases paths (normalizePath) — probe both forms.
16367
+ const c = centrality.get(abs) || centrality.get(abs.toLowerCase());
16368
+ if (c) {
16369
+ const bonus = CENTRALITY_BLEND_WEIGHT * c;
16370
+ entry.score += bonus;
16371
+ entry.signals.centrality = bonus;
16372
+ }
16373
+ }
16374
+ }
16375
+
16101
16376
  // Compute confidence levels based on score distribution
16102
16377
  if (scored.length > 0) {
16103
16378
  const scores = scored.map(s => s.score);
@@ -16373,7 +16648,7 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
16373
16648
  return 'search';
16374
16649
  }
16375
16650
 
16376
- module.exports = { rank, buildSigIndex, scoreFile, formatRankTable, formatRankJSON, DEFAULT_WEIGHTS, GRAPH_BOOST_AMOUNTS, detectIntent };
16651
+ module.exports = { rank, buildSigIndex, scoreFile, formatRankTable, formatRankJSON, DEFAULT_WEIGHTS, GRAPH_BOOST_AMOUNTS, CENTRALITY_BLEND_WEIGHT, detectIntent };
16377
16652
 
16378
16653
  };
16379
16654
 
@@ -18357,6 +18632,8 @@ __factories["./src/tracking/pricing"] = function(module, exports) {
18357
18632
  'gpt-4o-mini': 0.15,
18358
18633
  'gemini-1.5-pro': 1.25,
18359
18634
  'gemini-1.5-flash': 0.075,
18635
+ 'minimax-m3': 0.6,
18636
+ 'minimax-m2.7': 0.3,
18360
18637
  };
18361
18638
 
18362
18639
  const DEFAULT_MODEL = 'claude-sonnet';
@@ -19881,7 +20158,7 @@ function __tryGit(args, opts = {}) {
19881
20158
  catch (_) { return ''; }
19882
20159
  }
19883
20160
 
19884
- const VERSION = '8.20.0';
20161
+ const VERSION = '8.22.0';
19885
20162
  const MARKER = '\n\n## Auto-generated signatures\n<!-- Updated by gen-context.js -->\n';
19886
20163
 
19887
20164
  function requireSourceOrBundled(key) {
@@ -22215,8 +22492,16 @@ function main() {
22215
22492
  if (config && config.retrieval && config.retrieval.surfaceEnrichment) {
22216
22493
  try { requireSourceOrBundled('./src/retrieval/enrich-from-maps').enrichWithSurfaces(sigIndex, cwd); } catch (_) {}
22217
22494
  }
22495
+ // Opt-in import-graph centrality blend (retrieval.centralityBlend) — non-fatal
22496
+ let askCentrality = null;
22497
+ if (config && config.retrieval && config.retrieval.centralityBlend) {
22498
+ try {
22499
+ const askCentralityGraph = requireSourceOrBundled('./src/graph/builder').buildFromCwd(cwd);
22500
+ askCentrality = requireSourceOrBundled('./src/graph/centrality').computeCentrality(askCentralityGraph);
22501
+ } catch (_) {}
22502
+ }
22218
22503
 
22219
- let ranked = rank(query, sigIndex, { topK: 5, weights: intentWeights, cwd, callGraph: askCallGraph });
22504
+ let ranked = rank(query, sigIndex, { topK: 5, weights: intentWeights, cwd, callGraph: askCallGraph, centrality: askCentrality });
22220
22505
 
22221
22506
  // v6.10: Workspace scoping — infer package from query and apply boost
22222
22507
  const workspaces = detectWorkspaces(cwd);
@@ -24447,7 +24732,15 @@ function main() {
24447
24732
  if (config && config.retrieval && config.retrieval.surfaceEnrichment) {
24448
24733
  try { requireSourceOrBundled('./src/retrieval/enrich-from-maps').enrichWithSurfaces(index, cwd); } catch (_) {}
24449
24734
  }
24450
- const results = rank(query, index, { topK, recencyBoost, cwd, callGraph: queryCallGraph });
24735
+ // Opt-in import-graph centrality blend (retrieval.centralityBlend) non-fatal
24736
+ let queryCentrality = null;
24737
+ if (config && config.retrieval && config.retrieval.centralityBlend) {
24738
+ try {
24739
+ const centralityGraph = requireSourceOrBundled('./src/graph/builder').buildFromCwd(cwd);
24740
+ queryCentrality = requireSourceOrBundled('./src/graph/centrality').computeCentrality(centralityGraph);
24741
+ } catch (_) {}
24742
+ }
24743
+ const results = rank(query, index, { topK, recencyBoost, cwd, callGraph: queryCallGraph, centrality: queryCentrality });
24451
24744
  if (args.includes('--context')) {
24452
24745
  const miniCtx = buildMiniContext(results, cwd);
24453
24746
  const ctxOut = path.join(cwd, '.context', 'query-context.md');
package/llms-full.txt CHANGED
@@ -11,20 +11,20 @@ ranking keeps the relevant context in scope (cutting tokens ~97% as a side
11
11
  effect), with no LLM calls, embeddings, or vector database. Works with Claude,
12
12
  Cursor, GitHub Copilot, Aider, Windsurf, local LLMs, and MCP.
13
13
 
14
- # Version: 8.20.0 | Benchmark: sigmap-v8.20-main (2026-07-19)
14
+ # Version: 8.22.0 | Benchmark: sigmap-v8.22-main (2026-07-27)
15
15
  # Source: auto-generated from package.json, version.json, benchmarks/latest.json, src/mcp/tools.js, src/config/defaults.js
16
16
  # Regenerate: npm run generate:llms | Validate: npm run validate:llms
17
17
 
18
18
  ---
19
19
 
20
- ## Core metrics (benchmark: sigmap-v8.20-main, 2026-07-19)
20
+ ## Core metrics (benchmark: sigmap-v8.22-main, 2026-07-27)
21
21
 
22
22
  | Metric | Without SigMap | With SigMap |
23
23
  |--------|----------------|-------------|
24
- | Retrieval hit@5 | 42.7% (single-shot grep) | 85.6% (2.00× lift) |
24
+ | Retrieval hit@5 | 44.8% (single-shot grep) | 82.2% (1.59× lift) |
25
25
  | Token reduction | — | 96.8% average |
26
- | Task-success proxy (modeled) | — | 67.8% |
27
- | Prompts per task | 2.84 | 1.47 (48.4% fewer) |
26
+ | Task-success proxy (modeled) | — | 64.8% |
27
+ | Prompts per task | 2.84 | 1.53 (46.1% fewer) |
28
28
  | Supported languages | — | 33 |
29
29
  | MCP tools | — | 20 |
30
30
  | npm runtime dependencies | — | 0 |
@@ -345,7 +345,7 @@ testCoverage = false
345
345
  testDirs = ["tests","test","__tests__","spec"]
346
346
  sigCache = false
347
347
  impactRadius = false
348
- retrieval = {"topK":10,"recencyBoost":1.5,"callGraphBoost":false,"surfaceEnrichment":false}
348
+ retrieval = {"topK":10,"recencyBoost":1.5,"callGraphBoost":false,"centralityBlend":false,"surfaceEnrichment":false}
349
349
  impact = {"depth":3,"includeSigs":true}
350
350
  ```
351
351
 
package/llms.txt CHANGED
@@ -11,7 +11,7 @@ ranking keeps the relevant context in scope (cutting tokens ~97% as a side
11
11
  effect), with no LLM calls, embeddings, or vector database. Works with Claude,
12
12
  Cursor, GitHub Copilot, Aider, Windsurf, local LLMs, and MCP.
13
13
 
14
- # Version: 8.20.0 | Benchmark: sigmap-v8.20-main (2026-07-19)
14
+ # Version: 8.22.0 | Benchmark: sigmap-v8.22-main (2026-07-27)
15
15
  # Source: auto-generated from package.json, version.json, benchmarks/latest.json, src/mcp/tools.js, src/config/defaults.js
16
16
  # Regenerate: npm run generate:llms | Validate: npm run validate:llms
17
17
 
@@ -23,12 +23,12 @@ Cursor, GitHub Copilot, Aider, Windsurf, local LLMs, and MCP.
23
23
  - No blast-radius awareness before editing a hub file — `--impact` shows every file a change touches.
24
24
  - Pasted stack traces, CI logs, and JSON bloat the prompt — `squeeze` minimizes them and enriches the top frame from the symbol index.
25
25
 
26
- ## Core metrics (benchmark: sigmap-v8.20-main, 2026-07-19)
26
+ ## Core metrics (benchmark: sigmap-v8.22-main, 2026-07-27)
27
27
 
28
- - hit@5 retrieval: 85.6% vs 42.7% single-shot grep baseline (2.00× lift)
28
+ - hit@5 retrieval: 82.2% vs 44.8% single-shot grep baseline (1.59× lift)
29
29
  - Token reduction: 96.8% average across benchmark repos
30
- - Task-success proxy: 67.8% (modeled from retrieval tiers, not measured LLM sessions)
31
- - Prompts per task: 1.47 vs 2.84 baseline (48.4% fewer, modeled)
30
+ - Task-success proxy: 64.8% (modeled from retrieval tiers, not measured LLM sessions)
31
+ - Prompts per task: 1.53 vs 2.84 baseline (46.1% fewer, modeled)
32
32
  - Languages: 33 supported · MCP tools: 20
33
33
  - Dependencies: zero npm runtime dependencies · fully offline
34
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigmap",
3
- "version": "8.20.0",
3
+ "version": "8.22.0",
4
4
  "description": "The deterministic, verifiable grounding layer for AI code work — a zero-dependency signature-and-evidence map that grounds Claude, Cursor, Copilot, Aider, Windsurf, local LLMs & MCP agents against your real code (repo + installed libraries) so they stop hallucinating files, imports & APIs. Runs offline via npx; byte-stable output; ~97% token reduction as proof.",
5
5
  "main": "packages/core/index.js",
6
6
  "exports": {
@@ -31,6 +31,7 @@
31
31
  "benchmark:test-discovery": "node scripts/run-test-discovery-benchmark.mjs --save",
32
32
  "benchmark:terse": "node scripts/run-terse-benchmark.mjs --save",
33
33
  "benchmark:callgraph-boost": "node scripts/run-callgraph-boost-benchmark.mjs --save",
34
+ "benchmark:centrality-blend": "node scripts/run-centrality-blend-benchmark.mjs --save",
34
35
  "benchmark:surface-enrichment": "node scripts/run-surface-enrichment-benchmark.mjs --save",
35
36
  "validate:squeeze": "node scripts/run-squeeze-benchmark.mjs --gate",
36
37
  "health": "node gen-context.js --health",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigmap-cli",
3
- "version": "8.20.0",
3
+ "version": "8.22.0",
4
4
  "description": "SigMap CLI wrapper — thin adapter for programmatic CLI invocation",
5
5
  "main": "index.js",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigmap-core",
3
- "version": "8.20.0",
3
+ "version": "8.22.0",
4
4
  "description": "SigMap core library — zero-dependency code signature extraction, retrieval, and security scanning",
5
5
  "main": "index.js",
6
6
  "keywords": [
@@ -149,6 +149,8 @@ const DEFAULTS = {
149
149
  recencyBoost: 1.5,
150
150
  // Boost files call-graph-connected to query matches (opt-in, measure-gated)
151
151
  callGraphBoost: false,
152
+ // Blend import-graph centrality into ranking as a small prior (opt-in, measure-gated)
153
+ centralityBlend: false,
152
154
  // Append route pseudo-signatures to the rankable index (opt-in, measure-gated)
153
155
  surfaceEnrichment: false,
154
156
  },
@@ -0,0 +1,83 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Task-corpus hygiene (A3, v8.22 "Hard Corpus").
5
+ *
6
+ * A benchmark query "leaks" when it shares a token with the basenames of its
7
+ * expected files — hit@5 then partly measures filename matching, not
8
+ * retrieval. The criterion is deterministic and reuses the production
9
+ * tokenizer (identifier splitting + stemming from src/retrieval/bm25.js), so
10
+ * "payments" leaks against payment.js and "InterceptorManager" leaks against
11
+ * "interceptor manager" the same way the ranker would see them.
12
+ *
13
+ * Tasks carry an optional `split` field: 'hard' tasks MUST be leak-free
14
+ * (validateTasks reports them as violations); 'easy' tasks (the default) may
15
+ * leak — that is what makes them easy.
16
+ *
17
+ * Size buckets group repos by indexed file count so large repos stop being
18
+ * averaged away by tiny ones. Thresholds are the rough tertiles of the
19
+ * current benchmarks/repos corpus (43 repos, 27–3450 source files).
20
+ */
21
+
22
+ const { tokenize } = require('../retrieval/bm25');
23
+
24
+ const BUCKET_LIMITS = { small: 200, medium: 1000 }; // files; large = above medium
25
+
26
+ /**
27
+ * Stemmed tokens of a file path's basename (extension stripped).
28
+ * @param {string} filePath
29
+ * @returns {string[]}
30
+ */
31
+ function basenameTokens(filePath) {
32
+ const base = String(filePath).split('/').pop() || '';
33
+ return tokenize(base.replace(/\.[^.]*$/, ''));
34
+ }
35
+
36
+ /**
37
+ * Leaked tokens between a query and its expected files' basenames.
38
+ * @param {string} query
39
+ * @param {string[]} expectedFiles
40
+ * @returns {{ leaked: string[], clean: boolean }}
41
+ */
42
+ function queryLeakage(query, expectedFiles) {
43
+ const qToks = new Set(tokenize(query));
44
+ const leaked = new Set();
45
+ for (const f of expectedFiles || []) {
46
+ for (const t of basenameTokens(f)) {
47
+ if (qToks.has(t)) leaked.add(t);
48
+ }
49
+ }
50
+ return { leaked: [...leaked].sort(), clean: leaked.size === 0 };
51
+ }
52
+
53
+ /**
54
+ * Validate a task list: every task gets a leakage result; hard-split tasks
55
+ * that leak are violations.
56
+ * @param {Array<{id?:string, query:string, expected_files?:string[], split?:string}>} tasks
57
+ * @returns {{ results: object[], hardViolations: object[] }}
58
+ */
59
+ function validateTasks(tasks) {
60
+ const results = [];
61
+ const hardViolations = [];
62
+ for (const t of tasks || []) {
63
+ const split = t.split === 'hard' ? 'hard' : 'easy';
64
+ const { leaked, clean } = queryLeakage(t.query, t.expected_files);
65
+ const row = { id: t.id || '?', split, leaked, clean };
66
+ results.push(row);
67
+ if (split === 'hard' && !clean) hardViolations.push(row);
68
+ }
69
+ return { results, hardViolations };
70
+ }
71
+
72
+ /**
73
+ * Size bucket for a repo by indexed file count.
74
+ * @param {number} fileCount
75
+ * @returns {'small'|'medium'|'large'}
76
+ */
77
+ function sizeBucket(fileCount) {
78
+ if (fileCount < BUCKET_LIMITS.small) return 'small';
79
+ if (fileCount <= BUCKET_LIMITS.medium) return 'medium';
80
+ return 'large';
81
+ }
82
+
83
+ module.exports = { basenameTokens, queryLeakage, validateTasks, sizeBucket, BUCKET_LIMITS };
@@ -124,7 +124,7 @@ function estimateTokens(sigs) {
124
124
 
125
125
  /**
126
126
  * Load tasks from a JSONL file.
127
- * Each line: { id, query, expected_files, repo }
127
+ * Each line: { id, query, expected_files, repo, split? ('easy'|'hard') }
128
128
  * Invalid or blank lines are silently skipped.
129
129
  * @param {string} tasksFile - absolute or relative path
130
130
  * @returns {Array<{id:string, query:string, expected:string[], repo:string}>}
@@ -144,6 +144,7 @@ function loadTasks(tasksFile) {
144
144
  query: obj.query,
145
145
  expected: obj.expected_files,
146
146
  repo: obj.repo || '.',
147
+ split: obj.split === 'hard' ? 'hard' : 'easy',
147
148
  });
148
149
  }
149
150
  } catch {
@@ -12,6 +12,10 @@ const { lineAt, withAnchor } = require('./line-anchor');
12
12
  function extract(src) {
13
13
  if (!src || typeof src !== 'string') return [];
14
14
  const sigs = [];
15
+ const docHints = buildDocHints(src);
16
+ // Append the godoc hint after the anchor as ` # <hint>` — same convention
17
+ // as the Python/JS extractors' doc hints.
18
+ const hinted = (sig, name) => (docHints.has(name) ? `${sig} # ${docHints.get(name)}` : sig);
15
19
 
16
20
  const stripped = src
17
21
  .replace(/\/\/.*$/gm, '')
@@ -23,14 +27,14 @@ function extract(src) {
23
27
  // Structs
24
28
  for (const m of stripped.matchAll(/^type\s+(\w+)\s+struct\s*\{/gm)) {
25
29
  const end = blockEndIdx(m.index + m[0].length);
26
- sigs.push(withAnchor(`type ${m[1]} struct`, lineAt(stripped, m.index), lineAt(stripped, end)));
30
+ sigs.push(hinted(withAnchor(`type ${m[1]} struct`, lineAt(stripped, m.index), lineAt(stripped, end)), m[1]));
27
31
  }
28
32
 
29
33
  // Interfaces
30
34
  for (const m of stripped.matchAll(/^type\s+(\w+)\s+interface\s*\{/gm)) {
31
35
  const bodyStart = m.index + m[0].length;
32
36
  const block = extractBlock(stripped, bodyStart);
33
- sigs.push(withAnchor(`type ${m[1]} interface`, lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)));
37
+ sigs.push(hinted(withAnchor(`type ${m[1]} interface`, lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)), m[1]));
34
38
  for (const meth of extractInterfaceMethods(block)) {
35
39
  sigs.push(withAnchor(` ${meth.text}`, lineAt(stripped, bodyStart + meth.declIdx), lineAt(stripped, bodyStart + meth.endIdx)));
36
40
  }
@@ -42,7 +46,7 @@ function extract(src) {
42
46
  const retType = m[4] ? m[4].trim().replace(/\s+/g, ' ') : '';
43
47
  const retStr = retType ? ` → ${retType.slice(0, 30)}` : '';
44
48
  const end = blockEndIdx(m.index + m[0].length);
45
- sigs.push(withAnchor(`func ${receiver}${m[2]}(${normalizeParams(m[3])})${retStr}`, lineAt(stripped, m.index), lineAt(stripped, end)));
49
+ sigs.push(hinted(withAnchor(`func ${receiver}${m[2]}(${normalizeParams(m[3])})${retStr}`, lineAt(stripped, m.index), lineAt(stripped, end)), m[2]));
46
50
  }
47
51
 
48
52
  return sigs.slice(0, 25);
@@ -78,4 +82,28 @@ function normalizeParams(params) {
78
82
  return params.trim().replace(/\s+/g, ' ');
79
83
  }
80
84
 
85
+ // Godoc: the `//` comment block directly above a top-level func/type/method
86
+ // declaration → first prose sentence, 60-char cap. Runs on the ORIGINAL src
87
+ // (extract strips comments before matching). Compiler directives (`//go:...`)
88
+ // carry no prose and are skipped.
89
+ function buildDocHints(src) {
90
+ const hints = new Map();
91
+ const re = /((?:^\/\/[^\n]*\n)+)(?:func\s+(?:\(\w+\s+[\w*]+\)\s+)?(\w+)\s*\(|type\s+(\w+)\s+(?:struct|interface)\b)/gm;
92
+ for (const m of src.matchAll(re)) {
93
+ const name = m[2] || m[3];
94
+ const hint = firstDocSentence(m[1]);
95
+ if (hint && !hints.has(name)) hints.set(name, hint);
96
+ }
97
+ return hints;
98
+ }
99
+
100
+ // First non-directive prose line of a `//` block → first sentence, 60-char cap.
101
+ function firstDocSentence(block) {
102
+ const line = String(block).split('\n')
103
+ .map((l) => l.replace(/^\/\/\s?/, '').trim())
104
+ .find((l) => l && !l.startsWith('go:') && !l.startsWith('nolint'));
105
+ if (!line) return '';
106
+ return line.split(/[.!?]/)[0].trim().slice(0, 60);
107
+ }
108
+
81
109
  module.exports = { extract };
@@ -12,6 +12,10 @@ const { lineAt, withAnchor } = require('./line-anchor');
12
12
  function extract(src) {
13
13
  if (!src || typeof src !== 'string') return [];
14
14
  const sigs = [];
15
+ const docHints = buildDocHints(src);
16
+ // Append the Javadoc hint after the anchor as ` # <hint>` — same convention
17
+ // as the Python/JS extractors' doc hints.
18
+ const hinted = (sig, name) => (docHints.has(name) ? `${sig} # ${docHints.get(name)}` : sig);
15
19
 
16
20
  const stripped = src
17
21
  .replace(/\/\/.*$/gm, '')
@@ -22,9 +26,9 @@ function extract(src) {
22
26
  for (const m of stripped.matchAll(typeRegex)) {
23
27
  const bodyStart = m.index + m[0].length;
24
28
  const block = extractBlock(stripped, bodyStart);
25
- sigs.push(withAnchor(`${m[1]} ${m[2]}`, lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)));
29
+ sigs.push(hinted(withAnchor(`${m[1]} ${m[2]}`, lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)), m[2]));
26
30
  for (const meth of extractMembers(block)) {
27
- sigs.push(withAnchor(` ${meth.text}`, lineAt(stripped, bodyStart + meth.declIdx), lineAt(stripped, bodyStart + meth.endIdx)));
31
+ sigs.push(hinted(withAnchor(` ${meth.text}`, lineAt(stripped, bodyStart + meth.declIdx), lineAt(stripped, bodyStart + meth.endIdx)), meth.name));
28
32
  }
29
33
  }
30
34
 
@@ -51,6 +55,7 @@ function extractMembers(block) {
51
55
  const retStr = ret ? ` → ${ret}` : '';
52
56
  members.push({
53
57
  text: `${m[2]}(${normalizeParams(m[3])})${retStr}`,
58
+ name: m[2],
54
59
  declIdx: m.index + (m[0].length - m[0].trimStart().length),
55
60
  endIdx: m.index + m[0].length,
56
61
  });
@@ -68,4 +73,34 @@ function normalizeType(type) {
68
73
  return type.trim().replace(/\s+/g, ' ').slice(0, 30);
69
74
  }
70
75
 
76
+ // Javadoc: the `/** ... */` block directly above a type or public/protected
77
+ // member declaration → first prose sentence, 60-char cap. Runs on the
78
+ // ORIGINAL src (extract strips comments before matching). Annotation lines
79
+ // (`@Override` etc.) between the doc block and the declaration are tolerated.
80
+ // Body may not contain `*/` so a failed adjacency check can't expand across
81
+ // code to the next comment block and misattribute the hint.
82
+ function buildDocHints(src) {
83
+ const hints = new Map();
84
+ const patterns = [
85
+ /\/\*\*((?:[^*]|\*(?!\/))*)\*\/\s*(?:@\w+(?:\([^)]*\))?\s*)*(?:public\s+|protected\s+)?(?:abstract\s+|final\s+)?(?:class|interface|enum)\s+(\w+)/g,
86
+ /\/\*\*((?:[^*]|\*(?!\/))*)\*\/\s*(?:@\w+(?:\([^)]*\))?\s*)*(?:public|protected)\s+(?:static\s+)?(?:final\s+)?(?:synchronized\s+)?(?:<[^>]+>\s+)?[\w<>\[\], ?.]+\s+(\w+)\s*\(/g,
87
+ ];
88
+ for (const re of patterns) {
89
+ for (const m of src.matchAll(re)) {
90
+ const hint = firstDocSentence(m[1]);
91
+ if (hint && !hints.has(m[2])) hints.set(m[2], hint);
92
+ }
93
+ }
94
+ return hints;
95
+ }
96
+
97
+ // First non-tag prose line of a Javadoc body → first sentence, 60-char cap.
98
+ function firstDocSentence(body) {
99
+ const line = String(body).split('\n')
100
+ .map((l) => l.replace(/^\s*\*\s?/, '').trim())
101
+ .find((l) => l && !l.startsWith('@'));
102
+ if (!line) return '';
103
+ return line.split(/[.!?]/)[0].trim().slice(0, 60);
104
+ }
105
+
71
106
  module.exports = { extract };
@@ -12,6 +12,10 @@ const { lineAt, withAnchor } = require('./line-anchor');
12
12
  function extract(src) {
13
13
  if (!src || typeof src !== 'string') return [];
14
14
  const sigs = [];
15
+ const docHints = buildDocHints(src);
16
+ // Append the doc-comment hint after the anchor as ` # <hint>` — same
17
+ // convention as the Python/JS extractors' doc hints.
18
+ const hinted = (sig, name) => (docHints.has(name) ? `${sig} # ${docHints.get(name)}` : sig);
15
19
 
16
20
  const stripped = src
17
21
  .replace(/\/\/.*$/gm, '')
@@ -33,19 +37,19 @@ function extract(src) {
33
37
  // Structs
34
38
  for (const m of stripped.matchAll(/^pub\s+struct\s+(\w+)(?:<[^{]*>)?/gm)) {
35
39
  const [s, e] = rangeFor(m.index, m.index + m[0].length);
36
- sigs.push(withAnchor(`pub struct ${m[1]}`, s, e));
40
+ sigs.push(hinted(withAnchor(`pub struct ${m[1]}`, s, e), m[1]));
37
41
  }
38
42
 
39
43
  // Enums
40
44
  for (const m of stripped.matchAll(/^pub\s+enum\s+(\w+)(?:<[^{]*>)?/gm)) {
41
45
  const [s, e] = rangeFor(m.index, m.index + m[0].length);
42
- sigs.push(withAnchor(`pub enum ${m[1]}`, s, e));
46
+ sigs.push(hinted(withAnchor(`pub enum ${m[1]}`, s, e), m[1]));
43
47
  }
44
48
 
45
49
  // Traits
46
50
  for (const m of stripped.matchAll(/^pub\s+trait\s+(\w+)(?:<[^{]*>)?/gm)) {
47
51
  const [s, e] = rangeFor(m.index, m.index + m[0].length);
48
- sigs.push(withAnchor(`pub trait ${m[1]}`, s, e));
52
+ sigs.push(hinted(withAnchor(`pub trait ${m[1]}`, s, e), m[1]));
49
53
  }
50
54
 
51
55
  // impl blocks
@@ -54,7 +58,7 @@ function extract(src) {
54
58
  const block = extractBlock(stripped, bodyStart);
55
59
  sigs.push(withAnchor(`impl ${m[1]}`, lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)));
56
60
  for (const fn of extractMethods(block)) {
57
- sigs.push(withAnchor(` ${fn.text}`, lineAt(stripped, bodyStart + fn.declIdx), lineAt(stripped, bodyStart + fn.endIdx)));
61
+ sigs.push(hinted(withAnchor(` ${fn.text}`, lineAt(stripped, bodyStart + fn.declIdx), lineAt(stripped, bodyStart + fn.endIdx)), fn.name));
58
62
  }
59
63
  }
60
64
 
@@ -63,7 +67,7 @@ function extract(src) {
63
67
  const asyncKw = m[0].includes('async') ? 'async ' : '';
64
68
  const retStr = extractReturnType(m[3]);
65
69
  const [s, e] = rangeFor(m.index, m.index + m[0].length);
66
- sigs.push(withAnchor(`pub ${asyncKw}fn ${m[1]}(${normalizeParams(m[2])})${retStr}`, s, e));
70
+ sigs.push(hinted(withAnchor(`pub ${asyncKw}fn ${m[1]}(${normalizeParams(m[2])})${retStr}`, s, e), m[1]));
67
71
  }
68
72
 
69
73
  return sigs.slice(0, 25);
@@ -87,6 +91,7 @@ function extractMethods(block) {
87
91
  const retStr = extractReturnType(m[3]);
88
92
  methods.push({
89
93
  text: `pub ${asyncKw}fn ${m[1]}(${normalizeParams(m[2])})${retStr}`,
94
+ name: m[1],
90
95
  declIdx: m.index + (m[0].length - m[0].trimStart().length),
91
96
  endIdx: m.index + m[0].length,
92
97
  });
@@ -107,4 +112,27 @@ function extractReturnType(afterParen) {
107
112
  return ` → ${rt.length > 30 ? rt.slice(0, 27) + '...' : rt}`;
108
113
  }
109
114
 
115
+ // Rustdoc: the `///` block directly above a declaration → first prose
116
+ // sentence, 60-char cap. Runs on the ORIGINAL src (extract strips comments
117
+ // before matching). Attribute lines (`#[...]`) between the doc block and the
118
+ // declaration are tolerated.
119
+ function buildDocHints(src) {
120
+ const hints = new Map();
121
+ const re = /((?:^[ \t]*\/\/\/[^\n]*\n)+)(?:[ \t]*#\[[^\n]*\n)*[ \t]*pub(?:\s+async)?\s+(?:fn|struct|enum|trait)\s+(\w+)/gm;
122
+ for (const m of src.matchAll(re)) {
123
+ const hint = firstDocSentence(m[1]);
124
+ if (hint && !hints.has(m[2])) hints.set(m[2], hint);
125
+ }
126
+ return hints;
127
+ }
128
+
129
+ // First prose line of a `///` block → first sentence, 60-char cap.
130
+ function firstDocSentence(block) {
131
+ const line = String(block).split('\n')
132
+ .map((l) => l.replace(/^[ \t]*\/\/\/\s?/, '').trim())
133
+ .find((l) => l);
134
+ if (!line) return '';
135
+ return line.split(/[.!?]/)[0].trim().slice(0, 60);
136
+ }
137
+
110
138
  module.exports = { extract };
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Zero-dependency import-graph centrality (Semantic Bridge II, B3).
5
+ *
6
+ * Power iteration over the forward dependency graph: rank flows from each
7
+ * importer to the files it imports, so heavily-referenced files accumulate
8
+ * centrality and one-off helpers do not. Deterministic — fixed damping,
9
+ * fixed iteration count, nodes processed in sorted order.
10
+ *
11
+ * The result feeds the opt-in `retrieval.centralityBlend` ranking prior
12
+ * (see src/retrieval/ranker.js) — a principled deepening of the existing
13
+ * graph-boost idea, not a replacement for query relevance.
14
+ */
15
+
16
+ const DAMPING = 0.85;
17
+ const ITERATIONS = 20;
18
+
19
+ /**
20
+ * Compute a normalized centrality score for every file in a dependency graph.
21
+ *
22
+ * @param {{ forward: Map<string, string[]> }} graph - forward dependency graph
23
+ * (file → files it imports), as built by src/graph/builder.js
24
+ * @returns {Map<string, number>} file → centrality in (0, 1], max-normalized;
25
+ * empty Map when the graph is missing or empty
26
+ */
27
+ function computeCentrality(graph) {
28
+ if (!graph || !(graph.forward instanceof Map) || graph.forward.size === 0) return new Map();
29
+
30
+ const nodes = new Set(graph.forward.keys());
31
+ for (const deps of graph.forward.values()) {
32
+ for (const dep of deps || []) nodes.add(dep);
33
+ }
34
+ const nodeList = [...nodes].sort();
35
+ const n = nodeList.length;
36
+ const indexOf = new Map(nodeList.map((file, i) => [file, i]));
37
+ const outLinks = nodeList.map((file) =>
38
+ (graph.forward.get(file) || []).map((dep) => indexOf.get(dep)).filter((i) => i !== undefined));
39
+
40
+ let ranks = new Array(n).fill(1 / n);
41
+ for (let iter = 0; iter < ITERATIONS; iter++) {
42
+ const next = new Array(n).fill((1 - DAMPING) / n);
43
+ let dangling = 0;
44
+ for (let i = 0; i < n; i++) {
45
+ if (outLinks[i].length === 0) { dangling += ranks[i]; continue; }
46
+ const share = (DAMPING * ranks[i]) / outLinks[i].length;
47
+ for (const j of outLinks[i]) next[j] += share;
48
+ }
49
+ // Dangling mass (files that import nothing) is redistributed uniformly.
50
+ const danglingShare = (DAMPING * dangling) / n;
51
+ for (let i = 0; i < n; i++) next[i] += danglingShare;
52
+ ranks = next;
53
+ }
54
+
55
+ const max = Math.max(...ranks) || 1;
56
+ const result = new Map();
57
+ for (let i = 0; i < n; i++) result.set(nodeList[i], ranks[i] / max);
58
+ return result;
59
+ }
60
+
61
+ module.exports = { computeCentrality, DAMPING, ITERATIONS };
@@ -421,8 +421,9 @@ function queryContext(args, cwd) {
421
421
  // Build dependency graph for neighbor boost — non-fatal if it fails
422
422
  let graph = null;
423
423
  try { graph = buildFromCwd(cwd); } catch (_) {}
424
- // Opt-in call-graph neighbor boost + surface enrichment — non-fatal
424
+ // Opt-in call-graph neighbor boost + surface enrichment + centrality blend — non-fatal
425
425
  let callGraph = null;
426
+ let centrality = null;
426
427
  try {
427
428
  const { loadConfig } = require('../config/loader');
428
429
  const retrieval = loadConfig(cwd).retrieval;
@@ -432,8 +433,11 @@ function queryContext(args, cwd) {
432
433
  if (retrieval && retrieval.surfaceEnrichment) {
433
434
  require('../retrieval/enrich-from-maps').enrichWithSurfaces(index, cwd);
434
435
  }
436
+ if (retrieval && retrieval.centralityBlend && graph) {
437
+ centrality = require('../graph/centrality').computeCentrality(graph);
438
+ }
435
439
  } catch (_) {}
436
- const results = rank(args.query, index, { topK, cwd, graph, callGraph });
440
+ const results = rank(args.query, index, { topK, cwd, graph, callGraph, centrality });
437
441
  return formatRankTable(results, args.query);
438
442
  } catch (err) {
439
443
  return `_query_context failed: ${err.message}_`;
package/src/mcp/server.js CHANGED
@@ -18,7 +18,7 @@ const { readContext, searchSignatures, getMap, createCheckpoint, getRouting, exp
18
18
 
19
19
  const SERVER_INFO = {
20
20
  name: 'sigmap',
21
- version: '8.20.0',
21
+ version: '8.22.0',
22
22
  description: 'SigMap MCP server — code signatures on demand',
23
23
  };
24
24
 
@@ -40,6 +40,9 @@ const GRAPH_BOOST_AMOUNTS = {
40
40
  callHop: 0.30, // call-graph file neighbor (opt-in retrieval.callGraphBoost)
41
41
  };
42
42
 
43
+ // Max additive prior for import-graph centrality (opt-in retrieval.centralityBlend)
44
+ const CENTRALITY_BLEND_WEIGHT = 0.3;
45
+
43
46
  // Intent-specific weight adjustments
44
47
  const INTENT_WEIGHTS = {
45
48
  search: DEFAULT_WEIGHTS,
@@ -172,6 +175,8 @@ function scoreFile(filePath, sigs, queryTokens, weights) {
172
175
  * @param {{ forward: Map<string,string[]> }} [opts.graph] - dependency graph for neighbor boost
173
176
  * @param {{ forward: Map<string,string[]> }} [opts.callGraph] - file-level call-graph edges
174
177
  * (from buildCallFileGraph) for the opt-in call-neighbor boost
178
+ * @param {Map<string,number>} [opts.centrality] - absolute file → normalized
179
+ * centrality (from computeCentrality) for the opt-in centrality blend
175
180
  * @returns {{ file: string, score: number, sigs: string[], tokens: number, intent: string, signals: object }[]}
176
181
  */
177
182
  function rank(query, sigIndex, opts) {
@@ -320,6 +325,26 @@ function rank(query, sigIndex, opts) {
320
325
  }
321
326
  }
322
327
 
328
+ // Centrality blend (opt-in via retrieval.centralityBlend): a small additive
329
+ // prior from import-graph centrality so heavily-referenced files rank above
330
+ // one-off helpers on ambiguous queries. Applied only to positively-scored
331
+ // files — a tie-breaker among matches, never a way to surface non-matches.
332
+ const centrality = (opts && opts.centrality instanceof Map && opts.centrality.size > 0) ? opts.centrality : null;
333
+ if (centrality && cwd) {
334
+ const path = require('path');
335
+ for (const entry of scored) {
336
+ if (entry.score <= 0) continue;
337
+ const abs = path.resolve(cwd, entry.file);
338
+ // The graph builder lowercases paths (normalizePath) — probe both forms.
339
+ const c = centrality.get(abs) || centrality.get(abs.toLowerCase());
340
+ if (c) {
341
+ const bonus = CENTRALITY_BLEND_WEIGHT * c;
342
+ entry.score += bonus;
343
+ entry.signals.centrality = bonus;
344
+ }
345
+ }
346
+ }
347
+
323
348
  // Compute confidence levels based on score distribution
324
349
  if (scored.length > 0) {
325
350
  const scores = scored.map(s => s.score);
@@ -595,4 +620,4 @@ function detectIntent(query) {
595
620
  return 'search';
596
621
  }
597
622
 
598
- module.exports = { rank, buildSigIndex, scoreFile, formatRankTable, formatRankJSON, DEFAULT_WEIGHTS, GRAPH_BOOST_AMOUNTS, detectIntent };
623
+ module.exports = { rank, buildSigIndex, scoreFile, formatRankTable, formatRankJSON, DEFAULT_WEIGHTS, GRAPH_BOOST_AMOUNTS, CENTRALITY_BLEND_WEIGHT, detectIntent };
@@ -20,6 +20,8 @@ const PRICES = {
20
20
  'gpt-4o-mini': 0.15,
21
21
  'gemini-1.5-pro': 1.25,
22
22
  'gemini-1.5-flash': 0.075,
23
+ 'minimax-m3': 0.6,
24
+ 'minimax-m2.7': 0.3,
23
25
  };
24
26
 
25
27
  const DEFAULT_MODEL = 'claude-sonnet';