@aburi/diff 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/match.ts CHANGED
@@ -1,6 +1,7 @@
1
- import type { Symbol as IRSymbol, MatchRationale } from "@aburi/types"
1
+ import { trySymbolId } from "@aburi/core"
2
+ import type { Symbol as IRSymbol, MatchRationale, SymbolId } from "@aburi/types"
2
3
  import { signatureSimilarity } from "./signature"
3
- import { lastSegment, nameSimilarity, ownerSimilarity, tokenizeName } from "./similarity"
4
+ import { createNameScorer, lastSegment, type NameScorer, tokenizeName } from "./similarity"
4
5
 
5
6
  /**
6
7
  * A concluded pairing of one base Symbol with one head Symbol, along with the
@@ -19,9 +20,89 @@ export type GitRenameMap = ReadonlyMap<string, string>
19
20
  /** Fingerprint for the 12-hex-char "everything zero" (i.e. dropped) canonical hash. */
20
21
  const ZERO_LOGIC_FP = "000000000000"
21
22
 
23
+ /** §3.3 — the similarity a multi-candidate logic-fingerprint group must reach to pair. */
24
+ const NAME_DISAMBIGUATION_THRESHOLD = 0.85
25
+
26
+ /** One candidate pairing and the score that ranks it against the others. */
27
+ interface ScoredPair {
28
+ base: IRSymbol
29
+ head: IRSymbol
30
+ score: number
31
+ }
32
+
33
+ /** What one sweep settled: the pairings, and the ids each side spent on them. */
34
+ interface Assignment {
35
+ pairs: readonly ScoredPair[]
36
+ baseIds: ReadonlySet<SymbolId>
37
+ headIds: ReadonlySet<SymbolId>
38
+ }
39
+
40
+ /**
41
+ * §3.8 — accept candidate pairings highest score first, taking each base and each head at
42
+ * most once. Stages 3, 4 and 4.5 all choose from a set of possible pairings, and all three
43
+ * used to decide one head at a time — which let an earlier head consume a base that was a
44
+ * later head's exact match, and reported the loser as `added` beside the pair it lost.
45
+ *
46
+ * The two id sets come back with the pairings because every caller needs them to work out
47
+ * what is left over, and deriving them again at the call site is a chance to derive the
48
+ * wrong one: reading the head ids out as the base ids typechecks, and reproduces the very
49
+ * symptom this exists to prevent.
50
+ *
51
+ * Not free, and §8.2 carries the bound: `candidates` is every pairing that clears its
52
+ * threshold, so this holds O(base × head) records for stages 3 and 4 in the worst case where
53
+ * the per-head loop it replaced held one, and sorting them is O(C log C). Stage 4 buys that
54
+ * back and more with `createNameScorer`.
55
+ *
56
+ * It is not an optimal assignment — a greedy sweep can still strand a pair whose partners
57
+ * were both taken by higher-scoring ones — but it never passes over the best available
58
+ * pairing, which is the case that shows up in a review as one symbol appearing twice.
59
+ *
60
+ * Stage 4.5 does not call this, because that licence is what it cannot borrow: §3.4.5's
61
+ * candidates are all worth the same, so no pairing is the best available and stranding one
62
+ * buys nothing. See `bestPairing`.
63
+ */
64
+ function acceptInScoreOrder(candidates: ScoredPair[]): Assignment {
65
+ candidates.sort(compareCandidates)
66
+ const baseIds = new Set<SymbolId>()
67
+ const headIds = new Set<SymbolId>()
68
+ const pairs: ScoredPair[] = []
69
+ for (const candidate of candidates) {
70
+ if (baseIds.has(candidate.base.id) || headIds.has(candidate.head.id)) continue
71
+ baseIds.add(candidate.base.id)
72
+ headIds.add(candidate.head.id)
73
+ pairs.push(candidate)
74
+ }
75
+ return { pairs, baseIds, headIds }
76
+ }
77
+
78
+ /**
79
+ * Highest score first, then `(base.id, head.id)` ascending. The two id keys are what make
80
+ * the order total, and they are a total order only because ids are unique within a Document
81
+ * (ir-schema.md §14 #1), which `buildDiff` establishes before the first stage runs. Without
82
+ * them equal scores resolve to enumeration order, and the diff stops being a function of the
83
+ * two Documents.
84
+ */
85
+ function compareCandidates(a: ScoredPair, b: ScoredPair): number {
86
+ if (a.score !== b.score) return b.score - a.score
87
+ if (a.base.id !== b.base.id) return a.base.id < b.base.id ? -1 : 1
88
+ return a.head.id < b.head.id ? -1 : a.head.id > b.head.id ? 1 : 0
89
+ }
90
+
91
+ /** The entries of `symbols` that no accepted pairing claimed, in their original order. */
92
+ function unclaimed(symbols: readonly IRSymbol[], claimed: ReadonlySet<SymbolId>): IRSymbol[] {
93
+ return symbols.filter((symbol) => !claimed.has(symbol.id))
94
+ }
95
+
22
96
  /**
23
97
  * §3.1 — segments Symbols with identical `id` into paired matches. Runs first because it
24
98
  * is the highest-confidence signal (no heuristics, just hash lookup).
99
+ *
100
+ * Assumes `id` is unique on each side (ir-schema.md §14 #1) and does not check it:
101
+ * `buildDiff` establishes that before calling, and a caller reaching this export directly
102
+ * owns the obligation. A repeat on the head side loses all but the last entry to the lookup
103
+ * map and then removes every one of them from `remainingHead`; a repeat on the base side
104
+ * pairs the same head Symbol more than once. The later stages track consumed base Symbols
105
+ * by id and lose a repeat the same way.
25
106
  */
