返回 CodeWhale
context_budget.rs
根目录 / crates / tui / src / context_budget.rs
1 //! Unified context-budget math for the TUI.
2 //!
3 //! Given a model's context window, the current input token estimate, and a
4 //! configured output cap, [`ContextBudget`] derives the four numbers the rest
5 //! of the app needs to reason about a turn:
6 //!
7 //! * **available input budget** — how many input tokens may still be spent
8 //! after reserving room for the model's output;
9 //! * **output token cap** — the output reservation actually used to compute
10 //! that budget (clamped so it never starves the window);
11 //! * **compaction trigger** — the input-token level at which compaction
12 //! should be suggested (default: ~75% of the full window, clamped to the
13 //! spendable input ceiling);
14 //! * **[`PressureLevel`]** — a coarse Low/Medium/High/Critical signal the UI
15 //! can render without re-deriving thresholds.
16 //!
17 //! This module is the budget-math *foundation*. It is intentionally pure (no
18 //! I/O, no clock, no engine/config types) so it can be unit-tested in isolation.
19 //! Its consumers live outside it and call in, never the reverse: `route_budget`
20 //! and `core::engine::context` for [`ContextBudget`], `context_report` for
21 //! [`PressureLevel`].
22 //!
23 //! ### Route ceilings stay independent
24 //!
25 //! A route can publish a total context window, an output ceiling, and a
26 //! separate input ceiling. [`ContextBudget::new_with_input_limit`] intersects
27 //! all three without treating one as a substitute for another: the output
28 //! reservation is the exact route-effective wire request, the total-window
29 //! arithmetic remains saturating, and a concrete input limit clamps the final
30 //! spendable ceiling and compaction trigger.
31
32 // This module IS wired. `ContextBudget` is consumed by `route_budget.rs` and
33 // `core/engine/context.rs`; `PressureLevel` by `context_report.rs`. It sits on
34 // the do-not-delete list in AGENTS.md because a blanket `allow(dead_code)` here,
35 // plus a comment that used to claim the module was "not yet referenced," taught
36 // several dead-code audits to propose deleting a live file. The suppression is
37 // now `#[cfg_attr(not(test), expect(dead_code))]` on the three methods unused
38 // outside tests, so a suppression that stops matching the lint fails the
39 // build instead of hiding behind a module-wide waiver. Tests call them, so
40 // a bare `#[expect(dead_code)]` would be unfulfilled under `cfg(test)`.
41
42 /// Fraction of the window, expressed as a percentage, at or above which
43 /// compaction should be suggested. Mirrors the "high" pressure boundary the
44 /// existing context report uses for its diagnostic label, rounded up to the
45 /// conventional three-quarters-full trigger.
46 pub const DEFAULT_COMPACTION_TRIGGER_PERCENT: f64 = 75.0;
47
48 /// Percentage of the window at or above which pressure is [`PressureLevel::Critical`].
49 pub const CRITICAL_PRESSURE_PERCENT: f64 = 90.0;
50
51 /// Percentage of the window at or above which pressure is [`PressureLevel::High`].
52 /// Pressure and the requested compaction trigger are window-relative. The
53 /// trigger is clamped to the spendable input ceiling, so it can fire before
54 /// this UI boundary when output reservation consumes substantial window space.
55 pub const HIGH_PRESSURE_PERCENT: f64 = DEFAULT_COMPACTION_TRIGGER_PERCENT;
56
57 /// Percentage of the window at or above which pressure is [`PressureLevel::Medium`].
58 /// Matches the "moderate" boundary of the existing diagnostic report.
59 pub const MEDIUM_PRESSURE_PERCENT: f64 = 40.0;
60
61 /// Safety headroom (tokens) subtracted from the window in addition to the
62 /// reserved output, to avoid bumping a provider's hard limit. Matches the
63 /// engine's `CONTEXT_HEADROOM_TOKENS`.
64 pub const CONTEXT_HEADROOM_TOKENS: u64 = 1_024;
65
66 /// Smallest input budget (tokens) [`ContextBudget`] will report for any window
67 /// large enough to hold it. The output cap is clamped down as needed to
68 /// preserve this much input room, so a generous configured output cap can never
69 /// drive the available input budget to zero on a usable window.
70 pub const MIN_INPUT_BUDGET_TOKENS: u64 = 1_024;
71
72 /// Coarse, UI-facing description of how full the context window is.
73 ///
74 /// Ordered from least to most pressure so the variants can be compared
75 /// (`level >= PressureLevel::High`) and so the derived `Ord` matches intuition.
76 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
77 pub enum PressureLevel {
78 /// Plenty of room; nothing to surface.
79 Low,
80 /// Noticeably filling up; informational.
81 Medium,
82 /// At or past the compaction trigger; suggest compaction.
83 High,
84 /// Near the window limit; compaction/clear is urgent.
85 Critical,
86 }
87
88 impl PressureLevel {
89 /// Classify a window-usage percentage (0.0..=100.0) into a level.
90 ///
91 /// Inputs outside the range are clamped, so callers may pass a raw
92 /// percentage without pre-validating it.
93 #[must_use]
94 pub fn from_usage_percent(percent: f64) -> Self {
95 let percent = percent.clamp(0.0, 100.0);
96 if percent >= CRITICAL_PRESSURE_PERCENT {
97 PressureLevel::Critical
98 } else if percent >= HIGH_PRESSURE_PERCENT {
99 PressureLevel::High
100 } else if percent >= MEDIUM_PRESSURE_PERCENT {
101 PressureLevel::Medium
102 } else {
103 PressureLevel::Low
104 }
105 }
106
107 /// Lowercase, stable label suitable for status lines and logs.
108 ///
109 /// Kept aligned with the existing context-report vocabulary
110 /// (`low`/`moderate`/`high`/`critical`).
111 #[must_use]
112 pub const fn label(self) -> &'static str {
113 match self {
114 PressureLevel::Low => "low",
115 PressureLevel::Medium => "moderate",
116 PressureLevel::High => "high",
117 PressureLevel::Critical => "critical",
118 }
119 }
120
121 /// Whether this level is at or past the point where compaction should be
122 /// suggested to the user.
123 ///
124 /// Unused by the engine today; kept as the pressure-level counterpart of
125 /// `ContextBudget::should_compact` so both live next to their thresholds.
126 #[must_use]
127 #[cfg_attr(not(test), expect(dead_code))]
128 pub const fn suggests_compaction(self) -> bool {
129 matches!(self, PressureLevel::High | PressureLevel::Critical)
130 }
131 }
132
133 /// A computed snapshot of how a turn's input sits against a model's context
134 /// window, plus the derived output cap, compaction trigger, and pressure level.
135 ///
136 /// Construct via [`ContextBudget::new`]. All fields are token counts unless the
137 /// name says otherwise. The struct is `Copy` and holds no borrowed data so it
138 /// can be cached on UI state cheaply.
139 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
140 pub struct ContextBudget {
141 /// Total context window for the active route (input + output), in tokens.
142 pub window_tokens: u64,
143 /// Current estimated input tokens already committed to the turn.
144 pub input_tokens: u64,
145 /// Output tokens reserved (and thus the effective output cap) for the turn.
146 /// Derived from the configured cap, clamped to fit the window while leaving
147 /// at least [`MIN_INPUT_BUDGET_TOKENS`] of input room.
148 pub output_cap_tokens: u64,
149 /// Spendable input ceiling for the turn (`window - output_cap - headroom`,
150 /// saturating at 0). Compaction percentages use the full window and clamp
151 /// their resulting token threshold to this ceiling.
152 pub input_budget_ceiling: u64,
153 /// Input tokens still available before hitting the reserved boundary
154 /// (`input_budget_ceiling - input`, saturating at 0).
155 pub available_input_tokens: u64,
156 /// Input-token level at or above which compaction should be suggested
157 /// (`DEFAULT_COMPACTION_TRIGGER_PERCENT` of the window, clamped to the
158 /// input budget ceiling).
159 pub compaction_trigger_tokens: u64,
160 /// Coarse pressure level derived from window usage.
161 pub pressure: PressureLevel,
162 }
163
164 impl ContextBudget {
165 /// Build a budget snapshot for a route.
166 ///
167 /// * `window_tokens` — the route-effective context window (input + output).
168 /// * `input_tokens` — current estimated input tokens for the turn.
169 /// * `configured_output_cap` — the route-effective output request the
170 /// caller needs to reserve. It is clamped down so it never consumes the
171 /// headroom or the minimum input budget; on a window too small to hold
172 /// even the minimum input budget plus headroom, the cap collapses to
173 /// whatever is left (possibly zero).
174 ///
175 /// Never panics and never underflows: all arithmetic saturates.
176 #[must_use]
177 pub fn new(window_tokens: u64, input_tokens: u64, configured_output_cap: u64) -> Self {
178 Self::new_with_input_limit(window_tokens, input_tokens, configured_output_cap, None)
179 }
180
181 /// Build a budget snapshot and intersect it with a provider's independent
182 /// hard input ceiling when one is published by the resolved route.
183 #[must_use]
184 pub fn new_with_input_limit(
185 window_tokens: u64,
186 input_tokens: u64,
187 configured_output_cap: u64,
188 input_limit_tokens: Option<u64>,
189 ) -> Self {
190 let output_cap_tokens = clamp_output_cap(window_tokens, configured_output_cap);
191
192 // Reserve output + safety headroom; whatever remains is spendable input.
193 let reserved = output_cap_tokens.saturating_add(CONTEXT_HEADROOM_TOKENS);
194 let window_input_ceiling = window_tokens.saturating_sub(reserved);
195 let input_budget_ceiling = input_limit_tokens
196 .filter(|limit| *limit > 0)
197 .map_or(window_input_ceiling, |limit| {
198 window_input_ceiling.min(limit)
199 });
200 let available_input_tokens = input_budget_ceiling.saturating_sub(input_tokens);
201
202 let compaction_trigger_tokens =
203 percent_of(window_tokens, DEFAULT_COMPACTION_TRIGGER_PERCENT).min(input_budget_ceiling);
204
205 let pressure =
206 PressureLevel::from_usage_percent(usage_percent(window_tokens, input_tokens));
207
208 ContextBudget {
209 window_tokens,
210 input_tokens,
211 output_cap_tokens,
212 input_budget_ceiling,
213 available_input_tokens,
214 compaction_trigger_tokens,
215 pressure,
216 }
217 }
218
219 /// Derive a compaction trigger from a window percentage.
220 ///
221 /// The percentage means what the user-facing context meter says it means:
222 /// a fraction of the full route window (`80` on a 1M window is 800K input
223 /// tokens before the route ceiling clamp). One internal clamp applies:
224 ///
225 /// ```text
226 /// trigger = min(window × percent, window − output reservation − headroom)
227 /// ```
228 ///
229 /// so a late percentage can never push the trigger past the spendable
230 /// input ceiling and overflow the provider window. Previously the
231 /// percentage was applied to the ceiling itself, which silently pulled an
232 /// "80%" setting down to ~60% of the window on routes with large output
233 /// reservations; UI surfaces should disclose the clamp when it engages
234 /// instead of redefining what the percentage means.
235 #[must_use]
236 pub fn compaction_trigger_for_percent(&self, percent: f64) -> u64 {
237 percent_of(self.window_tokens, percent).min(self.input_budget_ceiling)
238 }
239
240 /// Fraction of the window currently consumed by input, as a percentage in
241 /// `0.0..=100.0`. A zero window reports `0.0` rather than dividing by zero.
242 #[must_use]
243 pub fn usage_percent(&self) -> f64 {
244 usage_percent(self.window_tokens, self.input_tokens)
245 }
246
247 /// Whether current input has reached the compaction trigger and compaction
248 /// should be suggested.
249 #[must_use]
250 #[cfg_attr(not(test), expect(dead_code))]
251 pub fn should_compact(&self) -> bool {
252 self.window_tokens > 0 && self.input_tokens >= self.compaction_trigger_tokens
253 }
254
255 /// Whether another `additional_input_tokens` of input would fit within the
256 /// available budget (i.e. not exceed the reserved boundary).
257 #[must_use]
258 #[cfg_attr(not(test), expect(dead_code))]
259 pub fn fits_additional(&self, additional_input_tokens: u64) -> bool {
260 additional_input_tokens <= self.available_input_tokens
261 }
262 }
263
264 /// Clamp a desired output cap so it fits the window while preserving at least
265 /// [`MIN_INPUT_BUDGET_TOKENS`] of input room plus [`CONTEXT_HEADROOM_TOKENS`].
266 ///
267 /// On a window too small to hold even that floor, returns whatever room is left
268 /// after the headroom (possibly zero) rather than underflowing.
269 fn clamp_output_cap(window_tokens: u64, configured_output_cap: u64) -> u64 {
270 // The most output we can reserve and still keep the input floor + headroom.
271 let reserved_floor = MIN_INPUT_BUDGET_TOKENS.saturating_add(CONTEXT_HEADROOM_TOKENS);
272 let max_output = window_tokens.saturating_sub(reserved_floor);
273 configured_output_cap.min(max_output)
274 }
275
276 /// Window usage as a percentage in `0.0..=100.0`. Zero window -> `0.0`.
277 fn usage_percent(window_tokens: u64, input_tokens: u64) -> f64 {
278 if window_tokens == 0 {
279 return 0.0;
280 }
281 ((input_tokens as f64 / window_tokens as f64) * 100.0).clamp(0.0, 100.0)
282 }
283
284 /// `percent`% of `window_tokens`, rounded to the nearest token. Saturates at
285 /// `u64::MAX` and treats out-of-range percentages by clamping to `0.0..=100.0`.
286 fn percent_of(window_tokens: u64, percent: f64) -> u64 {
287 let percent = percent.clamp(0.0, 100.0);
288 let value = (window_tokens as f64) * (percent / 100.0);
289 // `as u64` saturates on overflow and floors; add 0.5 to round to nearest.
290 (value + 0.5) as u64
291 }
292
293 #[cfg(test)]
294 mod tests {
295 use super::*;
296
297 /// A representative spread of real-world windows: a tight self-hosted
298 /// deployment, common provider sizes, and a V4-class 1M window.
299 const WINDOWS: &[u64] = &[8_192, 32_768, 131_072, 262_144, 1_048_576];
300
301 // -- PressureLevel boundaries ------------------------------------------
302
303 #[test]
304 fn pressure_level_boundaries_are_inclusive_lower_bounds() {
305 assert_eq!(PressureLevel::from_usage_percent(0.0), PressureLevel::Low);
306 assert_eq!(PressureLevel::from_usage_percent(39.9), PressureLevel::Low);
307 // 40% is the moderate boundary.
308 assert_eq!(
309 PressureLevel::from_usage_percent(40.0),
310 PressureLevel::Medium
311 );
312 assert_eq!(
313 PressureLevel::from_usage_percent(74.9),
314 PressureLevel::Medium
315 );
316 // 75% is the high / compaction boundary.
317 assert_eq!(PressureLevel::from_usage_percent(75.0), PressureLevel::High);
318 assert_eq!(PressureLevel::from_usage_percent(89.9), PressureLevel::High);
319 // 90% is the critical boundary.
320 assert_eq!(
321 PressureLevel::from_usage_percent(90.0),
322 PressureLevel::Critical
323 );
324 assert_eq!(
325 PressureLevel::from_usage_percent(100.0),
326 PressureLevel::Critical
327 );
328 }
329
330 #[test]
331 fn pressure_level_clamps_out_of_range_inputs() {
332 assert_eq!(PressureLevel::from_usage_percent(-10.0), PressureLevel::Low);
333 assert_eq!(
334 PressureLevel::from_usage_percent(150.0),
335 PressureLevel::Critical
336 );
337 assert_eq!(
338 PressureLevel::from_usage_percent(f64::INFINITY),
339 PressureLevel::Critical
340 );
341 }
342
343 #[test]
344 fn pressure_level_ordering_and_helpers() {
345 assert!(PressureLevel::Low < PressureLevel::Medium);
346 assert!(PressureLevel::Medium < PressureLevel::High);
347 assert!(PressureLevel::High < PressureLevel::Critical);
348
349 assert!(!PressureLevel::Low.suggests_compaction());
350 assert!(!PressureLevel::Medium.suggests_compaction());
351 assert!(PressureLevel::High.suggests_compaction());
352 assert!(PressureLevel::Critical.suggests_compaction());
353
354 assert_eq!(PressureLevel::Low.label(), "low");
355 assert_eq!(PressureLevel::Medium.label(), "moderate");
356 assert_eq!(PressureLevel::High.label(), "high");
357 assert_eq!(PressureLevel::Critical.label(), "critical");
358 }
359
360 // -- Compaction trigger -------------------------------------------------
361
362 #[test]
363 fn compaction_trigger_is_window_percent_clamped_to_input_ceiling() {
364 for &window in WINDOWS {
365 let budget = ContextBudget::new(window, 0, 64_000);
366 let expected = percent_of(window, DEFAULT_COMPACTION_TRIGGER_PERCENT)
367 .min(budget.input_budget_ceiling);
368 assert_eq!(
369 budget.compaction_trigger_tokens, expected,
370 "window {window}: trigger should be 75% of the window, clamped to the ceiling"
371 );
372 assert!(budget.compaction_trigger_tokens <= budget.input_budget_ceiling);
373 }
374 }
375
376 #[test]
377 fn should_compact_flips_at_the_trigger() {
378 let window = 1_048_576;
379 let cap = 262_144;
380 let trigger = ContextBudget::new(window, 0, cap).compaction_trigger_tokens;
381 assert!(trigger > 0);
382
383 let below = ContextBudget::new(window, trigger - 1, cap);
384 assert!(!below.should_compact());
385
386 let at = ContextBudget::new(window, trigger, cap);
387 assert!(at.should_compact());
388
389 let above = ContextBudget::new(window, trigger + 1, cap);
390 assert!(above.should_compact());
391 }
392
393 #[test]
394 fn trigger_never_exceeds_input_ceiling() {
395 const WINDOWS: &[u64] = &[
396 0, 512, 8_192, 65_536, 131_072, 262_144, 500_000, 1_048_576, 2_000_000,
397 ];
398 const CAPS: &[u64] = &[0, 1_024, 64_000, 131_072, 262_144, 1_000_000];
399 const INPUTS: &[u64] = &[0, 1_024, 200_000, 2_000_000];
400 const PERCENTS: &[f64] = &[10.0, 50.0, 75.0, 80.0, 95.0, 100.0];
401
402 for &window in WINDOWS {
403 for &cap in CAPS {
404 for &input in INPUTS {
405 let budget = ContextBudget::new(window, input, cap);
406 assert!(budget.compaction_trigger_tokens <= budget.input_budget_ceiling);
407 for &percent in PERCENTS {
408 assert!(
409 budget.compaction_trigger_for_percent(percent)
410 <= budget.input_budget_ceiling,
411 "window={window} cap={cap} input={input} percent={percent}"
412 );
413 }
414 }
415 }
416 }
417 }
418
419 #[test]
420 fn zero_window_never_suggests_compaction() {
421 let budget = ContextBudget::new(0, 0, 64_000);
422 assert_eq!(budget.compaction_trigger_tokens, 0);
423 assert_eq!(budget.input_budget_ceiling, 0);
424 assert!(!budget.should_compact());
425 assert_eq!(budget.pressure, PressureLevel::Low);
426 assert_eq!(budget.available_input_tokens, 0);
427 assert_eq!(budget.usage_percent(), 0.0);
428 }
429
430 // -- Output cap clamping & available budget ----------------------------
431
432 #[test]
433 fn output_cap_is_preserved_when_window_is_roomy() {
434 // 1M window, 64K configured cap: cap fits comfortably.
435 let budget = ContextBudget::new(1_048_576, 0, 64_000);
436 assert_eq!(budget.output_cap_tokens, 64_000);
437 // available = window - cap - headroom - input
438 let expected = 1_048_576 - 64_000 - CONTEXT_HEADROOM_TOKENS;
439 assert_eq!(budget.available_input_tokens, expected);
440 }
441
442 #[test]
443 fn output_cap_is_clamped_to_protect_input_floor_on_small_window() {
444 // This is the engine's hard-won lesson: a generous output reservation
445 // on a small window must not underflow the input budget. An 8,192-token
446 // window with a 262,144-token desired cap must still leave the input
447 // floor available rather than collapsing to zero or wrapping.
448 let window = 8_192u64;
449 let budget = ContextBudget::new(window, 0, 262_144);
450
451 let reserved_floor = MIN_INPUT_BUDGET_TOKENS + CONTEXT_HEADROOM_TOKENS;
452 let expected_cap = window - reserved_floor;
453 assert_eq!(budget.output_cap_tokens, expected_cap);
454 // With zero input committed, the whole remaining budget is available
455 // and is at least the protected floor.
456 assert!(budget.available_input_tokens >= MIN_INPUT_BUDGET_TOKENS);
457 assert_eq!(
458 budget.available_input_tokens,
459 window - budget.output_cap_tokens - CONTEXT_HEADROOM_TOKENS
460 );
461 }
462
463 #[test]
464 fn tiny_window_below_floor_saturates_without_panic() {
465 // Window smaller than the protected floor: cap collapses to 0 and the
466 // available budget saturates at 0 instead of underflowing.
467 let window = 512u64; // < MIN_INPUT_BUDGET_TOKENS + headroom
468 let budget = ContextBudget::new(window, 100, 262_144);
469 assert_eq!(budget.output_cap_tokens, 0);
470 assert_eq!(budget.available_input_tokens, 0);
471 // Usage still computes a sane percentage.
472 assert!((budget.usage_percent() - (100.0 / 512.0 * 100.0)).abs() < 1e-9);
473 }
474
475 #[test]
476 fn available_budget_saturates_when_input_exceeds_ceiling() {
477 let window = 131_072u64;
478 let cap = 32_000u64;
479 // Commit far more input than the window holds.
480 let budget = ContextBudget::new(window, window * 2, cap);
481 assert_eq!(budget.available_input_tokens, 0);
482 assert!(!budget.fits_additional(1));
483 assert!(budget.fits_additional(0));
484 // Usage is clamped to 100%.
485 assert_eq!(budget.usage_percent(), 100.0);
486 assert_eq!(budget.pressure, PressureLevel::Critical);
487 }
488
489 #[test]
490 fn fits_additional_respects_the_reserved_boundary() {
491 let window = 262_144u64;
492 let cap = 64_000u64;
493 let budget = ContextBudget::new(window, 100_000, cap);
494 let room = budget.available_input_tokens;
495 assert!(budget.fits_additional(room));
496 assert!(!budget.fits_additional(room + 1));
497 }
498
499 // -- Usage percent & pressure across window sizes ----------------------
500
501 #[test]
502 fn usage_percent_is_proportional_across_window_sizes() {
503 for &window in WINDOWS {
504 // Half-full should read ~50% and classify as Medium regardless of
505 // absolute window size.
506 let half = window / 2;
507 let budget = ContextBudget::new(window, half, 64_000);
508 let pct = budget.usage_percent();
509 assert!(
510 (pct - 50.0).abs() < 0.5,
511 "window {window}: half-full should be ~50%, got {pct}"
512 );
513 assert_eq!(budget.pressure, PressureLevel::Medium);
514 }
515 }
516
517 #[test]
518 fn pressure_tracks_input_growth_on_a_1m_window() {
519 let window = 1_048_576u64;
520 let cap = 262_144u64;
521
522 let low = ContextBudget::new(window, percent_of(window, 10.0), cap);
523 assert_eq!(low.pressure, PressureLevel::Low);
524
525 let medium = ContextBudget::new(window, percent_of(window, 50.0), cap);
526 assert_eq!(medium.pressure, PressureLevel::Medium);
527
528 let high = ContextBudget::new(window, percent_of(window, 80.0), cap);
529 assert_eq!(high.pressure, PressureLevel::High);
530 assert!(high.should_compact());
531
532 let critical = ContextBudget::new(window, percent_of(window, 95.0), cap);
533 assert_eq!(critical.pressure, PressureLevel::Critical);
534 }
535
536 #[test]
537 fn snapshot_fields_are_internally_consistent() {
538 for &window in WINDOWS {
539 for &input in &[0u64, window / 4, window / 2, window] {
540 let budget = ContextBudget::new(window, input, 64_000);
541 // Field mirrors the constructor arguments.
542 assert_eq!(budget.window_tokens, window);
543 assert_eq!(budget.input_tokens, input);
544 // available + input never claims more than the window minus
545 // reserved output and headroom.
546 let ceiling = window
547 .saturating_sub(budget.output_cap_tokens)
548 .saturating_sub(CONTEXT_HEADROOM_TOKENS);
549 assert!(budget.available_input_tokens <= ceiling);
550 // Pressure agrees with the standalone usage percentage.
551 assert_eq!(
552 budget.pressure,
553 PressureLevel::from_usage_percent(budget.usage_percent())
554 );
555 }
556 }
557 }
558
559 #[test]
560 fn percent_of_rounds_to_nearest_token() {
561 assert_eq!(percent_of(0, 75.0), 0);
562 assert_eq!(percent_of(100, 0.0), 0);
563 assert_eq!(percent_of(100, 100.0), 100);
564 assert_eq!(percent_of(100, 75.0), 75);
565 // 3 * 0.75 = 2.25 -> rounds to 2.
566 assert_eq!(percent_of(3, 75.0), 2);
567 // 2 * 0.75 = 1.5 -> rounds to 2.
568 assert_eq!(percent_of(2, 75.0), 2);
569 }
570
571 #[test]
572 fn budget_is_copy_and_comparable() {
573 let a = ContextBudget::new(131_072, 1_000, 32_000);
574 let b = a; // Copy, not move.
575 assert_eq!(a, b);
576 assert_eq!(a.window_tokens, b.window_tokens);
577 }
578 }
579
579 lines RUST