codehelion_core/maximal.rs
1//! Folding overlapping seed matches back into maximal shared statement runs.
2//!
3//! Statement windows slide with stride 1 over every block, so a shared run of
4//! `n` statements does not surface as one match — it surfaces as a fan of
5//! overlapping window matches, one per offset and per window length. Reporting
6//! those raw would bury a single duplicated block under a dozen findings that
7//! all describe the same code.
8//!
9//! This stage reverses the sliding: seed matches that describe the same shared
10//! run are folded into the maximal run they jointly cover, so a duplicated
11//! block is one region no matter how many windows detected it.
12//!
13//! # Why folding is sound
14//!
15//! A window match means the two windows' statement summaries are equal
16//! statement for statement. Two matches fold only when they agree on
17//! *alignment* — the same enclosing blocks on both sides and the same offset
18//! between them — and their runs touch. Under those conditions the equalities
19//! compose: if `a[0..4] == b[2..6]` and `a[2..6] == b[4..8]` then
20//! `a[0..6] == b[2..8]`, because each statement of the union is covered by at
21//! least one of the two matches at the same relative position. No similarity is
22//! re-estimated here and nothing is approximated: the folded region is exactly
23//! as much of an exact match as the seeds it came from.
24//!
25//! Gapped runs — a shared block interrupted by an edited statement — are *not*
26//! bridged here. Bridging a gap makes the region an approximate match, so it
27//! belongs behind the judge rather than in a fold that claims exactness.
28//!
29//! # What a window hash does not see
30//!
31//! A statement summary is its shape, its native kind and its leading token
32//! kinds — deliberately shallow, so the index stays cheap. A loop whose body is
33//! one line therefore summarises exactly like a loop whose body is forty, and
34//! two runs can match on summaries while covering wildly different amounts of
35//! code. That is not a duplicate of anything, so a seed whose two sides differ
36//! in source length by more than [`MaximalConfig::max_extent_ratio`] is dropped
37//! and counted: the size gap is the direct evidence that the summary hid the
38//! difference.
39//!
40//! # One duplication, not every pair of its copies
41//!
42//! A run copied into `n` places matches pairwise `n * (n - 1) / 2` times, and
43//! every one of those pairs describes the same duplication. The stage therefore
44//! also reports [`SharedRegion`]s: one entry per duplicated run holding all of
45//! its occurrences.
46//!
47//! # Nesting
48//!
49//! An inner block's run sits inside its enclosing statement, so a duplicated
50//! loop body is also detected as part of the duplicated loop. The larger region
51//! is the one worth reporting, so a region whose source spans are contained in
52//! another region's on both sides is absorbed into it and counted. Containment
53//! is indexed per file pair with a first-span sweep and two-dimensional Fenwick
54//! query, so a bucket of `m` folded regions costs `O(m log² m)`, not `O(m²)`.
55//!
56//! Output is deterministic: regions are keyed and ordered by content position
57//! alone, and the fold never depends on the order seeds arrive in.
58
59use std::collections::BTreeMap;
60
61use crate::candidate::{CandidatePair, StatementRun};
62use crate::features::FeatureKind;
63use crate::ir::ByteRange;
64
65/// Version of the maximal-region folding and containment rules.
66///
67/// This changes which structural findings survive when the folding, extent, or
68/// containment policy changes, so it is recorded in the detector contract.
69pub const MAXIMAL_VERSION: &str = "maximal-v1";
70
71/// Default minimum reportable region length, in statements: the shortest
72/// window length, so the floor never silently discards a run the seed layer
73/// could detect.
74///
75/// It is deliberately not raised past that. Length looks like the obvious way
76/// to shed lookalikes, but the labelled corpora say it does not sort them: a
77/// helper copied verbatim into two files is five lines, while a routine written
78/// once per concrete type is eighty tokens and still nothing anyone should
79/// merge. Calibrated on all but one project, a floor either sits low enough to
80/// remove nothing or high enough to take that project's clearest true copy.
81/// What the short lookalikes have in common is that their bodies follow from
82/// their signatures, and that is not a length.
83///
84/// Taken from the seed layer rather than written out again, so the two cannot
85/// drift apart. Below the shortest window the setting has nothing to apply to:
86/// a run shorter than any window indexed never becomes a seed, so lowering the
87/// floor recovers nothing. Above it the floor discards runs the seeds did find.
88pub const DEFAULT_MIN_STATEMENTS: u32 = shortest_window();
89
90/// The shortest statement window the seed layer indexes.
91// The window lengths are small literals written next to this, so the cast
92// cannot lose anything; there is no const `TryFrom` to say so instead.
93#[allow(clippy::cast_possible_truncation)]
94const fn shortest_window() -> u32 {
95 let mut shortest = usize::MAX;
96 let mut index = 0;
97 while index < crate::features::WINDOW_LENGTHS.len() {
98 if crate::features::WINDOW_LENGTHS[index] < shortest {
99 shortest = crate::features::WINDOW_LENGTHS[index];
100 }
101 index += 1;
102 }
103 shortest as u32
104}
105
106/// Default largest source-length ratio between a seed's two sides.
107///
108/// Generous on purpose: consistent renaming moves source length by a few
109/// percent, so anything near this factor means the summaries agreed over
110/// unequal amounts of code.
111pub const DEFAULT_MAX_EXTENT_RATIO: f64 = 2.0;
112
113/// Tuning for region consolidation.
114#[derive(Debug, Clone, PartialEq)]
115pub struct MaximalConfig {
116 /// Shortest run, in statements, that is still reported. Shorter regions
117 /// are dropped and counted in [`RegionStats::below_minimum`].
118 pub min_statements: u32,
119 /// Largest ratio between the source lengths of a seed's two sides before
120 /// the seed is dropped as a summary-level coincidence.
121 pub max_extent_ratio: f64,
122}
123
124impl Default for MaximalConfig {
125 fn default() -> Self {
126 Self {
127 min_statements: DEFAULT_MIN_STATEMENTS,
128 max_extent_ratio: DEFAULT_MAX_EXTENT_RATIO,
129 }
130 }
131}
132
133/// One side of a clone region: where the shared run sits in one unit.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
135pub struct RegionSide {
136 /// Index of the file in the analysed slice.
137 pub file: usize,
138 /// Index of the enclosing unit in the file's units.
139 pub unit: usize,
140 /// The statement run the region covers.
141 pub run: StatementRun,
142 /// Source bytes the run covers; reporting only.
143 pub range: ByteRange,
144}
145
146/// A maximal shared statement run between two units.
147///
148/// The two sides hold the same statement summaries, statement for statement,
149/// so `a.run.length == b.run.length` always.
150#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
151pub struct CloneRegion {
152 /// The lower side, by fragment order.
153 pub a: RegionSide,
154 /// The higher side.
155 pub b: RegionSide,
156 /// How many seed matches folded into this region.
157 pub seeds: usize,
158}
159
160/// One duplicated run and every place it occurs.
161///
162/// A run copied into `n` places produces `n * (n - 1) / 2` pairwise matches
163/// that all describe the same duplication, so the pairs are collapsed into the
164/// occurrence set they imply. Every occurrence in the set holds the same
165/// statement summaries as every other, not merely as its neighbours: see
166/// [`consolidate`] for why grouping by transitive closure is sound here and
167/// would not be for an approximate match.
168#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
169pub struct SharedRegion {
170 /// Where the run occurs, at least twice, in ascending order.
171 pub occurrences: Vec<RegionSide>,
172 /// Length of the run, in statements; the same at every occurrence.
173 pub statements: u32,
174}
175
176/// What consolidation saw and dropped.
177#[derive(Debug, Clone, Default, PartialEq, Eq)]
178pub struct RegionStats {
179 /// Statement-window seed matches offered to the fold.
180 pub seeds: usize,
181 /// Seeds dropped because their two sides cover very different amounts of
182 /// source, so the summaries matched over unequal code.
183 pub divergent_extent: usize,
184 /// Regions the seeds folded into, before the drops below.
185 pub folded: usize,
186 /// Regions absorbed by a region containing them on both sides.
187 pub absorbed: usize,
188 /// Regions whose two sides overlap each other in one block, so the
189 /// "clone" is a run overlapping itself rather than two instances.
190 pub self_overlapping: usize,
191 /// Regions shorter than [`MaximalConfig::min_statements`].
192 pub below_minimum: usize,
193 /// Pairwise regions emitted.
194 pub regions: usize,
195 /// Occurrence sets the pairwise regions collapse into: the number of
196 /// distinct duplicated runs.
197 pub shared: usize,
198}
199
200/// The consolidation stage's output.
201#[derive(Debug, Clone, Default, PartialEq, Eq)]
202pub struct RegionSet {
203 /// Maximal pairwise regions, deterministically ordered: the evidence the
204 /// occurrence sets are built from.
205 pub regions: Vec<CloneRegion>,
206 /// One entry per duplicated run, holding every place it occurs.
207 pub shared: Vec<SharedRegion>,
208 /// What the stage saw and dropped.
209 pub stats: RegionStats,
210}
211
212/// How two runs are aligned: the enclosing blocks and the offset between them.
213/// Seeds sharing an alignment describe one shared run.
214#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
215struct Alignment {
216 a_file: usize,
217 a_unit: usize,
218 a_block: u32,
219 b_file: usize,
220 b_unit: usize,
221 b_block: u32,
222 /// `b.start - a.start`, which is constant along one shared run.
223 shift: i64,
224}
225
226/// A run being grown, with the source bytes and seed count folded so far.
227#[derive(Debug, Clone, Copy)]
228struct Growing {
229 a_start: u32,
230 a_end: u32,
231 a_bytes: ByteRange,
232 b_bytes: ByteRange,
233 seeds: usize,
234}
235
236/// Fold statement-window seed matches into maximal shared runs.
237///
238/// Subtree seeds are ignored: a subtree is a tree region, not a run of
239/// sibling statements, so it has no adjacency to grow along. It still does its
240/// job upstream, where it proposes the unit pair.
241///
242/// The result is a pure function of the input.
243#[must_use]
244pub fn consolidate(pairs: &[CandidatePair], config: &MaximalConfig) -> RegionSet {
245 let mut stats = RegionStats::default();
246 let mut runs: BTreeMap<Alignment, Vec<(StatementRun, ByteRange, StatementRun, ByteRange)>> =
247 BTreeMap::new();
248
249 for pair in pairs {
250 if pair.kind != FeatureKind::StatementWindow {
251 continue;
252 }
253 let (Some(a_run), Some(b_run)) = (pair.a.run, pair.b.run) else {
254 continue;
255 };
256 stats.seeds += 1;
257 let (a_bytes, b_bytes) = pair_ranges(pair);
258 if diverges(a_bytes, b_bytes, config.max_extent_ratio) {
259 stats.divergent_extent += 1;
260 continue;
261 }
262 let alignment = Alignment {
263 a_file: pair.a.file,
264 a_unit: pair.a.unit,
265 a_block: a_run.block,
266 b_file: pair.b.file,
267 b_unit: pair.b.unit,
268 b_block: b_run.block,
269 shift: i64::from(b_run.start) - i64::from(a_run.start),
270 };
271 runs.entry(alignment)
272 .or_default()
273 .push((a_run, a_bytes, b_run, b_bytes));
274 }
275
276 let mut folded: Vec<CloneRegion> = Vec::new();
277 for (alignment, mut seeds) in runs {
278 seeds.sort_by_key(|&(a_run, _, _, _)| (a_run.start, a_run.length));
279 let mut current: Option<Growing> = None;
280 for (a_run, a_bytes, _, b_bytes) in seeds {
281 // Touching or overlapping the run grown so far: extend it.
282 let extends = current.is_some_and(|growing| a_run.start <= growing.a_end);
283 if let (true, Some(growing)) = (extends, current.as_mut()) {
284 growing.a_end = growing.a_end.max(a_run.end());
285 growing.a_bytes = union(growing.a_bytes, a_bytes);
286 growing.b_bytes = union(growing.b_bytes, b_bytes);
287 growing.seeds += 1;
288 continue;
289 }
290 if let Some(done) = current.take() {
291 folded.push(emit(&alignment, &done));
292 }
293 current = Some(Growing {
294 a_start: a_run.start,
295 a_end: a_run.end(),
296 a_bytes,
297 b_bytes,
298 seeds: 1,
299 });
300 }
301 if let Some(done) = current {
302 folded.push(emit(&alignment, &done));
303 }
304 }
305 stats.folded = folded.len();
306
307 // Containment can only hold between regions over the same two files. Keep
308 // those candidates together, then answer each two-span containment query
309 // through an offline index rather than scanning a generated-code bucket.
310 let mut buckets: BTreeMap<(usize, usize), Vec<CloneRegion>> = BTreeMap::new();
311 for region in folded {
312 if region.a.run.length < config.min_statements {
313 stats.below_minimum += 1;
314 continue;
315 }
316 if overlaps_itself(®ion) {
317 stats.self_overlapping += 1;
318 continue;
319 }
320 buckets
321 .entry((region.a.file, region.b.file))
322 .or_default()
323 .push(region);
324 }
325
326 let mut kept: Vec<CloneRegion> = Vec::new();
327 for bucket in buckets.into_values() {
328 let (bucket, absorbed) = remove_contained(bucket);
329 stats.absorbed += absorbed;
330 kept.extend(bucket);
331 }
332 kept.sort_unstable();
333 stats.regions = kept.len();
334 let shared = share(&kept);
335 stats.shared = shared.len();
336 RegionSet {
337 regions: kept,
338 shared,
339 stats,
340 }
341}
342
343/// The source byte spans carried by a candidate pair.
344const fn pair_ranges(pair: &CandidatePair) -> (ByteRange, ByteRange) {
345 (
346 ByteRange {
347 start: pair.a.start_byte,
348 end: pair.a.end_byte,
349 },
350 ByteRange {
351 start: pair.b.start_byte,
352 end: pair.b.end_byte,
353 },
354 )
355}
356
357/// Remove regions covered on both spans by an earlier region in one file pair.
358fn remove_contained(mut regions: Vec<CloneRegion>) -> (Vec<CloneRegion>, usize) {
359 // Sweep the first span from left to right. For equal starts, put the
360 // widest span first, so a pair of equal regions leaves one canonical
361 // representative instead of removing both.
362 regions.sort_by_key(|region| {
363 (
364 region.a.range.start,
365 std::cmp::Reverse(region.a.range.end),
366 region.b.range.start,
367 std::cmp::Reverse(region.b.range.end),
368 region.a,
369 region.b,
370 )
371 });
372 let mut index = ContainmentIndex::for_regions(®ions);
373 let mut kept = Vec::with_capacity(regions.len());
374 let mut absorbed = 0;
375 for region in regions {
376 if index.contains(®ion) {
377 absorbed += 1;
378 } else {
379 index.insert(®ion);
380 kept.push(region);
381 }
382 }
383 (kept, absorbed)
384}
385
386/// Offline two-dimensional range index for the second half of a clone region.
387///
388/// The outer sweep supplies the first-span start condition. Each Fenwick node
389/// covers a prefix of second-span starts and holds another Fenwick tree over
390/// first-span ends. Its value is the greatest second-span end seen there, so a
391/// query proves all remaining containment conditions in `O(log² m)`.
392struct ContainmentIndex {
393 /// Sorted unique starts of the second span.
394 second_starts: Vec<usize>,
395 /// Per outer Fenwick node, the possible ends of the first span.
396 first_ends: Vec<Vec<usize>>,
397 /// Per outer Fenwick node, maximum second-span ends by reversed first end.
398 greatest_second_ends: Vec<Vec<usize>>,
399}
400
401impl ContainmentIndex {
402 fn for_regions(regions: &[CloneRegion]) -> Self {
403 let mut second_starts: Vec<usize> =
404 regions.iter().map(|region| region.b.range.start).collect();
405 second_starts.sort_unstable();
406 second_starts.dedup();
407
408 let mut first_ends = vec![Vec::new(); second_starts.len() + 1];
409 for region in regions {
410 let mut node = second_starts.partition_point(|&start| start < region.b.range.start) + 1;
411 while node < first_ends.len() {
412 first_ends[node].push(region.a.range.end);
413 node += lowbit(node);
414 }
415 }
416 for ends in &mut first_ends {
417 ends.sort_unstable();
418 ends.dedup();
419 }
420 let greatest_second_ends = first_ends
421 .iter()
422 .map(|ends| vec![0; ends.len() + 1])
423 .collect();
424
425 Self {
426 second_starts,
427 first_ends,
428 greatest_second_ends,
429 }
430 }
431
432 /// Record one earlier region from the first-span sweep.
433 fn insert(&mut self, region: &CloneRegion) {
434 let mut node = self
435 .second_starts
436 .partition_point(|&start| start < region.b.range.start)
437 + 1;
438 while node < self.first_ends.len() {
439 let ends = &self.first_ends[node];
440 let reversed = ends.len() - ends.partition_point(|&end| end < region.a.range.end);
441 let values = &mut self.greatest_second_ends[node];
442 let mut position = reversed;
443 while position < values.len() {
444 values[position] = values[position].max(region.b.range.end);
445 position += lowbit(position);
446 }
447 node += lowbit(node);
448 }
449 }
450
451 /// Whether an earlier region covers both spans of `region`.
452 fn contains(&self, region: &CloneRegion) -> bool {
453 let mut node = self
454 .second_starts
455 .partition_point(|&start| start <= region.b.range.start);
456 while node > 0 {
457 let ends = &self.first_ends[node];
458 let reversed = ends.len() - ends.partition_point(|&end| end < region.a.range.end);
459 let values = &self.greatest_second_ends[node];
460 let mut greatest = 0;
461 let mut position = reversed;
462 while position > 0 {
463 greatest = greatest.max(values[position]);
464 position -= lowbit(position);
465 }
466 if greatest >= region.b.range.end {
467 return true;
468 }
469 node -= lowbit(node);
470 }
471 false
472 }
473}
474
475/// Least significant set bit of a one-based Fenwick index.
476const fn lowbit(index: usize) -> usize {
477 index & index.wrapping_neg()
478}
479
480/// Collapse pairwise regions into one entry per duplicated run.
481///
482/// Grouping is the transitive closure over the pairwise matches — plain
483/// connected components, which is exactly what clone grouping must *not* use
484/// for approximate matches, because similarity is not transitive and chaining
485/// fuses unrelated code. It is correct here for the opposite reason: these
486/// matches are statement-for-statement equalities, and equality is transitive,
487/// so a component really is a set of mutually equal runs. An occurrence's
488/// extent is part of its identity, so a run that matches one neighbour over
489/// six statements and another over four contributes two occurrences and lands
490/// in two sets, each internally consistent.
491///
492/// A set can hold occurrences that overlap each other, because the closure
493/// reaches them through a third occurrence they both match. Whether those are
494/// one stretch of code or two is not decidable from statement summaries, so it
495/// is left to content confirmation downstream.
496fn share(regions: &[CloneRegion]) -> Vec<SharedRegion> {
497 let mut index: BTreeMap<RegionSide, usize> = BTreeMap::new();
498 for region in regions {
499 let next = index.len();
500 index.entry(region.a).or_insert(next);
501 let next = index.len();
502 index.entry(region.b).or_insert(next);
503 }
504 let mut parent: Vec<usize> = (0..index.len()).collect();
505 for region in regions {
506 let (Some(&a), Some(&b)) = (index.get(®ion.a), index.get(®ion.b)) else {
507 continue;
508 };
509 join(&mut parent, a, b);
510 }
511
512 let mut sets: BTreeMap<usize, Vec<RegionSide>> = BTreeMap::new();
513 for (&side, &node) in &index {
514 sets.entry(find(&mut parent, node)).or_default().push(side);
515 }
516 let mut shared: Vec<SharedRegion> = sets
517 .into_values()
518 .filter(|occurrences| occurrences.len() >= 2)
519 .map(|mut occurrences| {
520 occurrences.sort_unstable();
521 let statements = occurrences.first().map_or(0, |side| side.run.length);
522 SharedRegion {
523 occurrences,
524 statements,
525 }
526 })
527 .collect();
528 shared.sort_unstable();
529 shared
530}
531
532fn find(parent: &mut [usize], mut node: usize) -> usize {
533 while parent[node] != node {
534 parent[node] = parent[parent[node]];
535 node = parent[node];
536 }
537 node
538}
539
540fn join(parent: &mut [usize], a: usize, b: usize) {
541 let (a, b) = (find(parent, a), find(parent, b));
542 if a != b {
543 parent[a.max(b)] = a.min(b);
544 }
545}
546
547/// Turn a grown run and its alignment into a region.
548fn emit(alignment: &Alignment, grown: &Growing) -> CloneRegion {
549 let length = grown.a_end - grown.a_start;
550 let b_start = u32::try_from(i64::from(grown.a_start) + alignment.shift).unwrap_or(0);
551 CloneRegion {
552 a: RegionSide {
553 file: alignment.a_file,
554 unit: alignment.a_unit,
555 run: StatementRun {
556 block: alignment.a_block,
557 start: grown.a_start,
558 length,
559 },
560 range: grown.a_bytes,
561 },
562 b: RegionSide {
563 file: alignment.b_file,
564 unit: alignment.b_unit,
565 run: StatementRun {
566 block: alignment.b_block,
567 start: b_start,
568 length,
569 },
570 range: grown.b_bytes,
571 },
572 seeds: grown.seeds,
573 }
574}
575
576/// Whether a region's two sides are the same stretch of source, which happens
577/// when a repetitive block matches a shifted copy of itself, or when a nested
578/// unit's run matches the enclosing unit's copy of it. Either way there is one
579/// stretch of code, not two instances of one.
580const fn overlaps_itself(region: &CloneRegion) -> bool {
581 region.a.file == region.b.file && intersects(region.a.range, region.b.range)
582}
583
584/// Whether two byte ranges share at least one byte.
585#[must_use]
586pub const fn intersects(a: ByteRange, b: ByteRange) -> bool {
587 a.start < b.end && b.start < a.end
588}
589
590/// Whether one run picks up exactly where the other stops, in the same block
591/// of the same unit.
592///
593/// Two runs that tile one stretch of code are that stretch's period, not two
594/// copies of it. A hand-unrolled loop repeats one operation by construction,
595/// and the second half is not a site anyone can be sent to: the duplication is
596/// the whole block, and the whole block is already where the reader is looking.
597///
598/// The question is asked in statements rather than bytes so that a blank line
599/// or a comment between the two halves cannot change the answer, and it is
600/// asked inside one block because adjacency across units means nothing — two
601/// functions are two sites however the file happens to lay them out.
602#[must_use]
603pub const fn adjoins(a: &RegionSide, b: &RegionSide) -> bool {
604 a.file == b.file
605 && a.unit == b.unit
606 && a.run.block == b.run.block
607 && (a.run.end() == b.run.start || b.run.end() == a.run.start)
608}
609
610/// Whether two matched sides cover source lengths further apart than `ratio`.
611/// A zero-length side is never divergent: there is nothing to compare.
612fn diverges(a: ByteRange, b: ByteRange, ratio: f64) -> bool {
613 let (short, long) = {
614 let (a, b) = (a.len(), b.len());
615 if a <= b { (a, b) } else { (b, a) }
616 };
617 if short == 0 {
618 return false;
619 }
620 #[allow(clippy::cast_precision_loss)]
621 let measured = long as f64 / short as f64;
622 measured > ratio
623}
624
625const fn union(a: ByteRange, b: ByteRange) -> ByteRange {
626 ByteRange {
627 start: if a.start < b.start { a.start } else { b.start },
628 end: if a.end > b.end { a.end } else { b.end },
629 }
630}
631
632#[cfg(test)]
633#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
634mod tests;