26
107
  export function matchStageId(
27
108
  base: readonly IRSymbol[],
@@ -31,11 +112,11 @@ export function matchStageId(
31
112
  remainingBase: IRSymbol[]
32
113
  remainingHead: IRSymbol[]
33
114
  } {
34
- const headById = new Map<string, IRSymbol>()
115
+ const headById = new Map<SymbolId, IRSymbol>()
35
116
  for (const s of head) headById.set(s.id, s)
36
117
  const matched: SymbolPair[] = []
37
118
  const remainingBase: IRSymbol[] = []
38
- const usedHead = new Set<string>()
119
+ const usedHead = new Set<SymbolId>()
39
120
  for (const b of base) {
40
121
  const h = headById.get(b.id)
41
122
  if (h !== undefined) {
@@ -51,7 +132,9 @@ export function matchStageId(
51
132
 
52
133
  /**
53
134
  * §3.2 — git rename detection. When a rename map is available (typical of ref-driven
54
- * scans), rewrite the base id with the head-side file path and look for a hit.
135
+ * scans), rewrite the base id with the head-side file path and look for a hit. Two base
136
+ * files renamed onto one target predict the same head id, so the claimants are collected
137
+ * before one is chosen.
55
138
  *
56
139
  * The rewriter only touches the `file` portion of the id — the `<language>:` prefix and
57
140
  * the trailing `#<qualified-name>` segment stay unchanged so a moved file with the same
@@ -70,47 +153,78 @@ export function matchStageGitRename(
70
153
  if (renameMap === null || renameMap.size === 0) {
71
154
  return { matched: [], remainingBase: [...remainingBase], remainingHead: [...remainingHead] }
72
155
  }
73
- const headById = new Map<string, IRSymbol>()
156
+ const headById = new Map<SymbolId, IRSymbol>()
74
157
  for (const h of remainingHead) headById.set(h.id, h)
75
- const matched: SymbolPair[] = []
76
- const usedHead = new Set<string>()
77
- const remaining: IRSymbol[] = []
158
+
159
+ // Collect every base that predicts a given head before choosing, rather than letting the
160
+ // first one in the array take it. Two files renamed onto one target both predict the same
161
+ // id, and which of them is the move source should not be a property of the array order.
162
+ const claimants = new Map<SymbolId, { head: IRSymbol; bases: [IRSymbol, ...IRSymbol[]] }>()
78
163
  for (const b of remainingBase) {
79
164
  const newPath = renameMap.get(b.source.file)
80
- if (newPath === undefined) {
81
- remaining.push(b)
82
- continue
83
- }
165
+ if (newPath === undefined) continue
84
166
  const expectedId = rewriteIdFile(b.id, b.source.file, newPath)
85
- const h = headById.get(expectedId)
86
- if (h === undefined || usedHead.has(h.id)) {
87
- remaining.push(b)
88
- continue
89
- }
90
- matched.push({ base: b, head: h, rationale: "git-rename" })
91
- usedHead.add(h.id)
167
+ if (expectedId === null) continue
168
+ const head = headById.get(expectedId)
169
+ if (head === undefined) continue
170
+ const claim = claimants.get(expectedId)
171
+ if (claim === undefined) claimants.set(expectedId, { head, bases: [b] })
172
+ else claim.bases.push(b)
173
+ }
174
+
175
+ const matched: SymbolPair[] = []
176
+ const usedBase = new Set<SymbolId>()
177
+ const usedHead = new Set<SymbolId>()
178
+ for (const { head, bases } of claimants.values()) {
179
+ const base = lowestId(bases)
180
+ matched.push({ base, head, rationale: "git-rename" })
181
+ usedBase.add(base.id)
182
+ usedHead.add(head.id)
183
+ }
184
+ return {
185
+ matched,
186
+ remainingBase: unclaimed(remainingBase, usedBase),
187
+ remainingHead: unclaimed(remainingHead, usedHead),
188
+ }
189
+ }
190
+
191
+ /** Total on the non-empty claim lists above: a claim is created holding its first base. */
192
+ function lowestId(symbols: readonly [IRSymbol, ...IRSymbol[]]): IRSymbol {
193
+ let lowest = symbols[0]
194
+ for (const symbol of symbols) {
195
+ if (symbol.id < lowest.id) lowest = symbol
92
196
  }
93
- const remainingHeadOut = remainingHead.filter((h) => !usedHead.has(h.id))
94
- return { matched, remainingBase: remaining, remainingHead: remainingHeadOut }
197
+ return lowest
95
198
  }
96
199
 
97
- function rewriteIdFile(id: string, oldPath: string, newPath: string): string {
200
+ /**
201
+ * Predict the id a base Symbol would carry after git moved its file. The second Symbol-id
202
+ * derivation in the codebase after `makeSymbolId` itself, so it goes back through the same
203
+ * constructor rather than re-concatenating the parts: `trySymbolId` rejects a rename target
204
+ * the id grammar cannot express (a backslash path, say) instead of minting an id no head
205
+ * Symbol can ever equal.
206
+ *
207
+ * Returns `null` for that case, and the unchanged id when no rename applies — the caller
208
+ * treats a null the same way it treats a lookup miss, leaving the pair for stage 3.
209
+ */
210
+ function rewriteIdFile(id: SymbolId, oldPath: string, newPath: string): SymbolId | null {
98
211
  const colon = id.indexOf(":")
99
212
  const hash = id.indexOf("#")
100
213
  if (colon < 0 || hash < 0 || hash < colon) return id
101
- const language = id.slice(0, colon)
102
214
  const filePart = id.slice(colon + 1, hash)
103
215
  if (filePart !== oldPath) return id
104
- const rest = id.slice(hash)
105
- return `${language}:${newPath}${rest}`
216
+ return trySymbolId({
217
+ language: id.slice(0, colon),
218
+ file: newPath,
219
+ qualifiedName: id.slice(hash + 1),
220
+ })
106
221
  }
107
222
 
108
223
  /**
109
- * §3.3 — group base Symbols by `fingerprint.logic` and match head Symbols whose logic
110
- * fingerprint is in the map. Two disambiguation branches:
111
- * - single candidate → paired with `logic-fingerprint`
112
- * - multiple candidates → best `nameSimilarity` wins if ≥ 0.85; otherwise leave both
113
- * sides in remaining for stage 4 to re-evaluate
224
+ * §3.3 — group both sides by `fingerprint.logic` and pair within each group. Two branches:
225
+ * - single base candidate → paired with `logic-fingerprint`, no similarity test
226
+ * - several → `nameSimilarity` disambiguates at ≥ 0.85; a group that cannot reach it is left
227
+ * whole for stage 4 to re-evaluate
114
228
  *
115
229
  * Dropped symbols are excluded (§3.3 tail) — their logic fingerprint is the sentinel
116
230
  * `"000000000000"` and would collide with every other dropped Symbol in the workspace.
@@ -124,64 +238,105 @@ export function matchStageLogicFingerprint(
124
238
  remainingBase: IRSymbol[]
125
239
  remainingHead: IRSymbol[]
126
240
  } {
127
- const logicMap = new Map<string, IRSymbol[]>()
128
- const carryBase: IRSymbol[] = []
129
- for (const b of remainingBase) {
130
- if (b.dropped || b.fingerprint.logic === ZERO_LOGIC_FP) {
131
- carryBase.push(b)
132
- continue
133
- }
134
- const bucket = logicMap.get(b.fingerprint.logic) ?? []
135
- bucket.push(b)
136
- logicMap.set(b.fingerprint.logic, bucket)
241
+ const baseGroups = groupByLogic(remainingBase)
242
+ const headGroups = groupByLogic(remainingHead)
243
+ const scorer = createNameScorer()
244
+ const matched: SymbolPair[] = []
245
+ for (const [logic, heads] of headGroups) {
246
+ const bases = baseGroups.get(logic)
247
+ if (bases === undefined) continue
248
+ matched.push(...pairWithinLogicGroup(bases, heads, scorer))
249
+ }
250
+ const usedBase = new Set(matched.map((pair) => pair.base.id))
251
+ const usedHead = new Set(matched.map((pair) => pair.head.id))
252
+ return {
253
+ matched,
254
+ remainingBase: unclaimed(remainingBase, usedBase),
255
+ remainingHead: unclaimed(remainingHead, usedHead),
137
256
  }
257
+ }
258
+
259
+ /**
260
+ * Symbols by logic fingerprint, skipping the ones §3.3 excludes. Both sides are grouped, so
261
+ * a group is a self-contained problem: the Symbols in it can only pair with each other, and
262
+ * no group’s outcome depends on any other’s.
263
+ */
264
+ function groupByLogic(symbols: readonly IRSymbol[]): Map<string, IRSymbol[]> {
265
+ const groups = new Map<string, IRSymbol[]>()
266
+ for (const symbol of symbols) {
267
+ if (symbol.dropped || symbol.fingerprint.logic === ZERO_LOGIC_FP) continue
268
+ const bucket = groups.get(symbol.fingerprint.logic) ?? []
269
+ bucket.push(symbol)
270
+ groups.set(symbol.fingerprint.logic, bucket)
271
+ }
272
+ return groups
273
+ }
274
+
275
+ /**
276
+ * §3.3’s two branches, over one logic-fingerprint group.
277
+ *
278
+ * A lone base candidate pairs with no similarity test — identical logic is taken as proof on
279
+ * its own. With more than one, names disambiguate and a group that cannot reach the
280
+ * threshold is left whole for stage 4.
281
+ *
282
+ * The loop is what keeps the second branch feeding the first: a scored round that consumes
283
+ * all but one base leaves that one unconditional, which is how the per-head version behaved
284
+ * when its candidate list shrank to a single entry.
285
+ */
286
+ function pairWithinLogicGroup(
287
+ bases: readonly IRSymbol[],
288
+ heads: readonly IRSymbol[],
289
+ scorer: NameScorer,
290
+ ): SymbolPair[] {
291
+ let freeBase = [...bases]
292
+ let freeHead = [...heads]
138
293
  const matched: SymbolPair[] = []
139
- const carryHead: IRSymbol[] = []
140
- const usedBaseIds = new Set<string>()
141
- for (const h of remainingHead) {
142
- if (h.dropped || h.fingerprint.logic === ZERO_LOGIC_FP) {
143
- carryHead.push(h)
144
- continue
294
+ while (freeBase.length > 0 && freeHead.length > 0) {
295
+ const lone = freeBase.length === 1 ? freeBase[0] : undefined
296
+ if (lone !== undefined) {
297
+ const head = closestNameTo(lone, freeHead, scorer)
298
+ if (head === undefined) break
299
+ matched.push({ base: lone, head, rationale: "logic-fingerprint" })
300
+ break
145
301
  }
146
- const candidates = logicMap.get(h.fingerprint.logic)
147
- if (candidates === undefined || candidates.length === 0) {
148
- carryHead.push(h)
149
- continue
150
- }
151
- if (candidates.length === 1) {
152
- const only = candidates[0]
153
- if (only === undefined) continue
154
- matched.push({ base: only, head: h, rationale: "logic-fingerprint" })
155
- usedBaseIds.add(only.id)
156
- logicMap.delete(h.fingerprint.logic)
157
- continue
302
+ const candidates: ScoredPair[] = []
303
+ for (const base of freeBase) {
304
+ for (const head of freeHead) {
305
+ const score = scorer.name(base.name, head.name)
306
+ if (score >= NAME_DISAMBIGUATION_THRESHOLD) candidates.push({ base, head, score })
307
+ }
158
308
  }
159
- let best: { symbol: IRSymbol; score: number } | null = null
160
- for (const c of candidates) {
161
- const score = nameSimilarity(c.name, h.name)
162
- if (best === null || score > best.score) best = { symbol: c, score }
163
- }
164
- if (best !== null && best.score >= 0.85) {
165
- matched.push({
166
- base: best.symbol,
167
- head: h,
168
- rationale: "logic-fingerprint+name-disambiguation",
169
- })
170
- usedBaseIds.add(best.symbol.id)
171
- const remainingBucket = candidates.filter((c) => c.id !== best?.symbol.id)
172
- if (remainingBucket.length === 0) logicMap.delete(h.fingerprint.logic)
173
- else logicMap.set(h.fingerprint.logic, remainingBucket)
174
- } else {
175
- carryHead.push(h)
309
+ const accepted = acceptInScoreOrder(candidates)
310
+ if (accepted.pairs.length === 0) break
311
+ for (const { base, head } of accepted.pairs) {
312
+ matched.push({ base, head, rationale: "logic-fingerprint+name-disambiguation" })
176
313
  }
314
+ freeBase = unclaimed(freeBase, accepted.baseIds)
315
+ freeHead = unclaimed(freeHead, accepted.headIds)
177
316
  }
178
- const finalBase: IRSymbol[] = [...carryBase]
179
- for (const bucket of logicMap.values()) {
180
- for (const b of bucket) {
181
- if (!usedBaseIds.has(b.id)) finalBase.push(b)
317
+ return matched
318
+ }
319
+
320
+ /**
321
+ * The head whose name is closest to `base`, ties going to the lower id. The lone-candidate
322
+ * branch pairs whatever it is given, so this only decides *which* head it takes — but that
323
+ * decision was array order, and it is visible in the diff.
324
+ */
325
+ function closestNameTo(
326
+ base: IRSymbol,
327
+ heads: readonly IRSymbol[],
328
+ scorer: NameScorer,
329
+ ): IRSymbol | undefined {
330
+ let best: { head: IRSymbol; score: number } | undefined
331
+ for (const head of heads) {
332
+ const score = scorer.name(base.name, head.name)
333
+ if (best === undefined || score > best.score) {
334
+ best = { head, score }
335
+ continue
182
336
  }
337
+ if (score === best.score && head.id < best.head.id) best = { head, score }
183
338
  }
184
- return { matched, remainingBase: finalBase, remainingHead: carryHead }
339
+ return best?.head
185
340
  }
186
341
 
187
342
  /**
@@ -189,17 +344,35 @@ export function matchStageLogicFingerprint(
189
344
  * `signatureNullness` distinguishes symbols with no signature (`interface`, `type`,
190
345
  * `class`) from callable ones so that a class body is never paired against a function.
191
346
  *
192
- * The composite score:
193
- * 0.5 * nameSimilarity + 0.3 * signatureSimilarity + 0.2 * ownerSimilarity
347
+ * §3.4.6's owner gate runs before the score: a pair whose owners are not the same class, or
348
+ * one a rename of the other, is not the same Symbol however well the rest agrees, and no
349
+ * weight small enough to leave the name axis meaning something can outvote a matching member
350
+ * name and signature.
351
+ *
352
+ * The composite score, over the pairs that clear it:
353
+ * 0.5 * memberSimilarity + 0.3 * signatureSimilarity + 0.2 * OWNER_AXIS_SATISFIED
194
354
  *
195
- * §3.4.3 threshold table:
196
- * - 1-token name → 1.0 (unreachable → never paired; false-positive shield)
197
- * - 2-token name → 0.95 (strict, avoids `getUser` vs `getUsers`)
198
- * - default → 0.85
355
+ * `memberSimilarity` reads the last segment, not the whole qualified name: the gate has
356
+ * already settled the owner, and reading it again on the name axis charges for it twice —
357
+ * which is what kept a renamed class from ever pairing (§3.4.6).
199
358
  *
200
- * §3.4.3 tail — if both sides are signature-less, we skip pairing entirely because
201
- * signatureSimilarity always returns 1.0 for `null + null` and would flood the bucket with
202
- * false pairings.
359
+ * §3.4.3 threshold table, applied per head by the token count of its last name segment:
360
+ * - 1 token → 1.0 (an exact match on both remaining axes)
361
+ * - 2 tokens → 0.95 (strict, avoids `getUser` vs `getUsers`)
362
+ * - default → 0.85
363
+ *
364
+ * §3.4.3 also names two Symbols the stage does not read at all, because for them the score is
365
+ * high on evidence it does not have rather than on a resemblance: one whose signature is
366
+ * null, since `signatureSimilarity(null, null)` is 1.0 and the bucket key restricts it to
367
+ * candidates that are equally signature-less; and one whose qualified name carries a single
368
+ * distinct token, since that is the whole of what its identity amounts to. Neither can be
369
+ * expressed as a threshold — the second wants a bar above the top of the scale.
370
+ *
371
+ * Every pairing in a bucket that clears its head’s threshold becomes a candidate, and the
372
+ * candidates are settled in score order rather than one head at a time — see
373
+ * `acceptInScoreOrder`. That doubles the pairings scored, from a bucket that shrank as heads
374
+ * consumed it to the full bucket every time; `createNameScorer` more than pays for it by
375
+ * tokenising each distinct name once per pass instead of once per comparison.
203
376
  */
204
377
  export function matchStageNameSignature(
205
378
  remainingBase: readonly IRSymbol[],
@@ -209,63 +382,116 @@ export function matchStageNameSignature(
209
382
  remainingBase: IRSymbol[]
210
383
  remainingHead: IRSymbol[]
211
384
  } {
212
- type BucketKey = string
213
- const buckets = new Map<BucketKey, IRSymbol[]>()
385
+ const buckets = new Map<string, Bucket>()
214
386
  for (const b of remainingBase) {
215
387
  if (b.dropped) continue
388
+ // §3.4.3 — see `saysEnoughToPair`. Read on the base as well as the head: the property
389
+ // belongs to a pairing, and either end being short is enough to make the score unearned.
390
+ if (!saysEnoughToPair(b.name)) continue
216
391
  const key = bucketKey(b)
217
- const bucket = buckets.get(key) ?? []
218
- bucket.push(b)
392
+ const bucket: Bucket = buckets.get(key) ?? { members: [], byToken: new Map() }
393
+ const position = bucket.members.length
394
+ bucket.members.push(b)
395
+ for (const token of memberTokens(b.name)) {
396
+ const sharing = bucket.byToken.get(token) ?? []
397
+ sharing.push(position)
398
+ bucket.byToken.set(token, sharing)
399
+ }
219
400
  buckets.set(key, bucket)
220
401
  }
221
- const matched: SymbolPair[] = []
222
- const remainingHeadOut: IRSymbol[] = []
223
- const usedBaseIds = new Set<string>()
402
+ const visitsOf = new Map<Bucket, Int32Array>()
403
+ for (const bucket of buckets.values()) visitsOf.set(bucket, new Int32Array(bucket.members.length))
404
+ const scorer = createNameScorer()
405
+ const candidates: ScoredPair[] = []
406
+ let visitCount = 0
224
407
  for (const h of remainingHead) {
225
- if (h.dropped) {
226
- remainingHeadOut.push(h)
227
- continue
228
- }
229
- const key = bucketKey(h)
230
- const bucket = buckets.get(key)
231
- if (bucket === undefined || bucket.length === 0) {
232
- remainingHeadOut.push(h)
233
- continue
234
- }
235
- const headSigNull = h.signature === null || h.signature === undefined
236
- if (headSigNull && bucket.every((c) => c.signature === null || c.signature === undefined)) {
237
- remainingHeadOut.push(h)
408
+ if (h.dropped) continue
409
+ // §3.4.3 tail — `signatureSimilarity(null, null)` is 1.0, so a signature-less head would
410
+ // score every candidate high on that axis. The bucket key already partitions by
411
+ // signature nullness, so a signature-less head only ever sees signature-less candidates:
412
+ // there is nothing here it could legitimately pair with.
413
+ if (h.signature === null || h.signature === undefined) continue
414
+ // §3.4.3 — a name of one distinct token scores 1.0 against any other of the same
415
+ // signature, which is the top of the scale and so above no threshold. See
416
+ // `saysEnoughToPair`.
417
+ if (!saysEnoughToPair(h.name)) continue
418
+ const bucket = buckets.get(bucketKey(h))
419
+ if (bucket === undefined) continue
420
+ const threshold = thresholdFor(h.name)
421
+ // §3.4.0 — the least a member name can score and still leave the threshold reachable,
422
+ // granting the other two axes in full.
423
+ const memberFloor = lowestUsefulMember(threshold)
424
+ // A base is reached once per token it shares, so a head whose postings add up to at least
425
+ // the whole bucket is not being narrowed by the index — only charged for the overlap.
426
+ // Every Symbol named `handleRequest` puts the entire bucket under both of its tokens.
427
+ // Walking the members straight through covers that without the de-duplication, and keeps
428
+ // the index from costing more than not having one. A bound rather than a promise of the
429
+ // cheaper branch: `reach` double-counts, so a head can trip it while covering much less.
430
+ const tokens = memberTokens(h.name)
431
+ let reach = 0
432
+ for (const token of tokens) reach += bucket.byToken.get(token)?.length ?? 0
433
+ if (reach >= bucket.members.length) {
434
+ for (const b of bucket.members) {
435
+ collectCandidate(b, h, scorer, memberFloor, threshold, candidates)
436
+ }
238
437
  continue
239
438
  }
240
- let best: { symbol: IRSymbol; score: number } | null = null
241
- for (const c of bucket) {
242
- const score =
243
- 0.5 * nameSimilarity(c.name, h.name) +
244
- 0.3 * signatureSimilarity(c.signature ?? null, h.signature ?? null) +
245
- 0.2 * ownerSimilarity(c.name, h.name)
246
- if (best === null || score > best.score) best = { symbol: c, score }
247
- }
248
- const threshold = thresholdFor(h.name)
249
- if (best !== null && best.score >= threshold) {
250
- matched.push({ base: best.symbol, head: h, rationale: "name-signature" })
251
- usedBaseIds.add(best.symbol.id)
252
- buckets.set(
253
- key,
254
- bucket.filter((c) => c.id !== best?.symbol.id),
255
- )
256
- } else {
257
- remainingHeadOut.push(h)
439
+ const visited = visitsOf.get(bucket)
440
+ if (visited === undefined) continue
441
+ visitCount++
442
+ for (const token of tokens) {
443
+ for (const position of bucket.byToken.get(token) ?? []) {
444
+ if (visited[position] === visitCount) continue
445
+ visited[position] = visitCount
446
+ const b = bucket.members[position]
447
+ if (b !== undefined) collectCandidate(b, h, scorer, memberFloor, threshold, candidates)
448
+ }
258
449
  }
259
450
  }
260
- const remainingBaseOut: IRSymbol[] = []
261
- for (const b of remainingBase) {
262
- if (b.dropped) {
263
- remainingBaseOut.push(b)
264
- continue
265
- }
266
- if (!usedBaseIds.has(b.id)) remainingBaseOut.push(b)
451
+ const accepted = acceptInScoreOrder(candidates)
452
+ return {
453
+ matched: accepted.pairs.map(({ base, head }) => ({
454
+ base,
455
+ head,
456
+ rationale: "name-signature" as const,
457
+ })),
458
+ remainingBase: unclaimed(remainingBase, accepted.baseIds),
459
+ remainingHead: unclaimed(remainingHead, accepted.headIds),
267
460
  }
268
- return { matched, remainingBase: remainingBaseOut, remainingHead: remainingHeadOut }
461
+ }
462
+
463
+ /**
464
+ * §3.4 — score one candidate pairing and keep it if it clears the head's threshold.
465
+ *
466
+ * A plain function rather than a closure over the head loop: both branches of the walk need
467
+ * it, and a closure built per head is the cross-product moved into the allocator — measurably
468
+ * so, at around 1.6x on a bucket the index cannot narrow.
469
+ *
470
+ * §3.4.3's floor is read before §3.4.6's gate and is the only early exit here. It is one
471
+ * Jaccard over token sets the pass already holds, where the gate splits both owners into
472
+ * segments, tokenises each and runs an augmenting-path matching. The gate short-circuits
473
+ * internally on identical owners and on first segments that cannot correspond, so there is
474
+ * nothing left worth hoisting out of it.
475
+ */
476
+ function collectCandidate(
477
+ base: IRSymbol,
478
+ head: IRSymbol,
479
+ scorer: NameScorer,
480
+ memberFloor: number,
481
+ threshold: number,
482
+ candidates: ScoredPair[],
483
+ ): void {
484
+ const member = scorer.member(base.name, head.name)
485
+ if (member < memberFloor) return
486
+ if (!scorer.ownersCompatible(base.name, head.name)) return
487
+ const score =
488
+ 0.5 * member +
489
+ 0.3 * signatureSimilarity(base.signature ?? null, head.signature ?? null) +
490
+ 0.2 * OWNER_AXIS_SATISFIED
491
+ // Filtering here rather than after the sweep keeps the candidate list to the pairings that
492
+ // could actually be accepted; the threshold belongs to the head, so a pair below it is never
493
+ // acceptable however the rest of the group resolves.
494
+ if (score >= threshold) candidates.push({ base, head, score })
269
495
  }
270
496
 
271
497
  function bucketKey(s: IRSymbol): string {
@@ -273,21 +499,138 @@ function bucketKey(s: IRSymbol): string {
273
499
  return `${s.kind}::${sig}`
274
500
  }
275
501
 
502
+ /**
503
+ * §3.4.0 — the least `memberSimilarity` that leaves `threshold` reachable.
504
+ *
505
+ * `0.5 * member + 0.3 * signature + 0.2 * 1` must reach the threshold, and the signature axis
506
+ * is worth at most 0.3, so `member >= 2 * (threshold - 0.5)`. Refusing below this is exact
507
+ * rather than approximate: nothing the other two axes can do rescues a pair under it.
508
+ *
509
+ * Reading it before §3.4.6's gate is what keeps stage 4 off the cross-product. The gate splits
510
+ * and tokenises two owners; this is one Jaccard over token sets the pass has already built,
511
+ * and it refuses most of what a shared token admits.
512
+ */
513
+ function lowestUsefulMember(threshold: number): number {
514
+ return 2 * (threshold - 0.5)
515
+ }
516
+
517
+ /**
518
+ * One `(kind, signatureNullness)` bucket: its base Symbols, and an index from each token a
519
+ * member name carries to the positions holding it.
520
+ *
521
+ * The de-duplication stamp lives beside the bucket rather than in it, in `visitsOf`, and is
522
+ * sized once the bucket is complete — a half-built bucket is then not representable, and the
523
+ * stamp cannot be indexed out of range. It is per bucket rather than per head because a head
524
+ * reaching every base would otherwise allocate a set the size of the bucket once per head,
525
+ * which is the cross-product the index exists to avoid moved into the allocator.
526
+ */
527
+ interface Bucket {
528
+ members: IRSymbol[]
529
+ byToken: Map<string, number[]>
530
+ }
531
+
532
+ /**
533
+ * §3.4.0 — the token that stands in for a member name carrying none.
534
+ *
535
+ * `Foo.Bar.` has an empty last segment and two tokens in its qualified name, so it is
536
+ * admissible, and two such Symbols score 1.0 on an axis comparing two empty sets. They have
537
+ * no token to be indexed under and cannot pair with anything that has one, so they share a
538
+ * key of their own. A real token cannot collide with it: `tokenizeName` never emits a space.
539
+ */
540
+ const NO_MEMBER_TOKENS = " none"
541
+
542
+ function memberTokens(qname: string): readonly string[] {
543
+ const tokens = tokenizeName(lastSegment(qname))
544
+ return tokens.length === 0 ? [NO_MEMBER_TOKENS] : tokens
545
+ }
546
+
547
+ /**
548
+ * §3.4.3 — the composite score a pair must reach, by how many tokens the head's last name
549
+ * segment has. A one-token segment still asks for the whole scale, which a pair reaches on an
550
+ * identical member name and signature past §3.4.6's gate: `0.5 + 0.3 + 0.2`, exactly 1 in
551
+ * IEEE 754. The owner need not be identical, only compatible — `UserRepo.get` and
552
+ * `UserRepos.get` reach it.
553
+ */
554
+ const EXACT_MATCH_ONLY = 1
555
+ const TWO_TOKEN_THRESHOLD = 0.95
556
+ const DEFAULT_THRESHOLD = 0.85
557
+
558
+ /**
559
+ * §3.4.6 — what the owner axis is worth to a pair that clears its gate, which is every pair
560
+ * that reaches the score. Written as a value rather than folded into the weights so the
561
+ * composite keeps the 0.5/0.3/0.2 shape §3.4.3's rows are calibrated against: dropping the
562
+ * term and renormalising would move every threshold without changing what any of them means.
563
+ */
564
+ const OWNER_AXIS_SATISFIED = 1
565
+
276
566
  function thresholdFor(qname: string): number {
277
567
  const tokens = tokenizeName(lastSegment(qname)).length
278
- if (tokens <= 1) return 1
279
- if (tokens === 2) return 0.95
280
- return 0.85
568
+ if (tokens <= 1) return EXACT_MATCH_ONLY
569
+ if (tokens === 2) return TWO_TOKEN_THRESHOLD
570
+ return DEFAULT_THRESHOLD
281
571
  }
282
572
 
283
573
  /**
284
- * §3.4.5 — dropped-only weak matcher. For dropped Symbols the fingerprint is zeroed and
285
- * name/signature are the only remaining signals. Score is a coarse "did the last name
286
- * segment or file basename survive?" — if either half matches we pair (threshold 0.5).
574
+ * §3.4.3 — whether a Symbol's qualified name says enough for stage 4 to read it at all.
575
+ *
576
+ * The score is built from three things the name supplies: its tokens, its owner's tokens,
577
+ * and a signature. A name of one distinct token supplies one word and an owner that either
578
+ * repeats it or is empty, and `main(x: string): void` against another `main(x: string): void`
579
+ * scores the full 1.0 on that — two unrelated CLI entry points reported as one move. The
580
+ * table's first row was written to refuse this and could not: 1.0 is the top of the scale,
581
+ * so no threshold sits above it. Being unpairable is a property of the name, not a score it
582
+ * failed to reach, so it is decided here rather than by the comparison.
583
+ *
584
+ * Counted over the whole qualified name, because that is what the score reads.
585
+ * `UserRepo.get` supplies three tokens and pairs, though its last segment is one — the
586
+ * measure `thresholdFor` uses, and the wrong one for this question. Tokens are deduped, so
587
+ * `Main.main` supplies one and does not.
588
+ *
589
+ * Read off both sides, because the property belongs to a pairing. It was once read off the
590
+ * head alone, on the arithmetic that a short name anywhere capped the score at 0.75 — true
591
+ * while the name axis read the whole qualified name, and false since §3.4.6 became a gate and
592
+ * the axis moved to the last segment. `Main.main` is one deduped token and clears the gate
593
+ * against `Mains.main`, whose member name is identical, so the pair reaches the top of the
594
+ * scale from a name saying one word.
287
595
  *
288
- * This is deliberately lax; diff-algorithm.md §3.4.5 already accepts that dropped Symbols live
289
- * outside the main IR review surface, so occasional false pairings only affect the
290
- * "Drop-rule variation" fold-out section in the Markdown projection.
596
+ * The count stands in for how much the name says, and `tokenizeName` splits on ASCII case
597
+ * boundaries, so a name written in a script that has none is one token however long it is:
598
+ * `ユーザー情報を取得する` is refused here on the same footing as `main`. For that name the
599
+ * proxy is wrong — two unrelated Symbols do not carry it by coincidence the way two carry
600
+ * `main` — and the cost is a stage-4 move this stage will not find. Fixing it means measuring
601
+ * the name by something other than a bare token count, which is §3.4.1's to change.
602
+ */
603
+ function saysEnoughToPair(qname: string): boolean {
604
+ return tokenizeName(qname).length > 1
605
+ }
606
+
607
+ /**
608
+ * §3.4.5 — dropped-only weak matcher. For dropped Symbols the fingerprint is zeroed, so the
609
+ * only signals left are the trailing segment of the qualified name and the file basename.
610
+ * Either one alone is enough to pair, which is deliberately lax: §3.4.5 accepts a
611
+ * false-positive risk because dropped Symbols sit outside the IR's main review surface.
612
+ *
613
+ * What it does not accept is a signal that identifies nothing. A basename hit on `index.ts`
614
+ * — the most common filename in a TypeScript monorepo — paired every dropped Symbol of one
615
+ * kind under one with every other, at a score they all tied on, and the results landed in
616
+ * `summary.moved`, which `--fail-on moved` gates on. So a half counts only when exactly one
617
+ * dropped base and one dropped head carry that key: such a key names a pair, where a key
618
+ * several Symbols carry names a group, and a group is not a pairing.
619
+ *
620
+ * That makes the candidates the identifying keys themselves — at most one pairing each,
621
+ * two axes, so at most `2 × min(base, head)` of them rather than the cross-product a shared
622
+ * basename used to produce.
623
+ *
624
+ * "Exactly one" is counted over the Symbols this stage is handed, not over every dropped
625
+ * Symbol in the Document — stages 1 and 2 have already taken theirs, and a key they emptied
626
+ * out identifies again. That is the intended reading, since the question is which leftover a
627
+ * key picks out, but it is also how a shared `index.ts` comes back: three dropped classes
628
+ * under one, two of them matched by id, and the basename identifies the two that are left.
629
+ *
630
+ * It also leaves nothing for §3.4.5's 0.5-per-half scale to rank, so §3.8's sweep is the
631
+ * wrong instrument: it settles conflicts by score, and with none to settle by it would drop
632
+ * one identified pairing for another over nothing but the id it sorts under. `bestPairing`
633
+ * takes a maximum matching instead.
291
634
  */
292
635
  export function matchStageDroppedWeak(
293
636
  remainingBase: readonly IRSymbol[],
@@ -297,39 +640,155 @@ export function matchStageDroppedWeak(
297
640
  remainingBase: IRSymbol[]
298
641
  remainingHead: IRSymbol[]
299
642
  } {
300
- const droppedBase = remainingBase.filter((s) => s.dropped)
301
- const nonDroppedBase = remainingBase.filter((s) => !s.dropped)
302
- const droppedHead = remainingHead.filter((s) => s.dropped)
303
- const nonDroppedHead = remainingHead.filter((s) => !s.dropped)
304
- const matched: SymbolPair[] = []
305
- const usedBaseIds = new Set<string>()
306
- const carryHead: IRSymbol[] = []
307
- for (const h of droppedHead) {
308
- let best: { symbol: IRSymbol; score: number } | null = null
309
- for (const b of droppedBase) {
310
- if (b.kind !== h.kind) continue
311
- if (usedBaseIds.has(b.id)) continue
312
- const nameHit = lastSegment(b.name) === lastSegment(h.name) ? 1 : 0
313
- const fileHit = basename(b.source.file) === basename(h.source.file) ? 1 : 0
314
- const score = 0.5 * nameHit + 0.5 * fileHit
315
- if (best === null || score > best.score) best = { symbol: b, score }
316
- }
317
- if (best !== null && best.score >= 0.5) {
318
- matched.push({ base: best.symbol, head: h, rationale: "dropped-weak-match" })
319
- usedBaseIds.add(best.symbol.id)
320
- } else {
321
- carryHead.push(h)
322
- }
643
+ const bases = remainingBase.filter((s) => s.dropped)
644
+ const heads = remainingHead.filter((s) => s.dropped)
645
+
646
+ // Both halves have the same standing, so the two axes are collected rather than consulted
647
+ // in an order. Both naming the same pairing needs no special handling: each key is sole on
648
+ // both sides, so neither Symbol carries another pairing and the repeat is a component of
649
+ // its own, which `bestPairing` takes once.
650
+ const identified: WeakEdge[] = []
651
+ for (const keyOf of [nameKey, fileKey]) {
652
+ identified.push(...pairsIdentifiedBy(bases, heads, keyOf))
323
653
  }
324
- const carryBase: IRSymbol[] = [
325
- ...nonDroppedBase,
326
- ...droppedBase.filter((b) => !usedBaseIds.has(b.id)),
327
- ]
654
+
655
+ const matched = bestPairing(identified)
328
656
  return {
329
- matched,
330
- remainingBase: carryBase,
331
- remainingHead: [...nonDroppedHead, ...carryHead],
657
+ matched: matched.map(({ base, head }) => ({
658
+ base,
659
+ head,
660
+ rationale: "dropped-weak-match" as const,
661
+ })),
662
+ remainingBase: unclaimed(remainingBase, new Set(matched.map((edge) => edge.base.id))),
663
+ remainingHead: unclaimed(remainingHead, new Set(matched.map((edge) => edge.head.id))),
664
+ }
665
+ }
666
+
667
+ /** A pairing that one of §3.4.5's two keys identifies. */
668
+ interface WeakEdge {
669
+ base: IRSymbol
670
+ head: IRSymbol
671
+ }
672
+
673
+ /**
674
+ * As many of the identified pairings as can hold at once.
675
+ *
676
+ * Each axis identifies a Symbol at most once, so a Symbol carries at most two pairings and
677
+ * the whole set is the union of two matchings — which is a disjoint union of simple paths and
678
+ * even cycles. Alternate pairings along a component are therefore a maximum matching of it,
679
+ * and walking each component from a fixed end makes the choice among the maximum matchings
680
+ * canonical rather than a property of the input order.
681
+ *
682
+ * The alternative would be §3.8's sweep, and it is the wrong instrument once §3.4.5's score
683
+ * is gone: over `Shared.ts#Alpha`, `Other.ts#Beta` against `Other.ts#Alpha`,
684
+ * `Shared.ts#Gamma`, all three pairings are identified and it takes one where two hold.
685
+ */
686
+ function bestPairing(edges: readonly WeakEdge[]): WeakEdge[] {
687
+ const ordered = [...edges].sort(byEndpoints)
688
+ const atBase = new Map<SymbolId, number[]>()
689
+ const atHead = new Map<SymbolId, number[]>()
690
+ for (const [index, edge] of ordered.entries()) {
691
+ appendTo(atBase, edge.base.id, index)
692
+ appendTo(atHead, edge.head.id, index)
693
+ }
694
+ const adjacent = (index: number): number[] => {
695
+ const edge = ordered[index]
696
+ if (edge === undefined) return []
697
+ const sharing = [...(atBase.get(edge.base.id) ?? []), ...(atHead.get(edge.head.id) ?? [])]
698
+ return sharing.filter((other) => other !== index)
699
+ }
700
+
701
+ const walked = ordered.map(() => false)
702
+ const matching: WeakEdge[] = []
703
+ /** Follow a component from `start`, taking every other pairing along it. */
704
+ const walkFrom = (start: number): void => {
705
+ let step = start
706
+ let take = true
707
+ while (!walked[step]) {
708
+ walked[step] = true
709
+ const edge = ordered[step]
710
+ if (take && edge !== undefined) matching.push(edge)
711
+ take = !take
712
+ const next = adjacent(step).find((other) => walked[other] === false)
713
+ if (next === undefined) return
714
+ step = next
715
+ }
716
+ }
717
+
718
+ // Paths first, from an end — a component walked from the middle would not alternate to a
719
+ // maximum. What is unwalked afterwards is on a cycle, where every pairing is an equally
720
+ // good place to start and the lowest-ordered one is the canonical choice.
721
+ for (const [index] of ordered.entries()) {
722
+ if (!walked[index] && adjacent(index).length <= 1) walkFrom(index)
332
723
  }
724
+ for (const [index] of ordered.entries()) {
725
+ if (!walked[index]) walkFrom(index)
726
+ }
727
+ return matching
728
+ }
729
+
730
+ function byEndpoints(a: WeakEdge, b: WeakEdge): number {
731
+ if (a.base.id !== b.base.id) return a.base.id < b.base.id ? -1 : 1
732
+ return a.head.id < b.head.id ? -1 : a.head.id > b.head.id ? 1 : 0
733
+ }
734
+
735
+ function appendTo(table: Map<SymbolId, number[]>, key: SymbolId, index: number): void {
736
+ const bucket = table.get(key)
737
+ if (bucket === undefined) table.set(key, [index])
738
+ else bucket.push(index)
739
+ }
740
+
741
+ /**
742
+ * The pairings a key picks out on its own: those whose key exactly one dropped base and one
743
+ * dropped head carry. A key held by two Symbols on either side is discarded rather than
744
+ * resolved — with the fingerprint zeroed there is nothing left to tell the members apart, and
745
+ * guessing between them is the false pairing this rule exists to refuse.
746
+ */
747
+ function pairsIdentifiedBy(
748
+ bases: readonly IRSymbol[],
749
+ heads: readonly IRSymbol[],
750
+ keyOf: (symbol: IRSymbol) => string,
751
+ ): WeakEdge[] {
752
+ const soleBase = soleCarriers(bases, keyOf)
753
+ const soleHead = soleCarriers(heads, keyOf)
754
+ const pairs: WeakEdge[] = []
755
+ for (const [key, base] of soleBase) {
756
+ const head = soleHead.get(key)
757
+ if (head !== undefined) pairs.push({ base, head })
758
+ }
759
+ return pairs
760
+ }
761
+
762
+ /** Keys carried by exactly one of `symbols`, mapped to it. */
763
+ function soleCarriers(
764
+ symbols: readonly IRSymbol[],
765
+ keyOf: (symbol: IRSymbol) => string,
766
+ ): Map<string, IRSymbol> {
767
+ const sole = new Map<string, IRSymbol>()
768
+ const shared = new Set<string>()
769
+ for (const symbol of symbols) {
770
+ const key = keyOf(symbol)
771
+ if (shared.has(key)) continue
772
+ if (sole.has(key)) {
773
+ sole.delete(key)
774
+ shared.add(key)
775
+ continue
776
+ }
777
+ sole.set(key, symbol)
778
+ }
779
+ return sole
780
+ }
781
+
782
+ /**
783
+ * The two halves §3.4.5 scores. `kind` leads because it gates a pair before either half is
784
+ * read, and `/` separates because no kind, qualified-name segment or basename contains one.
785
+ */
786
+ function nameKey(symbol: IRSymbol): string {
787
+ return `${symbol.kind}/${lastSegment(symbol.name)}`
788
+ }
789
+
790
+ function fileKey(symbol: IRSymbol): string {
791
+ return `${symbol.kind}/${basename(symbol.source.file)}`
333
792
  }
334
793
 
335
794
  function basename(path: string): string {