Skip to content

Commit 92f54e1

Browse files
committed
feat(retrieval): add adaptive budget controller for pipeline token management
- Introduce RetrievalBudgetController to track token consumption across all pipeline stages (Plan, Search, Evaluate) - Add BudgetStatus enum with Healthy, Constrained, and Exhausted states - Implement budget-aware strategy selection in Plan stage - Apply dynamic beam width adjustment based on budget status in Search stage - Enable graceful degradation when budget is exhausted - Track and report budget utilization throughout the pipeline - Add comprehensive tests for budget controller functionality
1 parent 07142b9 commit 92f54e1

7 files changed

Lines changed: 442 additions & 17 deletions

File tree

rust/src/retrieval/mod.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,9 @@ pub use types::StrategyPreference as Strategy;
7878

7979
// Pipeline exports
8080
pub use pipeline::{
81-
CandidateNode, ExecutionGroup, FailurePolicy, PipelineContext, RetrievalMetrics,
82-
RetrievalOrchestrator, RetrievalStage, SearchAlgorithm, SearchConfig, StageOutcome,
81+
CandidateNode, ExecutionGroup, FailurePolicy, PipelineContext, RetrievalBudgetController,
82+
RetrievalMetrics, RetrievalOrchestrator, RetrievalStage, SearchAlgorithm, SearchConfig,
83+
StageOutcome, BudgetStatus,
8384
};
8485

