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    /// Whether it has a weekly limit for all models, which keeps what it leaves unused in five
273    /// hours for later in the week. A reading that may leave limits out counts as having one.
274    pub weekly: bool,
275}
276
277/// The room an account whose reading holds `theirs` has for a switch away from `limit` of
278/// the account in use, whose limits are `mine`: `None` where any of `theirs` has reached
279/// `share` as it is drawn, or where nothing says what the account has used of one of `mine`.
280/// The automatic switch asks it with its share, and the app's advice with 100.
281///
282/// Where `theirs` are every limit the account has ([`Snapshot::lists_every_limit`]), one
283/// they leave out is one it does not have, so a plan without the limit that ran out, such as
284/// a Team seat with no weekly limit for all models, is a place to go. Any other reading says
285/// nothing of a limit it leaves out, which may be used up, as with no reading at all. Only a
286/// model's limit other than `limit` may be left out of one that gives the five-hour and
287/// weekly limits: the older named shape names no model's, and plans limit models apart.
288pub fn room(
289    theirs: &[Window],
290    lists_every_limit: bool,
291    mine: &[Window],
292    limit: &Window,
293    share: u8,
294    now: i64,
295) -> Option<Room> {
296    if theirs.is_empty() || theirs.iter().any(|window| reached(window.used(now), share)) {
297        return None;
298    }
299    let given = |had: &Window| theirs.iter().find(|window| window.same_limit(had));
300    let gives = |kind: &str| {
301        theirs
302            .iter()
303            .any(|window| window.scope.is_none() && limit_name(&window.kind) == kind)
304    };
305    if !lists_every_limit {
306        let overall = gives("session") && gives("weekly_all");
307        let unknown = |had: &Window| {
308            given(had).is_none() && !(overall && had.scope.is_some() && !had.same_limit(limit))
309        };
310        if std::iter::once(limit).chain(mine).any(unknown) {
311            return None;
312        }
313    }
314    let fullest = theirs
315        .iter()
316        .filter(|window| !window.same_limit(limit))
317        .map(|window| window.used(now))
318        .fold(0.0, f64::max);
319    Some(Room {
320        used: given(limit).map(|window| window.used(now)),
321        fullest,
322        weekly: !lists_every_limit || gives("weekly_all"),
323    })
324}
325
326/// The candidate to switch to. One without a weekly limit for all models comes first, since
327/// what it leaves unused in five hours is lost at the reset. Then the least used of the
328/// limit, one without it as one with none used, then the least full in its others. The first
329/// of those alike.
330pub fn preferred<T>(candidates: impl IntoIterator<Item = (T, Room)>) -> Option<(T, Room)> {
331    candidates.into_iter().min_by(|(_, a), (_, b)| {
332        a.weekly
333            .cmp(&b.weekly)
334            .then_with(|| a.used.unwrap_or(0.0).total_cmp(&b.used.unwrap_or(0.0)))
335            .then_with(|| a.fullest.total_cmp(&b.fullest))
336    })
337}
338
339/// A share of a limit. Past 100 is real, once a limit is exceeded; below zero is not.
340fn percent(v: &Value) -> Option<f64> {
341    let p = v.as_f64()?;
342    (p.is_finite() && p >= 0.0).then_some(p)
343}
344
345fn window_from_limit(l: &Value) -> Option<Window> {
346    // A row is scoped to a model or to a surface; either way the scope is what makes it
347    // narrower than the account's own limit.
348    let named = |what: &str| {
349        l.get("scope")
350            .and_then(|s| s.get(what))
351            .and_then(|m| m.get("display_name"))
352            .and_then(Value::as_str)
353            .map(str::to_owned)
354    };
355    Some(Window {
356        kind: l.get("kind")?.as_str()?.to_string(),
357        scope: named("model").or_else(|| named("surface")),
358        severity: l.get("severity").and_then(Value::as_str).map(str::to_owned),
359        percent: percent(l.get("percent")?)?,
360        resets_at: l
361            .get("resets_at")
362            .and_then(Value::as_str)
363            .and_then(time::parse),
364        is_active: l.get("is_active").and_then(Value::as_bool).unwrap_or(false),
365        length_seconds: l
366            .get("kind")
367            .and_then(Value::as_str)
368            .and_then(anthropic_window_length),
369    })
370}
371
372fn window_from_named(kind: &str, v: &Value) -> Option<Window> {
373    Some(Window {
374        kind: kind.to_string(),
375        scope: None,
376        severity: None,
377        percent: percent(v.get("utilization")?)?,
378        resets_at: v
379            .get("resets_at")
380            .and_then(Value::as_str)
381            .and_then(time::parse),
382        is_active: false,
383        length_seconds: anthropic_window_length(kind),
384    })
385}
386
387/// A reading taken from Anthropic's usage endpoint just now.
388///
389/// Its `limits` are every limit the account has: read on 8 October 2026 from the answers
390/// Claude Code 2.1.294 kept from `GET /api/oauth/usage` on one machine, where a Team seat's
391/// holds `session` and one model's `weekly_scoped` and no `weekly_all`. The reading says so
392/// only where `limits` held rows and every one was read. A row dropped for a value that
393/// does not normalise is still a limit of the account's, and an answer without `limits`
394/// names the five-hour and weekly limits alone.
395pub fn from_usage_object(u: &Value, observed_at: i64) -> Snapshot {
396    let limits = u.get("limits").and_then(Value::as_array);
397    let mut windows: Vec<Window> = limits
398        .into_iter()
399        .flatten()
400        .filter_map(window_from_limit)
401        .collect();
402    let lists_every_limit =
403        !windows.is_empty() && limits.is_some_and(|rows| rows.len() == windows.len());
404    if windows.is_empty() {
405        for kind in ["five_hour", "seven_day"] {
406            if let Some(w) = u.get(kind).and_then(|v| window_from_named(kind, v)) {
407                windows.push(w);
408            }
409        }
410    }
411    Snapshot {
412        windows,
413        observed_at: Some(observed_at),
414        answered_at: Some(observed_at),
415        lists_every_limit,
416        source: Source::Live,
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    /// An answer of Anthropic's usage endpoint as Claude Code kept it in this machine's
425    /// `~/.claude.json`, trimmed.
426    fn real_answer() -> Value {
427        serde_json::json!({
428        "five_hour": {"utilization": 62, "resets_at": "2026-09-20T22:20:00.095287+00:00"},
429        "seven_day": {"utilization": 48, "resets_at": "2026-09-27T02:00:00.095306+00:00"},
430        "limits": [
431            {"kind": "session", "group": "session", "percent": 62,
432             "resets_at": "2026-09-20T22:20:00.095287+00:00", "scope": null, "is_active": true},
433            {"kind": "weekly_all", "group": "weekly", "percent": 48,
434             "resets_at": "2026-09-27T02:00:00.095306+00:00", "scope": null, "is_active": false},
435            {"kind": "weekly_scoped", "group": "weekly", "percent": 0,
436             "resets_at": "2026-09-27T02:00:00+00:00",
437             "scope": {"model": {"id": null, "display_name": "Fable"}}, "is_active": false}
438        ]})
439    }
440
441    #[test]
442    fn reads_the_real_answers_shape() {
443        let s = from_usage_object(&real_answer(), 1789933772);
444        assert_eq!(s.windows.len(), 3);
445        assert_eq!(s.observed_at, Some(1789933772));
446        assert_eq!(s.answered_at, Some(1789933772));
447        assert!(s.lists_every_limit);
448        assert_eq!(s.source, Source::Live);
449        let scoped = s
450            .windows
451            .iter()
452            .find(|w| w.kind == "weekly_scoped")
453            .unwrap();
454        assert_eq!(scoped.scope.as_deref(), Some("Fable"));
455    }
456
457    #[test]
458    fn falls_back_to_the_named_windows_when_limits_is_missing() {
459        let mut answer = real_answer();
460        answer.as_object_mut().unwrap().remove("limits");
461        let s = from_usage_object(&answer, 1789933772);
462        assert_eq!(s.windows.len(), 2);
463        assert_eq!(s.windows[0].kind, "five_hour");
464        assert_eq!(s.windows[0].percent, 62.0);
465        assert!(!s.lists_every_limit, "nothing scoped to a model is named");
466    }
467
468    #[test]
469    fn a_nonsense_percentage_is_dropped_rather_than_drawn() {
470        for nonsense in [serde_json::json!(-5), serde_json::json!("75")] {
471            let mut answer = real_answer();
472            answer["limits"][0]["percent"] = nonsense;
473            let s = from_usage_object(&answer, 1789933772);
474            assert_eq!(s.windows.len(), 2);
475            assert!(
476                !s.lists_every_limit,
477                "the row dropped is a limit all the same"
478            );
479        }
480    }
481
482    #[test]
483    fn an_exceeded_limit_is_kept_not_dropped() {
484        let mut answer = real_answer();
485        answer["limits"][0]["percent"] = serde_json::json!(104);
486        let s = from_usage_object(&answer, 1789933772);
487        assert_eq!(s.windows.len(), 3);
488        assert_eq!(s.windows[0].percent, 104.0);
489    }
490
491    #[test]
492    fn a_share_is_one_whole_percentage_a_half_rounded_up() {
493        assert_eq!(whole(94.5), 95);
494        assert_eq!(whole(94.49), 94);
495        assert_eq!(whole(104.0), 104);
496        assert_eq!(whole(0.4), 0);
497    }
498
499    const NOW: i64 = 1_789_935_000;
500    const HOUR: i64 = 3_600;
501
502    fn measured(kind: &str, percent: f64, resets_at: Option<i64>) -> Window {
503        Window {
504            kind: kind.into(),
505            scope: None,
506            percent,
507            resets_at,
508            is_active: true,
509            severity: None,
510            length_seconds: anthropic_window_length(kind),
511        }
512    }
513
514    /// Anthropic's answer about an account, listing every limit it has, taken at `at`.
515    fn answer(windows: Vec<Window>, at: i64) -> Snapshot {
516        Snapshot {
517            windows,
518            observed_at: Some(at),
519            answered_at: Some(at),
520            lists_every_limit: true,
521            source: Source::Live,
522        }
523    }
524
525    /// What Pitboard holds of an account: last moved or confirmed at `observed_at`, and last
526    /// answered for at `answered_at`.
527    fn held(windows: Vec<Window>, observed_at: i64, answered_at: Option<i64>) -> Snapshot {
528        Snapshot {
529            windows,
530            observed_at: Some(observed_at),
531            answered_at,
532            lists_every_limit: true,
533            source: Source::Remembered,
534        }
535    }
536
537    fn shares(reading: &Snapshot) -> Vec<(&str, f64)> {
538        reading
539            .windows
540            .iter()
541            .map(|w| (w.kind.as_str(), w.percent))
542            .collect()
543    }
544
545    /// The rule the app keys what it has told somebody by: resets under a minute apart, in
546    /// either order, are one, and a minute apart are two.
547    #[test]
548    fn resets_under_a_minute_apart_are_one_reset() {
549        assert!(same_reset(NOW, NOW));
550        assert!(same_reset(NOW, NOW + 59));
551        assert!(same_reset(NOW + 59, NOW));
552        assert!(!same_reset(NOW, NOW + 60));
553        assert!(!same_reset(NOW + 60, NOW));
554        assert!(
555            !same_reset(0, NOW),
556            "no reset known is no reset in particular"
557        );
558    }
559
560    /// One reading of a window can give its reset up to a minute after another, so the window
561    /// may still run until a minute past the reset it was first given.
562    #[test]
563    fn a_window_may_still_run_until_a_minute_past_its_reset() {
564        assert!(may_still_run(NOW, NOW - 1));
565        assert!(may_still_run(NOW, NOW));
566        assert!(may_still_run(NOW, NOW + 59));
567        assert!(!may_still_run(NOW, NOW + 60));
568        assert!(!may_still_run(0, NOW));
569    }
570
571    /// An answer lists every limit the account has. Kept until its reset, a weekly limit
572    /// 0.9.0 filed under a Team seat, whose answer has no weekly limit for all models, stood
573    /// over every answer about that seat for days.
574    #[test]
575    fn an_answer_replaces_every_limit_it_does_not_give() {
576        let known = held(
577            vec![
578                measured("session", 10.0, Some(NOW + HOUR)),
579                measured("weekly_all", 100.0, Some(NOW + 50 * HOUR)),
580            ],
581            NOW - 60,
582            None,
583        );
584        let given = answer(
585            vec![
586                measured("session", 20.0, Some(NOW + HOUR)),
587                Window {
588                    scope: Some("Fable".into()),
589                    ..measured("weekly_scoped", 0.0, Some(NOW + 50 * HOUR))
590                },
591            ],
592            NOW,
593        );
594        let reading = answered(Some(&known), &given, NOW);
595        assert_eq!(
596            shares(&reading),
597            [("session", 20.0), ("weekly_scoped", 0.0)]
598        );
599        assert_eq!(reading.answered_at, Some(NOW));
600        assert_eq!(reading.observed_at, Some(NOW));
601    }
602
603    /// Anthropic's answer lists every limit, but Pitboard knows what one left out only where
604    /// it read every row. Taken as the whole list, an answer with a row it could not read
605    /// took a used-up weekly limit off the account.
606    #[test]
607    fn a_limit_whose_row_could_not_be_read_is_not_taken_away() {
608        let mut spent = real_answer();
609        spent["limits"][1]["percent"] = serde_json::json!(100);
610        let known = from_usage_object(&spent, NOW - 600);
611        let mut unread = spent.clone();
612        unread["limits"][0]["percent"] = serde_json::json!(70);
613        unread["limits"][1]["percent"] = Value::Null;
614        let reading = answered(Some(&known), &from_usage_object(&unread, NOW), NOW);
615        assert_eq!(
616            shares(&reading),
617            [
618                ("session", 70.0),
619                ("weekly_all", 100.0),
620                ("weekly_scoped", 0.0)
621            ]
622        );
623    }
624
625    /// An answer with no `limits` names the five-hour and weekly limits and nothing scoped to
626    /// a model, so a model's limit it leaves out is one it has no name for.
627    #[test]
628    fn an_answer_in_the_older_shape_takes_no_limit_away() {
629        let mut spent = real_answer();
630        spent["limits"][2]["percent"] = serde_json::json!(100);
631        let known = from_usage_object(&spent, NOW - 600);
632        let mut older = real_answer();
633        older.as_object_mut().unwrap().remove("limits");
634        let reading = answered(Some(&known), &from_usage_object(&older, NOW), NOW);
635        assert_eq!(
636            shares(&reading),
637            [
638                ("five_hour", 62.0),
639                ("seven_day", 48.0),
640                ("weekly_scoped", 100.0)
641            ]
642        );
643    }
644
645    /// A clock that ran ahead stamps a reading later than now. Taken as a later answer, that
646    /// stamp held every answer back until the clock caught up, and a session's share with it.
647    #[test]
648    fn a_reading_stamped_ahead_of_the_clock_holds_no_answer_back() {
649        let known = held(
650            vec![measured("session", 80.0, Some(NOW + 2 * HOUR))],
651            NOW + HOUR,
652            Some(NOW + HOUR),
653        );
654        let given = answer(vec![measured("session", 20.0, Some(NOW + 2 * HOUR))], NOW);
655        let reading = answered(Some(&known), &given, NOW);
656        assert_eq!(shares(&reading), [("session", 20.0)]);
657        assert_eq!(
658            (reading.observed_at, reading.answered_at),
659            (Some(NOW), Some(NOW))
660        );
661    }
662
663    /// Whether OpenAI's null window is one not running is not measured, so a window its
664    /// answer leaves out stands while it runs, and where it was: `pitboard status` and the
665    /// menu list limits in a reading's order. One whose reset has passed, or that has none,
666    /// is not running.
667    #[test]
668    fn a_window_openai_leaves_out_stands_until_its_reset() {
669        let known = held(
670            vec![
671                measured("five_hour", 30.0, Some(NOW + HOUR)),
672                measured("1_day", 60.0, Some(NOW - 60)),
673                measured("2_day", 20.0, None),
674                measured("seven_day", 40.0, Some(NOW + 50 * HOUR)),
675            ],
676            NOW - 600,
677            Some(NOW - 600),
678        );
679        let whole = answer(
680            vec![measured("seven_day", 45.0, Some(NOW + 50 * HOUR))],
681            NOW,
682        );
683        let given = Snapshot {
684            lists_every_limit: false,
685            ..whole.clone()
686        };
687        let reading = answered(Some(&known), &given, NOW);
688        assert_eq!(shares(&reading), [("five_hour", 30.0), ("seven_day", 45.0)]);
689        assert!(!reading.lists_every_limit);
690        assert_eq!(
691            shares(&answered(Some(&known), &whole, NOW)),
692            [("seven_day", 45.0)],
693            "where the answer lists every limit"
694        );
695    }
696
697    /// A session's numbers do not say whose they are. Within a window the service gave this
698    /// account they can only be this account's, and anywhere else they can be anybody's.
699    #[test]
700    fn a_session_moves_a_share_only_inside_a_window_it_was_given() {
701        let known = held(
702            vec![measured("session", 22.0, Some(NOW + HOUR))],
703            NOW - 60,
704            Some(NOW - 60),
705        );
706        let higher = moved(
707            &known,
708            &[measured("five_hour", 25.0, Some(NOW + HOUR))],
709            NOW,
710        )
711        .expect("a share moved");
712        assert_eq!(
713            shares(&higher),
714            [("session", 25.0)],
715            "under the name the service gave it"
716        );
717        assert_eq!(higher.answered_at, Some(NOW - 60), "and no answer since");
718        assert_eq!(
719            moved(
720                &known,
721                &[measured("five_hour", 5.0, Some(NOW + 6 * HOUR))],
722                NOW
723            ),
724            None,
725            "another reset still ahead"
726        );
727        assert_eq!(
728            moved(
729                &known,
730                &[measured("seven_day", 40.0, Some(NOW + 50 * HOUR))],
731                NOW
732            ),
733            None,
734            "a limit the service did not give"
735        );
736    }
737
738    /// A limit's next window starts only once the last has reset, so a session passing one
739    /// after the reset is passing this account's next window. A window with no reset is one
740    /// that is not running.
741    #[test]
742    fn a_session_takes_the_next_window_of_a_limit_whose_reset_has_passed() {
743        let mut over = measured("session", 90.0, Some(NOW - 60));
744        over.severity = Some("warning".into());
745        let not_running = measured("session", 0.0, None);
746        for had in [over, not_running] {
747            let known = held(vec![had.clone()], NOW - HOUR, Some(NOW - HOUR));
748            let next = moved(
749                &known,
750                &[measured("five_hour", 3.0, Some(NOW + 5 * HOUR))],
751                NOW,
752            )
753            .expect("the next window");
754            let window = &next.windows[0];
755            assert_eq!(window.kind, "session", "{had:?}");
756            assert_eq!(
757                (window.percent, window.resets_at),
758                (3.0, Some(NOW + 5 * HOUR))
759            );
760            assert_eq!(window.severity, None, "a share the service has not graded");
761            assert!(window.is_active, "working against it as the service said");
762            assert_eq!(next.observed_at, Some(NOW));
763            assert_eq!(next.answered_at, Some(NOW - HOUR));
764        }
765    }
766
767    /// A session passes the numbers of its last response, however long ago that was. An
768    /// idle one goes on passing a lower share than a busy one has since recorded, and a pane
769    /// left open past a reset goes on passing a window that is over.
770    #[test]
771    fn a_session_never_moves_a_share_back() {
772        let known = held(
773            vec![measured("session", 22.0, Some(NOW + HOUR))],
774            NOW - 60,
775            Some(NOW - 60),
776        );
777        for behind in [
778            measured("five_hour", 20.0, Some(NOW + HOUR)),
779            measured("five_hour", 22.0, Some(NOW + HOUR)),
780            measured("five_hour", 95.0, Some(NOW - 4 * HOUR)),
781        ] {
782            assert_eq!(
783                moved(&known, std::slice::from_ref(&behind), NOW),
784                None,
785                "{behind:?}"
786            );
787        }
788    }
789
790    /// Anthropic's answer gives a reset to a fraction of a second, which is dropped, and a
791    /// session is given whole seconds. Taken for another window, a session's higher share
792    /// would be taken for another account's.
793    #[test]
794    fn resets_a_second_apart_are_one_window() {
795        let known = held(
796            vec![measured("session", 22.0, Some(NOW + HOUR))],
797            NOW - 60,
798            Some(NOW - 60),
799        );
800        let ahead = moved(
801            &known,
802            &[measured("five_hour", 25.0, Some(NOW + HOUR + 1))],
803            NOW,
804        )
805        .expect("a share moved");
806        assert_eq!(shares(&ahead), [("session", 25.0)]);
807        assert_eq!(
808            ahead.windows[0].resets_at,
809            Some(NOW + HOUR),
810            "the reset is the service's"
811        );
812    }
813
814    /// Measured on this machine on 2026-09-29: an account at 100% of its weekly limit,
815    /// resetting at 20:00 UTC the next day, had a banked reset used on claude.ai. Its
816    /// sessions then passed 1%, 13% and 14% of the same limit, with the same reset. Ordered
817    /// by share, every lower answer lost to the 100%, and the account read as out for the
818    /// day and a half until the reset.
819    #[test]
820    fn an_answer_taken_after_everything_known_is_what_the_limits_are_now() {
821        let known = held(
822            vec![
823                measured("session", 0.0, None),
824                measured("weekly_all", 100.0, Some(NOW + 33 * HOUR)),
825            ],
826            NOW - HOUR,
827            Some(NOW - HOUR),
828        );
829        let given = answer(
830            vec![
831                measured("session", 5.0, Some(NOW + 5 * HOUR)),
832                measured("weekly_all", 14.0, Some(NOW + 33 * HOUR)),
833            ],
834            NOW,
835        );
836        let reading = answered(Some(&known), &given, NOW);
837        assert_eq!(shares(&reading), [("session", 5.0), ("weekly_all", 14.0)]);
838        assert_eq!(reading.observed_at, Some(NOW));
839        assert_eq!(reading.source, Source::Live);
840
841        let unused = answer(vec![measured("weekly_all", 0.0, None)], NOW);
842        assert_eq!(
843            shares(&answered(Some(&known), &unused, NOW)),
844            [("weekly_all", 0.0)],
845            "and one that finds nothing used and no window running"
846        );
847    }
848
849    /// An answer is what the limits were when it was taken. A share a session recorded
850    /// since, or in the same second, may have come with a later response.
851    #[test]
852    fn an_answer_taken_before_a_session_moved_a_share_does_not_lower_it() {
853        let known = held(
854            vec![measured("weekly_all", 100.0, Some(NOW + 33 * HOUR))],
855            NOW - 60,
856            Some(NOW - 2 * HOUR),
857        );
858        for taken in [NOW - 60, NOW - HOUR] {
859            let given = answer(
860                vec![measured("weekly_all", 14.0, Some(NOW + 33 * HOUR))],
861                taken,
862            );
863            let reading = answered(Some(&known), &given, NOW);
864            assert_eq!(
865                shares(&reading),
866                [("weekly_all", 100.0)],
867                "taken at {taken}"
868            );
869            assert_eq!(reading.observed_at, Some(NOW - 60));
870            assert_eq!(reading.answered_at, Some(taken));
871        }
872    }
873
874    /// Two front ends can each have a request out, and the older answer can land last.
875    #[test]
876    fn an_answer_older_than_the_last_changes_nothing() {
877        let known = held(
878            vec![measured("session", 40.0, Some(NOW + HOUR))],
879            NOW,
880            Some(NOW),
881        );
882        let late = answer(vec![measured("session", 30.0, Some(NOW + HOUR))], NOW - 60);
883        assert_eq!(answered(Some(&known), &late, NOW), known);
884    }
885
886    /// A session knows the five-hour and weekly limits and nothing scoped to a model.
887    #[test]
888    fn a_session_leaves_the_limits_it_does_not_pass_as_they_were() {
889        let scoped = Window {
890            scope: Some("Fable".into()),
891            ..measured("weekly_scoped", 5.0, Some(NOW + 50 * HOUR))
892        };
893        let known = held(
894            vec![measured("session", 22.0, Some(NOW + HOUR)), scoped],
895            NOW - 60,
896            Some(NOW - 60),
897        );
898        let reading = moved(
899            &known,
900            &[measured("five_hour", 25.0, Some(NOW + HOUR))],
901            NOW,
902        )
903        .expect("a share moved");
904        assert_eq!(
905            shares(&reading),
906            [("session", 25.0), ("weekly_scoped", 5.0)]
907        );
908    }
909
910    /// A session leaves out a window whose reset has passed. Taken for the service no
911    /// longer reporting it, every reset took the five-hour limit away until the next answer,
912    /// where it reads as nothing used.
913    #[test]
914    fn a_session_never_takes_a_limit_away() {
915        let known = held(
916            vec![
917                measured("session", 40.0, Some(NOW - 10)),
918                measured("weekly_all", 30.0, Some(NOW + 50 * HOUR)),
919            ],
920            NOW - HOUR,
921            Some(NOW - HOUR),
922        );
923        let reading = moved(
924            &known,
925            &[measured("seven_day", 31.0, Some(NOW + 50 * HOUR))],
926            NOW,
927        )
928        .expect("a share moved");
929        assert_eq!(shares(&reading), [("session", 40.0), ("weekly_all", 31.0)]);
930        assert_eq!(reading.windows[0].used(NOW), 0.0);
931    }
932
933    /// A reading is founded by the service's answer and by nothing else.
934    #[test]
935    fn a_first_answer_is_the_reading() {
936        let given = answer(vec![measured("session", 22.0, Some(NOW + HOUR))], NOW);
937        assert_eq!(answered(None, &given, NOW), given);
938        assert_eq!(
939            moved(&held(Vec::new(), NOW, None), &given.windows, NOW),
940            None
941        );
942    }
943
944    /// A session passes the numbers of its last response, however long ago that was, and
945    /// says nothing about when. Repeating them vouches for nothing; moving one forward means
946    /// the response that did it has only just come.
947    #[test]
948    fn a_reading_is_stamped_when_something_moves_or_the_service_answers() {
949        let known = held(
950            vec![measured("session", 22.0, Some(NOW + HOUR))],
951            NOW - 600,
952            Some(NOW - 600),
953        );
954        let repeated = measured("five_hour", 22.0, Some(NOW + HOUR));
955        assert_eq!(moved(&known, &[repeated], NOW), None);
956
957        let ahead = measured("five_hour", 23.0, Some(NOW + HOUR));
958        assert_eq!(moved(&known, &[ahead], NOW).unwrap().observed_at, Some(NOW));
959
960        let confirmed = answer(known.windows.clone(), NOW - 5);
961        let reading = answered(Some(&known), &confirmed, NOW);
962        assert_eq!(reading.windows, known.windows);
963        assert_eq!(
964            (reading.observed_at, reading.answered_at),
965            (Some(NOW - 5), Some(NOW - 5)),
966            "an answer that finds the same confirms it"
967        );
968        assert_eq!(reading.source, Source::Live);
969    }
970
971    /// Asked about an account that has done nothing since its window reset, Anthropic finds
972    /// nothing used and gives no reset, or the one that passed. Kept, a parked account that
973    /// had run out read as full, marked live, until somebody used it again.
974    #[test]
975    fn a_window_past_its_reset_is_reset_by_an_answer_that_finds_nothing_used() {
976        let known = held(
977            vec![measured("session", 100.0, Some(NOW - HOUR))],
978            NOW - 2 * HOUR,
979            Some(NOW - 2 * HOUR),
980        );
981        for said in [
982            measured("session", 0.0, None),
983            measured("session", 0.0, Some(NOW - HOUR)),
984            measured("five_hour", 0.0, None),
985        ] {
986            let given = answer(vec![said.clone()], NOW - 5);
987            let reading = answered(Some(&known), &given, NOW);
988            assert_eq!(reading.windows, std::slice::from_ref(&said), "{said:?}");
989            assert_eq!(
990                answered(Some(&reading), &given, NOW),
991                reading,
992                "a repeat changes nothing"
993            );
994        }
995        assert_eq!(
996            moved(&known, &[measured("five_hour", 0.0, None)], NOW),
997            None,
998            "a session's window with no reset says nothing about now"
999        );
1000    }
1001}