Skip to main content

lvz_context/
budget.rs

1//! The budget-fixture loop (§6.5): token-efficiency CI for the skeleton-radius
2//! knob `N`.
3//!
4//! A [`Fixture`] is `(repo snapshot + edit target)`; [`Fixture::measure`] builds the context
5//! the agent would send at a given radius and reports its estimated tokens plus the two §6.5
6//! diagnostics' deterministic half (kept-symbol count). The integration test
7//! `tests/budget.rs` pins per-fixture token **ceilings** as the committed baseline and fails
8//! CI when a change blows the budget.
9//!
10//! Scope: this harness measures the **input-construction** lever, which is deterministic and
11//! gateable offline. The round-trip / cache-hit half of the U-curve depends on live model
12//! behaviour and belongs to runtime ATO (§6.6); the fixtures here set its safe priors and the
13//! regression floor.
14
15use std::collections::HashSet;
16
17use crate::symbols::SymbolGraph;
18use crate::tokens::estimate_tokens;
19use crate::{skeleton, Lang};
20
21/// Coarse task shape; knob optima differ per archetype (§6.5). Mirrors
22/// `lvz_protocol::Archetype` but kept local so `lvz-context` stays protocol-independent.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Archetype {
25    SingleFileEdit,
26    Refactor,
27    Rename,
28    Feature,
29}
30
31/// A repo snapshot plus the symbol at the centre of the intended edit.
32pub struct Fixture {
33    pub name: &'static str,
34    pub archetype: Archetype,
35    /// The files of the snapshot, as `(language, source)`.
36    pub files: Vec<(Lang, String)>,
37    /// The symbol the task edits; skeleton radius expands outward from here.
38    pub target: String,
39}
40
41/// The deterministic measurement of a fixture at one radius.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub struct BudgetReport {
44    pub radius: u8,
45    pub est_tokens: usize,
46    pub kept_symbols: usize,
47}
48
49impl Fixture {
50    /// Build the context the agent would send at `radius`: every file skeletonised, with full
51    /// bodies kept for symbols within `radius` hops of [`target`](Self::target) across the
52    /// whole snapshot.
53    pub fn context_at(&self, radius: u8) -> String {
54        let graph = SymbolGraph::from_sources(self.files.iter().map(|(l, s)| (*l, s.as_str())));
55        // Keep bodies per file (scope-aware), so a same-named symbol's body is kept only in the
56        // file that actually owns the reached definition.
57        let keep = graph.neighbors_within_by_file(&self.target, radius);
58        self.files
59            .iter()
60            .enumerate()
61            .map(|(i, (lang, source))| skeleton::skeletonize(source, *lang, &keep[i]))
62            .collect::<Vec<_>>()
63            .join("\n")
64    }
65
66    /// Measure estimated context tokens and kept-symbol count at `radius`.
67    pub fn measure(&self, radius: u8) -> BudgetReport {
68        let graph = SymbolGraph::from_sources(self.files.iter().map(|(l, s)| (*l, s.as_str())));
69        let keep = graph.neighbors_within_by_file(&self.target, radius);
70        let context = self
71            .files
72            .iter()
73            .enumerate()
74            .map(|(i, (lang, source))| skeleton::skeletonize(source, *lang, &keep[i]))
75            .collect::<Vec<_>>()
76            .join("\n");
77        BudgetReport {
78            radius,
79            est_tokens: estimate_tokens(&context),
80            kept_symbols: keep.iter().map(HashSet::len).sum(),
81        }
82    }
83
84    /// Sweep radii `0..=max_radius`, returning one report each — the trend line §6.5 tracks.
85    pub fn sweep(&self, max_radius: u8) -> Vec<BudgetReport> {
86        (0..=max_radius).map(|r| self.measure(r)).collect()
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    fn fixture() -> Fixture {
95        Fixture {
96            name: "demo",
97            archetype: Archetype::SingleFileEdit,
98            files: vec![(
99                Lang::Rust,
100                "\
101fn a() -> i32 { b() + 1 }
102fn b() -> i32 { c() + 1 }
103fn c() -> i32 { 1 }
104fn unrelated() -> i32 { 99 }
105"
106                .to_string(),
107            )],
108            target: "a".to_string(),
109        }
110    }
111
112    #[test]
113    fn kept_set_never_shrinks_with_radius() {
114        // The reachable set only grows with radius. (Token count is *not* monotonic — eliding
115        // a trivial body can cost more than keeping it; §6.5's curve is U-shaped — so the kept
116        // set, not tokens, is the invariant.)
117        let reports = fixture().sweep(3);
118        for pair in reports.windows(2) {
119            assert!(
120                pair[1].kept_symbols >= pair[0].kept_symbols,
121                "kept set shrank from radius {} ({}) to {} ({})",
122                pair[0].radius,
123                pair[0].kept_symbols,
124                pair[1].radius,
125                pair[1].kept_symbols,
126            );
127        }
128    }
129
130    #[test]
131    fn radius_is_a_real_lever_for_substantial_bodies() {
132        // When a dependency has a non-trivial body, expanding to include it really does cost
133        // more tokens than the radius-0 skeleton.
134        let f = Fixture {
135            name: "big_dep",
136            archetype: Archetype::SingleFileEdit,
137            files: vec![(
138                Lang::Rust,
139                "\
140fn target() -> i32 { big() }
141fn big() -> i32 {
142    let mut total = 0;
143    for i in 0..100 { total += i * i - 3 * i + 7; }
144    total
145}
146"
147                .to_string(),
148            )],
149            target: "target".to_string(),
150        };
151        assert!(f.measure(1).est_tokens > f.measure(0).est_tokens);
152    }
153
154    #[test]
155    fn radius_expands_the_kept_set_along_the_dependency_chain() {
156        let f = fixture();
157        // a -> b -> c; unrelated is never pulled in.
158        assert_eq!(f.measure(0).kept_symbols, 1); // {a}
159        assert_eq!(f.measure(1).kept_symbols, 2); // {a, b}
160        assert_eq!(f.measure(2).kept_symbols, 3); // {a, b, c}
161        assert_eq!(f.measure(3).kept_symbols, 3); // saturated; unrelated excluded
162    }
163}