pi-supernova 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -33,9 +33,12 @@ const EVIDENCE_DEFAULTS = {
33
33
  };
34
34
 
35
35
  const IDENT = /[A-Za-z_$][\w$]*/g;
36
+
36
37
  // Verb forms only: "call sites" is a concept, "who calls X" is a usage question.
37
38
  const RELATION_WORDS = new Set(["calls", "caller", "callers", "uses", "usages", "used", "using", "imports", "imported", "depends", "references", "referenced", "invokes", "invoked"]);
39
+
38
40
  const HUB_FRACTION = 0.25;
41
+
39
42
  const HUB_MIN = 8;
40
43
 
41
44
  export { stem } from "./snap.js";
@@ -52,6 +55,7 @@ export function profileQuery(query) {
52
55
  const subjects = words.filter((w) => /[a-z][A-Z]|_/.test(w));
53
56
  const usage = words.some((w) => RELATION_WORDS.has(w.toLowerCase()));
54
57
  const relational = usage || subjects.length > 0;
58
+
55
59
  return {
56
60
  subjects: [...new Set(subjects)],
57
61
  keywords: tokens,
@@ -68,9 +72,11 @@ function spansOf(entry, filePath, maxSpanLines) {
68
72
  const lines = WorkspaceIndex.linesOf(entry);
69
73
  const base = { path: filePath, entry, lower: lines.lower, lines };
70
74
  const declared = WorkspaceIndex.spansOf(entry);
75
+
71
76
  if (declared.length === 0) {
72
77
  return [{ ...base, id: filePath + ":1", start: 1, end: Math.min(lines.raw.length, maxSpanLines), name: path.basename(filePath), kind: "file", sourceEnd: lines.raw.length }];
73
78
  }
79
+
74
80
  return declared.map((s, i) => ({ ...base, ...s, sourceEnd: s.end, id: filePath + ":" + s.start, end: Math.min(s.end, s.start + maxSpanLines - 1), index: i }));
75
81
  }
76
82
 
@@ -84,10 +90,12 @@ function buildGraph(spans) {
84
90
  const entityNames = new Set(spans.map((s) => s.name).filter((n) => n && n.length > 2));
85
91
  const spanEntities = new Map(); // span.id → Map(entity → w(d,e))
86
92
  const entitySpans = new Map(); // entity → Set(span.id)
93
+
87
94
  for (const span of spans) {
88
95
  const counts = new Map();
89
96
  let total = 0;
90
97
  const { idents } = span.lines;
98
+
91
99
  for (let li = span.start - 1; li < span.end; li++) {
92
100
  for (const word of idents[li]) {
93
101
  if (!entityNames.has(word)) continue;
@@ -95,17 +103,27 @@ function buildGraph(spans) {
95
103
  total += 1;
96
104
  }
97
105
  }
106
+
98
107
  const weights = new Map();
108
+
99
109
  for (const [e, c] of counts) {
100
110
  weights.set(e, c / total); // eq.4
111
+
101
112
  if (!entitySpans.has(e)) entitySpans.set(e, new Set());
102
113
  entitySpans.get(e).add(span.id);
103
114
  }
115
+
104
116
  spanEntities.set(span.id, weights);
105
117
  }
118
+
106
119
  // Entities present in a large share of spans (isString, path, …) carry no query signal; keep them out of propagation.
107
120
  const hubLimit = Math.max(HUB_MIN, Math.floor(spans.length * HUB_FRACTION));
108
- const hubs = new Set([...entitySpans].filter(([, ids]) => ids.size > hubLimit).map(([e]) => e));
121
+ const hubs = new Set();
122
+
123
+ for (const [entity, ids] of entitySpans) {
124
+ if (ids.size > hubLimit) hubs.add(entity);
125
+ }
126
+
109
127
  return { entityNames, spanEntities, entitySpans, hubs, byId: new Map(spans.map((s) => [s.id, s])) };
110
128
  }
111
129
 
@@ -114,33 +132,43 @@ function buildGraph(spans) {
114
132
  function lexicalSim(a, b) {
115
133
  const ta = new Set(splitIdentifier(a));
116
134
  const tb = new Set(splitIdentifier(b));
135
+
117
136
  if (ta.size === 0 || tb.size === 0) return 0;
118
137
  let inter = 0;
138
+
119
139
  for (const t of ta) if (tb.has(t)) inter++;
140
+
120
141
  return inter / (ta.size + tb.size - inter);
121
142
  }
122
143
 
123
144
  function activateEntities(profile, graph) {
124
145
  const eta = new Map();
125
146
  const anchors = profile.subjects.length ? profile.subjects : profile.keywords;
147
+
126
148
  for (const anchor of anchors) {
127
149
  let best = null;
128
150
  let bestSim = 0;
151
+
129
152
  for (const e of graph.entityNames) {
130
153
  const sim = e.toLowerCase() === anchor.toLowerCase() ? 1 : lexicalSim(e, anchor);
154
+
131
155
  if (sim > bestSim) {
132
156
  bestSim = sim;
133
157
  best = e;
134
158
  }
135
159
  }
160
+
136
161
  if (best && bestSim >= 0.5) eta.set(best, Math.max(eta.get(best) || 0, bestSim)); // eq.8
137
162
  }
163
+
138
164
  return eta;
139
165
  }
140
166
 
141
167
  function querySim(profile, lowerLine) {
142
168
  let hits = 0;
169
+
143
170
  for (const t of profile.stems) if (lowerLine.includes(t)) hits++;
171
+
144
172
  return profile.stems.length ? hits / profile.stems.length : 0;
145
173
  }
146
174
 
@@ -155,10 +183,12 @@ function activateCooccurring(e, line, weight, spanId, graph, eta1) {
155
183
 
156
184
  function propagateFrom(e, act, graph, profile, eta1) {
157
185
  const eLower = e.toLowerCase();
186
+
158
187
  for (const spanId of graph.entitySpans.get(e) || []) {
159
188
  for (const line of spanLines(graph.byId.get(spanId))) {
160
189
  if (!line.includes(eLower)) continue;
161
190
  const sim = querySim(profile, line);
191
+
162
192
  if (sim > 0) activateCooccurring(e, line, act * sim, spanId, graph, eta1);
163
193
  }
164
194
  }
@@ -166,7 +196,9 @@ function propagateFrom(e, act, graph, profile, eta1) {
166
196
 
167
197
  function propagate(eta0, spans, graph, profile) {
168
198
  const eta1 = new Map(eta0);
199
+
169
200
  for (const [e, act] of eta0) propagateFrom(e, act, graph, profile, eta1);
201
+
170
202
  return eta1;
171
203
  }
172
204
 
@@ -176,13 +208,17 @@ function resetDistribution(spans, graph, eta, prior) {
176
208
  const n = spans.length;
177
209
  const reset = new Float64Array(n);
178
210
  let sum = 0;
211
+
179
212
  for (let i = 0; i < n; i++) {
180
213
  let r = prior[i];
214
+
181
215
  for (const [e, w] of graph.spanEntities.get(spans[i].id)) if (!graph.hubs.has(e)) r += (eta.get(e) || 0) * w;
182
216
  reset[i] = r;
183
217
  sum += r;
184
218
  }
219
+
185
220
  if (sum > 0) for (let i = 0; i < n; i++) reset[i] /= sum;
221
+
186
222
  return { reset, sum };
187
223
  }
188
224
 
@@ -192,60 +228,83 @@ function resetDistribution(spans, graph, eta, prior) {
192
228
  */
193
229
  function transitionStructure(spans, graph) {
194
230
  const n = spans.length;
195
- const entities = [...graph.entitySpans].filter(([e, ids]) => ids.size >= 2 && !graph.hubs.has(e)).map(([e]) => e);
231
+ const entities = [];
232
+
233
+ for (const [entity, ids] of graph.entitySpans) {
234
+ if (ids.size >= 2 && !graph.hubs.has(entity)) entities.push(entity);
235
+ }
236
+
196
237
  const eIndex = new Map(entities.map((e, i) => [e, i]));
238
+
197
239
  const spanTerms = spans.map((s) => {
198
240
  const terms = [];
241
+
199
242
  for (const [e, w] of graph.spanEntities.get(s.id)) if (eIndex.has(e)) terms.push([eIndex.get(e), w]);
243
+
200
244
  return terms;
201
245
  });
246
+
202
247
  const entityMass = new Float64Array(entities.length);
248
+
203
249
  for (let i = 0; i < n; i++) for (const [ei, w] of spanTerms[i]) entityMass[ei] += w;
204
250
  const neighbours = spans.map((s, i) => [i - 1, i + 1].filter((j) => j >= 0 && j < n && spans[j].path === s.path));
205
251
  const outWeight = new Float64Array(n);
252
+
206
253
  for (let i = 0; i < n; i++) {
207
254
  let out = 0.5 * neighbours[i].length;
255
+
208
256
  for (const [ei, w] of spanTerms[i]) out += w * (entityMass[ei] - w);
209
257
  outWeight[i] = out;
210
258
  }
259
+
211
260
  return { spanTerms, entityCount: entities.length, neighbours, outWeight };
212
261
  }
213
262
 
214
263
  function pushStep(pi, next, acc, structure, gamma) {
215
264
  const { spanTerms, neighbours, outWeight } = structure;
216
265
  let dangling = 0;
266
+
217
267
  for (let i = 0; i < pi.length; i++) {
218
268
  if (outWeight[i] === 0) {
219
269
  dangling += pi[i];
220
270
  continue;
221
271
  }
272
+
222
273
  const flow = (gamma * pi[i]) / outWeight[i];
274
+
223
275
  for (const [ei, w] of spanTerms[i]) {
224
276
  acc[ei] += flow * w;
225
277
  next[i] -= flow * w * w; // remove the d → d self term
226
278
  }
279
+
227
280
  for (const j of neighbours[i]) next[j] += flow * 0.5;
228
281
  }
282
+
229
283
  return dangling;
230
284
  }
231
285
 
232
286
  function pageRank(spans, graph, eta, prior, { gamma, pprIterations }) {
233
287
  const n = spans.length;
234
288
  const { reset, sum } = resetDistribution(spans, graph, eta, prior);
289
+
235
290
  if (sum === 0) return reset;
236
291
  const structure = transitionStructure(spans, graph);
237
292
  const acc = new Float64Array(structure.entityCount);
238
293
  let pi = Float64Array.from(reset);
294
+
239
295
  for (let iter = 0; iter < pprIterations; iter++) {
240
296
  const next = new Float64Array(n);
241
297
  acc.fill(0);
242
298
  const dangling = pushStep(pi, next, acc, structure, gamma);
299
+
243
300
  for (let i = 0; i < n; i++) {
244
301
  for (const [ei, w] of structure.spanTerms[i]) next[i] += acc[ei] * w;
245
302
  next[i] += (1 - gamma) * reset[i] + gamma * dangling * reset[i]; // dangling mass follows the reset
246
303
  }
304
+
247
305
  pi = next;
248
306
  }
307
+
249
308
  return pi;
250
309
  }
251
310
 
@@ -255,7 +314,9 @@ function nameDefinitionScore(span, profile, usage) {
255
314
  if (usage) return 0; // eq.15 Rank_ϕ: a usage question is answered by callers, not the definer
256
315
  const nameTokens = splitIdentifier(span.name || "");
257
316
  let def = 0;
317
+
258
318
  for (const t of profile.stems) if (nameTokens.some((n) => n.startsWith(t))) def += 40;
319
+
259
320
  return def;
260
321
  }
261
322
 
@@ -265,20 +326,26 @@ function mentionScore(span, profile, usage, skipDeclaration) {
265
326
  let bestLine = span.start;
266
327
  let bestHits = 0;
267
328
  const lines = spanLines(span);
329
+
268
330
  for (let i = skipDeclaration ? 1 : 0; i < lines.length; i++) {
269
331
  let hits = 0;
332
+
270
333
  for (const t of profile.stems) if (lines[i].includes(t)) hits++;
334
+
271
335
  if (hits > bestHits) {
272
336
  bestHits = hits;
273
337
  bestLine = span.start + i;
274
338
  }
339
+
275
340
  mentions += hits * hits * perHit; // several query stems on one line is strong evidence
276
341
  }
342
+
277
343
  return { mentions, bestLine };
278
344
  }
279
345
 
280
346
  function hierarchicalScores(spans, fileScores, profile) {
281
347
  const usage = profile.answerType === "usage";
348
+
282
349
  return spans.map((span) => {
283
350
  const definesSubject = profile.subjects.includes(span.name);
284
351
  const def = nameDefinitionScore(span, profile, usage);
@@ -286,6 +353,7 @@ function hierarchicalScores(spans, fileScores, profile) {
286
353
  span.bestLine = bestLine;
287
354
  span.support = def + mentions; // span-level lexical evidence; file-level bonuses do not count
288
355
  const fileScore = Math.max(0, fileScores.get(span.path) || 0);
356
+
289
357
  return fileScore / 2 + def + Math.min(mentions, usage ? 200 : 120) + (span.isExport ? 10 : 0);
290
358
  });
291
359
  }
@@ -295,11 +363,15 @@ function hierarchicalScores(spans, fileScores, profile) {
295
363
  function normalize(scores) {
296
364
  let min = Infinity;
297
365
  let max = -Infinity;
366
+
298
367
  for (const s of scores) {
299
368
  if (s < min) min = s;
369
+
300
370
  if (s > max) max = s;
301
371
  }
372
+
302
373
  if (!(max > min)) return scores.map(() => 1);
374
+
303
375
  return scores.map((s) => (s - min) / (max - min));
304
376
  }
305
377
 
@@ -307,26 +379,36 @@ function normalize(scores) {
307
379
 
308
380
  function candidateFiles(files, profile, index, limit, overlayText) {
309
381
  const scored = [];
382
+
310
383
  for (const f of files) {
311
384
  const s = scorePathTopology(f, profile.keywords, profile.flags);
385
+
312
386
  if (s > 0) scored.push({ f, s });
313
387
  }
388
+
314
389
  scored.sort((a, b) => b.s - a.s);
315
390
  const chosen = new Set();
316
391
  const anchors = (profile.subjects.length ? profile.subjects : profile.keywords).map((a) => a.toLowerCase()).filter((a) => a.length > 2);
392
+
317
393
  const pendingHits = files.filter(file => {
318
394
  const pending = overlayText(file);
395
+
319
396
  return pending !== undefined && anchors.some(anchor => pending.toLowerCase().includes(anchor));
320
397
  });
398
+
321
399
  const hits = anchors.length ? [...new Set([...pendingHits, ...index.filesContaining(files, anchors, true)])] : [];
400
+
322
401
  for (const f of hits) {
323
402
  if (chosen.size >= limit) break;
403
+
324
404
  if (profile.flags.wantsTest || scorePathTopology(f, profile.keywords, profile.flags) > -50) chosen.add(f);
325
405
  }
406
+
326
407
  for (const { f } of scored) {
327
408
  if (chosen.size >= limit) break;
328
409
  chosen.add(f);
329
410
  }
411
+
330
412
  return { files: [...chosen], fileScores: new Map(scored.map(({ f, s }) => [f, s])) };
331
413
  }
332
414
 
@@ -335,13 +417,17 @@ function candidateFiles(files, profile, index, limit, overlayText) {
335
417
  function bridgesFor(i, spans, graph, fused, definers, chosen) {
336
418
  const byId = graph.byId;
337
419
  const bridges = [];
420
+
338
421
  for (const [e, w] of graph.spanEntities.get(spans[i].id)) {
339
422
  const defId = definers.get(e);
423
+
340
424
  if (!defId || graph.hubs.has(e) || chosen.has(defId) || defId === spans[i].id || w < 0.15) continue;
341
425
  const definer = byId.get(defId);
426
+
342
427
  if (definer.end - definer.start < 2) continue; // one-line helpers add no understanding
343
428
  bridges.push({ id: defId, w: w * fused[definer.index0] });
344
429
  }
430
+
345
431
  return bridges.sort((a, b) => b.w - a.w).slice(0, 2);
346
432
  }
347
433
 
@@ -349,35 +435,45 @@ function closure(main, spans, graph, fused, k) {
349
435
  spans.forEach((s, i) => (s.index0 = i));
350
436
  const chosen = new Map(main.map((i) => [spans[i].id, { i, why: "main" }]));
351
437
  const definers = new Map();
438
+
352
439
  for (const s of spans) if (s.name) definers.set(s.name, s.id);
353
440
  const neighboursOf = (i) => [i - 1, i + 1].filter((j) => j >= 0 && j < spans.length && spans[j].path === spans[i].path);
441
+
354
442
  for (const i of main) {
355
443
  // Ng: spans that define identifiers this span uses (relational bridges).
356
444
  for (const b of bridgesFor(i, spans, graph, fused, definers, chosen)) chosen.set(b.id, { i: graph.byId.get(b.id).index0, why: "bridge" });
445
+
357
446
  // Nh: in-file neighbours that still carry query signal.
358
447
  for (const j of neighboursOf(i)) {
359
448
  if (fused[j] > 0.2 && !chosen.has(spans[j].id)) chosen.set(spans[j].id, { i: j, why: "neighbor" });
360
449
  }
361
450
  }
451
+
362
452
  const supports = [...chosen.values()].filter((c) => c.why !== "main").sort((a, b) => fused[b.i] - fused[a.i]).slice(0, k);
453
+
363
454
  return [...main.map((i) => ({ i, why: "main" })), ...supports];
364
455
  }
365
456
 
366
457
  function render(spans, picks, fused, opts, root) {
367
458
  const out = [];
368
459
  let budget = opts.maxChars;
460
+
369
461
  for (const { i, why } of picks) {
370
462
  const span = spans[i];
371
463
  const lines = span.lines.raw.slice(span.start - 1, span.end);
372
464
  let text = lines.join("\n");
465
+
373
466
  if (text.length > budget) {
374
467
  const end = text.lastIndexOf("\n", budget);
468
+
375
469
  if (end < 0) {
376
470
  if (out.length) break;
377
471
  throw new Error("evidence source line exceeds maxChars; increase the budget or read the file directly");
378
472
  }
473
+
379
474
  text = text.slice(0, end);
380
475
  }
476
+
381
477
  const lastLine = span.start + text.split("\n").length - 1;
382
478
  const truncated = lastLine < span.sourceEnd;
383
479
  budget -= text.length;
@@ -391,8 +487,10 @@ function render(spans, picks, fused, opts, root) {
391
487
  why,
392
488
  text,
393
489
  });
490
+
394
491
  if (budget <= 0) break;
395
492
  }
493
+
396
494
  return out;
397
495
  }
398
496
 
@@ -403,23 +501,31 @@ function render(spans, picks, fused, opts, root) {
403
501
  export async function selectEvidence({ query, root, searchDir, index, overlayText = () => undefined, pendingPaths = [], options = {} }) {
404
502
  const opts = { ...EVIDENCE_DEFAULTS, ...options };
405
503
  const profile = profileQuery(query);
504
+
406
505
  if (profile.keywords.length === 0) throw new Error("evidence requires at least one searchable concept keyword");
407
506
  const searchRoot = path.resolve(searchDir || root);
507
+
408
508
  const staged = pendingPaths.filter(file => {
409
509
  const relative = path.relative(searchRoot, file);
510
+
410
511
  return relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative);
411
512
  });
513
+
412
514
  const files = [...new Set([...await index.files(searchRoot), ...staged])];
515
+
413
516
  if (files.length === 0) throw new Error(`no files found to search in ${searchDir || root}`);
414
517
 
415
518
  const { files: chosenFiles, fileScores } = candidateFiles(files, profile, index, opts.maxCandidateFiles, overlayText);
416
519
  const spans = [];
520
+
417
521
  for (const f of chosenFiles) {
418
522
  const pending = overlayText(f);
419
523
  const entry = pending === undefined ? index.entry(f) : WorkspaceIndex.fromText(f, pending);
524
+
420
525
  if (!entry) continue;
421
526
  spans.push(...spansOf(entry, f, opts.maxSpanLines));
422
527
  }
528
+
423
529
  if (spans.length === 0) return { route: profile.route, spans: [] };
424
530
 
425
531
  const graph = buildGraph(spans);
@@ -434,16 +540,24 @@ export async function selectEvidence({ query, root, searchDir, index, overlayTex
434
540
 
435
541
  // eq.15 Filter: boundary/type hard constraints and lexical support; Rank_ϕ: answer-type compatibility.
436
542
  const usage = profile.answerType === "usage";
437
- const admissible = spans.map((s, i) => i).filter((i) => {
438
- const p = spans[i].path;
543
+
544
+ const admissible = spans.flatMap((span, i) => {
545
+ const p = span.path;
439
546
  const isDoc = /\.(md|mdx|rst|txt)$/i.test(p);
440
- return spans[i].support > 0 && (profile.flags.wantsTest || !isTestPath(p)) && (profile.flags.wantsDoc || !isDoc);
547
+
548
+ return span.support > 0
549
+ && (profile.flags.wantsTest || !isTestPath(p))
550
+ && (profile.flags.wantsDoc || !isDoc)
551
+ ? [i] : [];
441
552
  });
553
+
442
554
  for (const i of admissible) {
443
555
  if (usage && profile.subjects.includes(spans[i].name)) fused[i] *= 0.5; // a usage question is answered by callers
444
556
  }
557
+
445
558
  const ranked = admissible.sort((a, b) => fused[b] - fused[a] || spans[a].path.localeCompare(spans[b].path) || spans[a].start - spans[b].start);
446
559
  const main = ranked.slice(0, opts.k);
447
560
  const picks = closure(main, spans, graph, fused, opts.k);
561
+
448
562
  return { route: profile.route, spans: render(spans, picks, fused, opts, root) };
449
563
  }
@@ -11,8 +11,11 @@
11
11
  // - distance penalty from the current (last touched) file: −1 per directory hop, floor −20
12
12
 
13
13
  const AI_DECAY = Math.LN2 / 3; // per day
14
+
14
15
  const AI_MAX_HISTORY_DAYS = 7;
16
+
15
17
  const MAX_TIMESTAMPS_PER_FILE = 128;
18
+
16
19
  const AI_MODIFICATION_THRESHOLDS = [[16, 30], [8, 300], [4, 900], [2, 3600], [1, 14400]]; // [boost, seconds]
17
20
 
18
21
  export class Frecency {
@@ -22,8 +25,10 @@ export class Frecency {
22
25
 
23
26
  record(filePath, at = Date.now() / 1000) {
24
27
  let list = this.access.get(filePath);
28
+
25
29
  if (!list) this.access.set(filePath, (list = []));
26
30
  list.push(at);
31
+
27
32
  if (list.length > MAX_TIMESTAMPS_PER_FILE) list.splice(0, list.length - MAX_TIMESTAMPS_PER_FILE);
28
33
  }
29
34
 
@@ -31,12 +36,15 @@ export class Frecency {
31
36
  score(filePath, mtimeSec, now = Date.now() / 1000) {
32
37
  let total = 0;
33
38
  const cutoff = now - AI_MAX_HISTORY_DAYS * 86400;
39
+
34
40
  for (const t of this.access.get(filePath) || []) {
35
41
  if (t < cutoff) continue;
36
42
  total += Math.exp(-AI_DECAY * ((now - t) / 86400));
37
43
  }
44
+
38
45
  if (mtimeSec) {
39
46
  const age = now - mtimeSec;
47
+
40
48
  for (const [boost, seconds] of AI_MODIFICATION_THRESHOLDS) {
41
49
  if (age <= seconds) {
42
50
  total += boost;
@@ -44,6 +52,7 @@ export class Frecency {
44
52
  }
45
53
  }
46
54
  }
55
+
47
56
  return total;
48
57
  }
49
58
  }
@@ -53,8 +62,10 @@ const SEPARATORS = new Set(["/", "\\", "_", "-", ".", " "]);
53
62
  function isBoundary(hay, i) {
54
63
  if (i === 0) return true;
55
64
  const prev = hay[i - 1];
65
+
56
66
  if (SEPARATORS.has(prev)) return true;
57
67
  const c = hay[i];
68
+
58
69
  return c >= "A" && c <= "Z" && !(prev >= "A" && prev <= "Z");
59
70
  }
60
71
 
@@ -67,18 +78,24 @@ function matchOnce(needle, hay, caseSensitive) {
67
78
  const nCmp = caseSensitive ? needle : needle.toLowerCase();
68
79
  let hi = 0;
69
80
  let firstAt = -1;
81
+
70
82
  for (let ni = 0; ni < nCmp.length; ni++) {
71
83
  hi = hayCmp.indexOf(nCmp[ni], hi);
84
+
72
85
  if (hi < 0) return null;
86
+
73
87
  if (firstAt < 0) firstAt = hi;
74
88
  hi++;
75
89
  }
90
+
76
91
  const end = hi;
77
92
  // Tighten: walk backwards from end to find the latest possible start.
78
93
  let start = end;
94
+
79
95
  for (let ni = nCmp.length - 1; ni >= 0; ni--) {
80
96
  start = hayCmp.lastIndexOf(nCmp[ni], start - 1);
81
97
  }
98
+
82
99
  return { score: scoreAlignment(needle, nCmp, hay, hayCmp, start), start, end };
83
100
  }
84
101
 
@@ -87,6 +104,7 @@ function scoreAlignment(needle, nCmp, hay, hayCmp, start) {
87
104
  let score = 0;
88
105
  let prev = -2;
89
106
  let cursor = start;
107
+
90
108
  for (let ni = 0; ni < nCmp.length; ni++) {
91
109
  const at = hayCmp.indexOf(nCmp[ni], cursor);
92
110
  score += isBoundary(hay, at) ? 16 : 0;
@@ -96,22 +114,29 @@ function scoreAlignment(needle, nCmp, hay, hayCmp, start) {
96
114
  prev = at;
97
115
  cursor = at + 1;
98
116
  }
117
+
99
118
  return score;
100
119
  }
101
120
 
102
121
  /** Best match allowing up to maxTypos skipped needle characters. */
103
122
  export function fuzzyMatch(needle, hay, { maxTypos = 0, caseSensitive = false } = {}) {
104
123
  const direct = matchOnce(needle, hay, caseSensitive);
124
+
105
125
  if (direct) return { ...direct, typos: 0, exact: hay.toLowerCase() === needle.toLowerCase() };
126
+
106
127
  if (maxTypos <= 0 || needle.length < 3) return null;
107
128
  let best = null;
129
+
108
130
  for (let i = 0; i < needle.length; i++) {
109
131
  const shorter = needle.slice(0, i) + needle.slice(i + 1);
110
132
  const m = fuzzyMatch(shorter, hay, { maxTypos: maxTypos - 1, caseSensitive });
133
+
111
134
  if (!m) continue;
112
135
  const scored = { ...m, score: m.score - 12, typos: m.typos + 1, exact: false };
136
+
113
137
  if (!best || scored.score > best.score) best = scored;
114
138
  }
139
+
115
140
  return best;
116
141
  }
117
142
 
@@ -125,8 +150,10 @@ function distancePenalty(currentDir, candidateDir) {
125
150
  const a = currentDir.split("/").filter(Boolean);
126
151
  const b = candidateDir.split("/").filter(Boolean);
127
152
  let common = 0;
153
+
128
154
  while (common < a.length && common < b.length && a[common] === b[common]) common++;
129
155
  const depth = a.length - common;
156
+
130
157
  return Math.max(-20, -depth);
131
158
  }
132
159
 
@@ -136,20 +163,25 @@ function distancePenalty(currentDir, candidateDir) {
136
163
  */
137
164
  export function rankPaths(query, paths, ctx = {}) {
138
165
  const parts = query.trim().split(/\s+/).filter((p) => p.length >= 2);
166
+
139
167
  if (parts.length === 0) return [];
140
168
  const caseSensitive = smartCase(query);
141
169
  const maxTypos = ctx.maxTypos ?? (parts[0].length >= 6 ? 2 : parts[0].length >= 4 ? 1 : 0);
142
170
  const currentDir = ctx.currentFile ? ctx.currentFile.slice(0, ctx.currentFile.lastIndexOf("/") + 1) : "";
143
171
  const out = [];
172
+
144
173
  for (const rel of paths) {
145
174
  const matched = matchParts(parts, rel, maxTypos, caseSensitive);
175
+
146
176
  if (!matched) continue;
147
177
  const { base, first, exact } = matched;
148
178
  const filenameStart = rel.lastIndexOf("/") + 1;
149
179
  const boosts = filenameBonus(base, rel, filenameStart, first, parts[0]) + contextBoost(base, rel, ctx) + distancePenalty(currentDir, rel.slice(0, filenameStart));
150
180
  out.push({ path: rel, score: base + boosts, exact, typos: first.typos });
151
181
  }
182
+
152
183
  out.sort((a, b) => b.score - a.score || a.path.length - b.path.length || a.path.localeCompare(b.path));
184
+
153
185
  return out;
154
186
  }
155
187
 
@@ -158,19 +190,23 @@ function matchParts(parts, rel, maxTypos, caseSensitive) {
158
190
  let sum = 0;
159
191
  let first = null;
160
192
  let exact = true;
193
+
161
194
  for (let pi = 0; pi < parts.length; pi++) {
162
195
  const m = fuzzyMatch(parts[pi], rel, { maxTypos: pi === 0 ? maxTypos : Math.min(maxTypos, 1), caseSensitive });
196
+
163
197
  if (!m) return null;
164
198
  first ??= m;
165
199
  sum += m.score;
166
200
  exact = exact && m.exact;
167
201
  }
202
+
168
203
  return { base: Math.max(1, Math.round(sum / parts.length)), first, exact };
169
204
  }
170
205
 
171
206
  /** fff: exact filename +40% of base, any filename match +20%. */
172
207
  function filenameBonus(base, rel, filenameStart, first, needle) {
173
208
  if (first.start < filenameStart) return 0;
209
+
174
210
  return rel.slice(filenameStart).toLowerCase() === needle.toLowerCase() ? Math.floor((base * 2) / 5) : Math.floor(base / 5);
175
211
  }
176
212
 
@@ -178,5 +214,6 @@ function filenameBonus(base, rel, filenameStart, first, needle) {
178
214
  function contextBoost(base, rel, ctx) {
179
215
  const frecency = ctx.frecency ? ctx.frecency.score(rel, ctx.mtimeOf?.(rel)) : 0;
180
216
  const gitBoost = ctx.modified?.has(rel) ? Math.floor((base * 15) / 100) : 0;
217
+
181
218
  return Math.floor((base * frecency) / 100) + gitBoost;
182
219
  }