Skip to main content

sparrow/reasoning/
inference_scaling.rs

1//! Inference-time reasoning amplification (test-time compute).
2//!
3//! This is the machinery that lets a *smaller* model approach a frontier model's
4//! single-pass quality on **verifiable** tasks: instead of trusting one greedy
5//! sample, we spend extra model calls to search and verify. Three composable
6//! primitives, all generic over the [`Brain`] trait:
7//!
8//! - [`best_of_n`] — sample N diverse drafts, then have a judge call pick the
9//!   best (best-of-N / self-consistency by selection).
10//! - [`self_refine_from`] — Reflexion loop: critique the answer with an
11//!   adversarial reviewer call, then revise, until the reviewer approves or the
12//!   round budget is spent.
13//! - [`reason_max`] — the full pipeline: best-of-N draft → judge-select →
14//!   iterative self-refine, with the compute [`ReasoningBudget`] scaled by task
15//!   tier and (optionally) by how weak the underlying model is.
16//!
17//! **Honest boundary.** Test-time compute buys the most on tasks with a
18//! verification signal (code that runs, math that checks, structured constraints
19//! a reviewer can spot). It buys less on open-ended tasks with no ground truth.
20//! The budget therefore scales with tier, not blindly.
21
22use futures::StreamExt;
23
24use crate::provider::{Brain, BrainEvent, BrainRequest, ContentBlock, Msg, PromptCacheConfig};
25use crate::router::TaskTier;
26
27const CRITIC_SYSTEM: &str = "You are a ruthless senior reviewer. Find the single most important flaw in the answer below: a wrong claim, a missing case, an unproven assertion, a violated constraint, or an unclear step. Be concrete and specific. If — and only if — the answer is genuinely correct, complete, and well-justified, reply with exactly the word APPROVED and nothing else.";
28
29const JUDGE_SYSTEM: &str = "You are an impartial judge. You will see a task and several candidate answers. Pick the single best one: most correct, complete, and well-justified. Reply with ONLY its index number, nothing else.";
30
31/// Compute budget for a reasoning-max run. Higher = more model calls = higher
32/// reliability at higher cost/latency.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct ReasoningBudget {
35    /// Number of independent drafts to sample before selecting (best-of-N).
36    pub samples: usize,
37    /// Number of critique→revise rounds applied to the selected draft.
38    pub refine_rounds: usize,
39}
40
41impl ReasoningBudget {
42    /// Default budget for a task tier. Cheap tiers get a single greedy pass;
43    /// hard tiers get search + iterative refinement.
44    pub fn for_tier(tier: &TaskTier) -> Self {
45        match tier {
46            TaskTier::Trivial | TaskTier::Small => Self {
47                samples: 1,
48                refine_rounds: 0,
49            },
50            TaskTier::Medium | TaskTier::Vision => Self {
51                samples: 1,
52                refine_rounds: 1,
53            },
54            TaskTier::Hard => Self {
55                samples: 3,
56                refine_rounds: 2,
57            },
58        }
59    }
60
61    /// Weaker models benefit MORE from test-time compute — they have more
62    /// headroom to recover from a bad first sample. Spend more on them. `band`
63    /// is the model capability band (1 = small … 5 = frontier); see
64    /// `model-capability-profile.json`.
65    pub fn scaled_for_capability(self, band: u8) -> Self {
66        match band {
67            0..=2 => Self {
68                samples: (self.samples + 2).min(6),
69                refine_rounds: (self.refine_rounds + 1).min(4),
70            },
71            3 => Self {
72                samples: (self.samples + 1).min(6),
73                refine_rounds: self.refine_rounds,
74            },
75            // Strong/frontier models need less external search to be reliable.
76            _ => self,
77        }
78    }
79
80    /// Rough upper bound on model calls this budget will issue, for cost gating.
81    pub fn max_calls(&self) -> usize {
82        let draft_calls = self.samples + if self.samples > 1 { 1 } else { 0 };
83        draft_calls + self.refine_rounds * 2
84    }
85}
86
87fn user_req(system: &str, user: &str, temperature: f32, max_tokens: u32) -> BrainRequest {
88    BrainRequest {
89        system: Some(system.to_string()),
90        messages: vec![Msg {
91            role: "user".into(),
92            content: vec![ContentBlock::Text {
93                text: user.to_string(),
94            }],
95        }],
96        tools: vec![],
97        max_tokens,
98        temperature,
99        stop: vec![],
100        cache: PromptCacheConfig::disabled(),
101    }
102}
103
104/// Drain a completion stream into a single string. `None` on stream error.
105pub async fn collect_text(brain: &dyn Brain, req: BrainRequest) -> Option<String> {
106    let mut stream = brain.complete(req).await.ok()?;
107    let mut out = String::new();
108    while let Some(ev) = stream.next().await {
109        match ev {
110            BrainEvent::TextDelta(t) => out.push_str(&t),
111            BrainEvent::Done(_) => break,
112            BrainEvent::Error(_) => return None,
113            _ => {}
114        }
115    }
116    Some(out)
117}
118
119fn is_approved(critique: &str) -> bool {
120    let t = critique.trim().to_ascii_uppercase();
121    t == "APPROVED" || t.starts_with("APPROVED")
122}
123
124/// Parse the first integer in `s` that is a valid index `< n`; fall back to 0.
125fn parse_index(s: &str, n: usize) -> usize {
126    let digits: String = s
127        .chars()
128        .skip_while(|c| !c.is_ascii_digit())
129        .take_while(|c| c.is_ascii_digit())
130        .collect();
131    digits.parse::<usize>().ok().filter(|&i| i < n).unwrap_or(0)
132}
133
134/// Have the model pick the best of several candidate answers (the verifier of
135/// best-of-N). Returns the chosen index. Single-candidate is index 0.
136pub async fn select_best(brain: &dyn Brain, task: &str, candidates: &[String]) -> usize {
137    if candidates.len() <= 1 {
138        return 0;
139    }
140    let mut listing = String::new();
141    for (i, c) in candidates.iter().enumerate() {
142        listing.push_str(&format!("[{i}]\n{}\n---\n", c.trim()));
143    }
144    let prompt = format!(
145        "Task:\n{task}\n\nCandidate answers:\n{listing}\nReply with ONLY the index number of the single best candidate."
146    );
147    match collect_text(brain, user_req(JUDGE_SYSTEM, &prompt, 0.0, 8)).await {
148        Some(out) => parse_index(&out, candidates.len()),
149        None => 0,
150    }
151}
152
153/// Sample `n` drafts (greedy if n=1, else temperature-diversified) and return
154/// the judge-selected best. `None` only if every draft failed.
155pub async fn best_of_n(brain: &dyn Brain, system: &str, task: &str, n: usize) -> Option<String> {
156    let n = n.max(1);
157    let mut candidates = Vec::with_capacity(n);
158    for i in 0..n {
159        let temperature = if n == 1 { 0.0 } else { 0.2 + 0.2 * i as f32 };
160        if let Some(c) = collect_text(brain, user_req(system, task, temperature, 2048)).await {
161            if !c.trim().is_empty() {
162                candidates.push(c);
163            }
164        }
165    }
166    match candidates.len() {
167        0 => None,
168        1 => candidates.pop(),
169        _ => {
170            let idx = select_best(brain, task, &candidates).await;
171            candidates.into_iter().nth(idx)
172        }
173    }
174}
175
176/// Reflexion loop: critique `answer` with an adversarial reviewer, then revise,
177/// up to `rounds` times. Stops early when the reviewer replies APPROVED.
178pub async fn self_refine_from(
179    brain: &dyn Brain,
180    system: &str,
181    task: &str,
182    mut answer: String,
183    rounds: usize,
184) -> Option<String> {
185    for _ in 0..rounds {
186        let critique_prompt =
187            format!("Task:\n{task}\n\nAnswer to review:\n{answer}\n\nYour critique:");
188        let critique =
189            collect_text(brain, user_req(CRITIC_SYSTEM, &critique_prompt, 0.0, 512)).await?;
190        if is_approved(&critique) {
191            break;
192        }
193        let revise_prompt = format!(
194            "Task:\n{task}\n\nYour previous answer:\n{answer}\n\nA reviewer raised this issue:\n{critique}\n\nProduce an improved answer that fully fixes it. Output only the improved answer.",
195        );
196        answer = collect_text(brain, user_req(system, &revise_prompt, 0.2, 2048)).await?;
197    }
198    Some(answer)
199}
200
201/// Full reasoning-max pipeline for a single answer: best-of-N draft → select →
202/// iterative self-refine, per `budget`. `None` only if drafting failed entirely.
203pub async fn reason_max(
204    brain: &dyn Brain,
205    system: &str,
206    task: &str,
207    budget: ReasoningBudget,
208) -> Option<String> {
209    let draft = if budget.samples > 1 {
210        best_of_n(brain, system, task, budget.samples).await?
211    } else {
212        collect_text(brain, user_req(system, task, 0.2, 2048)).await?
213    };
214    if budget.refine_rounds == 0 {
215        return Some(draft);
216    }
217    self_refine_from(brain, system, task, draft, budget.refine_rounds).await
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use crate::event::StopReason;
224    use crate::provider::{BrainStream, ModelCaps};
225    use std::collections::VecDeque;
226    use std::sync::Mutex;
227    use std::sync::atomic::{AtomicUsize, Ordering};
228
229    /// A Brain that returns scripted responses in order, counting calls — lets us
230    /// assert the orchestration's control flow deterministically without a model.
231    struct ScriptBrain {
232        responses: Mutex<VecDeque<String>>,
233        calls: AtomicUsize,
234    }
235    impl ScriptBrain {
236        fn new(responses: &[&str]) -> Self {
237            Self {
238                responses: Mutex::new(responses.iter().map(|s| s.to_string()).collect()),
239                calls: AtomicUsize::new(0),
240            }
241        }
242        fn calls(&self) -> usize {
243            self.calls.load(Ordering::SeqCst)
244        }
245    }
246    #[async_trait::async_trait]
247    impl Brain for ScriptBrain {
248        fn id(&self) -> &str {
249            "mock:script"
250        }
251        fn caps(&self) -> ModelCaps {
252            ModelCaps::default()
253        }
254        async fn complete(&self, _req: BrainRequest) -> anyhow::Result<BrainStream> {
255            self.calls.fetch_add(1, Ordering::SeqCst);
256            let resp = self
257                .responses
258                .lock()
259                .unwrap()
260                .pop_front()
261                .unwrap_or_else(|| "DEFAULT".into());
262            let events = vec![
263                BrainEvent::TextDelta(resp),
264                BrainEvent::Done(StopReason::EndTurn),
265            ];
266            Ok(Box::pin(futures::stream::iter(events)))
267        }
268    }
269
270    #[test]
271    fn budget_scales_with_tier_and_capability() {
272        assert_eq!(
273            ReasoningBudget::for_tier(&TaskTier::Trivial),
274            ReasoningBudget {
275                samples: 1,
276                refine_rounds: 0
277            }
278        );
279        let hard = ReasoningBudget::for_tier(&TaskTier::Hard);
280        assert_eq!(
281            hard,
282            ReasoningBudget {
283                samples: 3,
284                refine_rounds: 2
285            }
286        );
287        // A weak model gets MORE compute; a frontier model is left as-is.
288        assert!(hard.scaled_for_capability(1).max_calls() > hard.max_calls());
289        assert_eq!(hard.scaled_for_capability(5), hard);
290    }
291
292    #[test]
293    fn parse_index_is_robust() {
294        assert_eq!(parse_index("2", 3), 2);
295        assert_eq!(parse_index("The best is [1].", 3), 1);
296        assert_eq!(parse_index("99", 3), 0); // out of range → 0
297        assert_eq!(parse_index("none", 3), 0);
298    }
299
300    #[tokio::test]
301    async fn self_refine_revises_then_stops_on_approval() {
302        // critique(issue) → revise → critique(APPROVED) → stop
303        let b = ScriptBrain::new(&["ISSUE: missing empty-input case", "answer v2", "APPROVED"]);
304        let out = self_refine_from(&b, "sys", "task", "answer v1".into(), 3)
305            .await
306            .unwrap();
307        assert_eq!(out, "answer v2");
308        assert_eq!(b.calls(), 3, "critique + revise + critique");
309    }
310
311    #[tokio::test]
312    async fn self_refine_stops_immediately_when_approved() {
313        let b = ScriptBrain::new(&["APPROVED"]);
314        let out = self_refine_from(&b, "sys", "task", "good answer".into(), 3)
315            .await
316            .unwrap();
317        assert_eq!(out, "good answer", "unchanged");
318        assert_eq!(b.calls(), 1, "only the critique");
319    }
320
321    #[tokio::test]
322    async fn best_of_n_samples_then_judges() {
323        // 3 drafts, judge picks index 1
324        let b = ScriptBrain::new(&["cand A", "cand B", "cand C", "1"]);
325        let out = best_of_n(&b, "sys", "task", 3).await.unwrap();
326        assert_eq!(out, "cand B");
327        assert_eq!(b.calls(), 4, "3 drafts + 1 judge");
328    }
329
330    #[tokio::test]
331    async fn best_of_one_skips_the_judge() {
332        let b = ScriptBrain::new(&["only candidate"]);
333        let out = best_of_n(&b, "sys", "task", 1).await.unwrap();
334        assert_eq!(out, "only candidate");
335        assert_eq!(b.calls(), 1, "no judge call for n=1");
336    }
337
338    #[tokio::test]
339    async fn reason_max_runs_the_full_pipeline() {
340        // budget: 2 samples + 1 refine round
341        // d0, d1, judge→"1" (picks d1), critique(issue), revised
342        let b = ScriptBrain::new(&["d0", "d1", "1", "ISSUE: x", "refined answer"]);
343        let budget = ReasoningBudget {
344            samples: 2,
345            refine_rounds: 1,
346        };
347        let out = reason_max(&b, "sys", "task", budget).await.unwrap();
348        assert_eq!(out, "refined answer");
349        assert_eq!(b.calls(), 5, "2 drafts + judge + critique + revise");
350    }
351}