ftui_runtime/tick_strategy/
tick_allocation.rs1#[derive(Debug, Clone)]
15pub struct TickAllocation {
16 pub max_divisor: u64,
18 pub min_divisor: u64,
20 pub curve: AllocationCurve,
22}
23
24#[derive(Debug, Clone)]
26pub enum AllocationCurve {
27 Linear,
29 Exponential {
32 exponent: f64,
34 },
35 Stepped {
42 thresholds: Vec<(f64, u64)>,
44 },
45}
46
47impl TickAllocation {
48 #[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 #[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 #[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 #[must_use]
88 pub fn stepped(thresholds: Vec<(f64, u64)>) -> Self {
89 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 #[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 let range = (max - min) as f64;
128 max as f64 - range * prob
129 }
130 AllocationCurve::Exponential { exponent } => {
131 let range = (max - min) as f64;
133 min as f64 + range * (1.0 - prob).powf(*exponent)
134 }
135 AllocationCurve::Stepped { thresholds } => {
136 for &(threshold, divisor) in thresholds {
138 if prob >= threshold {
139 return divisor.clamp(min, max);
140 }
141 }
142 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#[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 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 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 let alloc = TickAllocation::new(); assert_eq!(alloc.divisor_for(0.50), 6);
244 assert_eq!(alloc.divisor_for(0.30), 10);
246 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); assert_eq!(alloc.divisor_for(0.31), 1); assert_eq!(alloc.divisor_for(0.20), 2); assert_eq!(alloc.divisor_for(0.05), 5); assert_eq!(alloc.divisor_for(0.01), 20); }
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 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), (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 assert!(alloc.divisor_for(1.0) >= 2);
291 assert!(alloc.divisor_for(0.0) <= 15);
292 assert!(alloc.divisor_for(1.5) >= 2); assert!(alloc.divisor_for(-0.5) <= 15); }
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 let div = alloc.divisor_for(0.5);
330 assert_eq!(div, alloc.max_divisor);
331 }
332
333 #[test]
338 fn max_divisor_one_always_returns_one() {
339 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 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 assert!(
373 d_steep < d_shallow,
374 "steep exponent should give lower divisor (more budget) for p={p}"
375 );
376 }
377}