Skip to main content

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(&region) {
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(&regions);
373    let mut kept = Vec::with_capacity(regions.len());
374    let mut absorbed = 0;
375    for region in regions {
376        if index.contains(&region) {
377            absorbed += 1;
378        } else {
379            index.insert(&region);
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(&region.a), index.get(&region.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;