8586
// Re-export PipelineContext as RetrievalContext for stages (alias for clarity)
Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
// Copyright (c) 2026 vectorless developers
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! Adaptive token budget controller for the retrieval pipeline.
5+
//!
6+
//! Unlike the Pilot-level [`BudgetController`](crate::retrieval::pilot::BudgetController)
7+
//! which only tracks Pilot LLM calls, this controller tracks the **entire pipeline's**
8+
//! token consumption across all stages and provides dynamic budget allocation decisions.
9+
//!
10+
//! # Design
11+
//!
12+
//! ```text
13+
//! ┌──────────────────────────────────────────────────┐
14+
//! │ RetrievalBudgetController │
15+
//! │ │
16+
//! │ total_budget ────────────────────────┬────────── │
17+
//! │ consumed (from all stages) │ remaining │
18+
//! │ │ │
19+
//! │ Plan stage: initial allocation │ │
20+
//! │ Search stage: check before iteration │ │
21+
//! │ Evaluate stage: report & decide │ │
22+
//! │ Graceful degradation when low │ │
23+
//! └──────────────────────────────────────────────────┘
24+
//! ```
25+
26+
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
27+
use std::sync::Arc;
28+
29+
/// Status of the budget for stage-level decision making.
30+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31+
pub enum BudgetStatus {
32+
/// Plenty of budget remaining, proceed normally.
33+
Healthy,
34+
/// Budget is getting low, consider cheaper strategies.
35+
Constrained,
36+
/// Budget is exhausted, stop LLM calls and return best results.
37+
Exhausted,
38+
}
39+
40+
impl BudgetStatus {
41+
/// Whether LLM calls should still be made.
42+
pub fn allow_llm(self) -> bool {
43+
matches!(self, Self::Healthy | Self::Constrained)
44+
}
45+
46+
/// Whether the pipeline should stop iterating and return current results.
47+
pub fn should_stop(self) -> bool {
48+
self == Self::Exhausted
49+
}
50+
}
51+
52+
/// Adaptive budget controller for the retrieval pipeline.
53+
///
54+
/// Tracks token consumption across all stages (Plan, Search, Evaluate)
55+
/// and provides budget-aware decisions for dynamic strategy adjustment.
56+
///
57+
/// # Example
58+
///
59+
/// ```rust,ignore
60+
/// let budget = RetrievalBudgetController::new(4000);
61+
///
62+
/// // In Search stage: check before starting an iteration
63+
/// if budget.status().should_stop() {
64+
/// return StageOutcome::complete(); // graceful degradation
65+
/// }
66+
///
67+
/// // After LLM call: record consumption
68+
/// budget.record_tokens(350);
69+
///
70+
/// // In Evaluate: decide based on remaining budget
71+
/// if budget.status() == BudgetStatus::Constrained {
72+
/// // Use cheaper sufficiency check
73+
/// }
74+
/// ```
75+
pub struct RetrievalBudgetController {
76+
/// Total token budget for this retrieval operation.
77+
total_budget: usize,
78+
/// Tokens consumed so far (atomic for thread safety).
79+
consumed: AtomicUsize,
80+
/// Whether budget exhaustion has been signaled to the pipeline.
81+
exhaustion_signaled: AtomicBool,
82+
/// Threshold ratio for "constrained" status (e.g. 0.7 = warn at 70% used).
83+
constrain_threshold: f32,
84+
}
85+
86+
// Manual Clone because AtomicUsize/AtomicBool don't impl Clone.
87+
impl Clone for RetrievalBudgetController {
88+
fn clone(&self) -> Self {
89+
Self {
90+
total_budget: self.total_budget,
91+
consumed: AtomicUsize::new(self.consumed.load(Ordering::Relaxed)),
92+
exhaustion_signaled: AtomicBool::new(
93+
self.exhaustion_signaled.load(Ordering::Relaxed),
94+
),
95+
constrain_threshold: self.constrain_threshold,
96+
}
97+
}
98+
}
99+
100+
impl RetrievalBudgetController {
101+
/// Create a new budget controller with the given total token budget.
102+
pub fn new(total_budget: usize) -> Self {
103+
Self {
104+
total_budget,
105+
consumed: AtomicUsize::new(0),
106+
exhaustion_signaled: AtomicBool::new(false),
107+
constrain_threshold: 0.7,
108+
}
109+
}
110+
111+
/// Create with a custom constrain threshold (0.0 - 1.0).
112+
///
113+
/// When consumption exceeds `total_budget * threshold`, status becomes Constrained.
114+
pub fn with_constrain_threshold(mut self, threshold: f32) -> Self {
115+
self.constrain_threshold = threshold.clamp(0.0, 1.0);
116+
self
117+
}
118+
119+
/// Get the current budget status.
120+
pub fn status(&self) -> BudgetStatus {
121+
if self.exhaustion_signaled.load(Ordering::Relaxed) {
122+
return BudgetStatus::Exhausted;
123+
}
124+
125+
let consumed = self.consumed.load(Ordering::Relaxed);
126+
if consumed >= self.total_budget {
127+
self.exhaustion_signaled.store(true, Ordering::Relaxed);
128+
return BudgetStatus::Exhausted;
129+
}
130+
131+
let utilization = consumed as f32 / self.total_budget as f32;
132+
if utilization >= self.constrain_threshold {
133+
BudgetStatus::Constrained
134+
} else {
135+
BudgetStatus::Healthy
136+
}
137+
}
138+
139+
/// Record tokens consumed by any stage.
140+
pub fn record_tokens(&self, tokens: usize) {
141+
self.consumed.fetch_add(tokens, Ordering::Relaxed);
142+
}
143+
144+
/// Get total tokens consumed so far.
145+
pub fn consumed(&self) -> usize {
146+
self.consumed.load(Ordering::Relaxed)
147+
}
148+
149+
/// Get remaining token budget.
150+
pub fn remaining(&self) -> usize {
151+
self.total_budget.saturating_sub(self.consumed.load(Ordering::Relaxed))
152+
}
153+
154+
/// Get total budget.
155+
pub fn total_budget(&self) -> usize {
156+
self.total_budget
157+
}
158+
159+
/// Get utilization ratio (0.0 - 1.0).
160+
pub fn utilization(&self) -> f32 {
161+
if self.total_budget == 0 {
162+
0.0
163+
} else {
164+
(self.consumed.load(Ordering::Relaxed) as f32 / self.total_budget as f32).min(1.0)
165+
}
166+
}
167+
168+
/// Signal that budget is exhausted (e.g. external trigger).
169+
pub fn signal_exhausted(&self) {
170+
self.exhaustion_signaled.store(true, Ordering::Relaxed);
171+
}
172+
173+
/// Whether budget exhaustion has been signaled.
174+
pub fn is_exhausted(&self) -> bool {
175+
self.exhaustion_signaled.load(Ordering::Relaxed)
176+
|| self.consumed.load(Ordering::Relaxed) >= self.total_budget
177+
}
178+
179+
/// Reset for a new query.
180+
pub fn reset(&self) {
181+
self.consumed.store(0, Ordering::Relaxed);
182+
self.exhaustion_signaled.store(false, Ordering::Relaxed);
183+
}
184+
185+
/// Suggest a search strategy based on budget status and query complexity.
186+
///
187+
/// Returns the recommended beam width for the next search iteration.
188+
pub fn suggested_beam_width(&self, current_beam: usize, iteration: usize) -> usize {
189+
match self.status() {
190+
BudgetStatus::Healthy => {
191+
// Full power, maybe even increase beam for complex queries
192+
current_beam
193+
}
194+
BudgetStatus::Constrained => {
195+
// Reduce beam to save tokens
196+
let reduced = if iteration <= 1 { current_beam } else { (current_beam / 2).max(1) };
197+
reduced
198+
}
199+
BudgetStatus::Exhausted => {
200+
// No more search iterations worth doing
201+
0
202+
}
203+
}
204+
}
205+
206+
/// Whether another search iteration is worthwhile given budget and confidence.
207+
pub fn should_continue_search(&self, current_confidence: f32, iteration: usize) -> bool {
208+
if self.is_exhausted() {
209+
return false;
210+
}
211+
// Don't continue if confidence is already good
212+
if current_confidence > 0.8 && iteration >= 1 {
213+
return false;
214+
}
215+
// Don't continue if budget is constrained and we have some results
216+
if self.status() == BudgetStatus::Constrained && current_confidence > 0.4 {
217+
return false;
218+
}
219+
true
220+
}
221+
}
222+
223+
#[cfg(test)]
224+
mod tests {
225+
use super::*;
226+
227+
#[test]
228+
fn test_budget_healthy() {
229+
let budget = RetrievalBudgetController::new(1000);
230+
assert_eq!(budget.status(), BudgetStatus::Healthy);
231+
assert!(!budget.is_exhausted());
232+
assert_eq!(budget.remaining(), 1000);
233+
}
234+
235+
#[test]
236+
fn test_budget_constrained() {
237+
let budget = RetrievalBudgetController::new(1000);
238+
budget.record_tokens(750); // 75% used, above 70% threshold
239+
assert_eq!(budget.status(), BudgetStatus::Constrained);
240+
assert!(budget.status().allow_llm());
241+
}
242+
243+
#[test]
244+
fn test_budget_exhausted() {
245+
let budget = RetrievalBudgetController::new(1000);
246+
budget.record_tokens(1000);
247+
assert_eq!(budget.status(), BudgetStatus::Exhausted);
248+
assert!(budget.status().should_stop());
249+
assert!(!budget.status().allow_llm());
250+
}
251+
252+
#[test]
253+
fn test_budget_exhausted_over() {
254+
let budget = RetrievalBudgetController::new(1000);
255+
budget.record_tokens(1500);
256+
assert_eq!(budget.status(), BudgetStatus::Exhausted);
257+
}
258+
259+
#[test]
260+
fn test_budget_signal_exhausted() {
261+
let budget = RetrievalBudgetController::new(1000);
262+
budget.signal_exhausted();
263+
assert_eq!(budget.status(), BudgetStatus::Exhausted);
264+
assert_eq!(budget.consumed(), 0); // No tokens actually consumed
265+
}
266+
267+
#[test]
268+
fn test_budget_reset() {
269+
let budget = RetrievalBudgetController::new(1000);
270+
budget.record_tokens(800);
271+
assert_eq!(budget.status(), BudgetStatus::Constrained);
272+
budget.reset();
273+
assert_eq!(budget.status(), BudgetStatus::Healthy);
274+
assert_eq!(budget.consumed(), 0);
275+
}
276+
277+
#[test]
278+
fn test_suggested_beam_width() {
279+
let budget = RetrievalBudgetController::new(1000);
280+
// Healthy: keep current beam
281+
assert_eq!(budget.suggested_beam_width(4, 0), 4);
282+
283+
// Constrained: first iteration keeps beam, later reduces
284+
budget.record_tokens(750);
285+
assert_eq!(budget.suggested_beam_width(4, 0), 4);
286+
assert_eq!(budget.suggested_beam_width(4, 2), 2);
287+
288+
// Exhausted: zero
289+
budget.record_tokens(300);
290+
assert_eq!(budget.suggested_beam_width(4, 0), 0);
291+
}
292+
293+
#[test]
294+
fn test_should_continue_search() {
295+
let budget = RetrievalBudgetController::new(1000);
296+
297+
// Fresh, low confidence: continue
298+
assert!(budget.should_continue_search(0.2, 0));
299+
300+
// High confidence after 1 iteration: stop
301+
assert!(!budget.should_continue_search(0.9, 1));
302+
303+
// Medium confidence, healthy budget: continue
304+
assert!(budget.should_continue_search(0.5, 1));
305+
306+
// Constrained, decent confidence: stop
307+
budget.record_tokens(750);
308+
assert!(!budget.should_continue_search(0.5, 2));
309+
310+
// Constrained, low confidence: continue
311+
assert!(budget.should_continue_search(0.2, 2));
312+
}
313+
314+
#[test]
315+
fn test_utilization() {
316+
let budget = RetrievalBudgetController::new(1000);
317+
assert!((budget.utilization() - 0.0).abs() < 0.01);
318+
319+
budget.record_tokens(500);
320+
assert!((budget.utilization() - 0.5).abs() < 0.01);
321+
}
322+
323+
#[test]
324+
fn test_custom_constrain_threshold() {
325+
let budget = RetrievalBudgetController::new(1000).with_constrain_threshold(0.5);
326+
budget.record_tokens(500);
327+
assert_eq!(budget.status(), BudgetStatus::Constrained);
328+
}
329+
}

