Skip to main content

subtr_actor/stats/
boost_invariants.rs

1use super::calculators::BoostStats;
2use crate::*;
3
4/// A small tolerance for replay-boost accounting checks.
5///
6/// Some pickup amounts are inferred from frame deltas before the pad is fully
7/// resolved, so nominal pad-value identities are expected to be approximate
8/// rather than bit-exact in production. The drift grows slowly with pickup
9/// count, so use a small base tolerance plus a per-pickup allowance.
10pub const BOOST_INVARIANT_BASE_TOLERANCE_RAW: f32 = 2.0;
11pub const BOOST_INVARIANT_PER_PICKUP_TOLERANCE_RAW: f32 = 0.3;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub enum BoostInvariantKind {
15    BucketAmounts,
16    NominalPickupAmount,
17    NominalStolenPickupAmount,
18    CurrentAmount,
19    UsedSplitAmounts,
20}
21
22impl BoostInvariantKind {
23    pub const ALL: [Self; 5] = [
24        Self::BucketAmounts,
25        Self::NominalPickupAmount,
26        Self::NominalStolenPickupAmount,
27        Self::CurrentAmount,
28        Self::UsedSplitAmounts,
29    ];
30
31    pub fn label(self) -> &'static str {
32        match self {
33            Self::BucketAmounts => "bucket_amounts",
34            Self::NominalPickupAmount => "nominal_pickup_amount",
35            Self::NominalStolenPickupAmount => "nominal_stolen_pickup_amount",
36            Self::CurrentAmount => "current_amount",
37            Self::UsedSplitAmounts => "used_split_amounts",
38        }
39    }
40}
41
42#[derive(Debug, Clone, PartialEq)]
43pub struct BoostInvariantViolation {
44    pub kind: BoostInvariantKind,
45    pub expected: f32,
46    pub actual: f32,
47    pub diff: f32,
48    pub tolerance: f32,
49}
50
51impl BoostInvariantViolation {
52    pub fn message(&self) -> String {
53        match self.kind {
54            BoostInvariantKind::BucketAmounts => format!(
55                "amount_collected_big + amount_collected_small should match amount_collected \
56                 (actual={:.1}, expected={:.1}, diff={:.1}, tolerance={:.1})",
57                self.actual, self.expected, self.diff, self.tolerance
58            ),
59            BoostInvariantKind::NominalPickupAmount => format!(
60                "amount_collected + overfill_total should match nominal pad value from pickup counts \
61                 (actual={:.1}, expected={:.1}, diff={:.1}, tolerance={:.1})",
62                self.actual, self.expected, self.diff, self.tolerance
63            ),
64            BoostInvariantKind::NominalStolenPickupAmount => format!(
65                "amount_stolen + overfill_from_stolen should match nominal stolen pad value from pickup counts \
66                 (actual={:.1}, expected={:.1}, diff={:.1}, tolerance={:.1})",
67                self.actual, self.expected, self.diff, self.tolerance
68            ),
69            BoostInvariantKind::CurrentAmount => format!(
70                "amount_obtained - amount_used should match observed current boost \
71                 (actual={:.1}, expected={:.1}, diff={:.1}, tolerance={:.1})",
72                self.actual, self.expected, self.diff, self.tolerance
73            ),
74            BoostInvariantKind::UsedSplitAmounts => format!(
75                "amount_used_while_grounded + amount_used_while_airborne should match amount_used \
76                 (actual={:.1}, expected={:.1}, diff={:.1}, tolerance={:.1})",
77                self.actual, self.expected, self.diff, self.tolerance
78            ),
79        }
80    }
81}
82
83pub fn nominal_pickup_amount_from_counts(stats: &BoostStats) -> f32 {
84    stats.big_pads_collected as f32 * BOOST_MAX_AMOUNT
85        + stats.small_pads_collected as f32 * boost_percent_to_amount(12.0)
86}
87
88pub fn nominal_stolen_pickup_amount_from_counts(stats: &BoostStats) -> f32 {
89    stats.big_pads_stolen as f32 * BOOST_MAX_AMOUNT
90        + stats.small_pads_stolen as f32 * boost_percent_to_amount(12.0)
91}
92
93fn nominal_pickup_tolerance(pickup_count: u32) -> f32 {
94    BOOST_INVARIANT_BASE_TOLERANCE_RAW
95        + BOOST_INVARIANT_PER_PICKUP_TOLERANCE_RAW * pickup_count as f32
96}
97
98fn push_violation(
99    violations: &mut Vec<BoostInvariantViolation>,
100    kind: BoostInvariantKind,
101    expected: f32,
102    actual: f32,
103    tolerance: f32,
104) {
105    let diff = (actual - expected).abs();
106    if diff > tolerance {
107        violations.push(BoostInvariantViolation {
108            kind,
109            expected,
110            actual,
111            diff,
112            tolerance,
113        });
114    }
115}
116
117/// Returns the per-snapshot boost accounting violations that can be checked
118/// from cumulative `BoostStats`, plus an optional observed current boost amount.
119pub fn boost_invariant_violations(
120    stats: &BoostStats,
121    observed_boost_amount: Option<f32>,
122) -> Vec<BoostInvariantViolation> {
123    let mut violations = Vec::new();
124
125    push_violation(
126        &mut violations,
127        BoostInvariantKind::BucketAmounts,
128        stats.amount_collected,
129        stats.amount_collected_big + stats.amount_collected_small,
130        1.0,
131    );
132    push_violation(
133        &mut violations,
134        BoostInvariantKind::NominalPickupAmount,
135        nominal_pickup_amount_from_counts(stats),
136        stats.amount_collected + stats.overfill_total,
137        nominal_pickup_tolerance(stats.big_pads_collected + stats.small_pads_collected),
138    );
139    push_violation(
140        &mut violations,
141        BoostInvariantKind::NominalStolenPickupAmount,
142        nominal_stolen_pickup_amount_from_counts(stats),
143        stats.amount_stolen + stats.overfill_from_stolen,
144        nominal_pickup_tolerance(stats.big_pads_stolen + stats.small_pads_stolen),
145    );
146    if let Some(current_boost_amount) = observed_boost_amount {
147        push_violation(
148            &mut violations,
149            BoostInvariantKind::CurrentAmount,
150            current_boost_amount,
151            stats.amount_obtained() - stats.amount_used,
152            1.0,
153        );
154    }
155    push_violation(
156        &mut violations,
157        BoostInvariantKind::UsedSplitAmounts,
158        stats.amount_used,
159        stats.amount_used_by_vertical_band(),
160        1.0,
161    );
162
163    violations
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn reports_used_split_mismatch() {
172        let stats = BoostStats {
173            amount_used: 20.0,
174            amount_used_while_grounded: 8.0,
175            amount_used_while_airborne: 9.0,
176            ..Default::default()
177        };
178
179        let violations = boost_invariant_violations(&stats, None);
180
181        assert!(violations
182            .iter()
183            .any(|violation| violation.kind == BoostInvariantKind::UsedSplitAmounts));
184    }
185
186    #[test]
187    fn accepts_matching_used_split() {
188        let stats = BoostStats {
189            amount_used: 20.0,
190            amount_used_while_grounded: 8.0,
191            amount_used_while_airborne: 12.0,
192            ..Default::default()
193        };
194
195        let violations = boost_invariant_violations(&stats, None);
196
197        assert!(!violations
198            .iter()
199            .any(|violation| violation.kind == BoostInvariantKind::UsedSplitAmounts));
200    }
201}