1use rust_decimal::prelude::*;
20use rust_decimal::Decimal;
21use serde::{Deserialize, Serialize};
22
23use crate::budget::Budget;
24
25const EWMA_ALPHA_NUM: u64 = 3;
29const EWMA_ALPHA_DEN: u64 = 10;
30
31#[derive(Debug, Clone)]
33pub struct ForecastInputs {
34 pub cost_so_far_cents: u64,
36 pub tool_calls_so_far: u32,
38 pub wall_time_ms_so_far: u64,
40 pub steps_completed: u32,
42 pub step_cost_cents: u64,
44 pub prior_ewma_step_cost_cents: Option<u64>,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum BreachKind {
53 CostCents,
54 ToolCalls,
55 WallTime,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct ForecastSnapshot {
61 pub projected_cost_cents: u64,
63 pub ewma_cost_cents: u64,
65 pub ewma_step_cost_cents: u64,
67 pub budget_breach_projected: bool,
70 pub breach_kind: Option<BreachKind>,
72}
73
74pub fn compute_forecast(inputs: ForecastInputs, budget: &Budget) -> ForecastSnapshot {
76 let projected_cost_cents = linear_projection(&inputs, budget);
77 let ewma_step_cost_cents = update_ewma(&inputs);
78 let ewma_cost_cents = ewma_projection(&inputs, budget, ewma_step_cost_cents);
79
80 let breach_kind = projected_breach(projected_cost_cents, ewma_cost_cents, &inputs, budget);
81
82 ForecastSnapshot {
83 projected_cost_cents,
84 ewma_cost_cents,
85 ewma_step_cost_cents,
86 budget_breach_projected: breach_kind.is_some(),
87 breach_kind,
88 }
89}
90
91fn linear_projection(inputs: &ForecastInputs, budget: &Budget) -> u64 {
95 let cost = Decimal::from(inputs.cost_so_far_cents);
96 if cost.is_zero() {
97 return inputs.cost_so_far_cents;
98 }
99
100 let mut max_fraction: Option<Decimal> = None;
104
105 if let Some(cap) = budget.max_cost_cents {
106 if let Some(f) = fraction(inputs.cost_so_far_cents, cap) {
107 max_fraction = max_decimal(max_fraction, f);
108 }
109 }
110 if let Some(cap) = budget.max_tool_calls {
111 if let Some(f) = fraction(inputs.tool_calls_so_far as u64, cap as u64) {
112 max_fraction = max_decimal(max_fraction, f);
113 }
114 }
115 if let Some(cap) = budget.max_wall_time_ms {
116 if let Some(f) = fraction(inputs.wall_time_ms_so_far, cap) {
117 max_fraction = max_decimal(max_fraction, f);
118 }
119 }
120
121 match max_fraction {
122 Some(frac) if frac > Decimal::ZERO => {
123 let projected = cost / frac;
124 projected
125 .round_dp_with_strategy(0, rust_decimal::RoundingStrategy::AwayFromZero)
126 .to_u64()
127 .unwrap_or(u64::MAX)
128 }
129 _ => inputs.cost_so_far_cents,
132 }
133}
134
135fn update_ewma(inputs: &ForecastInputs) -> u64 {
138 let step = Decimal::from(inputs.step_cost_cents);
139 let alpha_num = Decimal::from(EWMA_ALPHA_NUM);
140 let alpha_den = Decimal::from(EWMA_ALPHA_DEN);
141
142 match inputs.prior_ewma_step_cost_cents {
143 None => inputs.step_cost_cents,
144 Some(prior) => {
145 let prior = Decimal::from(prior);
146 let weighted = alpha_num * step + (alpha_den - alpha_num) * prior;
149 (weighted / alpha_den)
150 .round_dp_with_strategy(0, rust_decimal::RoundingStrategy::AwayFromZero)
151 .to_u64()
152 .unwrap_or(prior.to_u64().unwrap_or(0))
153 }
154 }
155}
156
157fn ewma_projection(inputs: &ForecastInputs, budget: &Budget, ewma_step: u64) -> u64 {
161 let remaining_steps = match budget.max_tool_calls {
162 Some(cap) if cap > inputs.tool_calls_so_far => (cap - inputs.tool_calls_so_far) as u64,
163 _ => return linear_projection(inputs, budget),
164 };
165
166 inputs
167 .cost_so_far_cents
168 .saturating_add(ewma_step.saturating_mul(remaining_steps))
169}
170
171fn projected_breach(
175 projected_cost_cents: u64,
176 ewma_cost_cents: u64,
177 inputs: &ForecastInputs,
178 budget: &Budget,
179) -> Option<BreachKind> {
180 if let Some(cap) = budget.max_wall_time_ms {
183 if inputs.wall_time_ms_so_far >= cap {
184 return Some(BreachKind::WallTime);
185 }
186 }
187
188 if let Some(cap) = budget.max_cost_cents {
189 if projected_cost_cents > cap || ewma_cost_cents > cap {
190 return Some(BreachKind::CostCents);
191 }
192 }
193
194 if let (Some(cap), Some(rate)) = (
195 budget.max_tool_calls,
196 per_ms_rate(inputs.tool_calls_so_far as u64, inputs.wall_time_ms_so_far),
197 ) {
198 if let Some(remaining_ms) = remaining_wall_ms(inputs, budget) {
199 let projected_calls = inputs.tool_calls_so_far as u64
200 + (rate * Decimal::from(remaining_ms))
201 .round_dp_with_strategy(0, rust_decimal::RoundingStrategy::AwayFromZero)
202 .to_u64()
203 .unwrap_or(0);
204 if projected_calls > cap as u64 {
205 return Some(BreachKind::ToolCalls);
206 }
207 }
208 }
209
210 None
211}
212
213fn fraction(used: u64, cap: u64) -> Option<Decimal> {
214 if cap == 0 {
215 return None;
216 }
217 Some(Decimal::from(used) / Decimal::from(cap))
218}
219
220fn per_ms_rate(used: u64, elapsed_ms: u64) -> Option<Decimal> {
221 if elapsed_ms == 0 {
222 return None;
223 }
224 Some(Decimal::from(used) / Decimal::from(elapsed_ms))
225}
226
227fn remaining_wall_ms(inputs: &ForecastInputs, budget: &Budget) -> Option<u64> {
228 let cap = budget.max_wall_time_ms?;
229 Some(cap.saturating_sub(inputs.wall_time_ms_so_far))
230}
231
232fn max_decimal(current: Option<Decimal>, candidate: Decimal) -> Option<Decimal> {
233 match current {
234 Some(c) if c >= candidate => Some(c),
235 _ => Some(candidate),
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242
243 fn small_budget() -> Budget {
244 Budget {
245 max_input_tokens: None,
246 max_output_tokens: None,
247 max_total_tokens: None,
248 max_tool_calls: Some(10),
249 max_wall_time_ms: Some(60_000),
250 max_cost_cents: Some(500),
251 }
252 }
253
254 #[test]
259 fn linear_projects_pro_rata_against_walltime() {
260 let inputs = ForecastInputs {
262 cost_so_far_cents: 100,
263 tool_calls_so_far: 1,
264 wall_time_ms_so_far: 10_000,
265 steps_completed: 1,
266 step_cost_cents: 100,
267 prior_ewma_step_cost_cents: None,
268 };
269 let snap = compute_forecast(inputs, &small_budget());
270 assert_eq!(snap.projected_cost_cents, 500);
273 }
274
275 #[test]
276 fn linear_zero_cost_stays_zero() {
277 let inputs = ForecastInputs {
278 cost_so_far_cents: 0,
279 tool_calls_so_far: 0,
280 wall_time_ms_so_far: 0,
281 steps_completed: 0,
282 step_cost_cents: 0,
283 prior_ewma_step_cost_cents: None,
284 };
285 let snap = compute_forecast(inputs, &small_budget());
286 assert_eq!(snap.projected_cost_cents, 0);
287 assert!(!snap.budget_breach_projected);
288 }
289
290 #[test]
291 fn linear_no_budget_returns_cost_so_far() {
292 let inputs = ForecastInputs {
293 cost_so_far_cents: 250,
294 tool_calls_so_far: 3,
295 wall_time_ms_so_far: 5_000,
296 steps_completed: 3,
297 step_cost_cents: 80,
298 prior_ewma_step_cost_cents: None,
299 };
300 let budget = Budget {
301 max_input_tokens: None,
302 max_output_tokens: None,
303 max_total_tokens: None,
304 max_tool_calls: None,
305 max_wall_time_ms: None,
306 max_cost_cents: None,
307 };
308 let snap = compute_forecast(inputs, &budget);
309 assert_eq!(snap.projected_cost_cents, 250);
310 assert!(!snap.budget_breach_projected);
311 }
312
313 #[test]
318 fn ewma_seeds_from_first_step() {
319 let inputs = ForecastInputs {
320 cost_so_far_cents: 50,
321 tool_calls_so_far: 1,
322 wall_time_ms_so_far: 5_000,
323 steps_completed: 1,
324 step_cost_cents: 50,
325 prior_ewma_step_cost_cents: None,
326 };
327 let snap = compute_forecast(inputs, &small_budget());
328 assert_eq!(snap.ewma_step_cost_cents, 50);
329 }
330
331 #[test]
332 fn ewma_converges_under_constant_step_cost() {
333 let mut ewma = 30u64;
335 for _ in 0..10 {
336 let inputs = ForecastInputs {
337 cost_so_far_cents: 30 * 10,
338 tool_calls_so_far: 3,
339 wall_time_ms_so_far: 10_000,
340 steps_completed: 3,
341 step_cost_cents: 30,
342 prior_ewma_step_cost_cents: Some(ewma),
343 };
344 let snap = compute_forecast(inputs, &small_budget());
345 ewma = snap.ewma_step_cost_cents;
346 }
347 assert_eq!(ewma, 30, "EWMA must converge to the constant step cost");
348 }
349
350 #[test]
351 fn ewma_dampens_single_spike() {
352 let inputs = ForecastInputs {
354 cost_so_far_cents: 50,
355 tool_calls_so_far: 5,
356 wall_time_ms_so_far: 5_000,
357 steps_completed: 5,
358 step_cost_cents: 100,
359 prior_ewma_step_cost_cents: Some(10),
360 };
361 let snap = compute_forecast(inputs, &small_budget());
362 assert_eq!(snap.ewma_step_cost_cents, 37);
364 }
365
366 #[test]
371 fn no_breach_when_well_under_budget() {
372 let inputs = ForecastInputs {
375 cost_so_far_cents: 25,
376 tool_calls_so_far: 1,
377 wall_time_ms_so_far: 30_000,
378 steps_completed: 1,
379 step_cost_cents: 25,
380 prior_ewma_step_cost_cents: None,
381 };
382 let snap = compute_forecast(inputs, &small_budget());
383 assert!(!snap.budget_breach_projected);
384 assert!(snap.breach_kind.is_none());
385 }
386
387 #[test]
388 fn breach_flagged_when_projection_exceeds_cost_cap() {
389 let inputs = ForecastInputs {
392 cost_so_far_cents: 350,
393 tool_calls_so_far: 6,
394 wall_time_ms_so_far: 10_000,
395 steps_completed: 6,
396 step_cost_cents: 50,
397 prior_ewma_step_cost_cents: Some(50),
398 };
399 let snap = compute_forecast(inputs, &small_budget());
400 assert!(snap.budget_breach_projected);
401 assert_eq!(snap.breach_kind, Some(BreachKind::CostCents));
402 }
403
404 #[test]
405 fn breach_flagged_when_walltime_already_exceeded() {
406 let inputs = ForecastInputs {
407 cost_so_far_cents: 100,
408 tool_calls_so_far: 1,
409 wall_time_ms_so_far: 65_000,
410 steps_completed: 1,
411 step_cost_cents: 100,
412 prior_ewma_step_cost_cents: None,
413 };
414 let snap = compute_forecast(inputs, &small_budget());
415 assert!(snap.budget_breach_projected);
416 assert_eq!(snap.breach_kind, Some(BreachKind::WallTime));
417 }
418
419 #[test]
420 fn breach_flagged_for_runaway_tool_calls() {
421 let inputs = ForecastInputs {
423 cost_so_far_cents: 50,
424 tool_calls_so_far: 4,
425 wall_time_ms_so_far: 10_000,
426 steps_completed: 4,
427 step_cost_cents: 10,
428 prior_ewma_step_cost_cents: Some(10),
429 };
430 let snap = compute_forecast(inputs, &small_budget());
431 assert!(snap.budget_breach_projected);
432 assert_eq!(snap.breach_kind, Some(BreachKind::ToolCalls));
435 }
436
437 #[test]
442 fn snapshot_is_serde_round_trip() {
443 let snap = ForecastSnapshot {
444 projected_cost_cents: 750,
445 ewma_cost_cents: 720,
446 ewma_step_cost_cents: 60,
447 budget_breach_projected: true,
448 breach_kind: Some(BreachKind::CostCents),
449 };
450 let json = serde_json::to_string(&snap).unwrap();
451 let back: ForecastSnapshot = serde_json::from_str(&json).unwrap();
452 assert_eq!(snap, back);
453 }
454}