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;