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}