use serde::{Deserialize, Serialize};
use super::content_weight::ContentWeight;
use super::vitals::WebVitals;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PerformanceGrade {
Platinum,
Gold,
Silver,
Bronze,
NeedsImprovement,
}
impl PerformanceGrade {
pub fn from_score(score: u32) -> Self {
match score {
90..=100 => PerformanceGrade::Platinum,
75..=89 => PerformanceGrade::Gold,
60..=74 => PerformanceGrade::Silver,
50..=59 => PerformanceGrade::Bronze,
_ => PerformanceGrade::NeedsImprovement,
}
}
pub fn emoji(&self) -> &'static str {
match self {
PerformanceGrade::Platinum => "🏆",
PerformanceGrade::Gold => "🥇",
PerformanceGrade::Silver => "🥈",
PerformanceGrade::Bronze => "🥉",
PerformanceGrade::NeedsImprovement => "⚠️",
}
}
pub fn label(&self) -> &'static str {
match self {
PerformanceGrade::Platinum => "SEHR GUT",
PerformanceGrade::Gold => "GUT",
PerformanceGrade::Silver => "STABIL",
PerformanceGrade::Bronze => "AUSBAUFÄHIG",
PerformanceGrade::NeedsImprovement => "UNGENÜGEND",
}
}
}
impl std::fmt::Display for PerformanceGrade {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} {}", self.emoji(), self.label())
}
}
const W_FCP: u32 = 10;
const W_LCP: u32 = 25;
const W_TBT: u32 = 30;
const W_CLS: u32 = 25;
const W_SI: u32 = 10;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PerformanceScore {
pub overall: u32,
pub grade: PerformanceGrade,
pub lcp_score: Option<u32>,
pub fcp_score: Option<u32>,
pub cls_score: Option<u32>,
pub interactivity_score: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub si_score: Option<u32>,
pub metrics_available: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub size_penalty: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub js_penalty: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_penalty: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dom_penalty: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_capped: Option<bool>,
}
pub fn calculate_performance_score(
vitals: &WebVitals,
content_weight: Option<&ContentWeight>,
) -> PerformanceScore {
let lcp_score = vitals.lcp.as_ref().map(|v| score_lcp(v.value));
let fcp_score = vitals.fcp.as_ref().map(|v| score_fcp(v.value));
let cls_score = vitals.cls.as_ref().map(|v| score_cls(v.value));
let interactivity_score = vitals.tbt.as_ref().map(|v| score_interactivity(v.value));
let si_score = vitals.speed_index.as_ref().map(|v| score_si(v.value));
let mut total = 0u32;
let mut max_possible = 0u32;
let mut metrics_available = 0u32;
if let Some(s) = lcp_score {
total += s;
max_possible += W_LCP;
metrics_available += 1;
}
if let Some(s) = fcp_score {
total += s;
max_possible += W_FCP;
metrics_available += 1;
}
if let Some(s) = cls_score {
total += s;
max_possible += W_CLS;
metrics_available += 1;
}
if let Some(s) = interactivity_score {
total += s;
max_possible += W_TBT;
metrics_available += 1;
}
if let Some(s) = si_score {
total += s;
max_possible += W_SI;
metrics_available += 1;
}
let base_overall = (total * 100)
.checked_div(max_possible)
.map(|n| n.min(100))
.unwrap_or(0);
let mut overall = base_overall;
let mut size_penalty = None;
let mut js_penalty = None;
let mut request_penalty = None;
let mut dom_penalty = None;
let mut is_capped = None;
let mut size_cap = 100u32;
let mut js_cap = 100u32;
let mut req_cap = 100u32;
if let Some(cw) = content_weight {
let total_mb = cw.total_bytes as f64 / 1_000_000.0;
if total_mb > 2.0 {
let penalty = ((total_mb - 2.0) * 5.0).min(30.0).round() as u32;
size_penalty = Some(penalty);
}
let js_mb = cw.breakdown.javascript.bytes as f64 / 1_000_000.0;
if js_mb > 1.0 {
let penalty = (((js_mb - 1.0) / 0.5) * 5.0).min(20.0).round() as u32;
js_penalty = Some(penalty);
}
if cw.request_count > 60 {
let penalty =
((((cw.request_count - 60) as f64 / 10.0).round() * 2.0).min(15.0)) as u32;
request_penalty = Some(penalty);
}
if cw.total_bytes > 10_000_000 {
size_cap = 39;
} else if cw.total_bytes > 5_000_000 {
size_cap = 59;
} else if cw.total_bytes > 3_000_000 {
size_cap = 74;
}
if cw.breakdown.javascript.bytes > 3_000_000 {
js_cap = 59;
} else if cw.breakdown.javascript.bytes > 1_500_000 {
js_cap = 74;
}
if cw.request_count > 120 {
req_cap = 74;
}
}
if let Some(nodes) = vitals.dom_nodes {
if nodes > 1000 {
let base = (((nodes - 1000) as f64 / 100.0).round() * 2.0).min(15.0);
let excess = nodes.saturating_sub(6000) as f64;
let surcharge = if excess > 0.0 {
(excess / 1000.0).ln_1p() * 4.0
} else {
0.0
};
let penalty = (base + surcharge).round().min(35.0) as u32;
dom_penalty = Some(penalty);
}
}
const MAX_WEIGHT_PENALTY: u32 = 45;
let raw_weight_penalty = size_penalty.unwrap_or(0)
+ js_penalty.unwrap_or(0)
+ request_penalty.unwrap_or(0)
+ dom_penalty.unwrap_or(0);
let relative_cap = (base_overall as f64 * 0.7).round() as u32;
let weight_penalty = raw_weight_penalty.min(MAX_WEIGHT_PENALTY).min(relative_cap);
overall = overall.saturating_sub(weight_penalty);
let min_cap = size_cap.min(js_cap).min(req_cap);
if overall > min_cap {
overall = min_cap;
is_capped = Some(true);
}
let grade = PerformanceGrade::from_score(overall);
PerformanceScore {
overall,
grade,
lcp_score,
fcp_score,
cls_score,
interactivity_score,
si_score,
metrics_available,
size_penalty,
js_penalty,
request_penalty,
dom_penalty,
is_capped,
}
}
fn log_normal_score(value: f64, p10: f64, p50: f64, max_points: u32) -> u32 {
if value <= 0.0 {
return max_points;
}
const Z90: f64 = 1.2815515655446004;
let p10_log = p10.ln();
let p50_log = p50.ln();
let value_log = value.ln();
let sigma = (p50_log - p10_log).abs() / Z90;
let z = (value_log - p50_log) / sigma;
let s = (1.0 - standard_normal_cdf(z)).clamp(0.0, 1.0);
(s * max_points as f64).round() as u32
}
fn standard_normal_cdf(x: f64) -> f64 {
0.5 * (1.0 + erf(x / std::f64::consts::SQRT_2))
}
fn erf(x: f64) -> f64 {
let sign = if x < 0.0 { -1.0 } else { 1.0 };
let x = x.abs();
let a1 = 0.254829592;
let a2 = -0.284496736;
let a3 = 1.421413741;
let a4 = -1.453152027;
let a5 = 1.061405429;
let p = 0.3275911;
let t = 1.0 / (1.0 + p * x);
let y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-x * x).exp();
sign * y
}
fn score_lcp(ms: f64) -> u32 {
log_normal_score(ms, 2500.0, 4000.0, W_LCP)
}
fn score_fcp(ms: f64) -> u32 {
log_normal_score(ms, 1800.0, 3000.0, W_FCP)
}
fn score_cls(value: f64) -> u32 {
if value > 0.5 {
return 0;
}
log_normal_score(value.max(0.0001), 0.1, 0.25, W_CLS)
}
fn score_interactivity(ms: f64) -> u32 {
log_normal_score(ms.max(1.0), 200.0, 600.0, W_TBT)
}
fn score_si(ms: f64) -> u32 {
log_normal_score(ms, 3400.0, 5800.0, W_SI)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::performance::vitals::VitalMetric;
use crate::performance::ResourceBreakdown;
#[test]
fn test_performance_grade_from_score() {
assert_eq!(PerformanceGrade::from_score(95), PerformanceGrade::Platinum);
assert_eq!(PerformanceGrade::from_score(80), PerformanceGrade::Gold);
assert_eq!(PerformanceGrade::from_score(65), PerformanceGrade::Silver);
assert_eq!(PerformanceGrade::from_score(55), PerformanceGrade::Bronze);
assert_eq!(
PerformanceGrade::from_score(30),
PerformanceGrade::NeedsImprovement
);
}
#[test]
fn test_score_lcp_good() {
assert!(score_lcp(2500.0) >= 22);
assert_eq!(score_lcp(1000.0), 25);
}
#[test]
fn test_score_lcp_poor() {
let s_p50 = score_lcp(4000.0);
assert!((10..=14).contains(&s_p50), "p50 score was {}", s_p50);
assert!(score_lcp(7000.0) <= 5);
assert!(score_lcp(10000.0) <= 1);
}
#[test]
fn test_score_cls_lighthouse_parity() {
assert!(score_cls(0.1) >= 22);
let s_p50 = score_cls(0.25);
assert!((10..=14).contains(&s_p50));
assert_eq!(score_cls(1.7), 0);
}
#[test]
fn test_overall_caps_when_lcp_is_extreme() {
let vitals = WebVitals {
lcp: Some(VitalMetric::new(7432.0, 2500.0, 4000.0)),
fcp: Some(VitalMetric::new(2400.0, 1800.0, 3000.0)),
cls: Some(VitalMetric::new(0.04, 0.1, 0.25)),
tbt: Some(VitalMetric::new(800.0, 200.0, 600.0)),
..Default::default()
};
let s = calculate_performance_score(&vitals, None);
assert!(s.overall < 50, "overall was {}", s.overall);
}
#[test]
fn test_calculate_performance_score() {
let vitals = WebVitals {
lcp: Some(VitalMetric::new(1500.0, 2500.0, 4000.0)),
fcp: Some(VitalMetric::new(1200.0, 1800.0, 3000.0)),
cls: Some(VitalMetric::new(0.05, 0.1, 0.25)),
tbt: Some(VitalMetric::new(150.0, 200.0, 600.0)),
..Default::default()
};
let score = calculate_performance_score(&vitals, None);
assert!(score.overall >= 80);
assert!(matches!(
score.grade,
PerformanceGrade::Gold | PerformanceGrade::Platinum
));
}
#[test]
fn test_partial_vitals_scores_on_available_metrics_only() {
let vitals = WebVitals {
lcp: Some(VitalMetric::new(1000.0, 2500.0, 4000.0)), cls: Some(VitalMetric::new(0.0, 0.1, 0.25)), ..Default::default()
};
let score = calculate_performance_score(&vitals, None);
assert_eq!(score.overall, 100, "all available metrics perfect → 100");
assert_eq!(score.metrics_available, 2);
}
#[test]
fn test_no_vitals_returns_zero() {
let vitals = WebVitals::default();
let score = calculate_performance_score(&vitals, None);
assert_eq!(score.overall, 0, "no vitals available → 0");
assert_eq!(score.metrics_available, 0);
}
#[test]
fn test_page_weight_capping_and_penalties() {
let vitals = WebVitals {
lcp: Some(VitalMetric::new(1000.0, 2500.0, 4000.0)),
fcp: Some(VitalMetric::new(800.0, 1800.0, 3000.0)),
cls: Some(VitalMetric::new(0.01, 0.1, 0.25)),
tbt: Some(VitalMetric::new(50.0, 200.0, 600.0)),
..Default::default()
};
let cw_light = ContentWeight {
total_bytes: 2_500_000,
transfer_bytes: 1_800_000,
breakdown: ResourceBreakdown::default(),
request_count: 30,
carbon: crate::performance::CarbonEstimate::default(),
recommendations: vec![],
};
let s_light = calculate_performance_score(&vitals, Some(&cw_light));
assert_eq!(s_light.size_penalty, Some(3));
assert_eq!(s_light.overall, 97);
assert_eq!(s_light.is_capped, None);
let cw_medium = ContentWeight {
total_bytes: 4_500_000,
transfer_bytes: 3_000_000,
breakdown: ResourceBreakdown::default(),
request_count: 50,
carbon: crate::performance::CarbonEstimate::default(),
recommendations: vec![],
};
let s_medium = calculate_performance_score(&vitals, Some(&cw_medium));
assert_eq!(s_medium.overall, 74);
assert_eq!(s_medium.is_capped, Some(true));
let cw_heavy = ContentWeight {
total_bytes: 12_000_000,
transfer_bytes: 9_000_000,
breakdown: ResourceBreakdown::default(),
request_count: 80,
carbon: crate::performance::CarbonEstimate::default(),
recommendations: vec![],
};
let s_heavy = calculate_performance_score(&vitals, Some(&cw_heavy));
assert_eq!(s_heavy.overall, 39);
assert_eq!(s_heavy.is_capped, Some(true));
}
#[test]
fn test_js_weight_capping_and_penalties() {
let vitals = WebVitals {
lcp: Some(VitalMetric::new(1000.0, 2500.0, 4000.0)),
fcp: Some(VitalMetric::new(800.0, 1800.0, 3000.0)),
cls: Some(VitalMetric::new(0.01, 0.1, 0.25)),
tbt: Some(VitalMetric::new(50.0, 200.0, 600.0)),
..Default::default()
};
let mut breakdown = ResourceBreakdown::default();
breakdown.javascript.bytes = 1_600_000;
let cw = ContentWeight {
total_bytes: 2_500_000,
transfer_bytes: 1_800_000,
breakdown,
request_count: 30,
carbon: crate::performance::CarbonEstimate::default(),
recommendations: vec![],
};
let s = calculate_performance_score(&vitals, Some(&cw));
assert_eq!(s.overall, 74);
assert_eq!(s.is_capped, Some(true));
assert_eq!(s.js_penalty, Some(6)); }
#[test]
fn test_weight_penalties_do_not_zero_out_good_vitals() {
let vitals = WebVitals {
lcp: Some(VitalMetric::new(7500.0, 2500.0, 4000.0)), fcp: Some(VitalMetric::new(200.0, 1800.0, 3000.0)), cls: Some(VitalMetric::new(0.003, 0.1, 0.25)), tbt: Some(VitalMetric::new(0.0, 200.0, 600.0)), dom_nodes: Some(30000),
..Default::default()
};
let mut breakdown = ResourceBreakdown::default();
breakdown.javascript.bytes = 2_000_000;
let cw = ContentWeight {
total_bytes: 2_900_000, transfer_bytes: 2_200_000,
breakdown,
request_count: 120,
carbon: crate::performance::CarbonEstimate::default(),
recommendations: vec![],
};
let s = calculate_performance_score(&vitals, Some(&cw));
assert!(
s.overall >= 20,
"heavy page with good CLS/TBT must not collapse to ~0, got {}",
s.overall
);
assert!(
s.overall < 50,
"still clearly penalised for weight, got {}",
s.overall
);
}
#[test]
fn test_weight_penalty_relative_cap_keeps_low_base_above_zero() {
let vitals = WebVitals {
lcp: Some(VitalMetric::new(9400.0, 2500.0, 4000.0)), fcp: Some(VitalMetric::new(1600.0, 1800.0, 3000.0)), cls: Some(VitalMetric::new(0.346, 0.1, 0.25)), tbt: Some(VitalMetric::new(282.0, 200.0, 600.0)), dom_nodes: Some(178000), ..Default::default()
};
let mut breakdown = ResourceBreakdown::default();
breakdown.javascript.bytes = 2_500_000;
let cw = ContentWeight {
total_bytes: 2_900_000,
transfer_bytes: 2_200_000,
breakdown,
request_count: 90,
carbon: crate::performance::CarbonEstimate::default(),
recommendations: vec![],
};
let s = calculate_performance_score(&vitals, Some(&cw));
assert!(
s.overall > 0,
"measured-but-mediocre heavy page must not hit exactly 0, got {}",
s.overall
);
}
#[test]
fn test_dom_nodes_penalty_for_common_sizes() {
let vitals = WebVitals {
lcp: Some(VitalMetric::new(1000.0, 2500.0, 4000.0)),
fcp: Some(VitalMetric::new(800.0, 1800.0, 3000.0)),
cls: Some(VitalMetric::new(0.01, 0.1, 0.25)),
tbt: Some(VitalMetric::new(50.0, 200.0, 600.0)),
dom_nodes: Some(2500),
..Default::default()
};
let s = calculate_performance_score(&vitals, None);
assert_eq!(s.dom_penalty, Some(15)); assert_eq!(s.is_capped, None);
assert_eq!(s.overall, 85); }
#[test]
fn test_dom_penalty_is_degressive_not_a_hard_cap() {
let base = WebVitals {
lcp: Some(VitalMetric::new(1000.0, 2500.0, 4000.0)),
fcp: Some(VitalMetric::new(800.0, 1800.0, 3000.0)),
cls: Some(VitalMetric::new(0.01, 0.1, 0.25)),
tbt: Some(VitalMetric::new(50.0, 200.0, 600.0)),
..Default::default()
};
let score_at = |nodes| {
calculate_performance_score(
&WebVitals {
dom_nodes: Some(nodes),
..base.clone()
},
None,
)
.overall
};
assert!(score_at(7000) > score_at(20000));
assert!(score_at(20000) >= score_at(250000));
assert!(
score_at(250000) >= 65,
"huge DOM scored {}",
score_at(250000)
);
}
#[test]
fn test_perfect_vitals_huge_dom_outranks_janky_cls() {
let perfect_big_dom = WebVitals {
lcp: Some(VitalMetric::new(1200.0, 2500.0, 4000.0)),
fcp: Some(VitalMetric::new(1200.0, 1800.0, 3000.0)),
cls: Some(VitalMetric::new(0.0, 0.1, 0.25)),
tbt: Some(VitalMetric::new(0.0, 200.0, 600.0)),
dom_nodes: Some(20000),
..Default::default()
};
let janky_cls = WebVitals {
lcp: Some(VitalMetric::new(900.0, 2500.0, 4000.0)),
fcp: Some(VitalMetric::new(800.0, 1800.0, 3000.0)),
cls: Some(VitalMetric::new(0.616, 0.1, 0.25)),
tbt: Some(VitalMetric::new(0.0, 200.0, 600.0)),
dom_nodes: Some(2000),
..Default::default()
};
let a = calculate_performance_score(&perfect_big_dom, None).overall;
let b = calculate_performance_score(&janky_cls, None).overall;
assert!(a > b, "perfect+bigDOM {a} must outrank jankyCLS {b}");
}
}