Skip to main content

pitboard_core/
usage.rs

1//! One view of "how much is left", whatever shape it arrived in. Usage comes as a
2//! `limits[]` array or as named `five_hour`/`seven_day` objects; both are normalised at the
3//! boundary, and a value that fails to normalise is dropped rather than drawn.
4
5use crate::time;
6use serde_json::Value;
7
8#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
9pub struct Window {
10    pub kind: String,
11    pub scope: Option<String>,
12    pub percent: f64,
13    pub resets_at: Option<i64>,
14    pub is_active: bool,
15    /// How Anthropic grades this row, when it grades it. Its word, not a threshold of
16    /// Pitboard's own, and absent in a reading taken before Pitboard read this field.
17    #[serde(default)]
18    pub severity: Option<String>,
19    /// How long the window runs, in seconds, where that is known.
20    ///
21    /// What makes a limit comparable to itself over time: a reset time alone cannot say
22    /// how long a window is, because the time left shrinks as the window runs out. Stated
23    /// outright by OpenAI, implied by the kind for Anthropic, and absent from a reading
24    /// taken before Pitboard kept it.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub length_seconds: Option<i64>,
27}
28
29/// How long one of Anthropic's windows runs, from its kind.
30///
31/// Anthropic names its windows rather than timing them: `session` and the older
32/// `five_hour` are the five-hour limit, and every `weekly_` kind, like the older
33/// `seven_day`, runs a week. A kind not listed here has no length Pitboard can vouch for.
34pub fn anthropic_window_length(kind: &str) -> Option<i64> {
35    match kind {
36        "session" | "five_hour" => Some(5 * 3600),
37        "seven_day" => Some(7 * 86_400),
38        weekly if weekly.starts_with("weekly_") => Some(7 * 86_400),
39        _ => None,
40    }
41}
42
43/// Where a measurement came from, so a stale number is never shown as a live one.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum Source {
47    /// Asked of Anthropic just now.
48    Live,
49    /// The last live reading Pitboard took itself.
50    Remembered,
51}
52
53#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
54pub struct Snapshot {
55    pub windows: Vec<Window>,
56    pub observed_at: Option<i64>,
57    /// When the service last answered for this account's own login. Absent from a reading
58    /// an older Pitboard wrote.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub answered_at: Option<i64>,
61    /// Whether `windows` are every limit the account has, so a limit they leave out is one
62    /// it does not have: true of an answer read whole from a service whose answers list
63    /// them all, and of the reading that answer set, since a session never adds a limit.
64    #[serde(default)]
65    pub lists_every_limit: bool,
66    pub source: Source,
67}
68
69impl Snapshot {
70    /// Whether the service gave this reading. Only its answer says whose numbers they are:
71    /// one an older Pitboard wrote can hold numbers filed under the wrong account.
72    pub(crate) fn answered(&self) -> bool {
73        self.answered_at.is_some()
74    }
75}
76
77impl Window {
78    /// The share used as of `now`. A window whose reset has passed counts as reset, though
79    /// no reading has said so yet.
80    pub(crate) fn used(&self, now: i64) -> f64 {
81        if self.resets_at.is_some_and(|at| at <= now) {
82            0.0
83        } else {
84            self.percent
85        }
86    }
87
88    /// Whether `other` measures the same limit, whichever name its source gave it.
89    pub(crate) fn same_limit(&self, other: &Window) -> bool {
90        limit_name(&self.kind) == limit_name(&other.kind) && self.scope == other.scope
91    }
92
93    /// Whether `other` is this very window: the same limit, resetting at the same time as
94    /// far as sources agree on one. A window with no reset time is no window in particular.
95    pub(crate) fn same_window(&self, other: &Window) -> bool {
96        self.same_limit(other)
97            && matches!(
98                (self.resets_at, other.resets_at),
99                (Some(x), Some(y)) if same_reset(x, y)
100            )
101    }
102}
103
104/// A limit by one name. A Claude Code session, and Anthropic's answer when it has no
105/// `limits`, use the older `five_hour` and `seven_day` for the limits `limits` calls
106/// `session` and `weekly_all`. Codex's windows borrow the older names for their lengths,
107/// which is harmless: every Codex reading names its windows the same way, and none is
108/// scoped to a model.
109pub(crate) fn limit_name(kind: &str) -> &str {
110    match kind {
111        "five_hour" => "session",
112        "seven_day" => "weekly_all",
113        other => other,
114    }
115}
116
117/// A percentage as every front end draws it and the automatic switch judges it: a whole
118/// one, a half rounded up, so a limit drawn at 95% is at 95%. Whether the account in use
119/// has run out of a limit is not judged by it: that is the service's 100, as read.
120pub fn whole(percent: f64) -> i64 {
121    percent.round() as i64
122}
123
124/// Whether a limit `percent` used has reached `share` as it is drawn: one drawn at 95% has
125/// reached 95%.
126pub(crate) fn reached(percent: f64, share: u8) -> bool {
127    whole(percent) >= i64::from(share)
128}
129
130/// Resets closer together than this are one reset.
131///
132/// Sources do not agree to the second on when a window resets: Anthropic's answer gives a
133/// fraction of a second, which is dropped, and a Claude Code session is given whole seconds.
134/// A limit's next window starts only once the last has reset, and the shortest window any
135/// service has shown runs five hours, so resets a minute apart are rounding and never two
136/// windows.
137const SAME_RESET: u64 = 60;
138
139/// Whether two resets of a limit are one: less than `SAME_RESET` apart. Public so that
140/// whatever else tells one window of a limit from its next, such as the app deciding
141/// whether a spent limit was already mentioned, counts them the way readings do.
142pub fn same_reset(a: i64, b: i64) -> bool {
143    a.abs_diff(b) < SAME_RESET
144}
145
146/// Whether a reading of the window that resets at `at` may still be running at `now`: until
147/// `SAME_RESET` past it, since another source can give that window's reset as late.
148pub(crate) fn may_still_run(at: i64, now: i64) -> bool {
149    at > now || same_reset(at, now)
150}
151
152/// One account's reading once its service has answered `answer` for that account's own
153/// login, as of the answer's `answered_at`.
154///
155/// Where the answer lists every limit the account has
156/// ([`Snapshot::lists_every_limit`]), its windows are the reading's: a limit it leaves out
157/// is gone at once, whatever its reset, and numbers filed under the wrong account go at
158/// that account's first answer. Otherwise each limit it gives replaces the one known, and
159/// one it leaves out stands where it was until an answer taken after its reset; a window
160/// with no reset is not running. An answer taken after everything known is what the limits
161/// are then, and may lower a share, as a banked reset on claude.ai or a plan upgraded in the
162/// middle of a window does.
163///
164/// Where a session moved a window after the answer was taken, or in the same second, its
165/// higher share stands: it came with a later response than the answer. An answer older
166/// than the last one known changes nothing. A time later than `now` was stamped by a clock
167/// that ran ahead and orders nothing: taken as one, it held back every answer until the
168/// clock caught up.
169pub(crate) fn answered(known: Option<&Snapshot>, answer: &Snapshot, now: i64) -> Snapshot {
170    let Some(known) = known else {
171        return answer.clone();
172    };
173    let stamped = |at: Option<i64>| at.filter(|at| *at <= now);
174    if answer.answered_at < stamped(known.answered_at) {
175        return known.clone();
176    }
177    let moved_since = stamped(known.observed_at) >= answer.answered_at;
178    let higher = |given: &Window| {
179        known
180            .windows
181            .iter()
182            .find(|had| moved_since && had.same_window(given) && had.percent > given.percent)
183    };
184    let take = |given: &Window| match higher(given) {
185        Some(had) => Window {
186            percent: had.percent,
187            severity: had.severity.clone(),
188            ..given.clone()
189        },
190        None => given.clone(),
191    };
192    let windows = if answer.lists_every_limit {
193        answer.windows.iter().map(take).collect()
194    } else {
195        let answer_of = |had: &Window| answer.windows.iter().find(|given| given.same_limit(had));
196        let running = |had: &Window| {
197            had.resets_at
198                .zip(answer.answered_at)
199                .is_some_and(|(reset, taken)| reset > taken)
200        };
201        let added = answer
202            .windows
203            .iter()
204            .filter(|given| !known.windows.iter().any(|had| had.same_limit(given)));
205        known
206            .windows
207            .iter()
208            .filter_map(|had| match answer_of(had) {
209                Some(given) => Some(take(given)),
210                None => running(had).then(|| had.clone()),
211            })
212            .chain(added.map(take))
213            .collect()
214    };
215    Snapshot {
216        windows,
217        observed_at: stamped(known.observed_at).max(answer.observed_at),
218        answered_at: answer.answered_at,
219        lists_every_limit: answer.lists_every_limit,
220        source: answer.source,
221    }
222}
223
224/// `known` with what a session passed its status line, or `None` where that moves nothing.
225///
226/// A session says how much of each limit is used and when it resets, and nothing else, so
227/// it only moves a limit the service gave this account. In the same window, a higher share
228/// is taken. Where the window's reset has passed, or none was given, the passed window is
229/// the next one. A window of another reset still ahead is not this account's, and a limit
230/// `known` does not list is not this session's to add: a session never adds a limit and
231/// never founds a reading. A passed window whose reset has passed, or that gives none, says
232/// nothing about now.
233///
234/// What only the service says stays as it said: the limit's name, whether the account is
235/// working against it, and how long it runs. A share a session moved is one the service has
236/// not graded. A session's numbers carry no time, and moving one means the response that
237/// did it has only just come, so the reading is as of `now`.
238pub(crate) fn moved(known: &Snapshot, passed: &[Window], now: i64) -> Option<Snapshot> {
239    let mut windows = known.windows.clone();
240    let mut changed = false;
241    for given in passed
242        .iter()
243        .filter(|w| w.resets_at.is_some_and(|at| at > now))
244    {
245        let Some(had) = windows.iter_mut().find(|had| had.same_limit(given)) else {
246            continue;
247        };
248        let running = had.resets_at.is_some_and(|at| at > now);
249        had.resets_at = match (had.same_window(given), running) {
250            (true, _) if given.percent > had.percent => had.resets_at,
251            (false, false) => given.resets_at,
252            _ => continue,
253        };
254        had.percent = given.percent;
255        had.severity = None;
256        changed = true;
257    }
258    changed.then(|| Snapshot {
259        windows,
260        observed_at: Some(now),
261        ..known.clone()
262    })
263}
264
265/// What an account with room for a switch away from one limit has used.
266#[derive(Debug, Clone, Copy, PartialEq)]
267pub struct Room {
268    /// Of that limit. `None` where the account does not have it.
269    pub used: Option<f64>,
270    /// Of the fullest of its other limits.
271    pub fullest: f64,
272}
273
274/// The room an account whose reading holds `theirs` has for a switch away from `limit` of
275/// the account in use, whose limits are `mine`: `None` where any of `theirs` has reached
276/// `share` as it is drawn, or where nothing says what the account has used of one of `mine`.
277/// The automatic switch asks it with its share, and the app's advice with 100.
278///
279/// Where `theirs` are every limit the account has ([`Snapshot::lists_every_limit`]), one
280/// they leave out is one it does not have, so a plan without the limit that ran out, such as
281/// a Team seat with no weekly limit for all models, is a place to go. Any other reading says
282/// nothing of a limit it leaves out, which may be used up, as with no reading at all. Only a
283/// model's limit other than `limit` may be left out of one that gives the five-hour and
284/// weekly limits: the older named shape names no model's, and plans limit models apart.
285pub fn room(
286    theirs: &[Window],
287    lists_every_limit: bool,
288    mine: &[Window],
289    limit: &Window,
290    share: u8,
291    now: i64,
292) -> Option<Room> {
293    if theirs.is_empty() || theirs.iter().any(|window| reached(window.used(now), share)) {
294        return None;
295    }
296    let given = |had: &Window| theirs.iter().find(|window| window.same_limit(had));
297    if !lists_every_limit {
298        let gives = |kind: &str| {
299            theirs
300                .iter()
301                .any(|window| window.scope.is_none() && limit_name(&window.kind) == kind)
302        };
303        let overall = gives("session") && gives("weekly_all");
304        let unknown = |had: &Window| {
305            given(had).is_none() && !(overall && had.scope.is_some() && !had.same_limit(limit))
306        };
307        if std::iter::once(limit).chain(mine).any(unknown) {
308            return None;
309        }
310    }
311    let fullest = theirs
312        .iter()
313        .filter(|window| !window.same_limit(limit))
314        .map(|window| window.used(now))
315        .fold(0.0, f64::max);
316    Some(Room {
317        used: given(limit).map(|window| window.used(now)),
318        fullest,
319    })
320}
321
322/// The candidate with the most room: the least used of the limit, one without it as one with
323/// none used, then the least full in its others. The first of those alike.
324pub fn roomiest<T>(candidates: impl IntoIterator<Item = (T, Room)>) -> Option<(T, Room)> {
325    candidates.into_iter().min_by(|(_, a), (_, b)| {
326        a.used
327            .unwrap_or(0.0)
328            .total_cmp(&b.used.unwrap_or(0.0))
329            .then_with(|| a.fullest.total_cmp(&b.fullest))
330    })
331}
332
333/// A share of a limit. Past 100 is real, once a limit is exceeded; below zero is not.
334fn percent(v: &Value) -> Option<f64> {
335    let p = v.as_f64()?;
336    (p.is_finite() && p >= 0.0).then_some(p)
337}
338
339fn window_from_limit(l: &Value) -> Option<Window> {
340    // A row is scoped to a model or to a surface; either way the scope is what makes it
341    // narrower than the account's own limit.
342    let named = |what: &str| {
343        l.get("scope")
344            .and_then(|s| s.get(what))
345            .and_then(|m| m.get("display_name"))
346            .and_then(Value::as_str)
347            .map(str::to_owned)
348    };
349    Some(Window {
350        kind: l.get("kind")?.as_str()?.to_string(),
351        scope: named("model").or_else(|| named("surface")),
352        severity: l.get("severity").and_then(Value::as_str).map(str::to_owned),
353        percent: percent(l.get("percent")?)?,
354        resets_at: l
355            .get("resets_at")
356            .and_then(Value::as_str)
357            .and_then(time::parse),
358        is_active: l.get("is_active").and_then(Value::as_bool).unwrap_or(false),
359        length_seconds: l
360            .get("kind")
361            .and_then(Value::as_str)
362            .and_then(anthropic_window_length),
363    })
364}
365
366fn window_from_named(kind: &str, v: &Value) -> Option<Window> {
367    Some(Window {
368        kind: kind.to_string(),
369        scope: None,
370        severity: None,
371        percent: percent(v.get("utilization")?)?,
372        resets_at: v
373            .get("resets_at")
374            .and_then(Value::as_str)
375            .and_then(time::parse),
376        is_active: false,
377        length_seconds: anthropic_window_length(kind),
378    })
379}
380
381/// A reading taken from Anthropic's usage endpoint just now.
382///
383/// Its `limits` are every limit the account has: read on 8 October 2026 from the answers
384/// Claude Code 2.1.294 kept from `GET /api/oauth/usage` on one machine, where a Team seat's
385/// holds `session` and one model's `weekly_scoped` and no `weekly_all`. The reading says so
386/// only where `limits` held rows and every one was read. A row dropped for a value that
387/// does not normalise is still a limit of the account's, and an answer without `limits`
388/// names the five-hour and weekly limits alone.
389pub fn from_usage_object(u: &Value, observed_at: i64) -> Snapshot {
390    let limits = u.get("limits").and_then(Value::as_array);
391    let mut windows: Vec<Window> = limits
392        .into_iter()
393        .flatten()
394        .filter_map(window_from_limit)
395        .collect();
396    let lists_every_limit =
397        !windows.is_empty() && limits.is_some_and(|rows| rows.len() == windows.len());
398    if windows.is_empty() {
399        for kind in ["five_hour", "seven_day"] {
400            if let Some(w) = u.get(kind).and_then(|v| window_from_named(kind, v)) {
401                windows.push(w);
402            }
403        }
404    }
405    Snapshot {
406        windows,
407        observed_at: Some(observed_at),
408        answered_at: Some(observed_at),
409        lists_every_limit,
410        source: Source::Live,
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    /// An answer of Anthropic's usage endpoint as Claude Code kept it in this machine's
419    /// `~/.claude.json`, trimmed.
420    fn real_answer() -> Value {
421        serde_json::json!({
422        "five_hour": {"utilization": 62, "resets_at": "2026-09-20T22:20:00.095287+00:00"},
423        "seven_day": {"utilization": 48, "resets_at": "2026-09-27T02:00:00.095306+00:00"},
424        "limits": [
425            {"kind": "session", "group": "session", "percent": 62,
426             "resets_at": "2026-09-20T22:20:00.095287+00:00", "scope": null, "is_active": true},
427            {"kind": "weekly_all", "group": "weekly", "percent": 48,
428             "resets_at": "2026-09-27T02:00:00.095306+00:00", "scope": null, "is_active": false},
429            {"kind": "weekly_scoped", "group": "weekly", "percent": 0,
430             "resets_at": "2026-09-27T02:00:00+00:00",
431             "scope": {"model": {"id": null, "display_name": "Fable"}}, "is_active": false}
432        ]})
433    }
434
435    #[test]
436    fn reads_the_real_answers_shape() {
437        let s = from_usage_object(&real_answer(), 1789933772);
438        assert_eq!(s.windows.len(), 3);
439        assert_eq!(s.observed_at, Some(1789933772));
440        assert_eq!(s.answered_at, Some(1789933772));
441        assert!(s.lists_every_limit);
442        assert_eq!(s.source, Source::Live);
443        let scoped = s
444            .windows
445            .iter()
446            .find(|w| w.kind == "weekly_scoped")
447            .unwrap();
448        assert_eq!(scoped.scope.as_deref(), Some("Fable"));
449    }
450
451    #[test]
452    fn falls_back_to_the_named_windows_when_limits_is_missing() {
453        let mut answer = real_answer();
454        answer.as_object_mut().unwrap().remove("limits");
455        let s = from_usage_object(&answer, 1789933772);
456        assert_eq!(s.windows.len(), 2);
457        assert_eq!(s.windows[0].kind, "five_hour");
458        assert_eq!(s.windows[0].percent, 62.0);
459        assert!(!s.lists_every_limit, "nothing scoped to a model is named");
460    }
461
462    #[test]
463    fn a_nonsense_percentage_is_dropped_rather_than_drawn() {
464        for nonsense in [serde_json::json!(-5), serde_json::json!("75")] {
465            let mut answer = real_answer();
466            answer["limits"][0]["percent"] = nonsense;
467            let s = from_usage_object(&answer, 1789933772);
468            assert_eq!(s.windows.len(), 2);
469            assert!(
470                !s.lists_every_limit,
471                "the row dropped is a limit all the same"
472            );
473        }
474    }
475
476    #[test]
477    fn an_exceeded_limit_is_kept_not_dropped() {
478        let mut answer = real_answer();
479        answer["limits"][0]["percent"] = serde_json::json!(104);
480        let s = from_usage_object(&answer, 1789933772);
481        assert_eq!(s.windows.len(), 3);
482        assert_eq!(s.windows[0].percent, 104.0);
483    }
484
485    #[test]
486    fn a_share_is_one_whole_percentage_a_half_rounded_up() {
487        assert_eq!(whole(94.5), 95);
488        assert_eq!(whole(94.49), 94);
489        assert_eq!(whole(104.0), 104);
490        assert_eq!(whole(0.4), 0);
491    }
492
493    const NOW: i64 = 1_789_935_000;
494    const HOUR: i64 = 3_600;
495
496    fn measured(kind: &str, percent: f64, resets_at: Option<i64>) -> Window {
497        Window {
498            kind: kind.into(),
499            scope: None,
500            percent,
501            resets_at,
502            is_active: true,
503            severity: None,
504            length_seconds: anthropic_window_length(kind),
505        }
506    }
507
508    /// Anthropic's answer about an account, listing every limit it has, taken at `at`.
509    fn answer(windows: Vec<Window>, at: i64) -> Snapshot {
510        Snapshot {
511            windows,
512            observed_at: Some(at),
513            answered_at: Some(at),
514            lists_every_limit: true,
515            source: Source::Live,
516        }
517    }
518
519    /// What Pitboard holds of an account: last moved or confirmed at `observed_at`, and last
520    /// answered for at `answered_at`.
521    fn held(windows: Vec<Window>, observed_at: i64, answered_at: Option<i64>) -> Snapshot {
522        Snapshot {
523            windows,
524            observed_at: Some(observed_at),
525            answered_at,
526            lists_every_limit: true,
527            source: Source::Remembered,
528        }
529    }
530
531    fn shares(reading: &Snapshot) -> Vec<(&str, f64)> {
532        reading
533            .windows
534            .iter()
535            .map(|w| (w.kind.as_str(), w.percent))
536            .collect()
537    }
538
539    /// The rule the app keys what it has told somebody by: resets under a minute apart, in
540    /// either order, are one, and a minute apart are two.
541    #[test]
542    fn resets_under_a_minute_apart_are_one_reset() {
543        assert!(same_reset(NOW, NOW));
544        assert!(same_reset(NOW, NOW + 59));
545        assert!(same_reset(NOW + 59, NOW));
546        assert!(!same_reset(NOW, NOW + 60));
547        assert!(!same_reset(NOW + 60, NOW));
548        assert!(
549            !same_reset(0, NOW),
550            "no reset known is no reset in particular"
551        );
552    }
553
554    /// One reading of a window can give its reset up to a minute after another, so the window
555    /// may still run until a minute past the reset it was first given.
556    #[test]
557    fn a_window_may_still_run_until_a_minute_past_its_reset() {
558        assert!(may_still_run(NOW, NOW - 1));
559        assert!(may_still_run(NOW, NOW));
560        assert!(may_still_run(NOW, NOW + 59));
561        assert!(!may_still_run(NOW, NOW + 60));
562        assert!(!may_still_run(0, NOW));
563    }
564
565    /// An answer lists every limit the account has. Kept until its reset, a weekly limit
566    /// 0.9.0 filed under a Team seat, whose answer has no weekly limit for all models, stood
567    /// over every answer about that seat for days.
568    #[test]
569    fn an_answer_replaces_every_limit_it_does_not_give() {
570        let known = held(
571            vec![
572                measured("session", 10.0, Some(NOW + HOUR)),
573                measured("weekly_all", 100.0, Some(NOW + 50 * HOUR)),
574            ],
575            NOW - 60,
576            None,
577        );
578        let given = answer(
579            vec![
580                measured("session", 20.0, Some(NOW + HOUR)),
581                Window {
582                    scope: Some("Fable".into()),
583                    ..measured("weekly_scoped", 0.0, Some(NOW + 50 * HOUR))
584                },
585            ],
586            NOW,
587        );
588        let reading = answered(Some(&known), &given, NOW);
589        assert_eq!(
590            shares(&reading),
591            [("session", 20.0), ("weekly_scoped", 0.0)]
592        );
593        assert_eq!(reading.answered_at, Some(NOW));
594        assert_eq!(reading.observed_at, Some(NOW));
595    }
596
597    /// Anthropic's answer lists every limit, but Pitboard knows what one left out only where
598    /// it read every row. Taken as the whole list, an answer with a row it could not read
599    /// took a used-up weekly limit off the account.
600    #[test]
601    fn a_limit_whose_row_could_not_be_read_is_not_taken_away() {
602        let mut spent = real_answer();
603        spent["limits"][1]["percent"] = serde_json::json!(100);
604        let known = from_usage_object(&spent, NOW - 600);
605        let mut unread = spent.clone();
606        unread["limits"][0]["percent"] = serde_json::json!(70);
607        unread["limits"][1]["percent"] = Value::Null;
608        let reading = answered(Some(&known), &from_usage_object(&unread, NOW), NOW);
609        assert_eq!(
610            shares(&reading),
611            [
612                ("session", 70.0),
613                ("weekly_all", 100.0),
614                ("weekly_scoped", 0.0)
615            ]
616        );
617    }
618
619    /// An answer with no `limits` names the five-hour and weekly limits and nothing scoped to
620    /// a model, so a model's limit it leaves out is one it has no name for.
621    #[test]
622    fn an_answer_in_the_older_shape_takes_no_limit_away() {
623        let mut spent = real_answer();
624        spent["limits"][2]["percent"] = serde_json::json!(100);
625        let known = from_usage_object(&spent, NOW - 600);
626        let mut older = real_answer();
627        older.as_object_mut().unwrap().remove("limits");
628        let reading = answered(Some(&known), &from_usage_object(&older, NOW), NOW);
629        assert_eq!(
630            shares(&reading),
631            [
632                ("five_hour", 62.0),
633                ("seven_day", 48.0),
634                ("weekly_scoped", 100.0)
635            ]
636        );
637    }
638
639    /// A clock that ran ahead stamps a reading later than now. Taken as a later answer, that
640    /// stamp held every answer back until the clock caught up, and a session's share with it.
641    #[test]
642    fn a_reading_stamped_ahead_of_the_clock_holds_no_answer_back() {
643        let known = held(
644            vec![measured("session", 80.0, Some(NOW + 2 * HOUR))],
645            NOW + HOUR,
646            Some(NOW + HOUR),
647        );
648        let given = answer(vec![measured("session", 20.0, Some(NOW + 2 * HOUR))], NOW);
649        let reading = answered(Some(&known), &given, NOW);
650        assert_eq!(shares(&reading), [("session", 20.0)]);
651        assert_eq!(
652            (reading.observed_at, reading.answered_at),
653            (Some(NOW), Some(NOW))
654        );
655    }
656
657    /// Whether OpenAI's null window is one not running is not measured, so a window its
658    /// answer leaves out stands while it runs, and where it was: `pitboard status` and the
659    /// menu list limits in a reading's order. One whose reset has passed, or that has none,
660    /// is not running.
661    #[test]
662    fn a_window_openai_leaves_out_stands_until_its_reset() {
663        let known = held(
664            vec![
665                measured("five_hour", 30.0, Some(NOW + HOUR)),
666                measured("1_day", 60.0, Some(NOW - 60)),
667                measured("2_day", 20.0, None),
668                measured("seven_day", 40.0, Some(NOW + 50 * HOUR)),
669            ],
670            NOW - 600,
671            Some(NOW - 600),
672        );
673        let whole = answer(
674            vec![measured("seven_day", 45.0, Some(NOW + 50 * HOUR))],
675            NOW,
676        );
677        let given = Snapshot {
678            lists_every_limit: false,
679            ..whole.clone()
680        };
681        let reading = answered(Some(&known), &given, NOW);
682        assert_eq!(shares(&reading), [("five_hour", 30.0), ("seven_day", 45.0)]);
683        assert!(!reading.lists_every_limit);
684        assert_eq!(
685            shares(&answered(Some(&known), &whole, NOW)),
686            [("seven_day", 45.0)],
687            "where the answer lists every limit"
688        );
689    }
690
691    /// A session's numbers do not say whose they are. Within a window the service gave this
692    /// account they can only be this account's, and anywhere else they can be anybody's.
693    #[test]
694    fn a_session_moves_a_share_only_inside_a_window_it_was_given() {
695        let known = held(
696            vec![measured("session", 22.0, Some(NOW + HOUR))],
697            NOW - 60,
698            Some(NOW - 60),
699        );
700        let higher = moved(
701            &known,
702            &[measured("five_hour", 25.0, Some(NOW + HOUR))],
703            NOW,
704        )
705        .expect("a share moved");
706        assert_eq!(
707            shares(&higher),
708            [("session", 25.0)],
709            "under the name the service gave it"
710        );
711        assert_eq!(higher.answered_at, Some(NOW - 60), "and no answer since");
712        assert_eq!(
713            moved(
714                &known,
715                &[measured("five_hour", 5.0, Some(NOW + 6 * HOUR))],
716                NOW
717            ),
718            None,
719            "another reset still ahead"
720        );
721        assert_eq!(
722            moved(
723                &known,
724                &[measured("seven_day", 40.0, Some(NOW + 50 * HOUR))],
725                NOW
726            ),
727            None,
728            "a limit the service did not give"
729        );
730    }
731
732    /// A limit's next window starts only once the last has reset, so a session passing one
733    /// after the reset is passing this account's next window. A window with no reset is one
734    /// that is not running.
735    #[test]
736    fn a_session_takes_the_next_window_of_a_limit_whose_reset_has_passed() {
737        let mut over = measured("session", 90.0, Some(NOW - 60));
738        over.severity = Some("warning".into());
739        let not_running = measured("session", 0.0, None);
740        for had in [over, not_running] {
741            let known = held(vec![had.clone()], NOW - HOUR, Some(NOW - HOUR));
742            let next = moved(
743                &known,
744                &[measured("five_hour", 3.0, Some(NOW + 5 * HOUR))],
745                NOW,
746            )
747            .expect("the next window");
748            let window = &next.windows[0];
749            assert_eq!(window.kind, "session", "{had:?}");
750            assert_eq!(
751                (window.percent, window.resets_at),
752                (3.0, Some(NOW + 5 * HOUR))
753            );
754            assert_eq!(window.severity, None, "a share the service has not graded");
755            assert!(window.is_active, "working against it as the service said");
756            assert_eq!(next.observed_at, Some(NOW));
757            assert_eq!(next.answered_at, Some(NOW - HOUR));
758        }
759    }
760
761    /// A session passes the numbers of its last response, however long ago that was. An
762    /// idle one goes on passing a lower share than a busy one has since recorded, and a pane
763    /// left open past a reset goes on passing a window that is over.
764    #[test]
765    fn a_session_never_moves_a_share_back() {
766        let known = held(
767            vec![measured("session", 22.0, Some(NOW + HOUR))],
768            NOW - 60,
769            Some(NOW - 60),
770        );
771        for behind in [
772            measured("five_hour", 20.0, Some(NOW + HOUR)),
773            measured("five_hour", 22.0, Some(NOW + HOUR)),
774            measured("five_hour", 95.0, Some(NOW - 4 * HOUR)),
775        ] {
776            assert_eq!(
777                moved(&known, std::slice::from_ref(&behind), NOW),
778                None,
779                "{behind:?}"
780            );
781        }
782    }
783
784    /// Anthropic's answer gives a reset to a fraction of a second, which is dropped, and a
785    /// session is given whole seconds. Taken for another window, a session's higher share
786    /// would be taken for another account's.
787    #[test]
788    fn resets_a_second_apart_are_one_window() {
789        let known = held(
790            vec![measured("session", 22.0, Some(NOW + HOUR))],
791            NOW - 60,
792            Some(NOW - 60),
793        );
794        let ahead = moved(
795            &known,
796            &[measured("five_hour", 25.0, Some(NOW + HOUR + 1))],
797            NOW,
798        )
799        .expect("a share moved");
800        assert_eq!(shares(&ahead), [("session", 25.0)]);
801        assert_eq!(
802            ahead.windows[0].resets_at,
803            Some(NOW + HOUR),
804            "the reset is the service's"
805        );
806    }
807
808    /// Measured on this machine on 2026-09-29: an account at 100% of its weekly limit,
809    /// resetting at 20:00 UTC the next day, had a banked reset used on claude.ai. Its
810    /// sessions then passed 1%, 13% and 14% of the same limit, with the same reset. Ordered
811    /// by share, every lower answer lost to the 100%, and the account read as out for the
812    /// day and a half until the reset.
813    #[test]
814    fn an_answer_taken_after_everything_known_is_what_the_limits_are_now() {
815        let known = held(
816            vec![
817                measured("session", 0.0, None),
818                measured("weekly_all", 100.0, Some(NOW + 33 * HOUR)),
819            ],
820            NOW - HOUR,
821            Some(NOW - HOUR),
822        );
823        let given = answer(
824            vec![
825                measured("session", 5.0, Some(NOW + 5 * HOUR)),
826                measured("weekly_all", 14.0, Some(NOW + 33 * HOUR)),
827            ],
828            NOW,
829        );
830        let reading = answered(Some(&known), &given, NOW);
831        assert_eq!(shares(&reading), [("session", 5.0), ("weekly_all", 14.0)]);
832        assert_eq!(reading.observed_at, Some(NOW));
833        assert_eq!(reading.source, Source::Live);
834
835        let unused = answer(vec![measured("weekly_all", 0.0, None)], NOW);
836        assert_eq!(
837            shares(&answered(Some(&known), &unused, NOW)),
838            [("weekly_all", 0.0)],
839            "and one that finds nothing used and no window running"
840        );
841    }
842
843    /// An answer is what the limits were when it was taken. A share a session recorded
844    /// since, or in the same second, may have come with a later response.
845    #[test]
846    fn an_answer_taken_before_a_session_moved_a_share_does_not_lower_it() {
847        let known = held(
848            vec![measured("weekly_all", 100.0, Some(NOW + 33 * HOUR))],
849            NOW - 60,
850            Some(NOW - 2 * HOUR),
851        );
852        for taken in [NOW - 60, NOW - HOUR] {
853            let given = answer(
854                vec![measured("weekly_all", 14.0, Some(NOW + 33 * HOUR))],
855                taken,
856            );
857            let reading = answered(Some(&known), &given, NOW);
858            assert_eq!(
859                shares(&reading),
860                [("weekly_all", 100.0)],
861                "taken at {taken}"
862            );
863            assert_eq!(reading.observed_at, Some(NOW - 60));
864            assert_eq!(reading.answered_at, Some(taken));
865        }
866    }
867
868    /// Two front ends can each have a request out, and the older answer can land last.
869    #[test]
870    fn an_answer_older_than_the_last_changes_nothing() {
871        let known = held(
872            vec![measured("session", 40.0, Some(NOW + HOUR))],
873            NOW,
874            Some(NOW),
875        );
876        let late = answer(vec![measured("session", 30.0, Some(NOW + HOUR))], NOW - 60);
877        assert_eq!(answered(Some(&known), &late, NOW), known);
878    }
879
880    /// A session knows the five-hour and weekly limits and nothing scoped to a model.
881    #[test]
882    fn a_session_leaves_the_limits_it_does_not_pass_as_they_were() {
883        let scoped = Window {
884            scope: Some("Fable".into()),
885            ..measured("weekly_scoped", 5.0, Some(NOW + 50 * HOUR))
886        };
887        let known = held(
888            vec![measured("session", 22.0, Some(NOW + HOUR)), scoped],
889            NOW - 60,
890            Some(NOW - 60),
891        );
892        let reading = moved(
893            &known,
894            &[measured("five_hour", 25.0, Some(NOW + HOUR))],
895            NOW,
896        )
897        .expect("a share moved");
898        assert_eq!(
899            shares(&reading),
900            [("session", 25.0), ("weekly_scoped", 5.0)]
901        );
902    }
903
904    /// A session leaves out a window whose reset has passed. Taken for the service no
905    /// longer reporting it, every reset took the five-hour limit away until the next answer,
906    /// where it reads as nothing used.
907    #[test]
908    fn a_session_never_takes_a_limit_away() {
909        let known = held(
910            vec![
911                measured("session", 40.0, Some(NOW - 10)),
912                measured("weekly_all", 30.0, Some(NOW + 50 * HOUR)),
913            ],
914            NOW - HOUR,
915            Some(NOW - HOUR),
916        );
917        let reading = moved(
918            &known,
919            &[measured("seven_day", 31.0, Some(NOW + 50 * HOUR))],
920            NOW,
921        )
922        .expect("a share moved");
923        assert_eq!(shares(&reading), [("session", 40.0), ("weekly_all", 31.0)]);
924        assert_eq!(reading.windows[0].used(NOW), 0.0);
925    }
926
927    /// A reading is founded by the service's answer and by nothing else.
928    #[test]
929    fn a_first_answer_is_the_reading() {
930        let given = answer(vec![measured("session", 22.0, Some(NOW + HOUR))], NOW);
931        assert_eq!(answered(None, &given, NOW), given);
932        assert_eq!(
933            moved(&held(Vec::new(), NOW, None), &given.windows, NOW),
934            None
935        );
936    }
937
938    /// A session passes the numbers of its last response, however long ago that was, and
939    /// says nothing about when. Repeating them vouches for nothing; moving one forward means
940    /// the response that did it has only just come.
941    #[test]
942    fn a_reading_is_stamped_when_something_moves_or_the_service_answers() {
943        let known = held(
944            vec![measured("session", 22.0, Some(NOW + HOUR))],
945            NOW - 600,
946            Some(NOW - 600),
947        );
948        let repeated = measured("five_hour", 22.0, Some(NOW + HOUR));
949        assert_eq!(moved(&known, &[repeated], NOW), None);
950
951        let ahead = measured("five_hour", 23.0, Some(NOW + HOUR));
952        assert_eq!(moved(&known, &[ahead], NOW).unwrap().observed_at, Some(NOW));
953
954        let confirmed = answer(known.windows.clone(), NOW - 5);
955        let reading = answered(Some(&known), &confirmed, NOW);
956        assert_eq!(reading.windows, known.windows);
957        assert_eq!(
958            (reading.observed_at, reading.answered_at),
959            (Some(NOW - 5), Some(NOW - 5)),
960            "an answer that finds the same confirms it"
961        );
962        assert_eq!(reading.source, Source::Live);
963    }
964
965    /// Asked about an account that has done nothing since its window reset, Anthropic finds
966    /// nothing used and gives no reset, or the one that passed. Kept, a parked account that
967    /// had run out read as full, marked live, until somebody used it again.
968    #[test]
969    fn a_window_past_its_reset_is_reset_by_an_answer_that_finds_nothing_used() {
970        let known = held(
971            vec![measured("session", 100.0, Some(NOW - HOUR))],
972            NOW - 2 * HOUR,
973            Some(NOW - 2 * HOUR),
974        );
975        for said in [
976            measured("session", 0.0, None),
977            measured("session", 0.0, Some(NOW - HOUR)),
978            measured("five_hour", 0.0, None),
979        ] {
980            let given = answer(vec![said.clone()], NOW - 5);
981            let reading = answered(Some(&known), &given, NOW);
982            assert_eq!(reading.windows, std::slice::from_ref(&said), "{said:?}");
983            assert_eq!(
984                answered(Some(&reading), &given, NOW),
985                reading,
986                "a repeat changes nothing"
987            );
988        }
989        assert_eq!(
990            moved(&known, &[measured("five_hour", 0.0, None)], NOW),
991            None,
992            "a session's window with no reset says nothing about now"
993        );
994    }
995}