Skip to main content

pitboard_core/
pace.rs

1//! How fast a limit is being used, against using it evenly across its window.
2//!
3//! A share on its own does not say whether it is a problem: 40% of a weekly limit is fine on
4//! Saturday and a warning on Monday morning. What does is the share an even use would have
5//! reached by then, which is the part of the window gone by, and how far the share used is
6//! from it. Every surface asks this module, so the command line, the status line, the app
7//! and `--json` say the same thing.
8//!
9//! Worked out from one reading: the share used, when it was taken, when the window resets,
10//! and how long it runs. The pace is the reading's, as of when it was taken: the share it
11//! holds was the share then, and set against a later moment it would drift towards under
12//! pace for as long as nobody read the limit again, hiding a warning just when the numbers
13//! are old. The rate is the window's own since it began, which is the share used over the
14//! time gone by, so a limit faster than even always runs out before it resets, counted
15//! down to the moment it is told, and one slower always lasts. It used to be a rate taken
16//! across fourteen days of readings, through every reset in them, and on one machine it
17//! said a weekly limit at 99% had a day and nine hours left when at its own rate it had one.
18
19use crate::usage::Window;
20
21/// How many points either side of an even pace still count as even: close enough that the
22/// difference is noise early in a five-hour window, and far enough that a limit really
23/// running ahead is said.
24pub const MARGIN: f64 = 5.0;
25
26/// The part of a window that has to have gone by before its pace means anything. A few
27/// minutes into a five-hour window one request is a large share of the time gone by.
28pub const TOO_EARLY: f64 = 0.03;
29
30/// How a limit's use compared with an even pace when it was read.
31#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
32pub struct Pace {
33    /// The share an even use would have reached when the limit was read, in percent of the
34    /// limit: the part of the window gone by then.
35    pub expected: f64,
36    /// The share used less `expected`, in percentage points. Above zero is faster than an
37    /// even pace.
38    pub delta: f64,
39    #[serde(flatten)]
40    pub standing: Standing,
41}
42
43/// Which side of an even pace a limit is on, beyond [`MARGIN`].
44#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
45#[serde(tag = "standing", rename_all = "snake_case")]
46pub enum Standing {
47    /// Slower than even: at its own rate the limit lasts until it resets.
48    Under,
49    /// Within [`MARGIN`] of even.
50    Even,
51    /// Faster than even: at its own rate the limit runs out this many seconds from the
52    /// moment this is told, before it resets.
53    Over { runs_out_in: i64 },
54}
55
56/// The pace of a limit read at `at` with `used` percent used, whose window of
57/// `length_seconds` resets at `resets_at`, told at `now`. A reading taken later than `now`,
58/// by a clock ahead of this one, counts as taken now.
59///
60/// `None` where it means nothing: a window whose length or reset is not known, one that
61/// has reset since, or that cannot have begun when it was read, one used up, which its
62/// share says already, and one read less than [`TOO_EARLY`] in.
63pub fn of(
64    used: f64,
65    resets_at: Option<i64>,
66    length_seconds: Option<i64>,
67    at: i64,
68    now: i64,
69) -> Option<Pace> {
70    let length = length_seconds.filter(|length| *length > 0)?;
71    let resets_at = resets_at?;
72    let at = at.min(now);
73    let left = resets_at - at;
74    if resets_at <= now || left > length || used >= 100.0 {
75        return None;
76    }
77    let gone = length - left;
78    let part = gone as f64 / length as f64;
79    if part < TOO_EARLY {
80        return None;
81    }
82    let used = used.max(0.0);
83    let expected = part * 100.0;
84    let delta = used - expected;
85    let standing = if delta > MARGIN {
86        // What is left, at the share used per second gone by, less the time since the
87        // reading. Past MARGIN the share used is above zero, and the time this gives is
88        // short of the reset.
89        let from_reading = ((100.0 - used) * gone as f64 / used) as i64;
90        Standing::Over {
91            runs_out_in: (from_reading - (now - at)).max(0),
92        }
93    } else if delta < -MARGIN {
94        Standing::Under
95    } else {
96        Standing::Even
97    };
98    Some(Pace {
99        expected,
100        delta,
101        standing,
102    })
103}
104
105impl Window {
106    /// This limit's pace as read at `at`, told at `now`.
107    pub fn pace(&self, at: i64, now: i64) -> Option<Pace> {
108        of(self.percent, self.resets_at, self.length_seconds, at, now)
109    }
110}
111
112/// The limit that runs out first at its pace, and in how many seconds, of `limits` each
113/// with its pace. `None` where none runs out before it resets.
114pub fn first_to_run_out<T>(
115    limits: impl IntoIterator<Item = (T, Option<Pace>)>,
116) -> Option<(T, i64)> {
117    limits
118        .into_iter()
119        .filter_map(|(limit, pace)| match pace?.standing {
120            Standing::Over { runs_out_in } => Some((limit, runs_out_in)),
121            Standing::Under | Standing::Even => None,
122        })
123        .min_by_key(|(_, runs_out_in)| *runs_out_in)
124}
125
126/// How long an account lasts, from its limits.
127#[derive(Debug, Clone, Copy, PartialEq)]
128pub enum Lasts<'a> {
129    /// This limit runs out first, at its pace, this many seconds from now.
130    RunsOut { limit: &'a Window, in_seconds: i64 },
131    /// No limit runs out before it resets, and the first reset is this many seconds from now.
132    UntilReset { in_seconds: i64 },
133}
134
135impl Lasts<'_> {
136    pub fn seconds(self) -> i64 {
137        match self {
138            Lasts::RunsOut { in_seconds, .. } | Lasts::UntilReset { in_seconds } => in_seconds,
139        }
140    }
141}
142
143/// How long an account in use with `windows`, read at `at`, lasts as of `now`: until the
144/// first of them to run out at its pace does, or where none does, until the first of them
145/// resets. `None` where no window says when it resets.
146pub fn lasts(windows: &[Window], at: i64, now: i64) -> Option<Lasts<'_>> {
147    let paced = windows.iter().map(|w| (w, w.pace(at, now)));
148    if let Some((limit, in_seconds)) = first_to_run_out(paced) {
149        return Some(Lasts::RunsOut { limit, in_seconds });
150    }
151    until_reset(windows, now)
152}
153
154/// How long an account not in use with `windows` lasts as of `now`: until the first of them
155/// resets, since nothing of it is being used. `None` where no window says when it resets.
156pub fn until_reset(windows: &[Window], now: i64) -> Option<Lasts<'_>> {
157    windows
158        .iter()
159        .filter_map(|w| w.resets_at.map(|at| at - now))
160        .filter(|left| *left > 0)
161        .min()
162        .map(|in_seconds| Lasts::UntilReset { in_seconds })
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    const NOW: i64 = 1_789_935_000;
170    const HOUR: i64 = 3_600;
171    const DAY: i64 = 86_400;
172    const WEEK: i64 = 7 * DAY;
173
174    /// A window of `length` with `gone` of it gone by.
175    fn pace(used: f64, gone: i64, length: i64) -> Option<Pace> {
176        of(used, Some(NOW + length - gone), Some(length), NOW, NOW)
177    }
178
179    fn window(kind: &str, used: f64, gone: i64, length: i64) -> Window {
180        Window {
181            kind: kind.into(),
182            scope: None,
183            severity: None,
184            percent: used,
185            resets_at: Some(NOW + length - gone),
186            is_active: true,
187            length_seconds: Some(length),
188        }
189    }
190
191    /// The share an even use reaches is the part of the window gone by, and the distance
192    /// from it says which side of even the limit is on, past the margin.
193    #[test]
194    fn a_limit_is_measured_against_the_part_of_its_window_gone_by() {
195        let half = pace(50.0, WEEK / 2, WEEK).unwrap();
196        assert!((half.expected - 50.0).abs() < 1e-9);
197        assert!(half.delta.abs() < 1e-9);
198        assert_eq!(half.standing, Standing::Even);
199
200        let gone = 10 * HOUR + 18 * 60;
201        let busy = pace(33.0, gone, WEEK).unwrap();
202        assert!((busy.expected - 6.13).abs() < 0.01, "{}", busy.expected);
203        assert!(matches!(busy.standing, Standing::Over { .. }));
204
205        assert_eq!(
206            pace(10.0, WEEK / 2, WEEK).unwrap().standing,
207            Standing::Under
208        );
209        assert_eq!(
210            pace(54.9, WEEK / 2, WEEK).unwrap().standing,
211            Standing::Even,
212            "within the margin"
213        );
214        assert_eq!(
215            pace(44.9, WEEK / 2, WEEK).unwrap().standing,
216            Standing::Under
217        );
218    }
219
220    /// Faster than even runs out before the reset, at the window's own rate since it
221    /// began: 33% in ten hours runs out about twenty hours later, days before a week ends.
222    #[test]
223    fn a_limit_faster_than_even_says_when_it_runs_out() {
224        let gone = 10 * HOUR;
225        let Standing::Over { runs_out_in } = pace(33.0, gone, WEEK).unwrap().standing else {
226            panic!("faster than even");
227        };
228        let at_its_rate = (67.0 * gone as f64 / 33.0) as i64;
229        assert_eq!(runs_out_in, at_its_rate);
230        assert!(runs_out_in < WEEK - gone, "before the reset");
231
232        // Only just past the margin, still before the reset.
233        let Standing::Over { runs_out_in } = pace(55.1, 5 * HOUR / 2, 5 * HOUR).unwrap().standing
234        else {
235            panic!("faster than even");
236        };
237        assert!(runs_out_in < 5 * HOUR / 2, "{runs_out_in}");
238    }
239
240    /// Nothing is said where nothing can be: a length or a reset not known, a reset passed,
241    /// a reset further off than the window is long, a limit used up, and the first few
242    /// minutes of a window.
243    #[test]
244    fn a_pace_is_said_only_where_it_means_something() {
245        assert_eq!(of(40.0, None, Some(WEEK), NOW, NOW), None);
246        assert_eq!(of(40.0, Some(NOW + DAY), None, NOW, NOW), None);
247        assert_eq!(of(40.0, Some(NOW + DAY), Some(0), NOW, NOW), None);
248        assert_eq!(of(40.0, Some(NOW), Some(WEEK), NOW, NOW), None);
249        assert_eq!(of(40.0, Some(NOW - 60), Some(WEEK), NOW, NOW), None);
250        assert_eq!(of(40.0, Some(NOW + WEEK + 60), Some(WEEK), NOW, NOW), None);
251        assert_eq!(pace(100.0, DAY, WEEK), None, "used up says so itself");
252        assert_eq!(pace(104.0, DAY, WEEK), None);
253
254        let early = (TOO_EARLY * (5 * HOUR) as f64) as i64;
255        assert_eq!(pace(20.0, early - 1, 5 * HOUR), None);
256        assert!(pace(20.0, early + 1, 5 * HOUR).is_some());
257    }
258
259    /// A reading's pace is the pace when it was taken: an hour later its share is the share
260    /// it was, and an even pace is where it was then, so a limit over pace stays over pace
261    /// and only counts down to running out. Told against the later moment instead, the same
262    /// reading drifted to under pace and hid the warning while nobody read it again.
263    #[test]
264    fn a_readings_pace_is_its_own_and_only_counts_down() {
265        let read = NOW - HOUR;
266        let resets = NOW + WEEK - 11 * HOUR;
267        let then = of(33.0, Some(resets), Some(WEEK), read, read).unwrap();
268        let later = of(33.0, Some(resets), Some(WEEK), read, NOW).unwrap();
269        assert_eq!(later.expected, then.expected);
270        assert_eq!(later.delta, then.delta);
271        let (Standing::Over { runs_out_in: was }, Standing::Over { runs_out_in: is }) =
272            (then.standing, later.standing)
273        else {
274            panic!("over pace both times");
275        };
276        assert_eq!(is, was - HOUR);
277
278        let long_ago = of(90.0, Some(NOW + 3 * DAY), Some(WEEK), NOW - 3 * DAY, NOW).unwrap();
279        assert_eq!(
280            long_ago.standing,
281            Standing::Over { runs_out_in: 0 },
282            "projected to have run out already: about to, as far as anyone knows"
283        );
284        assert_eq!(
285            of(33.0, Some(NOW - 1), Some(WEEK), NOW - DAY, NOW),
286            None,
287            "it has reset since it was read"
288        );
289        assert_eq!(
290            of(33.0, Some(resets), Some(WEEK), NOW + HOUR, NOW),
291            of(33.0, Some(resets), Some(WEEK), NOW, NOW),
292            "a reading from a clock ahead of this one counts as taken now"
293        );
294    }
295
296    /// A limit nobody has touched is under pace, and lasts.
297    #[test]
298    fn an_untouched_limit_is_under_pace() {
299        assert_eq!(pace(0.0, DAY, WEEK).unwrap().standing, Standing::Under);
300        assert_eq!(pace(-1.0, DAY, WEEK).unwrap().standing, Standing::Under);
301    }
302
303    /// An account in use lasts until its first limit to run out at its pace does, and
304    /// otherwise until its first reset. One not in use is not being used, so it lasts until
305    /// its first reset whatever its paces were.
306    #[test]
307    fn an_account_lasts_until_its_first_limit_runs_out_or_resets() {
308        let session = window("session", 20.0, 4 * HOUR, 5 * HOUR);
309        let week = window("weekly_all", 33.0, 10 * HOUR, WEEK);
310        let busy = [session.clone(), week];
311        let Some(Lasts::RunsOut { limit, in_seconds }) = lasts(&busy, NOW, NOW) else {
312            panic!("the week runs out");
313        };
314        assert_eq!(limit.kind, "weekly_all");
315        assert_eq!(in_seconds, (67.0 * (10 * HOUR) as f64 / 33.0) as i64);
316
317        let calm = window("weekly_all", 5.0, 3 * DAY, WEEK);
318        assert_eq!(
319            lasts(&[session.clone(), calm], NOW, NOW),
320            Some(Lasts::UntilReset { in_seconds: HOUR })
321        );
322
323        let mut unknown = window("weekly_all", 5.0, 3 * DAY, WEEK);
324        unknown.resets_at = None;
325        assert_eq!(lasts(&[unknown], NOW, NOW), None);
326        assert_eq!(
327            until_reset(&busy, NOW),
328            Some(Lasts::UntilReset { in_seconds: HOUR }),
329            "the week's pace says nothing of an account nobody is using"
330        );
331    }
332
333    /// Of two limits running out, the sooner is the one that counts.
334    #[test]
335    fn the_first_limit_to_run_out_is_the_soonest() {
336        let paces = [
337            ("week", pace(40.0, DAY, WEEK)),
338            ("session", pace(90.0, 2 * HOUR, 5 * HOUR)),
339            ("calm", pace(1.0, DAY, WEEK)),
340            ("unknown", None),
341        ];
342        let (first, _) = first_to_run_out(paces).unwrap();
343        assert_eq!(first, "session");
344        assert_eq!(first_to_run_out([("calm", pace(1.0, DAY, WEEK))]), None);
345    }
346
347    /// In `--json`, a pace is its two figures and its standing, and an over-pace one when it
348    /// runs out.
349    #[test]
350    fn a_pace_reads_in_json_as_its_figures_and_standing() {
351        let over = Pace {
352            expected: 6.0,
353            delta: 27.0,
354            standing: Standing::Over {
355                runs_out_in: 72_000,
356            },
357        };
358        assert_eq!(
359            serde_json::to_value(over).unwrap(),
360            serde_json::json!({
361                "expected": 6.0,
362                "delta": 27.0,
363                "standing": "over",
364                "runs_out_in": 72_000,
365            })
366        );
367        let under = Pace {
368            expected: 50.0,
369            delta: -40.0,
370            standing: Standing::Under,
371        };
372        assert_eq!(
373            serde_json::to_value(under).unwrap(),
374            serde_json::json!({"expected": 50.0, "delta": -40.0, "standing": "under"})
375        );
376    }
377}