rust/src/retrieval/pipeline/context.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use std::time::Instant;
1212

1313
use crate::document::{DocumentTree, NodeId, RetrievalIndex};
1414
use crate::retrieval::pilot::Pilot;
15+
use crate::retrieval::pipeline::budget::RetrievalBudgetController;
1516
use crate::retrieval::types::{
1617
NavigationDecision, QueryComplexity, ReasoningChain, ReasoningStep, RetrieveOptions,
1718
RetrieveResponse, SearchPath, StageName, StrategyPreference, SufficiencyLevel,
@@ -201,6 +202,8 @@ pub struct PipelineContext {
201202
pub options: RetrieveOptions,
202203
/// Optional Pilot for navigation guidance.
203204
pub pilot: Option<Arc<dyn Pilot>>,
205+
/// Adaptive token budget controller for the entire pipeline.
206+
pub budget_controller: RetrievalBudgetController,
204207

205208
// ============ Analyze Stage Output ============
206209
/// Detected query complexity.
@@ -260,13 +263,15 @@ impl PipelineContext {
260263
) -> Self {
261264
// Build retrieval index for efficient operations
262265
let retrieval_index = Some(tree.build_retrieval_index());
266+
let budget_controller = RetrievalBudgetController::new(options.max_tokens);
263267

264268
Self {
265269
query: query.into(),
266270
tree,
267271
retrieval_index,
268272
options,
269273
pilot: None,
274+
budget_controller,
270275
complexity: None,
271276
keywords: Vec::new(),
272277
target_sections: Vec::new(),

rust/src/retrieval/pipeline/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,13 @@
4444
//! let response = orchestrator.execute(tree, query, options).await?;
4545
//! ```
4646
47+
mod budget;
4748
mod context;
4849
mod orchestrator;
4950
mod outcome;
5051
mod stage;
5152

53+
pub use budget::{BudgetStatus, RetrievalBudgetController};
5254
pub use context::{
5355
CandidateNode, PipelineContext, RetrievalMetrics, SearchAlgorithm, SearchConfig, StageResult,
5456
};

0 commit comments

Comments
 (0)