Skip to main content

codehelion_core/
verify.rs

1//! Structural-mode weighted verification: the precise judgment of a candidate
2//! pair.
3//!
4//! The candidate stages ([`crate::candidate`], [`crate::near_match`]) propose
5//! pairs cheaply and over-approximate; this stage decides. It compares two
6//! units across several independent dimensions, keeps every dimension's score
7//! rather than collapsing to one opaque number (AGENTS.md §22), and only then
8//! forms a composite and a clone classification.
9//!
10//! The dimensions:
11//!
12//! - **lexical** — how much of the aligned statements' text matches verbatim;
13//!   separates a verbatim copy from a renamed one;
14//! - **structural** — the statement-summary alignment (a rename-invariant LCS)
15//!   folded with the characteristic-vector cosine and the subtree overlap;
16//! - **control flow** — the approximate control-flow profiles (a syntactic
17//!   approximation, refined by a real CFG in Semantic mode);
18//! - **type** — how much the two units' resolved types agree, as
19//!   [`crate::types::TypeEvidence`]. Unavailable in Structural mode, which
20//!   resolves no types: the dimension is then `None` and the classification's
21//!   confidence is penalised accordingly rather than guessing. Supplying
22//!   evidence for both sides is what lifts that penalty, and only a compiler
23//!   can supply it;
24//! - **api** — how much the two call surfaces overlap. Semantic mode uses
25//!   compiler-resolved targets when both units have them; otherwise it retains
26//!   Structural mode's call-name comparison. It is unavailable when neither
27//!   unit calls anything, since two empty call surfaces are an absence of
28//!   evidence rather than agreement.
29//!
30//! Alignment is a by-product: the LCS backtrace records which statements
31//! matched and which are unique to each side, which is the diff `explain`
32//! shows. The composite weights are configurable and versioned
33//! ([`WEIGHT_VERSION`]), and that version travels with the detector identity
34//! (AGENTS.md §2-4) so two results can be compared knowing which weights
35//! produced them. Changing the weights changes findings, and before the first
36//! release that invalidates the results recorded under the old ones rather
37//! than raising the version, which stays at v1. Everything here is a pure
38//! function of its inputs.
39//!
40//! # What the composite can and cannot separate
41//!
42//! The acceptance threshold is what separates clones from lookalikes, and the
43//! labelled corpora bound how well it can: functions written to share a
44//! skeleton while computing different things score up to 0.69, and the weakest
45//! pair that is a real copy scores 0.71.
46//! [`VerifyConfig::type3_min_composite`] sits between them.
47//!
48//! Two properties of that gap are worth stating, because they decide where
49//! future accuracy work belongs.
50//!
51//! First, **lexical is the dimension that discriminates**. Lookalikes agree on
52//! shape by construction — that is what makes them lookalikes — so structural
53//! and control-flow agreement is high for both populations and only lexical
54//! agreement pulls them apart. Weighting shape more heavily than text therefore
55//! costs precision rather than buying it, and no reweighting of these five
56//! dimensions separates the two populations by more than a hair unless lexical
57//! is the one carrying the weight.
58//!
59//! Second, **a unit can be a genuine clone and still not be worth reporting**.
60//! Two one-line accessors are copies of each other by every measure in this
61//! module, and they score accordingly. Suppressing them is
62//! [`crate::boilerplate`]'s job, not this one's: lowering a similarity score to
63//! hide a triviality would corrupt the evidence the score exists to carry.
64
65use crate::clone_class::CloneClass;
66use crate::features::{ApiCallFeature, CfgFeature, SubtreeFeature, UnitFeatures};
67use crate::frontend::Token;
68use crate::ir::{IrNode, Shape, StatementSummary};
69use crate::stable_id::FragmentFingerprint;
70use crate::types::{ApiEvidence, TypeEvidence};
71
72/// Version of the composite-weight recipe and judgment rules. Bump it when any
73/// weight default or classification rule changes, since findings change with
74/// it. Recorded as a detector version.
75pub const WEIGHT_VERSION: &str = "structural-verify-v1";
76
77/// Relative weights of the similarity dimensions in the composite score.
78///
79/// A dimension that is unavailable for a pair (a `None` type similarity in
80/// Structural mode) drops out and the remaining weights renormalise, so the
81/// composite is always a weighted mean over the dimensions that were actually
82/// measured.
83#[derive(Debug, Clone, PartialEq)]
84pub struct Weights {
85    /// Weight of the lexical dimension.
86    pub lexical: f64,
87    /// Weight of the structural dimension.
88    pub structural: f64,
89    /// Weight of the control-flow dimension.
90    pub control_flow: f64,
91    /// Weight of the type dimension, applied only when it is available.
92    pub type_similarity: f64,
93    /// Weight of the api dimension.
94    pub api: f64,
95}
96
97impl Default for Weights {
98    fn default() -> Self {
99        Self {
100            lexical: 0.20,
101            structural: 0.45,
102            control_flow: 0.20,
103            type_similarity: 0.15,
104            api: 0.15,
105        }
106    }
107}
108
109/// Tuning for verification. Thresholds are provisional and calibrated against
110/// the mutation corpus.
111#[derive(Debug, Clone, PartialEq)]
112pub struct VerifyConfig {
113    /// Composite-score weights.
114    pub weights: Weights,
115    /// Smallest composite a Type-3 pair must reach to be a clone at all.
116    ///
117    /// Calibrated against the labelled corpora: the pairs deliberately built
118    /// to share a skeleton while computing different things reach 0.69, and
119    /// the weakest pair that is a real copy reaches 0.71. The threshold sits
120    /// in that gap.
121    ///
122    /// The gap is narrow, and which side an unlabelled pair falls on is
123    /// decided almost entirely by lexical agreement — the lookalikes reach
124    /// 0.77 there while the weakest real copy reaches 0.91. A composite near
125    /// this threshold is therefore weak evidence by construction, which is
126    /// what the low confidence band exists to say.
127    pub type3_min_composite: f64,
128    /// Smallest lexical agreement required before an otherwise exact
129    /// structural match can be called a safe Type-2 clone.
130    ///
131    /// Below this floor, matching shape alone is insufficient evidence that
132    /// the differing code is a consistent rename or literal substitution.
133    /// Such pairs remain eligible for the ordinary Type-3 decision.
134    pub type2_min_lexical: f64,
135    /// Composite at or above which a Type-3 finding is high confidence.
136    pub high_confidence: f64,
137    /// Composite at or above which a Type-3 finding is medium confidence.
138    ///
139    /// Kept above [`Self::type3_min_composite`], or the low band could never
140    /// be reached and a finding sitting just over the acceptance threshold
141    /// would be reported as confidently as one well clear of it.
142    pub medium_confidence: f64,
143    /// Tolerance for treating a similarity as exactly `1.0`.
144    pub exact_epsilon: f64,
145    /// How far the statement alignment may stray from the diagonal that joins
146    /// the two sequences' ends, in statements.
147    ///
148    /// The band bounds the alignment's cost at `O(min(n, m) * band)` instead
149    /// of `O(n * m)`. Widening it can only raise a pair's similarity, so the
150    /// banded result is a lower bound: a real clone, whose alignment hugs that
151    /// diagonal, is measured exactly, while a pair that would need to wander
152    /// further is scored no higher than it deserves.
153    pub alignment_band: usize,
154    /// Largest alignment table, in cells.
155    ///
156    /// The band alone bounds the table for units of comparable length; this
157    /// bounds it for a pair whose lengths also differ widely, by narrowing the
158    /// band further until the table fits. Narrowing only weakens the lower
159    /// bound, so nothing is dropped — such a pair cannot align well enough to
160    /// be a clone in any case.
161    pub max_alignment_cells: usize,
162}
163
164impl Default for VerifyConfig {
165    fn default() -> Self {
166        Self {
167            weights: Weights::default(),
168            type3_min_composite: 0.70,
169            type2_min_lexical: 0.90,
170            high_confidence: 0.85,
171            medium_confidence: 0.75,
172            exact_epsilon: 1e-9,
173            // Wide enough that the gapped clones the mode targets — copies
174            // with statements inserted or removed — align exactly.
175            alignment_band: 64,
176            // 4M cells is ~16 MiB of table, reached only by a pair of units
177            // in the tens of thousands of statements with lengths far apart.
178            max_alignment_cells: 4_000_000,
179        }
180    }
181}
182
183/// How far past the acceptance threshold a finding's composite similarity
184/// sits.
185///
186/// It bands one number and says nothing beyond it. In particular it is not a
187/// prediction that the finding is worth acting on, and over hand-labelled real
188/// code it runs the other way: the shapes that are alike without being worth
189/// reporting — one routine per integer width, one accessor per variant — are
190/// alike almost exactly, so they land in the top band. The labelled corpora
191/// print the band's measured precision beside this ordering rather than
192/// leaving the names to imply one.
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub enum Confidence {
195    /// The two agree well clear of the threshold.
196    High,
197    /// The two agree, with room between the score and the threshold.
198    Medium,
199    /// The two agree just past the threshold.
200    Low,
201}
202
203impl Confidence {
204    /// Stable lowercase identifier.
205    #[must_use]
206    pub const fn name(self) -> &'static str {
207        match self {
208            Self::High => "high",
209            Self::Medium => "medium",
210            Self::Low => "low",
211        }
212    }
213
214    /// Lower `High` to `Medium`, leaving the other bands unchanged; used to
215    /// penalise a Type-3 finding for which no type evidence was available.
216    const fn without_type_evidence(self) -> Self {
217        match self {
218            Self::High | Self::Medium => Self::Medium,
219            Self::Low => Self::Low,
220        }
221    }
222}
223
224/// The per-dimension similarity scores and their composite.
225///
226/// Every dimension stays visible: the composite is a convenience, never a
227/// replacement for the breakdown.
228#[derive(Debug, Clone, Copy, PartialEq)]
229pub struct SimilarityBreakdown {
230    /// Verbatim agreement of aligned statements' leading tokens.
231    pub lexical: f64,
232    /// Rename-invariant structural agreement.
233    pub structural: f64,
234    /// Control-flow-profile agreement (a syntactic approximation), or `None`
235    /// when neither side has control-flow operations to compare.
236    pub control_flow: Option<f64>,
237    /// Type agreement, or `None` when types are unavailable (Structural mode).
238    pub type_similarity: Option<f64>,
239    /// Call-name multiset agreement, or `None` when neither unit calls
240    /// anything and there is therefore nothing to compare.
241    pub api: Option<f64>,
242    /// Weighted mean of the available dimensions.
243    pub composite: f64,
244}
245
246/// The statement alignment behind a verdict: the diff `explain` renders.
247///
248/// Indices are into the two units' statement sequences as passed to
249/// [`verify`].
250#[derive(Debug, Clone, Default, PartialEq, Eq)]
251pub struct Alignment {
252    /// Matched statement index pairs, in order.
253    pub matched: Vec<(usize, usize)>,
254    /// Indices of statements present only in the first unit.
255    pub only_a: Vec<usize>,
256    /// Indices of statements present only in the second unit.
257    pub only_b: Vec<usize>,
258}
259
260impl Alignment {
261    /// The same alignment read from the other unit's side.
262    ///
263    /// An alignment is monotone in both coordinates, so swapping each matched
264    /// pair leaves the sequence ordered.
265    fn mirrored(self) -> Self {
266        Self {
267            matched: self.matched.into_iter().map(|(i, j)| (j, i)).collect(),
268            only_a: self.only_b,
269            only_b: self.only_a,
270        }
271    }
272}
273
274/// One unit's inputs to verification: its flattened statement sequence, the
275/// token stream those statements span, and its extracted features.
276#[derive(Debug, Clone, Copy)]
277pub struct UnitView<'a> {
278    /// The unit's statements, flattened in pre-order (see
279    /// [`statement_sequence`]).
280    pub statements: &'a [StatementSummary],
281    /// The whole token stream of the file the statements came from. A
282    /// statement span indexes this, so it must be the same stream the
283    /// summaries were built against.
284    pub tokens: &'a [Token],
285    /// Position-free content fingerprint of the verified unit. It makes the
286    /// pair ordering total when feature summaries happen to tie.
287    pub content: FragmentFingerprint,
288    /// The unit's extracted features.
289    pub features: &'a UnitFeatures,
290    /// The types a compiler resolved inside the unit, when one did.
291    ///
292    /// `None` in the modes that run no compiler, which is a different claim
293    /// from empty evidence: absent means nobody looked, and empty means
294    /// somebody looked and found nothing to compare.
295    pub types: Option<&'a TypeEvidence>,
296    /// The call targets a compiler resolved inside the unit, when both sides
297    /// of a comparison can use them. Missing targets deliberately retain the
298    /// Structural call-name comparison rather than claiming disagreement.
299    pub apis: Option<&'a ApiEvidence>,
300}
301
302/// The outcome of verifying a candidate pair.
303#[derive(Debug, Clone, PartialEq)]
304pub struct Verdict {
305    /// The clone class, or `None` when the pair is not a clone.
306    pub class: Option<CloneClass>,
307    /// Confidence of the classification; `Some` exactly when `class` is.
308    pub confidence: Option<Confidence>,
309    /// The similarity breakdown.
310    pub breakdown: SimilarityBreakdown,
311    /// The statement alignment.
312    pub alignment: Alignment,
313}
314
315/// Flatten a unit subtree into its statement summaries, in pre-order: each
316/// block contributes its direct statements before its nested blocks do.
317#[must_use]
318pub fn statement_sequence(unit: &IrNode, tokens: &[Token]) -> Vec<StatementSummary> {
319    let mut out = Vec::new();
320    collect_statements(unit, tokens, &mut out);
321    out
322}
323
324fn collect_statements(node: &IrNode, tokens: &[Token], out: &mut Vec<StatementSummary>) {
325    if matches!(node.shape, Shape::Block) {
326        out.extend(node.statement_summaries(tokens));
327    }
328    for child in &node.children {
329        collect_statements(child, tokens, out);
330    }
331}
332
333/// Verify a candidate pair, producing its similarity breakdown, alignment and
334/// clone classification.
335///
336/// The verdict is a property of the pair, not of the order the two units were
337/// passed in: several alignments can be equally long, and which one the
338/// recurrence settles on depends on which unit leads, so the same two units
339/// would otherwise score differently depending on which of them a group picked
340/// as its medoid. The pair is therefore ordered by content before measuring,
341/// and the alignment is mirrored back so the caller still reads it as
342/// `(a, b)`.
343#[must_use]
344pub fn verify(a: &UnitView<'_>, b: &UnitView<'_>, config: &VerifyConfig) -> Verdict {
345    if order_key(b) < order_key(a) {
346        let mut verdict = measure(b, a, config);
347        verdict.alignment = verdict.alignment.mirrored();
348        return verdict;
349    }
350    measure(a, b, config)
351}
352
353/// Content-derived ordering key of one unit.
354///
355/// Nothing positional enters it: a unit's key must not change because the unit
356/// moved within its file.
357const fn order_key(unit: &UnitView<'_>) -> (usize, [u8; 16], [u8; 16], u8, [u8; 16]) {
358    (
359        unit.statements.len(),
360        *unit.features.cfg.hash.as_bytes(),
361        *unit.features.api.multiset_hash.as_bytes(),
362        unit.features.shape_tag,
363        *unit.content.as_bytes(),
364    )
365}
366
367/// Measure and classify one ordered pair.
368fn measure(a: &UnitView<'_>, b: &UnitView<'_>, config: &VerifyConfig) -> Verdict {
369    let (lcs, alignment) = align(a.statements, b.statements, config);
370    let seq_sim = sequence_similarity(lcs, a.statements.len(), b.statements.len());
371    let lexical = lexical_similarity(a, b, &alignment);
372    let vector = a.features.vector.cosine_similarity(&b.features.vector);
373    let structural = subtree_jaccard(&a.features.subtrees, &b.features.subtrees).map_or_else(
374        || seq_sim.midpoint(vector),
375        |subtree| mean3(seq_sim, vector, subtree),
376    );
377    let control_flow = cfg_similarity(&a.features.cfg, &b.features.cfg);
378    let api = a
379        .apis
380        .zip(b.apis)
381        .and_then(|(a, b)| ApiEvidence::agreement(a, b))
382        .or_else(|| api_similarity(&a.features.api, &b.features.api));
383    // Absent unless a compiler resolved types for both sides. Structural mode
384    // resolves none, and the dimension is then missing rather than zero: a
385    // zero would say the two units' types disagree, which nothing measured.
386    let type_similarity = a
387        .types
388        .zip(b.types)
389        .and_then(|(a, b)| TypeEvidence::agreement(a, b));
390
391    let composite = composite(
392        &config.weights,
393        lexical,
394        structural,
395        control_flow,
396        type_similarity,
397        api,
398    );
399    let breakdown = SimilarityBreakdown {
400        lexical,
401        structural,
402        control_flow,
403        type_similarity,
404        api,
405        composite,
406    };
407
408    let (class, confidence) = classify(&breakdown, config);
409    Verdict {
410        class,
411        confidence,
412        breakdown,
413        alignment,
414    }
415}
416
417/// Classify a breakdown into a clone class and confidence, or `None` when the
418/// pair falls below the Type-3 threshold.
419fn classify(
420    breakdown: &SimilarityBreakdown,
421    config: &VerifyConfig,
422) -> (Option<CloneClass>, Option<Confidence>) {
423    let eps = config.exact_epsilon;
424    let exact = |value: f64| (1.0 - value).abs() <= eps;
425
426    // Identical structure: the statement alignment, the shape vector and the
427    // subtree set all agree completely.
428    if exact(breakdown.structural) {
429        // A Type-1 claim says the copies differ only in whitespace and
430        // comments. A statement summary keeps just its leading tokens, so a
431        // rename further into a statement leaves `lexical` exact; the call
432        // surface is the dimension that carries identifier text, and a
433        // difference there is evidence of renaming that outranks the silence
434        // of the head tokens. Type-2 is then the claim the evidence supports.
435        if exact(breakdown.lexical) && breakdown.api.is_none_or(exact) {
436            return (Some(CloneClass::Type1), Some(Confidence::High));
437        }
438        if breakdown.lexical >= config.type2_min_lexical {
439            return (Some(CloneClass::Type2), Some(Confidence::High));
440        }
441        // Structural agreement does not prove that two low-lexical snippets
442        // differ only by safe mechanical substitutions. Continue to the
443        // Type-3 threshold below.
444    }
445    if breakdown.composite >= config.type3_min_composite {
446        let band = if breakdown.composite >= config.high_confidence {
447            Confidence::High
448        } else if breakdown.composite >= config.medium_confidence {
449            Confidence::Medium
450        } else {
451            Confidence::Low
452        };
453        // Type-3 leans on structure without type evidence: penalise the band.
454        let band = if breakdown.type_similarity.is_none() {
455            band.without_type_evidence()
456        } else {
457            band
458        };
459        return (Some(CloneClass::Type3), Some(band));
460    }
461    (None, None)
462}
463
464/// The composite: a weighted mean over the dimensions that were measured. A
465/// `None` type similarity drops out and the remaining weights renormalise.
466fn composite(
467    weights: &Weights,
468    lexical: f64,
469    structural: f64,
470    control_flow: Option<f64>,
471    type_similarity: Option<f64>,
472    api: Option<f64>,
473) -> f64 {
474    let mut acc = 0.0;
475    let mut total = 0.0;
476    let mut add = |value: f64, weight: f64| {
477        acc = value.mul_add(weight, acc);
478        total += weight;
479    };
480    add(lexical, weights.lexical);
481    add(structural, weights.structural);
482    if let Some(control_flow) = control_flow {
483        add(control_flow, weights.control_flow);
484    }
485    if let Some(api) = api {
486        add(api, weights.api);
487    }
488    if let Some(type_sim) = type_similarity {
489        add(type_sim, weights.type_similarity);
490    }
491    if total > 0.0 { acc / total } else { 0.0 }
492}
493
494/// The band of `second` indices row `ia` of the alignment table covers, as an
495/// offset range around `ia` itself.
496///
497/// The range normally contains both `0` (the table's start corner sits on
498/// `jb == ia`) and `len_b - len_a` (its end corner), with the configured slack
499/// either way, so the trivial paths are never banded out. When that range
500/// alone would exceed the cell budget — two very large units of very different
501/// lengths — it is narrowed around the start corner instead. A narrower band
502/// only weakens the lower bound the alignment reports; it never invents a
503/// match.
504struct Band {
505    /// How far `jb` may lag `ia`.
506    back: usize,
507    /// How far `jb` may lead `ia`.
508    forward: usize,
509}
510
511impl Band {
512    fn new(len_a: usize, len_b: usize, config: &VerifyConfig) -> Self {
513        let slack = config.alignment_band;
514        // The end corner sits at `jb - ia == len_b - len_a`, so the longer
515        // side gets the length difference on top of the slack.
516        let mut back = slack.saturating_add(len_a.saturating_sub(len_b));
517        let mut forward = slack.saturating_add(len_b.saturating_sub(len_a));
518        back = back.min(len_a);
519        forward = forward.min(len_b);
520        let allowed = (config.max_alignment_cells / (len_a + 1)).max(1);
521        if back + forward + 1 > allowed {
522            // Keep the diagonals nearest the start corner, where the
523            // backtrace begins.
524            back = back.min((allowed - 1) / 2);
525            forward = forward.min(allowed - 1 - back);
526        }
527        Self { back, forward }
528    }
529
530    /// Number of cells per row.
531    const fn width(&self) -> usize {
532        self.back + self.forward + 1
533    }
534
535    /// First `jb` row `ia` covers.
536    const fn first(&self, ia: usize) -> usize {
537        ia.saturating_sub(self.back)
538    }
539
540    /// Last `jb` row `ia` covers, given a `second` of length `len_b`.
541    const fn last(&self, ia: usize, len_b: usize) -> usize {
542        let end = ia.saturating_add(self.forward);
543        if end < len_b { end } else { len_b - 1 }
544    }
545
546    /// Index of `(ia, jb)` in the banded table, or `None` when `jb` lies
547    /// outside row `ia`'s band.
548    fn index(&self, ia: usize, jb: usize) -> Option<usize> {
549        let offset = (jb + self.back).checked_sub(ia)?;
550        (offset < self.width()).then(|| ia * self.width() + offset)
551    }
552}
553
554/// The longest common subsequence of two statement sequences under
555/// rename-invariant equality — equal shape tag and native kind — with its
556/// alignment. Returns the LCS length and the matched/unmatched indices.
557///
558/// The search is banded: only alignments staying within
559/// [`VerifyConfig::alignment_band`] statements of the diagonal joining the two
560/// sequences' ends are considered, and the band narrows further if the table
561/// would exceed [`VerifyConfig::max_alignment_cells`]. Since every considered
562/// alignment is a real common subsequence, the result is a lower bound on the
563/// true LCS — never an overestimate — and it is exact for the copy-with-edits
564/// shapes the mode targets.
565fn align(
566    first: &[StatementSummary],
567    second: &[StatementSummary],
568    config: &VerifyConfig,
569) -> (usize, Alignment) {
570    let (len_a, len_b) = (first.len(), second.len());
571    let band = Band::new(len_a, len_b, config);
572    // Row-major banded table: row `ia` holds the cells whose `jb` lies inside
573    // the band around `ia`. Out-of-band cells read as zero, which is the
574    // identity for the maximum below, so a path that would leave the band is
575    // simply not taken.
576    let mut dp = vec![0u32; (len_a + 1) * band.width()];
577    let at = |dp: &[u32], ia: usize, jb: usize| -> u32 {
578        if ia > len_a || jb > len_b {
579            return 0;
580        }
581        band.index(ia, jb).map_or(0, |index| dp[index])
582    };
583    if len_b > 0 {
584        for ia in (0..len_a).rev() {
585            for jb in (band.first(ia)..=band.last(ia, len_b)).rev() {
586                let value = if summaries_align(&first[ia], &second[jb]) {
587                    at(&dp, ia + 1, jb + 1) + 1
588                } else {
589                    at(&dp, ia + 1, jb).max(at(&dp, ia, jb + 1))
590                };
591                // `jb` came from row `ia`'s own band, so the cell exists.
592                if let Some(index) = band.index(ia, jb) {
593                    dp[index] = value;
594                }
595            }
596        }
597    }
598
599    let mut alignment = Alignment::default();
600    let (mut ia, mut jb) = (0, 0);
601    while ia < len_a && jb < len_b {
602        if summaries_align(&first[ia], &second[jb]) {
603            alignment.matched.push((ia, jb));
604            ia += 1;
605            jb += 1;
606        } else if at(&dp, ia + 1, jb) >= at(&dp, ia, jb + 1) {
607            alignment.only_a.push(ia);
608            ia += 1;
609        } else {
610            alignment.only_b.push(jb);
611            jb += 1;
612        }
613    }
614    while ia < len_a {
615        alignment.only_a.push(ia);
616        ia += 1;
617    }
618    while jb < len_b {
619        alignment.only_b.push(jb);
620        jb += 1;
621    }
622    (at(&dp, 0, 0).try_into().unwrap_or(usize::MAX), alignment)
623}
624
625/// Two statements align when their shape and native kind match; identifier and
626/// literal texts are ignored, so a consistent rename still aligns.
627fn summaries_align(a: &StatementSummary, b: &StatementSummary) -> bool {
628    a.shape_tag == b.shape_tag && a.native_kind == b.native_kind
629}
630
631/// Structural sequence similarity: the LCS as a fraction of the two lengths.
632fn sequence_similarity(lcs: usize, n: usize, m: usize) -> f64 {
633    if n == 0 && m == 0 {
634        return 1.0;
635    }
636    ratio(2 * lcs, n + m)
637}
638
639/// Lexical agreement: the mean, over aligned statement pairs, of how much of
640/// their text matches verbatim. `1.0` when every aligned pair reads the same
641/// (a verbatim copy); lower when identifiers or literals were changed.
642///
643/// A compound statement spans its whole body, so the statements nested inside
644/// it are measured both on their own and again as part of it. That is
645/// deliberate: whether a loop's body was copied wholesale is the evidence that
646/// separates a copy from a routine that merely has a loop in the same place,
647/// and weighting a construct by how much code it encloses is what makes that
648/// evidence count. Comparing each statement's own text instead — a loop header
649/// without its body — measurably fails to tell the two apart, because
650/// lookalikes share those headers exactly.
651fn lexical_similarity(a: &UnitView<'_>, b: &UnitView<'_>, alignment: &Alignment) -> f64 {
652    if alignment.matched.is_empty() {
653        return 0.0;
654    }
655    let mut total = 0.0;
656    for &(i, j) in &alignment.matched {
657        total += text_agreement(
658            a.statements[i].tokens(a.tokens),
659            b.statements[j].tokens(b.tokens),
660        );
661    }
662    total / ratio_denominator(alignment.matched.len())
663}
664
665/// Fraction of token positions that carry the same text, over the longer of
666/// the two statements so that extra text counts against the match.
667fn text_agreement(a: &[Token], b: &[Token]) -> f64 {
668    let longest = a.len().max(b.len());
669    if longest == 0 {
670        return 1.0;
671    }
672    let equal = a.iter().zip(b).filter(|(x, y)| x.text == y.text).count();
673    ratio(equal, longest)
674}
675
676/// Control-flow agreement. Identical control-op hashes score `1.0`; otherwise
677/// the score falls with the normalised difference of the shape statistics
678/// (op count, loop depth, branch count) — a syntactic approximation.
679fn cfg_similarity(a: &CfgFeature, b: &CfgFeature) -> Option<f64> {
680    let empty = |feature: &CfgFeature| {
681        feature.op_count == 0 && feature.max_loop_depth == 0 && feature.branch_count == 0
682    };
683    if empty(a) && empty(b) {
684        return None;
685    }
686    if a.hash == b.hash {
687        return Some(1.0);
688    }
689    let diff = a.op_count.abs_diff(b.op_count)
690        + a.max_loop_depth.abs_diff(b.max_loop_depth)
691        + a.branch_count.abs_diff(b.branch_count);
692    let scale = (a.op_count + a.max_loop_depth + a.branch_count)
693        .max(b.op_count + b.max_loop_depth + b.branch_count);
694    if scale == 0 {
695        return None;
696    }
697    Some(1.0 - ratio(diff as usize, scale as usize))
698}
699
700/// Jaccard similarity of two subtree-hash sets, absent when both are empty.
701fn subtree_jaccard(a: &[SubtreeFeature], b: &[SubtreeFeature]) -> Option<f64> {
702    let mut sa: Vec<[u8; 16]> = a.iter().map(|s| *s.hash.as_bytes()).collect();
703    let mut sb: Vec<[u8; 16]> = b.iter().map(|s| *s.hash.as_bytes()).collect();
704    sa.sort_unstable();
705    sa.dedup();
706    sb.sort_unstable();
707    sb.dedup();
708    (!sa.is_empty() || !sb.is_empty()).then(|| set_jaccard(&sa, &sb))
709}
710
711/// Api agreement: Jaccard of the two call-name multisets, treated as sets of
712/// distinct callee names.
713///
714/// `None` when neither unit calls anything: the dimension then has nothing to
715/// compare, and reporting that as perfect agreement would hand every call-free
716/// pair the dimension's full weight on no evidence at all.
717fn api_similarity(a: &ApiCallFeature, b: &ApiCallFeature) -> Option<f64> {
718    let mut sa: Vec<&str> = a
719        .names
720        .iter()
721        .map(crate::frontend::Lexeme::as_str)
722        .collect();
723    let mut sb: Vec<&str> = b
724        .names
725        .iter()
726        .map(crate::frontend::Lexeme::as_str)
727        .collect();
728    if sa.is_empty() && sb.is_empty() {
729        return None;
730    }
731    sa.sort_unstable();
732    sa.dedup();
733    sb.sort_unstable();
734    sb.dedup();
735    Some(set_jaccard(&sa, &sb))
736}
737
738/// Jaccard of two sorted, deduplicated slices. `1.0` when both are empty.
739fn set_jaccard<T: Ord>(a: &[T], b: &[T]) -> f64 {
740    if a.is_empty() && b.is_empty() {
741        return 1.0;
742    }
743    let (mut i, mut j, mut inter) = (0, 0, 0usize);
744    while i < a.len() && j < b.len() {
745        match a[i].cmp(&b[j]) {
746            std::cmp::Ordering::Less => i += 1,
747            std::cmp::Ordering::Greater => j += 1,
748            std::cmp::Ordering::Equal => {
749                inter += 1;
750                i += 1;
751                j += 1;
752            }
753        }
754    }
755    let union = a.len() + b.len() - inter;
756    ratio(inter, union)
757}
758
759/// Arithmetic mean of three scores.
760fn mean3(a: f64, b: f64, c: f64) -> f64 {
761    (a + b + c) / 3.0
762}
763
764/// Lossless `usize` ratio via `u32`, `0.0` when the denominator is zero.
765fn ratio(numer: usize, denom: usize) -> f64 {
766    let n = u32::try_from(numer).unwrap_or(u32::MAX);
767    let d = u32::try_from(denom).unwrap_or(u32::MAX);
768    if d == 0 {
769        0.0
770    } else {
771        f64::from(n) / f64::from(d)
772    }
773}
774
775/// `count` as an `f64` denominator, never zero (callers guard emptiness).
776fn ratio_denominator(count: usize) -> f64 {
777    f64::from(u32::try_from(count).unwrap_or(u32::MAX)).max(1.0)
778}
779
780#[cfg(test)]
781#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
782mod tests;