Skip to main content

salvor_replay/
budgets.rs

1//! Runtime budgets: the declared limits ([`Budgets`]), the pricing table a
2//! cost budget needs ([`Pricing`]), the extensions a human grants at resume
3//! time ([`BudgetExtensions`]), and the crossing check itself.
4//!
5//! Named `Budgets` (plural) deliberately: [`Budget`] is the *event payload*
6//! naming which single limit was crossed; this type is the *declaration* of
7//! every limit an agent runs under.
8//!
9//! # Why this lives in the pure crate
10//!
11//! Every input to a check is replayed data (see the determinism section
12//! below), so the check itself never touches a clock, a store, or a network:
13//! it is arithmetic over recorded numbers. It sits here rather than in
14//! `salvor-runtime` for the same reason [`derive_state`](crate::derive_state)
15//! does: the runtime enforces budgets at the IO edge and a browser wants to
16//! evaluate the identical rule client-side, and one implementation serving
17//! both is the only way the two cannot disagree. `salvor-runtime` re-exports
18//! every name here, so `salvor_runtime::Budgets` and the rest keep resolving.
19//!
20//! [`budget_observations`] closes the loop: it folds a recorded log into the
21//! [`BudgetObservations`] the loop would have built at that point, so a caller
22//! holding only the log can run the real check rather than approximate it.
23//!
24//! # Determinism
25//!
26//! Budget checks run between events, before each model call, and every
27//! input to a check is replayed data:
28//!
29//! - **steps** counts completed model calls in this drive of the loop.
30//! - **tokens** and **cost** accumulate the recorded usage of completed
31//!   model calls (cost multiplies those integers by the agent's fixed
32//!   [`Pricing`]).
33//! - **wall time** is derived only from recorded `ctx.now()` observations
34//!   taken at each loop-iteration start, never from the ambient clock, minus
35//!   any span the run spent asleep on a durable timer (see
36//!   [`budget_observations`] for why).
37//!
38//! So a crossing that fired live recomputes identically on replay, and the
39//! cursor matches it against the recorded `BudgetExceeded` event. A check
40//! fires when the observed value reaches or passes the effective limit
41//! (`observed >= limit`), and checks are evaluated in a fixed documented
42//! order: steps, tokens, cost, wall time.
43//!
44//! # The extension shape
45//!
46//! A budget crossing parks the run. Resuming it may carry an extension in
47//! the resume input, under the reserved `extend` key:
48//!
49//! ```json
50//! {
51//!     "extend": {
52//!         "steps": 5,
53//!         "tokens": 20000,
54//!         "cost_usd": 1.5,
55//!         "wall_time_seconds": 600.0
56//!     }
57//! }
58//! ```
59//!
60//! Every field is optional; `steps` and `tokens` are unsigned integers,
61//! `cost_usd` and `wall_time_seconds` are numbers. The effective limit for
62//! each dimension is the declared limit plus the sum of every recorded
63//! extension. Extensions live inside recorded `Resumed` events, so replay
64//! sees exactly the extensions the live run saw, in the same order, and the
65//! effective budget evolves identically. [`validate_extension_input`] is the
66//! shape check `Runtime::resume` applies before recording anything: the top
67//! level may contain only `extend`, and `extend` may contain only the four
68//! keys above with the right JSON types.
69
70use std::time::Duration;
71
72use serde_json::Value;
73use time::OffsetDateTime;
74
75use crate::{Budget, BudgetKind, Event, EventEnvelope};
76
77/// The limits an agent declares. Every dimension is optional; an absent
78/// dimension is never checked.
79#[derive(Clone, Debug, Default, PartialEq)]
80pub struct Budgets {
81    /// Maximum loop iterations, counted as completed model calls.
82    pub max_steps: Option<u64>,
83    /// Maximum total recorded tokens (input plus output) across the run.
84    pub max_tokens: Option<u64>,
85    /// Maximum cost in US dollars, computed from recorded usage and the
86    /// agent's [`Pricing`]. Declaring this without pricing is a build-time
87    /// error on the agent builder.
88    pub max_cost_usd: Option<f64>,
89    /// Maximum wall time, measured between recorded `ctx.now()`
90    /// observations, never against the ambient clock.
91    pub max_wall_time: Option<Duration>,
92}
93
94impl Budgets {
95    /// Whether any dimension is declared at all.
96    #[must_use]
97    pub fn any_declared(&self) -> bool {
98        self.max_steps.is_some()
99            || self.max_tokens.is_some()
100            || self.max_cost_usd.is_some()
101            || self.max_wall_time.is_some()
102    }
103
104    /// The first crossing, if any, in the fixed check order (steps, tokens,
105    /// cost, wall time). Returns the crossed [`Budget`] (whose `limit` is
106    /// the *effective* limit: declared plus extensions) and the observed
107    /// value, both exactly as they will be recorded.
108    #[must_use]
109    pub fn first_crossing(
110        &self,
111        extensions: &BudgetExtensions,
112        pricing: Option<&Pricing>,
113        observations: &BudgetObservations,
114    ) -> Option<(Budget, f64)> {
115        if let Some(max_steps) = self.max_steps {
116            let limit = to_f64(max_steps.saturating_add(extensions.steps));
117            let observed = to_f64(observations.steps);
118            if observed >= limit {
119                return Some((
120                    Budget {
121                        kind: BudgetKind::Steps,
122                        limit,
123                    },
124                    observed,
125                ));
126            }
127        }
128        if let Some(max_tokens) = self.max_tokens {
129            let limit = to_f64(max_tokens.saturating_add(extensions.tokens));
130            let observed = to_f64(
131                observations
132                    .input_tokens
133                    .saturating_add(observations.output_tokens),
134            );
135            if observed >= limit {
136                return Some((
137                    Budget {
138                        kind: BudgetKind::Tokens,
139                        limit,
140                    },
141                    observed,
142                ));
143            }
144        }
145        if let (Some(max_cost), Some(pricing)) = (self.max_cost_usd, pricing) {
146            let limit = max_cost + extensions.cost_usd;
147            let observed = pricing.cost_usd(observations.input_tokens, observations.output_tokens);
148            if observed >= limit {
149                return Some((
150                    Budget {
151                        kind: BudgetKind::CostUsd,
152                        limit,
153                    },
154                    observed,
155                ));
156            }
157        }
158        if let Some(max_wall) = self.max_wall_time {
159            let limit = max_wall.as_secs_f64() + extensions.wall_time_seconds;
160            let observed = observations.elapsed_seconds;
161            if observed >= limit {
162                return Some((
163                    Budget {
164                        kind: BudgetKind::WallTime,
165                        limit,
166                    },
167                    observed,
168                ));
169            }
170        }
171        None
172    }
173}
174
175/// Per-token pricing, in US dollars per million tokens. Required by the
176/// agent builder whenever a cost budget is declared.
177#[derive(Clone, Copy, Debug, PartialEq)]
178pub struct Pricing {
179    /// Dollars per million input tokens.
180    pub input_per_mtok: f64,
181    /// Dollars per million output tokens.
182    pub output_per_mtok: f64,
183}
184
185impl Pricing {
186    /// The cost of the given recorded token counts under this pricing. A
187    /// pure function of integers and the fixed rates, so it reproduces bit
188    /// for bit on replay.
189    #[must_use]
190    pub fn cost_usd(&self, input_tokens: u64, output_tokens: u64) -> f64 {
191        to_f64(input_tokens) / 1_000_000.0 * self.input_per_mtok
192            + to_f64(output_tokens) / 1_000_000.0 * self.output_per_mtok
193    }
194}
195
196/// The replay-derived quantities a budget check consumes. The loop builds
197/// one of these at each iteration start, exclusively from recorded data.
198#[derive(Clone, Copy, Debug, Default, PartialEq)]
199pub struct BudgetObservations {
200    /// Completed model calls so far.
201    pub steps: u64,
202    /// Recorded input tokens accumulated so far.
203    pub input_tokens: u64,
204    /// Recorded output tokens accumulated so far.
205    pub output_tokens: u64,
206    /// Seconds between the first recorded `ctx.now()` observation and the
207    /// latest one, less every recorded sleep span (see
208    /// [`budget_observations`]). Never negative.
209    pub elapsed_seconds: f64,
210}
211
212/// Folds a recorded log into the observations a budget check consumes.
213///
214/// The runtime's loop accumulates these as it drives, but every quantity it
215/// accumulates is itself recorded, so the same numbers can be read back out
216/// of the log afterwards. That is what makes a check reproducible off the
217/// event stream alone, which is what a browser has:
218///
219/// - **steps** is the count of `ModelCallCompleted` events, the loop's
220///   "completed model calls".
221/// - **tokens** are the recorded `usage` totals on those same events.
222/// - **elapsed** is the span between the first and the last `NowObserved`,
223///   the loop's baseline and its latest reading, less every span the run spent
224///   asleep. Fewer than two observations means no span has elapsed, which is
225///   zero.
226///
227/// Pass the prefix the check would have seen. The loop checks *before* each
228/// model call, so the observations behind a recorded
229/// [`Event::BudgetExceeded`] at position `n` are the fold of `log[..n]`.
230///
231/// # Why recorded sleep is excluded from wall time
232///
233/// A wall-time budget bounds how long a run may take, and a durable timer is
234/// time the run deliberately did not take: a run told to sleep a week would
235/// cross any declared `max_wall_time` the instant it woke, before doing a
236/// single further step, which would turn every timer into a budget crossing.
237/// So each span between an [`Event::SleepStarted`] and its
238/// [`Event::SleepCompleted`] is summed and subtracted, using the two events'
239/// recorded envelope timestamps as the span's endpoints. A sleep still open at
240/// the end of the log contributes the span from its start to the last
241/// observation, so a prefix cut mid-sleep excludes what it has seen of the
242/// sleep so far.
243///
244/// Gate-wait time is deliberately not excluded. A run waiting on a human is
245/// blocked on the outside world with no promised end, which is exactly the
246/// thing a wall-time budget is there to catch, and every log recorded before
247/// timers existed holds none of these events, so its elapsed figure and its
248/// budget verdict are unchanged to the byte.
249///
250/// # Scope
251///
252/// This is the accounting of one agent run's log: what `salvor run` and
253/// `salvor resume` record, and what the loop counts. It is the whole log
254/// because the loop replays: on resume the driver re-enters at iteration
255/// zero and every recorded call is replayed through it, so its counters
256/// arrive at the live edge holding the run's full recorded history.
257///
258/// A graph run is deliberately not that shape. Its engine drives each node
259/// through its own loop with its own counters, so folding a graph log whole
260/// would sum quantities no single check ever saw. Fold one node's span, or
261/// do not use this on a graph log.
262#[must_use]
263pub fn budget_observations(log: &[EventEnvelope]) -> BudgetObservations {
264    let mut observations = BudgetObservations::default();
265    let mut first_now = None;
266    let mut last_now = None;
267    let mut slept_seconds = 0.0;
268    let mut sleeping_since: Option<OffsetDateTime> = None;
269
270    for envelope in log {
271        match &envelope.event {
272            Event::ModelCallCompleted { usage, .. } => {
273                observations.steps = observations.steps.saturating_add(1);
274                observations.input_tokens = observations
275                    .input_tokens
276                    .saturating_add(u64::from(usage.input_tokens));
277                observations.output_tokens = observations
278                    .output_tokens
279                    .saturating_add(u64::from(usage.output_tokens));
280            }
281            Event::NowObserved { now } => {
282                first_now.get_or_insert(*now);
283                last_now = Some(*now);
284            }
285            // The sleep span's endpoints are the two events' recorded
286            // timestamps, the one instant each of them carries. A sleep with
287            // no start before it closes nothing: the fold stays total over
288            // every prefix, including one cut between the two.
289            Event::SleepStarted { .. } => {
290                sleeping_since = Some(envelope.recorded_at);
291            }
292            Event::SleepCompleted {} => {
293                if let Some(started) = sleeping_since.take() {
294                    slept_seconds += span_seconds(started, envelope.recorded_at);
295                }
296            }
297            _ => {}
298        }
299    }
300
301    if let (Some(first), Some(last)) = (first_now, last_now) {
302        // A sleep the log never closed still ran until the last thing the run
303        // observed, so it excludes what elapsed counted of it: no more, since
304        // elapsed itself stops at that observation.
305        if let Some(started) = sleeping_since {
306            slept_seconds += span_seconds(started, last);
307        }
308        observations.elapsed_seconds = (span_seconds(first, last) - slept_seconds).max(0.0);
309    }
310    observations
311}
312
313/// The seconds from `from` to `to`, floored at zero.
314///
315/// Recorded timestamps come off the wire and this fold is total over whatever
316/// a log holds, so a span that runs backwards contributes nothing rather than
317/// crediting a run with time it never spent.
318fn span_seconds(from: OffsetDateTime, to: OffsetDateTime) -> f64 {
319    (to - from).as_seconds_f64().max(0.0)
320}
321
322/// The accumulated budget extensions granted by recorded resume inputs.
323/// See the module docs for the JSON shape they are parsed from.
324#[derive(Clone, Copy, Debug, Default, PartialEq)]
325pub struct BudgetExtensions {
326    /// Extra steps granted.
327    pub steps: u64,
328    /// Extra tokens granted.
329    pub tokens: u64,
330    /// Extra dollars granted.
331    pub cost_usd: f64,
332    /// Extra wall-time seconds granted.
333    pub wall_time_seconds: f64,
334}
335
336impl BudgetExtensions {
337    /// Folds one resume input's `extend` object (if present) into the
338    /// accumulated totals. Unknown or ill-typed fields are ignored here;
339    /// rejecting them is [`validate_extension_input`]'s job, applied before
340    /// the input was ever recorded.
341    pub fn absorb(&mut self, resume_input: &Value) {
342        let Some(extend) = resume_input.get("extend").and_then(Value::as_object) else {
343            return;
344        };
345        if let Some(steps) = extend.get("steps").and_then(Value::as_u64) {
346            self.steps = self.steps.saturating_add(steps);
347        }
348        if let Some(tokens) = extend.get("tokens").and_then(Value::as_u64) {
349            self.tokens = self.tokens.saturating_add(tokens);
350        }
351        if let Some(cost) = extend.get("cost_usd").and_then(Value::as_f64) {
352            self.cost_usd += cost;
353        }
354        if let Some(seconds) = extend.get("wall_time_seconds").and_then(Value::as_f64) {
355            self.wall_time_seconds += seconds;
356        }
357    }
358}
359
360/// Folds a recorded log into the extensions a budget check has been granted.
361///
362/// The loop absorbs an extension exactly when a resume answers a budget
363/// crossing, so this absorbs the input of a [`Event::Resumed`] whose
364/// immediately preceding event is a [`Event::BudgetExceeded`], and no other.
365/// That adjacency is not a heuristic: `ctx.budget_exceeded` records the
366/// crossing and the `await_resume` that follows it records the answer, with
367/// nothing in between. A resume answering a *suspension* is a different
368/// conversation and is deliberately not absorbed here, even if its input
369/// happens to carry a key spelled `extend`.
370///
371/// Like [`budget_observations`], this is the whole log because the loop
372/// replays: on resume the driver re-absorbs every recorded extension in
373/// order before it reaches the live edge.
374#[must_use]
375pub fn budget_extensions(log: &[EventEnvelope]) -> BudgetExtensions {
376    let mut extensions = BudgetExtensions::default();
377    for pair in log.windows(2) {
378        if let (Event::BudgetExceeded { .. }, Event::Resumed { input }) =
379            (&pair[0].event, &pair[1].event)
380        {
381            extensions.absorb(input);
382        }
383    }
384    extensions
385}
386
387/// Validates a resume input against the budget-extension shape documented
388/// at module level. Applied by `Runtime::resume` when the run parked on a
389/// budget crossing, *before* the input is recorded.
390///
391/// # Errors
392///
393/// Returns a human-readable description of the first violation: a non-object
394/// input, an unexpected top-level key, a non-object `extend`, an unknown
395/// key inside `extend`, or a field with the wrong JSON type.
396pub fn validate_extension_input(input: &Value) -> Result<(), String> {
397    let Some(top) = input.as_object() else {
398        return Err("a budget-crossing resume input must be a JSON object".to_owned());
399    };
400    for key in top.keys() {
401        if key != "extend" {
402            return Err(format!(
403                "unexpected top-level key `{key}`; a budget-crossing resume input may only carry `extend`"
404            ));
405        }
406    }
407    let Some(extend) = top.get("extend") else {
408        return Ok(());
409    };
410    let Some(extend) = extend.as_object() else {
411        return Err("`extend` must be a JSON object".to_owned());
412    };
413    for (key, value) in extend {
414        match key.as_str() {
415            "steps" | "tokens" => {
416                if value.as_u64().is_none() {
417                    return Err(format!("`extend.{key}` must be an unsigned integer"));
418                }
419            }
420            "cost_usd" | "wall_time_seconds" => {
421                if value.as_f64().is_none() {
422                    return Err(format!("`extend.{key}` must be a number"));
423                }
424            }
425            other => {
426                return Err(format!(
427                    "unknown key `extend.{other}`; expected steps, tokens, cost_usd, or wall_time_seconds"
428                ));
429            }
430        }
431    }
432    Ok(())
433}
434
435/// Widens an integer count to `f64` for the wire's numeric budget fields.
436/// Exact for every count below 2^53, far beyond any real run.
437#[allow(clippy::cast_precision_loss)]
438fn to_f64(count: u64) -> f64 {
439    count as f64
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445    use crate::id::{RunId, SequenceNumber};
446    use serde_json::json;
447    use time::macros::datetime;
448    use uuid::Uuid;
449
450    /// The run every log in these tests belongs to.
451    fn run_id() -> RunId {
452        RunId::from_uuid(Uuid::parse_str("00000000-0000-4000-8000-000000000008").unwrap())
453    }
454
455    /// The baseline instant every scripted timestamp is an offset from.
456    fn base() -> OffsetDateTime {
457        datetime!(2026-07-09 12:00:00 UTC)
458    }
459
460    /// Wraps events, each carrying the recorded timestamp its `(seconds after
461    /// base)` says. The envelope timestamp matters here: it is the endpoint a
462    /// sleep span is measured from.
463    fn log(events: Vec<(i64, Event)>) -> Vec<EventEnvelope> {
464        events
465            .into_iter()
466            .enumerate()
467            .map(|(i, (offset, event))| {
468                EventEnvelope::new(
469                    run_id(),
470                    SequenceNumber::new(i as u64),
471                    base() + time::Duration::seconds(offset),
472                    event,
473                )
474            })
475            .collect()
476    }
477
478    fn started() -> Event {
479        Event::RunStarted {
480            agent_def_hash: "sha256:agent".into(),
481            input: json!({}),
482            labels: None,
483            driven_by: None,
484        }
485    }
486
487    /// A one-minute wall-time budget, the limit every wall-time case below
488    /// asks about.
489    fn one_minute() -> Budgets {
490        Budgets {
491            max_wall_time: Some(Duration::from_secs(60)),
492            ..Budgets::default()
493        }
494    }
495
496    /// Whether the declared wall-time budget is crossed by these observations.
497    fn crosses_wall_time(observations: &BudgetObservations) -> bool {
498        matches!(
499            one_minute().first_crossing(&BudgetExtensions::default(), None, observations),
500            Some((budget, _)) if budget.kind == BudgetKind::WallTime
501        )
502    }
503
504    const WEEK: i64 = 7 * 24 * 60 * 60;
505
506    /// The same span as a count of seconds, for comparing against a derived
507    /// `elapsed_seconds` without casting an integer by hand.
508    fn seconds(count: i64) -> f64 {
509        time::Duration::seconds(count).as_seconds_f64()
510    }
511
512    /// A run that slept a week and then worked for five seconds has five
513    /// seconds of wall time, and does not cross a one-minute budget. The same
514    /// log with the two sleep events removed does cross it, which is what the
515    /// exclusion is for: without it every timer longer than the budget would
516    /// park the run the moment it woke.
517    #[test]
518    fn a_week_long_sleep_is_excluded_from_wall_time() {
519        let slept = budget_observations(&log(vec![
520            (0, started()),
521            (0, Event::NowObserved { now: base() }),
522            (
523                0,
524                Event::SleepStarted {
525                    wake_at: base() + time::Duration::seconds(WEEK),
526                },
527            ),
528            (WEEK, Event::SleepCompleted {}),
529            (
530                WEEK + 5,
531                Event::NowObserved {
532                    now: base() + time::Duration::seconds(WEEK + 5),
533                },
534            ),
535        ]));
536        assert!((slept.elapsed_seconds - 5.0).abs() < 1e-9);
537        assert!(!crosses_wall_time(&slept));
538
539        let without_the_sleep = budget_observations(&log(vec![
540            (0, started()),
541            (0, Event::NowObserved { now: base() }),
542            (
543                WEEK + 5,
544                Event::NowObserved {
545                    now: base() + time::Duration::seconds(WEEK + 5),
546                },
547            ),
548        ]));
549        assert!(
550            (without_the_sleep.elapsed_seconds - seconds(WEEK + 5)).abs() < 1e-9,
551            "the same span, uncredited, is the whole week"
552        );
553        assert!(
554            crosses_wall_time(&without_the_sleep),
555            "without the exclusion this run crosses the budget on waking"
556        );
557    }
558
559    /// A sleep the log never closed is handled at both shapes a prefix can cut
560    /// it at: a log ending at the sleep start has nothing observed after it, so
561    /// elapsed stops where it already stopped; a log that observed the clock
562    /// again without recording a completion excludes the span it saw.
563    #[test]
564    fn an_open_sleep_at_the_end_of_a_log_is_handled() {
565        let parked = budget_observations(&log(vec![
566            (0, started()),
567            (0, Event::NowObserved { now: base() }),
568            (
569                2,
570                Event::SleepStarted {
571                    wake_at: base() + time::Duration::seconds(WEEK),
572                },
573            ),
574        ]));
575        assert_eq!(
576            parked.elapsed_seconds, 0.0,
577            "one observation is no span, and the open sleep never credits one back"
578        );
579
580        let observed_while_open = budget_observations(&log(vec![
581            (0, started()),
582            (0, Event::NowObserved { now: base() }),
583            (
584                0,
585                Event::SleepStarted {
586                    wake_at: base() + time::Duration::seconds(WEEK),
587                },
588            ),
589            (
590                WEEK,
591                Event::NowObserved {
592                    now: base() + time::Duration::seconds(WEEK),
593                },
594            ),
595        ]));
596        assert_eq!(
597            observed_while_open.elapsed_seconds, 0.0,
598            "the open sleep runs to the last observation, so nothing is left over"
599        );
600        assert!(!crosses_wall_time(&observed_while_open));
601    }
602
603    /// The no-change proof: a run that waited a week on a human gate still
604    /// counts every second of it. Gate waiting is exactly what a wall-time
605    /// budget is meant to catch, and no log recorded before durable timers
606    /// existed changes its verdict.
607    #[test]
608    fn a_gate_suspension_still_counts_as_wall_time() {
609        let waited = budget_observations(&log(vec![
610            (0, started()),
611            (0, Event::NowObserved { now: base() }),
612            (
613                0,
614                Event::Suspended {
615                    reason: "awaiting approval".into(),
616                    input_schema: json!({"type": "object"}),
617                    kind: None,
618                },
619            ),
620            (
621                WEEK,
622                Event::Resumed {
623                    input: json!({"approved": true}),
624                },
625            ),
626            (
627                WEEK,
628                Event::NowObserved {
629                    now: base() + time::Duration::seconds(WEEK),
630                },
631            ),
632        ]));
633        assert!((waited.elapsed_seconds - seconds(WEEK)).abs() < 1e-9);
634        assert!(
635            crosses_wall_time(&waited),
636            "a week spent waiting on a human is a week of wall time"
637        );
638    }
639
640    /// The two rules together, in one log: a run waits ten minutes on a
641    /// human gate, then sleeps for three hours before waking. Only the sleep
642    /// span is excluded; the gate wait counts in full, so elapsed wall time
643    /// is the whole span minus just the sleep.
644    #[test]
645    fn a_gate_wait_and_a_sleep_in_the_same_log_are_treated_differently() {
646        const GATE_MINUTES: i64 = 10 * 60;
647        const SLEEP_HOURS: i64 = 3 * 60 * 60;
648
649        let mixed = budget_observations(&log(vec![
650            (0, started()),
651            (0, Event::NowObserved { now: base() }),
652            (
653                0,
654                Event::Suspended {
655                    reason: "awaiting approval".into(),
656                    input_schema: json!({"type": "object"}),
657                    kind: None,
658                },
659            ),
660            (
661                GATE_MINUTES,
662                Event::Resumed {
663                    input: json!({"approved": true}),
664                },
665            ),
666            (
667                GATE_MINUTES,
668                Event::SleepStarted {
669                    wake_at: base() + time::Duration::seconds(GATE_MINUTES + SLEEP_HOURS),
670                },
671            ),
672            (GATE_MINUTES + SLEEP_HOURS, Event::SleepCompleted {}),
673            (
674                GATE_MINUTES + SLEEP_HOURS,
675                Event::NowObserved {
676                    now: base() + time::Duration::seconds(GATE_MINUTES + SLEEP_HOURS),
677                },
678            ),
679        ]));
680        assert!(
681            (mixed.elapsed_seconds - seconds(GATE_MINUTES)).abs() < 1e-9,
682            "the total span minus the sleep is just the gate wait"
683        );
684        assert!(
685            crosses_wall_time(&mixed),
686            "the ten-minute gate wait alone crosses a one-minute budget"
687        );
688    }
689
690    /// Checks fire on reaching the limit and honor absorbed extensions.
691    #[test]
692    fn crossing_fires_at_limit_and_extensions_raise_it() {
693        let budgets = Budgets {
694            max_steps: Some(2),
695            ..Budgets::default()
696        };
697        let mut extensions = BudgetExtensions::default();
698        let observations = BudgetObservations {
699            steps: 2,
700            ..BudgetObservations::default()
701        };
702
703        let (budget, observed) = budgets
704            .first_crossing(&extensions, None, &observations)
705            .expect("steps crossing fires at the limit");
706        assert_eq!(budget.kind, BudgetKind::Steps);
707        assert_eq!(budget.limit, 2.0);
708        assert_eq!(observed, 2.0);
709
710        extensions.absorb(&json!({"extend": {"steps": 3}}));
711        assert_eq!(
712            budgets.first_crossing(&extensions, None, &observations),
713            None,
714            "the extension raises the effective limit past the observation"
715        );
716    }
717
718    /// The documented check order: steps beats tokens when both cross.
719    #[test]
720    fn check_order_is_steps_first() {
721        let budgets = Budgets {
722            max_steps: Some(1),
723            max_tokens: Some(10),
724            ..Budgets::default()
725        };
726        let observations = BudgetObservations {
727            steps: 1,
728            input_tokens: 100,
729            output_tokens: 100,
730            ..BudgetObservations::default()
731        };
732        let (budget, _) = budgets
733            .first_crossing(&BudgetExtensions::default(), None, &observations)
734            .expect("a crossing fires");
735        assert_eq!(budget.kind, BudgetKind::Steps);
736    }
737
738    /// Cost uses pricing over recorded token counts.
739    #[test]
740    fn cost_crossing_uses_pricing() {
741        let budgets = Budgets {
742            max_cost_usd: Some(1.0),
743            ..Budgets::default()
744        };
745        let pricing = Pricing {
746            input_per_mtok: 3.0,
747            output_per_mtok: 15.0,
748        };
749        let observations = BudgetObservations {
750            input_tokens: 200_000,
751            output_tokens: 40_000,
752            ..BudgetObservations::default()
753        };
754        // 0.2 mtok * 3 + 0.04 mtok * 15 = 0.6 + 0.6 = 1.2 >= 1.0.
755        let (budget, observed) = budgets
756            .first_crossing(&BudgetExtensions::default(), Some(&pricing), &observations)
757            .expect("cost crossing fires");
758        assert_eq!(budget.kind, BudgetKind::CostUsd);
759        assert!((observed - 1.2).abs() < 1e-12);
760    }
761
762    /// The extension validator accepts the documented shape and rejects
763    /// obviously wrong ones.
764    #[test]
765    fn extension_validation_rejects_wrong_shapes() {
766        assert!(validate_extension_input(&json!({})).is_ok());
767        assert!(validate_extension_input(&json!({"extend": {"steps": 2}})).is_ok());
768        assert!(
769            validate_extension_input(&json!({
770                "extend": {"steps": 1, "tokens": 2, "cost_usd": 0.5, "wall_time_seconds": 60}
771            }))
772            .is_ok()
773        );
774        assert!(validate_extension_input(&json!("more please")).is_err());
775        assert!(validate_extension_input(&json!({"other": 1})).is_err());
776        assert!(validate_extension_input(&json!({"extend": 5})).is_err());
777        assert!(validate_extension_input(&json!({"extend": {"stepz": 1}})).is_err());
778        assert!(validate_extension_input(&json!({"extend": {"steps": -1}})).is_err());
779        assert!(validate_extension_input(&json!({"extend": {"cost_usd": "1"}})).is_err());
780    }
781}