use crate::clone_class::{CloneClass, CloneScope};
pub const RECIPE_VERSION: &str = "1";
const NORMALIZED_MATCH_DISCOUNT: f64 = 0.8;
const SIZE_HALF_CREDIT_MULTIPLE: u64 = 2;
const RISK_HALF_INSTANCES: f64 = 2.0;
const RISK_HALF_TOKENS: f64 = 120.0;
const RISK_HALF_DIRECTORIES: f64 = 1.0;
const DIFFICULTY_HALF_TOKENS: f64 = 200.0;
const WORTH_FLOOR: f64 = 0.5;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Weights {
pub maintenance_risk: u32,
pub refactoring_ease: u32,
}
impl Default for Weights {
fn default() -> Self {
Self {
maintenance_risk: 2,
refactoring_ease: 1,
}
}
}
impl Weights {
#[must_use]
pub fn recipe(&self) -> String {
format!(
"{RECIPE_VERSION}-risk{}-ease{}",
self.maintenance_risk, self.refactoring_ease
)
}
fn worth(self, risk: f64, difficulty: f64) -> f64 {
let total = f64::from(self.maintenance_risk) + f64::from(self.refactoring_ease);
if total <= 0.0 {
return 0.5;
}
f64::from(self.refactoring_ease)
.mul_add(1.0 - difficulty, f64::from(self.maintenance_risk) * risk)
/ total
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GroupFacts {
pub clone_type: CloneClass,
pub scope: CloneScope,
pub instances: u64,
pub smallest_member_tokens: u64,
pub largest_member_tokens: u64,
pub min_pairwise: f64,
pub files: u64,
pub directories: u64,
pub languages: u64,
pub min_clone_tokens: u64,
pub identifier_jaccard: Option<f64>,
pub api_similarity: Option<f64>,
pub has_loop: Option<bool>,
pub has_dynamic_allocation: Option<bool>,
pub call_count: Option<u64>,
pub churn: Option<f64>,
pub ownership_spread: Option<f64>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Priority {
pub clone_confidence: f64,
pub maintenance_risk: f64,
pub refactoring_difficulty: f64,
pub final_priority: f64,
pub semantic_confidence: Option<f64>,
pub source_artifact_confidence: Option<f64>,
pub savings_confidence: Option<f64>,
pub inputs: GroupFacts,
}
#[must_use]
pub fn saturating(value: f64, half: f64) -> f64 {
if half <= 0.0 || value <= 0.0 {
return 0.0;
}
value / (value + half)
}
#[allow(clippy::cast_precision_loss)]
const fn as_f64(count: u64) -> f64 {
count as f64
}
#[must_use]
pub fn clone_confidence(facts: &GroupFacts) -> f64 {
let half = as_f64(
facts
.min_clone_tokens
.saturating_mul(SIZE_HALF_CREDIT_MULTIPLE),
);
let length = saturating(as_f64(facts.smallest_member_tokens), half);
let reshaped = if facts.clone_type == CloneClass::Type1 {
1.0
} else {
NORMALIZED_MATCH_DISCOUNT
};
facts.min_pairwise.clamp(0.0, 1.0) * length * reshaped
}
#[must_use]
pub fn maintenance_risk(facts: &GroupFacts) -> f64 {
let copies = saturating(
as_f64(facts.instances.saturating_sub(1)),
RISK_HALF_INSTANCES,
);
let extent = saturating(as_f64(facts.largest_member_tokens), RISK_HALF_TOKENS);
let spread = saturating(
as_f64(facts.directories.saturating_sub(1)),
RISK_HALF_DIRECTORIES,
);
let baseline = 0.50f64.mul_add(copies, 0.35f64.mul_add(extent, 0.15 * spread));
let (materiality, measurements) = [
facts.has_loop.map(f64::from),
facts.has_dynamic_allocation.map(f64::from),
facts.call_count.map(|count| saturating(as_f64(count), 4.0)),
]
.into_iter()
.flatten()
.fold((0.0, 0_u64), |(sum, count), value| (sum + value, count + 1));
if measurements == 0 {
return baseline;
}
0.80f64.mul_add(baseline, 0.20 * (materiality / as_f64(measurements)))
}
#[must_use]
pub fn refactoring_difficulty(facts: &GroupFacts) -> f64 {
let extent = saturating(as_f64(facts.largest_member_tokens), DIFFICULTY_HALF_TOKENS);
let unbounded = f64::from(facts.scope == CloneScope::Fragment);
let divergence = 1.0 - facts.min_pairwise.clamp(0.0, 1.0);
let cross_language = f64::from(facts.languages > 1);
let baseline = 0.40f64.mul_add(
extent,
0.25f64.mul_add(
unbounded,
0.20f64.mul_add(divergence, 0.15 * cross_language),
),
);
let (surface_divergence, measurements) = [
facts
.identifier_jaccard
.map(|value| 1.0 - value.clamp(0.0, 1.0)),
facts
.api_similarity
.map(|value| 1.0 - value.clamp(0.0, 1.0)),
]
.into_iter()
.flatten()
.fold((0.0, 0_u64), |(sum, count), value| (sum + value, count + 1));
if measurements == 0 {
return baseline;
}
0.85f64.mul_add(baseline, 0.15 * (surface_divergence / as_f64(measurements)))
}
#[must_use]
pub fn rank(facts: &GroupFacts, weights: &Weights) -> Priority {
let confidence = clone_confidence(facts);
let risk = maintenance_risk(facts);
let difficulty = refactoring_difficulty(facts);
let worth = weights.worth(risk, difficulty);
Priority {
clone_confidence: confidence,
maintenance_risk: risk,
refactoring_difficulty: difficulty,
final_priority: confidence * (1.0 - WORTH_FLOOR).mul_add(worth, WORTH_FLOOR),
semantic_confidence: None,
source_artifact_confidence: None,
savings_confidence: None,
inputs: *facts,
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
fn facts() -> GroupFacts {
GroupFacts {
clone_type: CloneClass::Type1,
scope: CloneScope::Unit,
instances: 2,
smallest_member_tokens: 80,
largest_member_tokens: 80,
min_pairwise: 1.0,
files: 2,
directories: 1,
languages: 1,
min_clone_tokens: 20,
identifier_jaccard: None,
api_similarity: None,
has_loop: None,
has_dynamic_allocation: None,
call_count: None,
churn: None,
ownership_spread: None,
}
}
#[test]
fn every_measure_stays_inside_the_range_it_claims() {
let mut smallest = facts();
smallest.instances = 2;
smallest.smallest_member_tokens = 1;
smallest.largest_member_tokens = 1;
smallest.min_pairwise = 0.0;
smallest.clone_type = CloneClass::Type3;
smallest.scope = CloneScope::Fragment;
let mut largest = facts();
largest.instances = u64::MAX;
largest.smallest_member_tokens = u64::MAX;
largest.largest_member_tokens = u64::MAX;
largest.directories = u64::MAX;
largest.languages = 3;
largest.scope = CloneScope::Fragment;
for probe in [smallest, largest, facts()] {
let ranked = rank(&probe, &Weights::default());
for (name, value) in [
("clone confidence", ranked.clone_confidence),
("maintenance risk", ranked.maintenance_risk),
("refactoring difficulty", ranked.refactoring_difficulty),
("final priority", ranked.final_priority),
] {
assert!(
(0.0..=1.0).contains(&value),
"{name} left its range at {value}"
);
}
}
}
#[test]
fn a_clone_sitting_on_the_length_floor_is_the_least_convincing_one() {
for floor in [10, 20, 50] {
let mut probe = facts();
probe.min_clone_tokens = floor;
probe.smallest_member_tokens = floor;
probe.largest_member_tokens = floor;
let at_floor = clone_confidence(&probe);
probe.smallest_member_tokens = floor * 8;
probe.largest_member_tokens = floor * 8;
let well_past = clone_confidence(&probe);
assert!(at_floor < 0.4, "floor {floor}: {at_floor}");
assert!(well_past > 0.7, "floor {floor}: {well_past}");
}
}
#[test]
fn a_match_the_normalization_had_to_reshape_is_trusted_less() {
let mut verbatim = facts();
verbatim.clone_type = CloneClass::Type1;
let mut renamed = facts();
renamed.clone_type = CloneClass::Type2;
assert!(clone_confidence(&renamed) < clone_confidence(&verbatim));
}
#[test]
fn a_gapped_group_is_discounted_twice_over() {
let mut renamed = facts();
renamed.clone_type = CloneClass::Type2;
let mut gapped = facts();
gapped.clone_type = CloneClass::Type3;
gapped.min_pairwise = 0.8;
assert!(clone_confidence(&gapped) < clone_confidence(&renamed));
}
#[test]
fn more_copies_further_apart_cost_more_to_keep_in_step() {
let base = maintenance_risk(&facts());
let mut many = facts();
many.instances = 9;
assert!(maintenance_risk(&many) > base);
let mut scattered = facts();
scattered.directories = 4;
assert!(maintenance_risk(&scattered) > base);
}
#[test]
fn material_bodies_raise_maintenance_risk_without_predicting_binary_size() {
let plain = facts();
let mut material = plain;
material.has_loop = Some(true);
material.has_dynamic_allocation = Some(true);
material.call_count = Some(8);
assert!(maintenance_risk(&material) > maintenance_risk(&plain));
}
#[test]
fn a_run_inside_a_unit_is_harder_to_lift_out_than_a_whole_unit() {
let mut whole = facts();
whole.scope = CloneScope::Unit;
let mut run = facts();
run.scope = CloneScope::Fragment;
assert!(refactoring_difficulty(&run) > refactoring_difficulty(&whole));
}
#[test]
fn copies_in_two_languages_are_harder_to_share_than_copies_in_one() {
let mut one = facts();
one.languages = 1;
let mut two = facts();
two.languages = 2;
assert!(refactoring_difficulty(&two) > refactoring_difficulty(&one));
}
#[test]
fn distinct_names_and_call_surfaces_raise_refactoring_difficulty() {
let ordinary = facts();
let mut divergent = ordinary;
divergent.clone_type = CloneClass::Type3;
divergent.identifier_jaccard = Some(0.0);
divergent.api_similarity = Some(0.0);
assert!(
refactoring_difficulty(&divergent) > refactoring_difficulty(&ordinary),
"unshared names and APIs make a single extraction less plausible"
);
}
#[test]
fn unavailable_surface_evidence_does_not_change_refactoring_difficulty() {
let ordinary = facts();
let mut unavailable = ordinary;
unavailable.identifier_jaccard = None;
unavailable.api_similarity = None;
assert!(
(refactoring_difficulty(&unavailable) - refactoring_difficulty(&ordinary)).abs()
< f64::EPSILON
);
}
#[test]
fn the_maintenance_argument_cannot_lift_a_finding_past_its_confidence() {
let mut lookalike = facts();
lookalike.clone_type = CloneClass::Type2;
lookalike.smallest_member_tokens = 20;
lookalike.largest_member_tokens = 20;
lookalike.instances = 40;
lookalike.directories = 12;
let mut genuine = facts();
genuine.smallest_member_tokens = 400;
genuine.largest_member_tokens = 400;
genuine.instances = 2;
genuine.directories = 1;
let weights = Weights::default();
assert!(maintenance_risk(&lookalike) > maintenance_risk(&genuine));
assert!(
rank(&lookalike, &weights).final_priority < rank(&genuine, &weights).final_priority
);
}
#[test]
fn a_findings_place_does_not_depend_on_what_else_was_found() {
let weights = Weights::default();
let alone = rank(&facts(), &weights);
let mut crowd = facts();
crowd.instances = 300;
let _ = rank(&crowd, &weights);
assert_eq!(rank(&facts(), &weights), alone);
}
#[test]
fn turning_both_weights_off_ranks_on_confidence_alone() {
let off = Weights {
maintenance_risk: 0,
refactoring_ease: 0,
};
let mut risky = facts();
risky.instances = 20;
let calm = facts();
let a = rank(&risky, &off);
let b = rank(&calm, &off);
assert!((a.final_priority - b.final_priority).abs() < 1e-12);
assert!(a.maintenance_risk > b.maintenance_risk);
}
#[test]
fn the_reserved_measures_are_reported_absent_rather_than_zero() {
let ranked = rank(&facts(), &Weights::default());
assert_eq!(ranked.semantic_confidence, None);
assert_eq!(ranked.source_artifact_confidence, None);
assert_eq!(ranked.savings_confidence, None);
assert_eq!(ranked.inputs.churn, None);
assert_eq!(ranked.inputs.ownership_spread, None);
}
#[test]
fn the_recipe_names_the_weights_it_was_composed_under() {
assert_eq!(Weights::default().recipe(), "1-risk2-ease1");
assert_ne!(
Weights {
maintenance_risk: 1,
refactoring_ease: 3,
}
.recipe(),
Weights::default().recipe()
);
}
}