use crate::clone_class::CloneClass;
use crate::features::{ApiCallFeature, CfgFeature, SubtreeFeature, UnitFeatures};
use crate::frontend::Token;
use crate::ir::{IrNode, Shape, StatementSummary};
use crate::stable_id::FragmentFingerprint;
use crate::types::{ApiEvidence, TypeEvidence};
pub const WEIGHT_VERSION: &str = "structural-verify-v1";
#[derive(Debug, Clone, PartialEq)]
pub struct Weights {
pub lexical: f64,
pub structural: f64,
pub control_flow: f64,
pub type_similarity: f64,
pub api: f64,
}
impl Default for Weights {
fn default() -> Self {
Self {
lexical: 0.20,
structural: 0.45,
control_flow: 0.20,
type_similarity: 0.15,
api: 0.15,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct VerifyConfig {
pub weights: Weights,
pub type3_min_composite: f64,
pub type2_min_lexical: f64,
pub high_confidence: f64,
pub medium_confidence: f64,
pub exact_epsilon: f64,
pub alignment_band: usize,
pub max_alignment_cells: usize,
}
impl Default for VerifyConfig {
fn default() -> Self {
Self {
weights: Weights::default(),
type3_min_composite: 0.70,
type2_min_lexical: 0.90,
high_confidence: 0.85,
medium_confidence: 0.75,
exact_epsilon: 1e-9,
alignment_band: 64,
max_alignment_cells: 4_000_000,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Confidence {
High,
Medium,
Low,
}
impl Confidence {
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::High => "high",
Self::Medium => "medium",
Self::Low => "low",
}
}
const fn without_type_evidence(self) -> Self {
match self {
Self::High | Self::Medium => Self::Medium,
Self::Low => Self::Low,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SimilarityBreakdown {
pub lexical: f64,
pub structural: f64,
pub control_flow: Option<f64>,
pub type_similarity: Option<f64>,
pub api: Option<f64>,
pub composite: f64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Alignment {
pub matched: Vec<(usize, usize)>,
pub only_a: Vec<usize>,
pub only_b: Vec<usize>,
}
impl Alignment {
fn mirrored(self) -> Self {
Self {
matched: self.matched.into_iter().map(|(i, j)| (j, i)).collect(),
only_a: self.only_b,
only_b: self.only_a,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct UnitView<'a> {
pub statements: &'a [StatementSummary],
pub tokens: &'a [Token],
pub content: FragmentFingerprint,
pub features: &'a UnitFeatures,
pub types: Option<&'a TypeEvidence>,
pub apis: Option<&'a ApiEvidence>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Verdict {
pub class: Option<CloneClass>,
pub confidence: Option<Confidence>,
pub breakdown: SimilarityBreakdown,
pub alignment: Alignment,
}
#[must_use]
pub fn statement_sequence(unit: &IrNode, tokens: &[Token]) -> Vec<StatementSummary> {
let mut out = Vec::new();
collect_statements(unit, tokens, &mut out);
out
}
fn collect_statements(node: &IrNode, tokens: &[Token], out: &mut Vec<StatementSummary>) {
if matches!(node.shape, Shape::Block) {
out.extend(node.statement_summaries(tokens));
}
for child in &node.children {
collect_statements(child, tokens, out);
}
}
#[must_use]
pub fn verify(a: &UnitView<'_>, b: &UnitView<'_>, config: &VerifyConfig) -> Verdict {
if order_key(b) < order_key(a) {
let mut verdict = measure(b, a, config);
verdict.alignment = verdict.alignment.mirrored();
return verdict;
}
measure(a, b, config)
}
const fn order_key(unit: &UnitView<'_>) -> (usize, [u8; 16], [u8; 16], u8, [u8; 16]) {
(
unit.statements.len(),
*unit.features.cfg.hash.as_bytes(),
*unit.features.api.multiset_hash.as_bytes(),
unit.features.shape_tag,
*unit.content.as_bytes(),
)
}
fn measure(a: &UnitView<'_>, b: &UnitView<'_>, config: &VerifyConfig) -> Verdict {
let (lcs, alignment) = align(a.statements, b.statements, config);
let seq_sim = sequence_similarity(lcs, a.statements.len(), b.statements.len());
let lexical = lexical_similarity(a, b, &alignment);
let vector = a.features.vector.cosine_similarity(&b.features.vector);
let structural = subtree_jaccard(&a.features.subtrees, &b.features.subtrees).map_or_else(
|| seq_sim.midpoint(vector),
|subtree| mean3(seq_sim, vector, subtree),
);
let control_flow = cfg_similarity(&a.features.cfg, &b.features.cfg);
let api = a
.apis
.zip(b.apis)
.and_then(|(a, b)| ApiEvidence::agreement(a, b))
.or_else(|| api_similarity(&a.features.api, &b.features.api));
let type_similarity = a
.types
.zip(b.types)
.and_then(|(a, b)| TypeEvidence::agreement(a, b));
let composite = composite(
&config.weights,
lexical,
structural,
control_flow,
type_similarity,
api,
);
let breakdown = SimilarityBreakdown {
lexical,
structural,
control_flow,
type_similarity,
api,
composite,
};
let (class, confidence) = classify(&breakdown, config);
Verdict {
class,
confidence,
breakdown,
alignment,
}
}
fn classify(
breakdown: &SimilarityBreakdown,
config: &VerifyConfig,
) -> (Option<CloneClass>, Option<Confidence>) {
let eps = config.exact_epsilon;
let exact = |value: f64| (1.0 - value).abs() <= eps;
if exact(breakdown.structural) {
if exact(breakdown.lexical) && breakdown.api.is_none_or(exact) {
return (Some(CloneClass::Type1), Some(Confidence::High));
}
if breakdown.lexical >= config.type2_min_lexical {
return (Some(CloneClass::Type2), Some(Confidence::High));
}
}
if breakdown.composite >= config.type3_min_composite {
let band = if breakdown.composite >= config.high_confidence {
Confidence::High
} else if breakdown.composite >= config.medium_confidence {
Confidence::Medium
} else {
Confidence::Low
};
let band = if breakdown.type_similarity.is_none() {
band.without_type_evidence()
} else {
band
};
return (Some(CloneClass::Type3), Some(band));
}
(None, None)
}
fn composite(
weights: &Weights,
lexical: f64,
structural: f64,
control_flow: Option<f64>,
type_similarity: Option<f64>,
api: Option<f64>,
) -> f64 {
let mut acc = 0.0;
let mut total = 0.0;
let mut add = |value: f64, weight: f64| {
acc = value.mul_add(weight, acc);
total += weight;
};
add(lexical, weights.lexical);
add(structural, weights.structural);
if let Some(control_flow) = control_flow {
add(control_flow, weights.control_flow);
}
if let Some(api) = api {
add(api, weights.api);
}
if let Some(type_sim) = type_similarity {
add(type_sim, weights.type_similarity);
}
if total > 0.0 { acc / total } else { 0.0 }
}
struct Band {
back: usize,
forward: usize,
}
impl Band {
fn new(len_a: usize, len_b: usize, config: &VerifyConfig) -> Self {
let slack = config.alignment_band;
let mut back = slack.saturating_add(len_a.saturating_sub(len_b));
let mut forward = slack.saturating_add(len_b.saturating_sub(len_a));
back = back.min(len_a);
forward = forward.min(len_b);
let allowed = (config.max_alignment_cells / (len_a + 1)).max(1);
if back + forward + 1 > allowed {
back = back.min((allowed - 1) / 2);
forward = forward.min(allowed - 1 - back);
}
Self { back, forward }
}
const fn width(&self) -> usize {
self.back + self.forward + 1
}
const fn first(&self, ia: usize) -> usize {
ia.saturating_sub(self.back)
}
const fn last(&self, ia: usize, len_b: usize) -> usize {
let end = ia.saturating_add(self.forward);
if end < len_b { end } else { len_b - 1 }
}
fn index(&self, ia: usize, jb: usize) -> Option<usize> {
let offset = (jb + self.back).checked_sub(ia)?;
(offset < self.width()).then(|| ia * self.width() + offset)
}
}
fn align(
first: &[StatementSummary],
second: &[StatementSummary],
config: &VerifyConfig,
) -> (usize, Alignment) {
let (len_a, len_b) = (first.len(), second.len());
let band = Band::new(len_a, len_b, config);
let mut dp = vec![0u32; (len_a + 1) * band.width()];
let at = |dp: &[u32], ia: usize, jb: usize| -> u32 {
if ia > len_a || jb > len_b {
return 0;
}
band.index(ia, jb).map_or(0, |index| dp[index])
};
if len_b > 0 {
for ia in (0..len_a).rev() {
for jb in (band.first(ia)..=band.last(ia, len_b)).rev() {
let value = if summaries_align(&first[ia], &second[jb]) {
at(&dp, ia + 1, jb + 1) + 1
} else {
at(&dp, ia + 1, jb).max(at(&dp, ia, jb + 1))
};
if let Some(index) = band.index(ia, jb) {
dp[index] = value;
}
}
}
}
let mut alignment = Alignment::default();
let (mut ia, mut jb) = (0, 0);
while ia < len_a && jb < len_b {
if summaries_align(&first[ia], &second[jb]) {
alignment.matched.push((ia, jb));
ia += 1;
jb += 1;
} else if at(&dp, ia + 1, jb) >= at(&dp, ia, jb + 1) {
alignment.only_a.push(ia);
ia += 1;
} else {
alignment.only_b.push(jb);
jb += 1;
}
}
while ia < len_a {
alignment.only_a.push(ia);
ia += 1;
}
while jb < len_b {
alignment.only_b.push(jb);
jb += 1;
}
(at(&dp, 0, 0).try_into().unwrap_or(usize::MAX), alignment)
}
fn summaries_align(a: &StatementSummary, b: &StatementSummary) -> bool {
a.shape_tag == b.shape_tag && a.native_kind == b.native_kind
}
fn sequence_similarity(lcs: usize, n: usize, m: usize) -> f64 {
if n == 0 && m == 0 {
return 1.0;
}
ratio(2 * lcs, n + m)
}
fn lexical_similarity(a: &UnitView<'_>, b: &UnitView<'_>, alignment: &Alignment) -> f64 {
if alignment.matched.is_empty() {
return 0.0;
}
let mut total = 0.0;
for &(i, j) in &alignment.matched {
total += text_agreement(
a.statements[i].tokens(a.tokens),
b.statements[j].tokens(b.tokens),
);
}
total / ratio_denominator(alignment.matched.len())
}
fn text_agreement(a: &[Token], b: &[Token]) -> f64 {
let longest = a.len().max(b.len());
if longest == 0 {
return 1.0;
}
let equal = a.iter().zip(b).filter(|(x, y)| x.text == y.text).count();
ratio(equal, longest)
}
fn cfg_similarity(a: &CfgFeature, b: &CfgFeature) -> Option<f64> {
let empty = |feature: &CfgFeature| {
feature.op_count == 0 && feature.max_loop_depth == 0 && feature.branch_count == 0
};
if empty(a) && empty(b) {
return None;
}
if a.hash == b.hash {
return Some(1.0);
}
let diff = a.op_count.abs_diff(b.op_count)
+ a.max_loop_depth.abs_diff(b.max_loop_depth)
+ a.branch_count.abs_diff(b.branch_count);
let scale = (a.op_count + a.max_loop_depth + a.branch_count)
.max(b.op_count + b.max_loop_depth + b.branch_count);
if scale == 0 {
return None;
}
Some(1.0 - ratio(diff as usize, scale as usize))
}
fn subtree_jaccard(a: &[SubtreeFeature], b: &[SubtreeFeature]) -> Option<f64> {
let mut sa: Vec<[u8; 16]> = a.iter().map(|s| *s.hash.as_bytes()).collect();
let mut sb: Vec<[u8; 16]> = b.iter().map(|s| *s.hash.as_bytes()).collect();
sa.sort_unstable();
sa.dedup();
sb.sort_unstable();
sb.dedup();
(!sa.is_empty() || !sb.is_empty()).then(|| set_jaccard(&sa, &sb))
}
fn api_similarity(a: &ApiCallFeature, b: &ApiCallFeature) -> Option<f64> {
let mut sa: Vec<&str> = a
.names
.iter()
.map(crate::frontend::Lexeme::as_str)
.collect();
let mut sb: Vec<&str> = b
.names
.iter()
.map(crate::frontend::Lexeme::as_str)
.collect();
if sa.is_empty() && sb.is_empty() {
return None;
}
sa.sort_unstable();
sa.dedup();
sb.sort_unstable();
sb.dedup();
Some(set_jaccard(&sa, &sb))
}
fn set_jaccard<T: Ord>(a: &[T], b: &[T]) -> f64 {
if a.is_empty() && b.is_empty() {
return 1.0;
}
let (mut i, mut j, mut inter) = (0, 0, 0usize);
while i < a.len() && j < b.len() {
match a[i].cmp(&b[j]) {
std::cmp::Ordering::Less => i += 1,
std::cmp::Ordering::Greater => j += 1,
std::cmp::Ordering::Equal => {
inter += 1;
i += 1;
j += 1;
}
}
}
let union = a.len() + b.len() - inter;
ratio(inter, union)
}
fn mean3(a: f64, b: f64, c: f64) -> f64 {
(a + b + c) / 3.0
}
fn ratio(numer: usize, denom: usize) -> f64 {
let n = u32::try_from(numer).unwrap_or(u32::MAX);
let d = u32::try_from(denom).unwrap_or(u32::MAX);
if d == 0 {
0.0
} else {
f64::from(n) / f64::from(d)
}
}
fn ratio_denominator(count: usize) -> f64 {
f64::from(u32::try_from(count).unwrap_or(u32::MAX)).max(1.0)
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests;