Skip to main content

codehelion_core/
engine.rs

1//! The Fast-mode clone-detection engine.
2//!
3//! Input is the lexed token stream and unit boundaries of every file in
4//! scope; output is a set of clone groups with per-group noise signals and
5//! run statistics. Two passes run over the input:
6//!
7//! - a **raw pass** that finds Type-1 (verbatim) clones anywhere — winnowed
8//!   k-gram fingerprints seed candidates, each seed is verified token-by-token
9//!   and extended to a maximal run bounded by function boundaries;
10//! - a **fragment pass** that finds Type-2 (consistently renamed) clones —
11//!   candidate fragments are normalized scope-locally and matched whole, so a
12//!   renamed statement run transplanted into an unrelated host function still
13//!   matches its origin.
14//!
15//! The engine never executes the code it reads, uses no randomness, and sorts
16//! every output deterministically: the same input produces the same report,
17//! token by token. Candidate-explosion controls (posting caps, a global pair
18//! budget, rarest-first pairing) act before the quadratic pairing step, and
19//! everything they drop is counted in [`EngineStats`] rather than vanishing.
20
21pub mod fingerprint;
22pub mod normalize;
23
24mod detect;
25mod group;
26mod segment;
27
28pub use group::{content_entropy_bits, entropy_ratio, group_pairs};
29pub use normalize::LiteralNorm;
30
31use crate::clone_class::CloneClass;
32use crate::conditional::ArmPath;
33use crate::frontend::{Token, Unit};
34
35/// Version of Fast-mode detection and cross-pass consolidation rules.
36pub const ENGINE_VERSION: &str = "fast-engine-v1";
37
38/// One lexed file, as the engine consumes it.
39///
40/// The `file` index of every [`Instance`] in the report refers to the position
41/// of its file in the slice passed to [`detect`].
42#[derive(Debug, Clone, Copy)]
43pub struct InputFile<'a> {
44    /// The file's token stream.
45    pub tokens: &'a [Token],
46    /// The file's unit boundaries, used as barriers and report anchors.
47    pub units: &'a [Unit],
48}
49
50/// Engine tuning. The defaults are the evaluated configuration.
51#[derive(Debug, Clone)]
52pub struct EngineConfig {
53    /// Smallest clone length in tokens; also the k-gram length.
54    pub min_clone_tokens: usize,
55    /// Winnowing window: every run of at least `winnow_window +
56    /// min_clone_tokens - 1` shared tokens is guaranteed a shared fingerprint.
57    pub winnow_window: usize,
58    /// Literal-normalization strategy for the Type-2 pass.
59    pub literals: LiteralNorm,
60    /// Longest posting list (raw pass) or fragment class (Type-2 pass) that
61    /// still enters pairing; longer ones are dropped and counted.
62    pub posting_cap: usize,
63    /// Upper bound on candidate pairs examined *by each pass*. Pairing is
64    /// rarest-first, so exhaustion sacrifices the lowest-signal candidates.
65    /// The allowance is per pass rather than shared: the raw pass runs first
66    /// and would otherwise be able to spend the whole of it, which stops the
67    /// renamed-copy pass finding anything at all.
68    pub pair_budget: usize,
69    /// Largest number of consecutive statements cut as one candidate fragment.
70    pub max_statement_window: usize,
71    /// Groups whose normalized content-entropy ratio is below this value are
72    /// marked suppressed as degenerate repetition.
73    pub entropy_ratio_floor: f64,
74    /// Groups with more members than this are marked suppressed as recurring
75    /// boilerplate.
76    pub degree_cap: usize,
77}
78
79impl Default for EngineConfig {
80    fn default() -> Self {
81        Self {
82            min_clone_tokens: 20,
83            winnow_window: 4,
84            literals: LiteralNorm::Full,
85            posting_cap: 64,
86            pair_budget: 1_000_000,
87            max_statement_window: 8,
88            entropy_ratio_floor: 0.60,
89            degree_cap: 16,
90        }
91    }
92}
93
94/// One occurrence of matched content.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct Instance {
97    /// Index of the file in the input slice.
98    pub file: usize,
99    /// First matched token.
100    pub token_start: usize,
101    /// One past the last matched token.
102    pub token_end: usize,
103    /// 1-based first line, for reporting.
104    pub start_line: u32,
105    /// 1-based last line, for reporting.
106    pub end_line: u32,
107    /// Index into the file's `units` of the innermost enclosing unit, when
108    /// the match sits inside one; the report anchor for partial clones.
109    pub unit: Option<usize>,
110}
111
112/// Why a group was marked suppressed.
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum SuppressReason {
115    /// Content entropy below the floor: degenerate repetition.
116    LowEntropy,
117    /// More instances than the degree cap: recurring boilerplate.
118    HighFrequency,
119}
120
121impl SuppressReason {
122    /// Stable lowercase identifier used in reports.
123    #[must_use]
124    pub const fn name(self) -> &'static str {
125        match self {
126            Self::LowEntropy => "low-entropy",
127            Self::HighFrequency => "high-frequency",
128        }
129    }
130}
131
132/// A verified match between two instances of the same content.
133#[derive(Debug, Clone)]
134pub struct ClonePair {
135    /// Compact candidate-derived content key retained for deterministic
136    /// presentation ordering.
137    pub content_key: u64,
138    /// Collision-resistant matched-content identity used for grouping.
139    pub(crate) content_digest: fingerprint::ContentDigest,
140    /// Clone classification.
141    pub clone_type: CloneClass,
142    /// Fraction of positions whose raw text also matches (1.0 for Type-1).
143    pub score: f64,
144    /// First instance (smaller `(file, token_start)`).
145    pub a: Instance,
146    /// Second instance.
147    pub b: Instance,
148}
149
150/// A set of instances sharing identical matched content.
151#[derive(Debug, Clone)]
152pub struct CloneGroup {
153    /// Compact content key retained for deterministic presentation ordering.
154    pub content_key: u64,
155    /// Clone classification: Type-2 if any member differs in raw text.
156    pub clone_type: CloneClass,
157    /// Minimum pairwise raw-text similarity across the group (1.0 for Type-1).
158    pub score: f64,
159    /// Deduplicated instances, sorted by `(file, token range)`; the first
160    /// member is the canonical instance.
161    pub members: Vec<Instance>,
162    /// Shannon entropy of the content's normalized-token distribution.
163    pub entropy_bits: f64,
164    /// Noise marker, if a suppression signal fired. The group is still
165    /// reported; presentation decides what to do with marked groups.
166    pub suppressed: Option<SuppressReason>,
167}
168
169/// Counters describing what a detection run saw and dropped.
170#[derive(Debug, Clone, Default, PartialEq, Eq)]
171pub struct EngineStats {
172    /// Files analysed.
173    pub files: usize,
174    /// Tokens across all files.
175    pub tokens: usize,
176    /// Winnowed fingerprints indexed by the raw pass.
177    pub raw_fingerprints: usize,
178    /// Distinct fingerprint values in the raw index.
179    pub raw_distinct: usize,
180    /// Distinct fingerprints dropped for exceeding the posting cap.
181    pub stop_fingerprints: usize,
182    /// Postings dropped with them.
183    pub stop_postings: usize,
184    /// Candidate fragments cut for the Type-2 pass.
185    pub fragments: usize,
186    /// Malformed or excessively long control headers that did not become
187    /// Type-2 body fragments.
188    pub control_headers_over_limit: usize,
189    /// Fragment classes (≥ 2 members) that entered pairing.
190    pub fragment_classes: usize,
191    /// Fragment classes dropped for exceeding the posting cap.
192    pub class_cap_dropped: usize,
193    /// Candidate seed pairs examined by the raw pass.
194    pub seed_candidates: usize,
195    /// Pairs the raw pass's eligible posting lists held in total.
196    ///
197    /// Reported beside what was examined so a truncated run says how much of
198    /// its work it did. "The budget ran out" is compatible with having skipped
199    /// one candidate and with having skipped nine in ten, and those are not
200    /// the same result to hand someone.
201    pub raw_pairs_available: usize,
202    /// Candidate fragment pairs examined by the fragment pass.
203    pub fragment_candidates: usize,
204    /// Pairs the fragment pass's eligible classes held in total.
205    pub fragment_pairs_available: usize,
206    /// Verified clone pairs across both passes.
207    pub pairs: usize,
208    /// Members evicted from a class whose normal form did not match its hash.
209    pub hash_collisions: usize,
210    /// Whether the pair budget ran out before all candidates were examined.
211    pub pair_budget_exhausted: bool,
212    /// Candidate pairs that cannot coexist because they occupy alternative
213    /// preprocessor arms, or an arm known to be unreachable.
214    pub conditional_pairs: usize,
215    /// Type-1 groups absorbed by a containing Type-2 group after both passes.
216    pub subsumed_groups: usize,
217}
218
219/// The engine's output: clone groups plus run statistics.
220#[derive(Debug, Clone)]
221pub struct EngineReport {
222    /// Detected clone groups, deterministically ordered.
223    pub groups: Vec<CloneGroup>,
224    /// What the run saw and dropped.
225    pub stats: EngineStats,
226}
227
228/// Detect clones across `files`.
229///
230/// The result is a pure function of the input: file order only affects the
231/// `file` indices inside instances, and every collection in the report is
232/// deterministically sorted.
233#[must_use]
234pub fn detect(files: &[InputFile<'_>], config: &EngineConfig) -> EngineReport {
235    detect_inner(files, None, config)
236}
237
238/// Detect clones while excluding pairs separated by C-family preprocessor arms.
239///
240/// `arm_paths` is parallel to `files`, and each path slice is parallel to its
241/// file's token stream. Invalid metadata is ignored rather than causing an
242/// analysis failure; the ordinary Fast result is safer than trusting a partial
243/// conditional map.
244#[must_use]
245pub fn detect_with_arm_paths(
246    files: &[InputFile<'_>],
247    arm_paths: &[Option<&[ArmPath]>],
248    config: &EngineConfig,
249) -> EngineReport {
250    let arm_paths = (arm_paths.len() == files.len()
251        && arm_paths
252            .iter()
253            .zip(files)
254            .all(|(paths, file)| paths.is_none_or(|paths| paths.len() == file.tokens.len())))
255    .then_some(arm_paths);
256    detect_inner(files, arm_paths, config)
257}
258
259/// Shared Fast detection implementation, with optional preprocessor context.
260fn detect_inner(
261    files: &[InputFile<'_>],
262    arm_paths: Option<&[Option<&[ArmPath]>]>,
263    config: &EngineConfig,
264) -> EngineReport {
265    let mut stats = EngineStats {
266        files: files.len(),
267        tokens: files.iter().map(|f| f.tokens.len()).sum(),
268        ..EngineStats::default()
269    };
270
271    let segments: Vec<Vec<segment::SegmentId>> = files
272        .iter()
273        .map(|f| segment::segment_ids(f.tokens, f.units))
274        .collect();
275    let anchors: Vec<Vec<Option<usize>>> = files
276        .iter()
277        .map(|f| segment::anchor_ids(f.tokens, f.units))
278        .collect();
279
280    // One allowance per pass, not one between them. The two passes answer
281    // different questions over different candidate spaces, and the raw pass
282    // runs first: sharing an allowance lets it spend the whole thing and
283    // leave the renamed-copy pass none, which does not slow the mode down —
284    // it turns half of it off, and says only that some budget somewhere ran
285    // out.
286    let mut raw_budget = detect::PairBudget::new(config.pair_budget);
287    let mut fragment_budget = detect::PairBudget::new(config.pair_budget);
288    let mut pairs = detect::raw_pass(
289        files,
290        &segments,
291        &anchors,
292        config,
293        &mut stats,
294        &mut raw_budget,
295    );
296    pairs.extend(detect::fragment_pass(
297        files,
298        &anchors,
299        config,
300        &mut stats,
301        &mut fragment_budget,
302    ));
303    if let Some(arm_paths) = arm_paths {
304        let before = pairs.len();
305        pairs.retain(|pair| pair_can_coexist(arm_paths, pair));
306        stats.conditional_pairs = before.saturating_sub(pairs.len());
307    }
308    stats.pairs = pairs.len();
309    stats.pair_budget_exhausted = raw_budget.exhausted() || fragment_budget.exhausted();
310
311    let mut groups = group_pairs(&pairs, files, config);
312    stats.subsumed_groups = drop_subsumed_type1_groups(&mut groups);
313    EngineReport { groups, stats }
314}
315
316/// Remove an exact group already represented by a broader renamed class.
317///
318/// The fragment pass can connect two verbatim instances through a third,
319/// renamed instance even though it correctly leaves their direct exact pair
320/// to the raw pass. Once grouped, that produces one Type-1 group whose every
321/// member occupies the same or a containing/contained range as one member of
322/// the Type-2 group. Retaining both would count the shared instances twice.
323fn drop_subsumed_type1_groups(groups: &mut Vec<CloneGroup>) -> usize {
324    let mut dropped = vec![false; groups.len()];
325    for (index, group) in groups.iter().enumerate() {
326        if group.clone_type != CloneClass::Type1 {
327            continue;
328        }
329        dropped[index] = groups.iter().any(|outer| {
330            outer.clone_type == CloneClass::Type2
331                && group.members.iter().all(|member| {
332                    outer.members.iter().any(|candidate| {
333                        candidate.file == member.file
334                            && ((candidate.token_start <= member.token_start
335                                && member.token_end <= candidate.token_end)
336                                || (member.token_start <= candidate.token_start
337                                    && candidate.token_end <= member.token_end))
338                    })
339                })
340        });
341    }
342    let count = dropped.iter().filter(|&&drop| drop).count();
343    let mut position = 0;
344    groups.retain(|_| {
345        let keep = !dropped[position];
346        position += 1;
347        keep
348    });
349    count
350}
351
352/// Whether a reported pair can be present in one C-family build.
353fn pair_can_coexist(paths: &[Option<&[ArmPath]>], pair: &ClonePair) -> bool {
354    let left = paths.get(pair.a.file).and_then(|paths| *paths);
355    let right = paths.get(pair.b.file).and_then(|paths| *paths);
356    match (left, right) {
357        (Some(left), Some(right)) => instance_arm_path(left, &pair.a)
358            .zip(instance_arm_path(right, &pair.b))
359            .is_none_or(|(left, right)| {
360                !left.is_unreachable()
361                    && !right.is_unreachable()
362                    && (pair.a.file != pair.b.file || !left.excludes(right))
363            }),
364        _ => true,
365    }
366}
367
368/// Return a common conditional path only when a match remains in one arm.
369fn instance_arm_path<'a>(paths: &'a [ArmPath], instance: &Instance) -> Option<&'a ArmPath> {
370    let range = paths.get(instance.token_start..instance.token_end)?;
371    let first = range.first()?;
372    range.iter().all(|path| path == first).then_some(first)
373}
374
375#[cfg(test)]
376#[allow(clippy::expect_used, clippy::unwrap_used)]
377mod tests {
378    use super::*;
379    use crate::frontend::{SourceSpan, TokenKind, UnitKind};
380
381    /// Tokenize a whitespace-separated pseudo-source: known keywords become
382    /// keywords, words become identifiers, digit-words become integer
383    /// literals, everything else punctuation. One word per line for stable
384    /// line numbers.
385    fn quick(src: &str) -> Vec<Token> {
386        src.split_whitespace()
387            .enumerate()
388            .map(|(i, w)| {
389                let kind = match w {
390                    "fn" | "let" | "for" | "while" | "loop" | "if" | "else" | "match" | "in"
391                    | "return" => TokenKind::Keyword,
392                    _ if w.chars().all(|c| c.is_ascii_digit()) => {
393                        TokenKind::Literal(crate::frontend::LiteralKind::Integer)
394                    }
395                    _ if w
396                        .chars()
397                        .next()
398                        .is_some_and(|c| c.is_alphabetic() || c == '_') =>
399                    {
400                        TokenKind::Identifier
401                    }
402                    _ => TokenKind::Punctuation,
403                };
404                Token {
405                    kind,
406                    text: w.into(),
407                    span: SourceSpan {
408                        start_byte: i,
409                        end_byte: i + 1,
410                        start_line: u32::try_from(i).unwrap_or(u32::MAX) + 1,
411                        start_column: 1,
412                    },
413                }
414            })
415            .collect()
416    }
417
418    fn function_unit(token_start: usize, token_end: usize) -> Unit {
419        Unit {
420            kind: UnitKind::Function,
421            name: None,
422            token_start,
423            token_end,
424            span: SourceSpan {
425                start_byte: 0,
426                end_byte: 0,
427                start_line: 1,
428                start_column: 1,
429            },
430        }
431    }
432
433    /// A 24-token function with distinctive content and no internal repetition.
434    const FN_A: &str =
435        "fn alpha ( ) { let acc = base + rate * step ; emit ( acc , base , rate , step ) ; }";
436    /// The same function with every local consistently renamed.
437    const FN_A_RENAMED: &str =
438        "fn beta ( ) { let sum = seed + gain * width ; emit ( sum , seed , gain , width ) ; }";
439    /// An unrelated function of similar length.
440    const FN_OTHER: &str = "fn omega ( ) { while cursor < bound { cursor = cursor + probe ; } yield_all ( cursor , bound ) ; }";
441
442    #[test]
443    fn empty_input_yields_an_empty_report() {
444        let report = detect(&[], &EngineConfig::default());
445        assert!(report.groups.is_empty());
446        assert_eq!(report.stats.files, 0);
447    }
448
449    #[test]
450    fn verbatim_functions_across_files_form_a_type1_group() {
451        let a = quick(&format!("{FN_A} {FN_OTHER}"));
452        let b = quick(FN_A);
453        let units_a = vec![function_unit(0, 24), function_unit(24, a.len())];
454        let units_b = vec![function_unit(0, 24)];
455        let files = [
456            InputFile {
457                tokens: &a,
458                units: &units_a,
459            },
460            InputFile {
461                tokens: &b,
462                units: &units_b,
463            },
464        ];
465        let report = detect(&files, &EngineConfig::default());
466        let type1: Vec<_> = report
467            .groups
468            .iter()
469            .filter(|g| g.clone_type == CloneClass::Type1)
470            .collect();
471        assert_eq!(type1.len(), 1, "groups: {:?}", report.groups);
472        let group = type1[0];
473        assert_eq!(group.members.len(), 2);
474        assert_eq!(group.members[0].file, 0);
475        assert_eq!(group.members[1].file, 1);
476        // The match is anchored to the enclosing function on both sides.
477        assert_eq!(group.members[0].unit, Some(0));
478        assert!((group.score - 1.0).abs() < f64::EPSILON);
479    }
480
481    #[test]
482    fn alternative_preprocessor_arms_do_not_form_a_fast_clone_group() {
483        use crate::conditional::{ArmTracker, StaticCondition};
484
485        let first = quick(FN_A);
486        let mut tokens = first.clone();
487        tokens.extend(quick(FN_A));
488        let split = first.len();
489        let units = [function_unit(0, split), function_unit(split, tokens.len())];
490        let files = [InputFile {
491            tokens: &tokens,
492            units: &units,
493        }];
494        let mut tracker = ArmTracker::default();
495        tracker.begin(StaticCondition::Unknown);
496        let mut paths = vec![tracker.current(); split];
497        tracker.next_arm(StaticCondition::Unknown);
498        paths.extend(vec![tracker.current(); tokens.len() - split]);
499
500        let report = detect_with_arm_paths(&files, &[Some(&paths)], &EngineConfig::default());
501        assert!(report.groups.is_empty(), "groups: {:?}", report.groups);
502        assert!(report.stats.conditional_pairs > 0);
503    }
504
505    #[test]
506    fn literal_false_preprocessor_arm_does_not_form_a_fast_clone_group() {
507        use crate::conditional::{ArmTracker, StaticCondition};
508
509        let first = quick(FN_A);
510        let mut tokens = first.clone();
511        tokens.extend(quick(FN_A));
512        let split = first.len();
513        let units = [function_unit(0, split), function_unit(split, tokens.len())];
514        let files = [InputFile {
515            tokens: &tokens,
516            units: &units,
517        }];
518        let mut tracker = ArmTracker::default();
519        tracker.begin(StaticCondition::False);
520        let mut paths = vec![tracker.current(); split];
521        tracker.end();
522        paths.extend(vec![tracker.current(); tokens.len() - split]);
523
524        let report = detect_with_arm_paths(&files, &[Some(&paths)], &EngineConfig::default());
525        assert!(report.groups.is_empty(), "groups: {:?}", report.groups);
526        assert!(report.stats.conditional_pairs > 0);
527    }
528
529    #[test]
530    fn renamed_function_bodies_form_a_type2_group() {
531        let a = quick(FN_A);
532        let b = quick(FN_A_RENAMED);
533        let units_a = vec![function_unit(0, a.len())];
534        let units_b = vec![function_unit(0, b.len())];
535        let files = [
536            InputFile {
537                tokens: &a,
538                units: &units_a,
539            },
540            InputFile {
541                tokens: &b,
542                units: &units_b,
543            },
544        ];
545        let report = detect(&files, &EngineConfig::default());
546        let type2: Vec<_> = report
547            .groups
548            .iter()
549            .filter(|g| g.clone_type == CloneClass::Type2)
550            .collect();
551        assert_eq!(type2.len(), 1, "groups: {:?}", report.groups);
552        let group = type2[0];
553        assert_eq!(group.members.len(), 2);
554        assert!(group.score < 1.0, "renames must lower raw similarity");
555        // No Type-1 group: the renames leave no 20-token verbatim run.
556        assert!(
557            report
558                .groups
559                .iter()
560                .all(|g| g.clone_type != CloneClass::Type1)
561        );
562    }
563
564    #[test]
565    fn a_type2_group_absorbs_its_exact_type1_subset() {
566        let first = quick(FN_A);
567        let second = quick(FN_A);
568        let renamed = quick(FN_A_RENAMED);
569        let first_units = [function_unit(0, first.len())];
570        let second_units = [function_unit(0, second.len())];
571        let renamed_units = [function_unit(0, renamed.len())];
572        let files = [
573            InputFile {
574                tokens: &first,
575                units: &first_units,
576            },
577            InputFile {
578                tokens: &second,
579                units: &second_units,
580            },
581            InputFile {
582                tokens: &renamed,
583                units: &renamed_units,
584            },
585        ];
586
587        let report = detect(&files, &EngineConfig::default());
588
589        assert_eq!(report.groups.len(), 1, "groups: {:#?}", report.groups);
590        assert_eq!(report.groups[0].clone_type, CloneClass::Type2);
591        assert_eq!(report.groups[0].members.len(), 3);
592        assert_eq!(report.stats.subsumed_groups, 1);
593    }
594
595    #[test]
596    fn unrelated_functions_do_not_match() {
597        let a = quick(FN_A);
598        let b = quick(FN_OTHER);
599        let units_a = vec![function_unit(0, a.len())];
600        let units_b = vec![function_unit(0, b.len())];
601        let files = [
602            InputFile {
603                tokens: &a,
604                units: &units_a,
605            },
606            InputFile {
607                tokens: &b,
608                units: &units_b,
609            },
610        ];
611        let report = detect(&files, &EngineConfig::default());
612        assert!(report.groups.is_empty(), "groups: {:?}", report.groups);
613    }
614
615    #[test]
616    fn intra_file_duplicates_are_found() {
617        let src = format!("{FN_A} {FN_A}");
618        let tokens = quick(&src);
619        let units = vec![function_unit(0, 24), function_unit(24, 48)];
620        let files = [InputFile {
621            tokens: &tokens,
622            units: &units,
623        }];
624        let report = detect(&files, &EngineConfig::default());
625        assert_eq!(report.groups.len(), 1);
626        assert_eq!(report.groups[0].members.len(), 2);
627        assert_eq!(report.groups[0].members[0].file, 0);
628        assert_eq!(report.groups[0].members[1].file, 0);
629    }
630
631    #[test]
632    fn maximal_runs_stop_at_function_boundaries() {
633        // Two identical files, each holding two identical adjacent functions:
634        // the match must not fuse across the boundary into one giant run.
635        let src = format!("{FN_A} {FN_A}");
636        let a = quick(&src);
637        let b = quick(&src);
638        let units = vec![function_unit(0, 24), function_unit(24, 48)];
639        let files = [
640            InputFile {
641                tokens: &a,
642                units: &units,
643            },
644            InputFile {
645                tokens: &b,
646                units: &units,
647            },
648        ];
649        let report = detect(&files, &EngineConfig::default());
650        for group in &report.groups {
651            for member in &group.members {
652                assert!(
653                    member.token_end - member.token_start <= 24,
654                    "run crossed a function boundary: {member:?}"
655                );
656            }
657        }
658    }
659
660    #[test]
661    fn exhausted_pair_budget_is_reported_not_silent() {
662        let a = quick(FN_A);
663        let b = quick(FN_A);
664        let units_a = vec![function_unit(0, a.len())];
665        let units_b = vec![function_unit(0, b.len())];
666        let files = [
667            InputFile {
668                tokens: &a,
669                units: &units_a,
670            },
671            InputFile {
672                tokens: &b,
673                units: &units_b,
674            },
675        ];
676        let config = EngineConfig {
677            pair_budget: 0,
678            ..EngineConfig::default()
679        };
680        let report = detect(&files, &config);
681        assert!(report.stats.pair_budget_exhausted);
682        assert!(report.groups.is_empty());
683    }
684
685    #[test]
686    fn a_pair_budget_never_reports_a_partial_candidate_class() {
687        // Seven consistently renamed bodies share one Type-2 candidate class
688        // with 21 relationships. A budget for only three relationships must
689        // omit the entire class, not report an arbitrary three-member group.
690        let sources: Vec<Vec<Token>> = (0..7)
691            .map(|index| {
692                quick(&format!(
693                    "fn function_{index} ( ) {{ let local_{index} = input_{index} + delta_{index} ; emit ( local_{index} , input_{index} , delta_{index} ) ; }}"
694                ))
695            })
696            .collect();
697        let units: Vec<Vec<Unit>> = sources
698            .iter()
699            .map(|tokens| vec![function_unit(0, tokens.len())])
700            .collect();
701        let files: Vec<InputFile<'_>> = sources
702            .iter()
703            .zip(&units)
704            .map(|(tokens, units)| InputFile { tokens, units })
705            .collect();
706        let complete = EngineConfig {
707            min_clone_tokens: 12,
708            posting_cap: 7,
709            pair_budget: 21,
710            ..EngineConfig::default()
711        };
712        let complete_report = detect(&files, &complete);
713        let complete_groups: Vec<_> = complete_report
714            .groups
715            .iter()
716            .filter(|group| group.clone_type == CloneClass::Type2)
717            .collect();
718        assert_eq!(complete_groups.len(), 1, "groups: {complete_groups:#?}");
719        assert_eq!(complete_groups[0].members.len(), 7);
720
721        let truncated = EngineConfig {
722            pair_budget: 3,
723            ..complete
724        };
725        let truncated_report = detect(&files, &truncated);
726        assert!(truncated_report.stats.pair_budget_exhausted);
727        assert_eq!(truncated_report.stats.fragment_pairs_available, 21);
728        assert_eq!(truncated_report.stats.fragment_candidates, 0);
729        assert!(
730            truncated_report.groups.is_empty(),
731            "a partial class must not become a smaller group: {:?}",
732            truncated_report.groups
733        );
734
735        // Fast-mode Type-1 seeding uses the same whole-class rule. The
736        // repeated source yields several eligible winnow lists, each with
737        // seven members; none may leak a three-member prefix.
738        let repeated = quick(FN_A);
739        let repeated_units = vec![function_unit(0, repeated.len())];
740        let repeated_files: Vec<InputFile<'_>> = (0..7)
741            .map(|_| InputFile {
742                tokens: &repeated,
743                units: &repeated_units,
744            })
745            .collect();
746        let raw_report = detect(&repeated_files, &truncated);
747        assert!(raw_report.stats.pair_budget_exhausted);
748        assert!(raw_report.stats.raw_pairs_available >= 21);
749        assert_eq!(raw_report.stats.seed_candidates, 0);
750        assert!(
751            raw_report.groups.is_empty(),
752            "groups: {:?}",
753            raw_report.groups
754        );
755    }
756
757    /// The pass that finds renamed copies must not be starved by the pass
758    /// that finds verbatim ones.
759    ///
760    /// The raw pass runs first over a much larger candidate space. Sharing one
761    /// allowance between the two means that on any sizeable tree the raw pass
762    /// spends all of it, and renamed-copy detection — half of what the mode
763    /// claims to do — quietly stops happening. The report would say a budget
764    /// ran out, which reads as "some low-signal candidates were skipped", not
765    /// as "one of the two detectors did not run".
766    #[test]
767    fn spending_the_allowance_on_verbatim_copies_still_leaves_renamed_ones_found() {
768        // Eight copies of one function give the raw pass far more seeds than
769        // the allowance covers; the renamed copy of another function is
770        // reachable only through the fragment pass.
771        let mut sources = vec![
772            quick(&format!("{FN_A} {FN_OTHER}")),
773            quick(&format!("{FN_A_RENAMED} {FN_OTHER}")),
774        ];
775        sources.extend((0..6).map(|_| quick(FN_OTHER)));
776        let units: Vec<Vec<Unit>> = sources
777            .iter()
778            .enumerate()
779            .map(|(index, tokens)| {
780                if index < 2 {
781                    vec![function_unit(0, 26), function_unit(26, tokens.len())]
782                } else {
783                    vec![function_unit(0, tokens.len())]
784                }
785            })
786            .collect();
787        let files: Vec<InputFile<'_>> = sources
788            .iter()
789            .zip(&units)
790            .map(|(tokens, units)| InputFile { tokens, units })
791            .collect();
792        let config = EngineConfig {
793            pair_budget: 20,
794            ..EngineConfig::default()
795        };
796        let report = detect(&files, &config);
797        assert!(
798            report.stats.pair_budget_exhausted,
799            "the allowance has to run out for this to be measuring anything"
800        );
801        let found: Vec<CloneClass> = report.groups.iter().map(|group| group.clone_type).collect();
802        assert!(
803            found.contains(&CloneClass::Type2),
804            "the renamed copy is still found: {found:?}"
805        );
806    }
807
808    #[test]
809    fn posting_cap_drops_and_counts_high_frequency_fingerprints() {
810        let a = quick(FN_A);
811        let units: Vec<Unit> = vec![function_unit(0, a.len())];
812        let many: Vec<InputFile<'_>> = (0..3)
813            .map(|_| InputFile {
814                tokens: &a,
815                units: &units,
816            })
817            .collect();
818        let config = EngineConfig {
819            posting_cap: 1,
820            ..EngineConfig::default()
821        };
822        let report = detect(&many, &config);
823        assert!(report.stats.stop_fingerprints > 0);
824        assert!(report.stats.stop_postings > 0);
825    }
826
827    #[test]
828    fn degenerate_repetition_is_marked_low_entropy() {
829        // 30 identical tokens: entropy 0, still reported but marked.
830        let src = "x ".repeat(30);
831        let a = quick(&src);
832        let b = quick(&src);
833        let files = [
834            InputFile {
835                tokens: &a,
836                units: &[],
837            },
838            InputFile {
839                tokens: &b,
840                units: &[],
841            },
842        ];
843        let report = detect(&files, &EngineConfig::default());
844        assert!(!report.groups.is_empty());
845        assert_eq!(
846            report.groups[0].suppressed,
847            Some(SuppressReason::LowEntropy)
848        );
849    }
850
851    #[test]
852    fn detection_is_deterministic() {
853        let a = quick(&format!("{FN_A} {FN_OTHER}"));
854        let b = quick(FN_A_RENAMED);
855        let units_a = vec![function_unit(0, 24), function_unit(24, a.len())];
856        let units_b = vec![function_unit(0, b.len())];
857        let files = [
858            InputFile {
859                tokens: &a,
860                units: &units_a,
861            },
862            InputFile {
863                tokens: &b,
864                units: &units_b,
865            },
866        ];
867        let first = detect(&files, &EngineConfig::default());
868        let second = detect(&files, &EngineConfig::default());
869        assert_eq!(first.stats, second.stats);
870        assert_eq!(first.groups.len(), second.groups.len());
871        for (x, y) in first.groups.iter().zip(second.groups.iter()) {
872            assert_eq!(x.content_key, y.content_key);
873            assert_eq!(x.members, y.members);
874        }
875    }
876}