Skip to main content

fd_policy/
forecast.rs

1//! Predictive run-budget forecasting.
2//!
3//! After each step completes, [`compute_forecast`] projects what the run's
4//! total cost (and tool-call / wall-time consumption) will be at completion,
5//! and flags `budget_breach_projected = true` if any axis is on track to
6//! exceed the cap before the run terminates.
7//!
8//! Two projections are produced:
9//!
10//! * **Linear** — extrapolates from current cost/rate against the binding
11//!   axis (wall-time if capped, else tool-calls, else current cost).
12//! * **EWMA** — exponentially-weighted-moving-average of per-step cost, used
13//!   to dampen single-step spikes. The EWMA state is carried across steps
14//!   via [`ForecastInputs::prior_ewma_step_cost_cents`].
15//!
16//! All math is deterministic via [`rust_decimal::Decimal`] — no ML
17//! dependency at this layer.
18
19use rust_decimal::prelude::*;
20use rust_decimal::Decimal;
21use serde::{Deserialize, Serialize};
22
23use crate::budget::Budget;
24
25/// EWMA smoothing factor (α) applied to the most recent step's cost.
26///
27/// Numerator/denominator form keeps the math deterministic in decimal.
28const EWMA_ALPHA_NUM: u64 = 3;
29const EWMA_ALPHA_DEN: u64 = 10;
30
31/// Inputs gathered just after a step completes.
32#[derive(Debug, Clone)]
33pub struct ForecastInputs {
34    /// Cumulative cost spent so far on the run (in cents).
35    pub cost_so_far_cents: u64,
36    /// Number of tool calls consumed so far.
37    pub tool_calls_so_far: u32,
38    /// Wall-time elapsed since the run started (in milliseconds).
39    pub wall_time_ms_so_far: u64,
40    /// Number of completed steps (used as the divisor for per-step EWMA).
41    pub steps_completed: u32,
42    /// Cost in cents incurred by the step that just completed.
43    pub step_cost_cents: u64,
44    /// Carry-over of the per-step EWMA in cents. `None` when this is the
45    /// first step (the EWMA is then seeded from `step_cost_cents`).
46    pub prior_ewma_step_cost_cents: Option<u64>,
47}
48
49/// Axis that is projected to exceed its configured cap first.
50#[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/// Output of [`compute_forecast`].
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct ForecastSnapshot {
61    /// Linear projection of end-of-run cost in cents.
62    pub projected_cost_cents: u64,
63    /// EWMA-smoothed projection of end-of-run cost in cents.
64    pub ewma_cost_cents: u64,
65    /// Updated EWMA per-step cost (carry forward for the next step).
66    pub ewma_step_cost_cents: u64,
67    /// `true` when any axis is projected to exceed its cap before the run
68    /// can terminate.
69    pub budget_breach_projected: bool,
70    /// Which axis triggered the breach projection, if any.
71    pub breach_kind: Option<BreachKind>,
72}
73
74/// Compute a forecast snapshot for the run.
75pub 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
91/// Linear projection: extrapolate end-of-run cost from the rate against the
92/// binding axis. Picks the most-aggressively-burning ratio across cost,
93/// tool-calls, and wall-time so the projection is conservative.
94fn 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    // Compute the largest "fraction of axis consumed" across the configured caps.
101    // The most-burned axis is the binding constraint; project total cost as
102    // `cost_so_far / fraction_consumed`.
103    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        // No binding axis configured (or no consumption yet) — best estimate is
130        // current cost.
131        _ => inputs.cost_so_far_cents,
132    }
133}
134
135/// Update the per-step EWMA carry value. Seeds from `step_cost_cents` on the
136/// first step (when no prior is available).
137fn 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            // ewma = α * step + (1 - α) * prior
147            //      = (α_num * step + (α_den - α_num) * prior) / α_den
148            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
157/// Project remaining cost using EWMA-smoothed per-step cost over the most
158/// generous estimate of remaining steps. Falls back to the linear projection
159/// when no step-based cap is configured.
160fn 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
171/// Decide whether any axis is projected to exceed its cap before the run can
172/// terminate. Cost uses the linear projection (conservative). Tool-calls and
173/// wall-time use a steady-rate extrapolation.
174fn projected_breach(
175    projected_cost_cents: u64,
176    ewma_cost_cents: u64,
177    inputs: &ForecastInputs,
178    budget: &Budget,
179) -> Option<BreachKind> {
180    // Hard facts come before projections: a run that has already burned past
181    // its wall-time cap should report WallTime, not a derived cost breach.
182    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    // -------------------------------------------------------------------------
255    // Linear projection
256    // -------------------------------------------------------------------------
257
258    #[test]
259    fn linear_projects_pro_rata_against_walltime() {
260        // Spent $1.00 in 10s of a 60s budget — projected ~$6.00
261        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        // Tool-call axis is 1/10 burned = 10%; wall-time is 10/60 = 16.7%; cost
271        // is 100/500 = 20%. Cost is the binding axis, so projection = 500.
272        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    // -------------------------------------------------------------------------
314    // EWMA
315    // -------------------------------------------------------------------------
316
317    #[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        // Feed the same step cost repeatedly — the EWMA should stay at that value.
334        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        // Prior steady state 10c/step, then a single 100c spike.
353        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        // ewma = 0.3 * 100 + 0.7 * 10 = 37
363        assert_eq!(snap.ewma_step_cost_cents, 37);
364    }
365
366    // -------------------------------------------------------------------------
367    // Breach projection
368    // -------------------------------------------------------------------------
369
370    #[test]
371    fn no_breach_when_well_under_budget() {
372        // 25c spent on 1 call halfway through wall-time → projection stays
373        // well inside all caps.
374        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        // 60% burned in 10s of a 60s budget on tool-calls — projection blows
390        // through the cost cap.
391        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        // 4 calls in 10s of a 60s budget = 24-call extrapolated total vs cap 10
422        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        // Cost projection blows the cap first (50 * 6 = 300 — actually still
433        // under 500, so tool-calls should be the binding breach).
434        assert_eq!(snap.breach_kind, Some(BreachKind::ToolCalls));
435    }
436
437    // -------------------------------------------------------------------------
438    // Stability properties
439    // -------------------------------------------------------------------------
440
441    #[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}