Skip to main content

ftui_runtime/tick_strategy/
tick_allocation.rs

1//! Probability-to-divisor tick rate allocation.
2//!
3//! [`TickAllocation`] maps a transition probability (0.0..1.0) to a tick
4//! divisor (min..max). This bridges the gap between "how likely is the user
5//! to switch to screen X?" and "how often should we tick screen X?"
6//!
7//! Three allocation curves are provided:
8//!
9//! - [`AllocationCurve::Linear`]: `divisor = max - (max - min) * prob`
10//! - [`AllocationCurve::Exponential`]: concentrates budget on likely screens
11//! - [`AllocationCurve::Stepped`]: explicit threshold-to-divisor tiers
12
13/// Maps transition probability to tick divisor.
14#[derive(Debug, Clone)]
15pub struct TickAllocation {
16    /// Ceiling: least-likely screens tick at most every max_divisor frames.
17    pub max_divisor: u64,
18    /// Floor: most-likely screens tick at least every min_divisor frames.
19    pub min_divisor: u64,
20    /// How probability maps to divisor.
21    pub curve: AllocationCurve,
22}
23
24/// Curve controlling how probability maps to divisor.
25#[derive(Debug, Clone)]
26pub enum AllocationCurve {
27    /// Linear: `divisor = max - (max - min) * probability`.
28    Linear,
29    /// Exponential: `divisor = max * (1 - probability)^exponent`.
30    /// Gives more budget to high-probability screens with sharp falloff.
31    Exponential {
32        /// Controls curve steepness (typically 2.0).
33        exponent: f64,
34    },
35    /// Stepped: bucket probabilities into tiers.
36    ///
37    /// Thresholds are checked in descending order; first match wins.
38    /// Must be sorted descending by threshold value.
39    ///
40    /// Example: `[(0.3, 1), (0.1, 2), (0.03, 5), (0.0, 20)]`
41    Stepped {
42        /// `(threshold, divisor)` pairs, sorted descending by threshold.
43        thresholds: Vec<(f64, u64)>,
44    },
45}
46
47impl TickAllocation {
48    /// Create with exponential curve (recommended default).
49    ///
50    /// Exponent=2.0, max_divisor=20, min_divisor=1.
51    #[must_use]
52    pub fn new() -> Self {
53        Self {
54            max_divisor: 20,
55            min_divisor: 1,
56            curve: AllocationCurve::Exponential { exponent: 2.0 },
57        }
58    }
59
60    /// Create with a linear curve.
61    #[must_use]
62    pub fn linear(min_divisor: u64, max_divisor: u64) -> Self {
63        Self {
64            max_divisor: max_divisor.max(1),
65            min_divisor: min_divisor.max(1),
66            curve: AllocationCurve::Linear,
67        }
68    }
69
70    /// Create with an exponential curve.
71    #[must_use]
72    pub fn exponential(min_divisor: u64, max_divisor: u64, exponent: f64) -> Self {
73        Self {
74            max_divisor: max_divisor.max(1),
75            min_divisor: min_divisor.max(1),
76            curve: AllocationCurve::Exponential {
77                exponent: exponent.max(0.1),
78            },
79        }
80    }
81
82    /// Create with stepped thresholds.
83    ///
84    /// # Panics
85    ///
86    /// Panics if thresholds are not sorted descending by threshold value.
87    #[must_use]
88    pub fn stepped(thresholds: Vec<(f64, u64)>) -> Self {
89        // Validate descending order
90        for window in thresholds.windows(2) {
91            assert!(
92                window[0].0 >= window[1].0,
93                "Stepped thresholds must be sorted descending: {} >= {} violated",
94                window[0].0,
95                window[1].0,
96            );
97        }
98
99        let max_divisor = thresholds
100            .iter()
101            .map(|(_, d)| *d)
102            .max()
103            .unwrap_or(20)
104            .max(1);
105        let min_divisor = thresholds.iter().map(|(_, d)| *d).min().unwrap_or(1).max(1);
106
107        Self {
108            max_divisor,
109            min_divisor,
110            curve: AllocationCurve::Stepped { thresholds },
111        }
112    }
113
114    /// Map a probability (0.0..1.0) to a tick divisor.
115    ///
116    /// Higher probability → lower divisor (faster ticking).
117    /// Result is always clamped to `[min_divisor, max_divisor]`.
118    #[must_use]
119    pub fn divisor_for(&self, probability: f64) -> u64 {
120        let prob = probability.clamp(0.0, 1.0);
121        let min = self.min_divisor.max(1);
122        let max = self.max_divisor.max(min);
123
124        let raw = match &self.curve {
125            AllocationCurve::Linear => {
126                // divisor = max - (max - min) * prob
127                let range = (max - min) as f64;
128                max as f64 - range * prob
129            }
130            AllocationCurve::Exponential { exponent } => {
131                // divisor = min + (max - min) * (1 - prob)^exponent
132                let range = (max - min) as f64;
133                min as f64 + range * (1.0 - prob).powf(*exponent)
134            }
135            AllocationCurve::Stepped { thresholds } => {
136                // First threshold where prob >= threshold wins
137                for &(threshold, divisor) in thresholds {
138                    if prob >= threshold {
139                        return divisor.clamp(min, max);
140                    }
141                }
142                // No threshold matched → max divisor
143                max as f64
144            }
145        };
146
147        (raw.round() as u64).clamp(min, max)
148    }
149}
150
151impl Default for TickAllocation {
152    fn default() -> Self {
153        Self::new()
154    }
155}
156
157// =============================================================================
158// Tests
159// =============================================================================
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn default_probability_one_returns_min() {
167        let alloc = TickAllocation::new();
168        assert_eq!(alloc.divisor_for(1.0), 1);
169    }
170
171    #[test]
172    fn default_probability_zero_returns_max() {
173        let alloc = TickAllocation::new();
174        assert_eq!(alloc.divisor_for(0.0), 20);
175    }
176
177    #[test]
178    fn monotonically_decreasing() {
179        let alloc = TickAllocation::new();
180        let mut prev = u64::MAX;
181        for i in 0..=100 {
182            let prob = i as f64 / 100.0;
183            let div = alloc.divisor_for(prob);
184            assert!(
185                div <= prev,
186                "not monotonic at prob={prob}: div={div}, prev={prev}"
187            );
188            prev = div;
189        }
190    }
191
192    #[test]
193    fn linear_curve() {
194        let alloc = TickAllocation::linear(1, 20);
195
196        assert_eq!(alloc.divisor_for(1.0), 1);
197        assert_eq!(alloc.divisor_for(0.0), 20);
198        // 0.5 → 20 - 19 * 0.5 = 10.5 → 11
199        assert_eq!(alloc.divisor_for(0.5), 11);
200    }
201
202    #[test]
203    fn linear_monotonic() {
204        let alloc = TickAllocation::linear(1, 100);
205        let mut prev = u64::MAX;
206        for i in 0..=100 {
207            let prob = i as f64 / 100.0;
208            let div = alloc.divisor_for(prob);
209            assert!(div <= prev);
210            prev = div;
211        }
212    }
213
214    #[test]
215    fn exponential_curve() {
216        let alloc = TickAllocation::exponential(1, 20, 2.0);
217
218        assert_eq!(alloc.divisor_for(1.0), 1);
219        assert_eq!(alloc.divisor_for(0.0), 20);
220
221        // 0.5 → 1 + 19 * (0.5)^2 = 1 + 19*0.25 = 1 + 4.75 = 5.75 → 6
222        assert_eq!(alloc.divisor_for(0.5), 6);
223    }
224
225    #[test]
226    fn exponential_monotonic() {
227        let alloc = TickAllocation::exponential(1, 20, 2.0);
228        let mut prev = u64::MAX;
229        for i in 0..=100 {
230            let prob = i as f64 / 100.0;
231            let div = alloc.divisor_for(prob);
232            assert!(div <= prev);
233            prev = div;
234        }
235    }
236
237    #[test]
238    fn exponential_default_table() {
239        // Verify the recommended defaults match the design table
240        let alloc = TickAllocation::new(); // exponential, exp=2.0, max=20, min=1
241
242        // p=0.50 → 1 + 19*(0.5)^2 = 5.75 → 6
243        assert_eq!(alloc.divisor_for(0.50), 6);
244        // p=0.30 → 1 + 19*(0.7)^2 = 1 + 19*0.49 = 10.31 → 10
245        assert_eq!(alloc.divisor_for(0.30), 10);
246        // p=0.05 → 1 + 19*(0.95)^2 = 1 + 19*0.9025 = 18.15 → 18
247        assert_eq!(alloc.divisor_for(0.05), 18);
248    }
249
250    #[test]
251    fn stepped_curve() {
252        let alloc = TickAllocation::stepped(vec![(0.30, 1), (0.10, 2), (0.03, 5), (0.00, 20)]);
253
254        assert_eq!(alloc.divisor_for(0.50), 1); // > 0.30
255        assert_eq!(alloc.divisor_for(0.31), 1); // > 0.30
256        assert_eq!(alloc.divisor_for(0.20), 2); // > 0.10
257        assert_eq!(alloc.divisor_for(0.05), 5); // > 0.03
258        assert_eq!(alloc.divisor_for(0.01), 20); // > 0.00
259    }
260
261    #[test]
262    fn stepped_first_match_wins() {
263        let alloc = TickAllocation::stepped(vec![(0.50, 1), (0.25, 5), (0.00, 10)]);
264        // 0.60 > 0.50, so first threshold matches
265        assert_eq!(alloc.divisor_for(0.60), 1);
266    }
267
268    #[test]
269    fn stepped_threshold_is_inclusive() {
270        let alloc = TickAllocation::stepped(vec![(0.30, 1), (0.10, 2), (0.00, 20)]);
271        assert_eq!(alloc.divisor_for(0.30), 1);
272        assert_eq!(alloc.divisor_for(0.10), 2);
273        assert_eq!(alloc.divisor_for(0.00), 20);
274    }
275
276    #[test]
277    #[should_panic(expected = "sorted descending")]
278    fn stepped_panics_on_unsorted() {
279        let _ = TickAllocation::stepped(vec![
280            (0.10, 2), // wrong: should be higher first
281            (0.30, 1),
282            (0.00, 20),
283        ]);
284    }
285
286    #[test]
287    fn clamps_to_range() {
288        let alloc = TickAllocation::exponential(2, 15, 1.0);
289        // Even extreme probabilities stay in [2, 15]
290        assert!(alloc.divisor_for(1.0) >= 2);
291        assert!(alloc.divisor_for(0.0) <= 15);
292        assert!(alloc.divisor_for(1.5) >= 2); // clamped input
293        assert!(alloc.divisor_for(-0.5) <= 15); // clamped input
294    }
295
296    #[test]
297    fn all_curves_in_range() {
298        let curves: Vec<TickAllocation> = vec![
299            TickAllocation::linear(1, 20),
300            TickAllocation::exponential(1, 20, 2.0),
301            TickAllocation::stepped(vec![(0.5, 1), (0.0, 20)]),
302        ];
303
304        for alloc in &curves {
305            for i in 0..=100 {
306                let prob = i as f64 / 100.0;
307                let div = alloc.divisor_for(prob);
308                assert!(
309                    div >= alloc.min_divisor && div <= alloc.max_divisor,
310                    "out of range: div={div}, min={}, max={}, prob={prob}",
311                    alloc.min_divisor,
312                    alloc.max_divisor,
313                );
314            }
315        }
316    }
317
318    #[test]
319    fn default_impl() {
320        let alloc = TickAllocation::default();
321        assert_eq!(alloc.max_divisor, 20);
322        assert_eq!(alloc.min_divisor, 1);
323    }
324
325    #[test]
326    fn empty_stepped_returns_max() {
327        let alloc = TickAllocation::stepped(vec![]);
328        // No thresholds → divisor_for should return max (which defaults based on empty vec)
329        let div = alloc.divisor_for(0.5);
330        assert_eq!(div, alloc.max_divisor);
331    }
332
333    // ========================================================================
334    // Additional tests (I.3 coverage)
335    // ========================================================================
336
337    #[test]
338    fn max_divisor_one_always_returns_one() {
339        // When max_divisor=1 (no throttling), every screen gets ticked every frame.
340        let linear = TickAllocation::linear(1, 1);
341        let exp = TickAllocation::exponential(1, 1, 2.0);
342
343        for prob in [0.0, 0.25, 0.5, 0.75, 1.0] {
344            let d_lin = linear.divisor_for(prob);
345            let d_exp = exp.divisor_for(prob);
346            eprintln!("prob={prob}: linear={d_lin}, exponential={d_exp}");
347            assert_eq!(
348                d_lin, 1,
349                "linear with max=1 should return 1 for prob={prob}"
350            );
351            assert_eq!(
352                d_exp, 1,
353                "exponential with max=1 should return 1 for prob={prob}"
354            );
355        }
356    }
357
358    #[test]
359    fn exponential_high_exponent_concentrates_budget() {
360        // With a high exponent, the curve drops steeply — only very high
361        // probabilities get low divisors.
362        let steep = TickAllocation::exponential(1, 100, 5.0);
363        let shallow = TickAllocation::exponential(1, 100, 1.0);
364
365        let p = 0.5;
366        let d_steep = steep.divisor_for(p);
367        let d_shallow = shallow.divisor_for(p);
368
369        eprintln!("p={p}: steep(exp=5)={d_steep}, shallow(exp=1)={d_shallow}");
370        // Steep exponent: (1-0.5)^5 = 0.03125 → 1 + 99*0.03125 ≈ 4
371        // Shallow exponent: (1-0.5)^1 = 0.5 → 1 + 99*0.5 ≈ 51
372        assert!(
373            d_steep < d_shallow,
374            "steep exponent should give lower divisor (more budget) for p={p}"
375        );
376    }
377}