Skip to main content

boundgraph_core/
context.rs

1//! The Context (`Γ`) — the exact admitted token window, and what was refused.
2//!
3//! Law II says a step receives only the context it declared as necessary.
4//! That law is only *auditable* if you can also see what the admission
5//! controller turned away. Recording admissions alone makes the law
6//! unfalsifiable: "the agent saw these five things" tells you nothing about
7//! the sixth thing it needed and never got.
8//!
9//! So [`Context`] carries both halves. When someone asks "why did the agent
10//! not know X?", the answer is a lookup: X is in [`Context::evicted`] with a
11//! [`EvictionReason`] attached.
12
13use serde::{Deserialize, Serialize};
14
15use crate::digest::Digest;
16
17/// The token ceiling an admission ran against — `s_max` in the cost model.
18///
19/// This is the term that makes total cost linear rather than quadratic: a step
20/// admits at most `s_max` tokens regardless of how much history exists.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
22#[serde(transparent)]
23pub struct TokenBudget(pub u32);
24
25impl TokenBudget {
26    /// The ceiling as a plain count.
27    pub const fn tokens(self) -> u32 {
28        self.0
29    }
30}
31
32/// Where an item of context came from.
33#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum ContextSource {
36    /// The flow's system or task prompt.
37    Prompt,
38    /// A condensate from an earlier step.
39    Condensate,
40    /// Retrieved material, e.g. from a vector store.
41    Retrieval,
42    /// Verified evidence from an earlier step.
43    Evidence,
44    /// Anything else, named by the admitter.
45    Other(String),
46}
47
48/// One item that made it into the window.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct AdmittedItem {
51    /// Where it came from.
52    pub source: ContextSource,
53    /// Content address of the item, so it can be retrieved and verified.
54    pub digest: Digest,
55    /// What it cost against the budget.
56    pub tokens: u32,
57    /// Why the admitter let it in.
58    pub reason: String,
59}
60
61/// Why an item was refused.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum EvictionReason {
65    /// Admitting it would have exceeded `s_max`.
66    BudgetExhausted {
67        /// Tokens the item needed.
68        needed: u32,
69        /// Tokens still available when it was considered.
70        remaining: u32,
71    },
72    /// The step did not declare this source as necessary.
73    NotDeclared,
74    /// Superseded by a fresher item covering the same ground.
75    Superseded {
76        /// The item that replaced it.
77        by: Digest,
78    },
79    /// Withheld by policy, e.g. a secret or out-of-scope material.
80    PolicyDenied {
81        /// The policy that refused it.
82        policy: String,
83    },
84    /// Anything else, named by the admitter.
85    Other(String),
86}
87
88/// One item that was considered and refused.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct EvictionRecord {
91    /// Where it would have come from.
92    pub source: ContextSource,
93    /// Content address, so the auditor can fetch what was withheld.
94    pub digest: Digest,
95    /// What it would have cost.
96    pub tokens: u32,
97    /// Why it did not make it in.
98    pub reason: EvictionReason,
99}
100
101/// The context was over budget — an admitter bug, not a user error.
102#[derive(Debug, thiserror::Error)]
103#[error(
104    "context exceeds its token budget: {admitted} tokens admitted against a ceiling of {budget}"
105)]
106pub struct ContextOverBudget {
107    /// Total tokens the admitted items came to.
108    pub admitted: u32,
109    /// The ceiling that was supposed to hold.
110    pub budget: u32,
111}
112
113/// `Γ` — the exact admitted token window. Immutable, fully attributed.
114///
115/// # Example
116///
117/// ```
118/// use boundgraph_core::{AdmittedItem, Context, ContextSource, Digest, EvictionReason,
119///                 EvictionRecord, TokenBudget};
120///
121/// let ctx = Context::new(
122///     vec![AdmittedItem {
123///         source: ContextSource::Prompt,
124///         digest: Digest::of(b"task"),
125///         tokens: 120,
126///         reason: "declared by the step".into(),
127///     }],
128///     vec![EvictionRecord {
129///         source: ContextSource::Retrieval,
130///         digest: Digest::of(b"a long document"),
131///         tokens: 9_000,
132///         reason: EvictionReason::BudgetExhausted { needed: 9_000, remaining: 880 },
133///     }],
134///     TokenBudget(1_000),
135/// )?;
136///
137/// assert_eq!(ctx.admitted_tokens(), 120);
138/// // The law is auditable: we can say exactly what was withheld, and why.
139/// assert_eq!(ctx.evicted.len(), 1);
140/// # Ok::<(), boundgraph_core::context::ContextOverBudget>(())
141/// ```
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143pub struct Context {
144    /// What the reasoner actually saw.
145    pub items: Vec<AdmittedItem>,
146    /// What it did not, and why.
147    pub evicted: Vec<EvictionRecord>,
148    /// The ceiling this admission ran against.
149    pub budget: TokenBudget,
150}
151
152impl Context {
153    /// Build a context, enforcing the budget invariant.
154    ///
155    /// # Errors
156    ///
157    /// Returns [`ContextOverBudget`] if the admitted items exceed `budget`.
158    /// This is deliberately a hard error rather than a silent truncation: an
159    /// admitter that overruns has broken Law II, and quietly dropping the tail
160    /// would hide that while also losing the eviction record that explains it.
161    pub fn new(
162        items: Vec<AdmittedItem>,
163        evicted: Vec<EvictionRecord>,
164        budget: TokenBudget,
165    ) -> Result<Self, ContextOverBudget> {
166        let admitted: u32 = items.iter().map(|i| i.tokens).sum();
167        if admitted > budget.tokens() {
168            return Err(ContextOverBudget {
169                admitted,
170                budget: budget.tokens(),
171            });
172        }
173        Ok(Self {
174            items,
175            evicted,
176            budget,
177        })
178    }
179
180    /// An empty window under a given ceiling.
181    pub fn empty(budget: TokenBudget) -> Self {
182        Self {
183            items: Vec::new(),
184            evicted: Vec::new(),
185            budget,
186        }
187    }
188
189    /// Tokens actually admitted. Never exceeds [`Context::budget`].
190    pub fn admitted_tokens(&self) -> u32 {
191        self.items.iter().map(|i| i.tokens).sum()
192    }
193
194    /// Tokens left unused under the ceiling.
195    pub fn headroom(&self) -> u32 {
196        self.budget.tokens().saturating_sub(self.admitted_tokens())
197    }
198
199    /// Tokens that were considered and refused.
200    ///
201    /// A large number here against small [`headroom`](Self::headroom) means the
202    /// budget was the binding constraint; a large number against *ample*
203    /// headroom means something was withheld for another reason, which is
204    /// usually the more interesting case when debugging.
205    pub fn evicted_tokens(&self) -> u32 {
206        self.evicted.iter().map(|e| e.tokens).sum()
207    }
208
209    /// Whether this item was considered and refused, and if so why.
210    ///
211    /// This is the "why did the agent not know X?" query.
212    pub fn why_evicted(&self, digest: &Digest) -> Option<&EvictionReason> {
213        self.evicted
214            .iter()
215            .find(|e| &e.digest == digest)
216            .map(|e| &e.reason)
217    }
218
219    /// Whether this item made it into the window.
220    pub fn admitted(&self, digest: &Digest) -> bool {
221        self.items.iter().any(|i| &i.digest == digest)
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use crate::canonical::Canonical;
229
230    fn item(tokens: u32, tag: &[u8]) -> AdmittedItem {
231        AdmittedItem {
232            source: ContextSource::Prompt,
233            digest: Digest::of(tag),
234            tokens,
235            reason: "declared".into(),
236        }
237    }
238
239    fn eviction(tokens: u32, tag: &[u8], reason: EvictionReason) -> EvictionRecord {
240        EvictionRecord {
241            source: ContextSource::Retrieval,
242            digest: Digest::of(tag),
243            tokens,
244            reason,
245        }
246    }
247
248    #[test]
249    fn carries_items_evicted_and_budget() {
250        let ctx = Context::new(
251            vec![item(10, b"a")],
252            vec![eviction(50, b"b", EvictionReason::NotDeclared)],
253            TokenBudget(100),
254        )
255        .unwrap();
256        assert_eq!(ctx.items.len(), 1);
257        assert_eq!(ctx.evicted.len(), 1);
258        assert_eq!(ctx.budget, TokenBudget(100));
259    }
260
261    #[test]
262    fn admitted_items_record_source_digest_tokens_and_reason() {
263        let ctx = Context::new(vec![item(10, b"a")], vec![], TokenBudget(100)).unwrap();
264        let i = &ctx.items[0];
265        assert_eq!(i.source, ContextSource::Prompt);
266        assert_eq!(i.digest, Digest::of(b"a"));
267        assert_eq!(i.tokens, 10);
268        assert_eq!(i.reason, "declared");
269    }
270
271    #[test]
272    fn exceeding_the_budget_is_a_typed_error_not_a_truncation() {
273        let err = Context::new(vec![item(101, b"a")], vec![], TokenBudget(100)).unwrap_err();
274        assert_eq!(err.admitted, 101);
275        assert_eq!(err.budget, 100);
276    }
277
278    #[test]
279    fn exactly_at_the_budget_is_allowed() {
280        assert!(Context::new(vec![item(100, b"a")], vec![], TokenBudget(100)).is_ok());
281    }
282
283    #[test]
284    fn answers_why_the_agent_did_not_know_something() {
285        let missing = Digest::of(b"the missing document");
286        let ctx = Context::new(
287            vec![item(10, b"a")],
288            vec![eviction(
289                9_000,
290                b"the missing document",
291                EvictionReason::BudgetExhausted {
292                    needed: 9_000,
293                    remaining: 90,
294                },
295            )],
296            TokenBudget(100),
297        )
298        .unwrap();
299
300        assert!(!ctx.admitted(&missing));
301        assert!(matches!(
302            ctx.why_evicted(&missing),
303            Some(EvictionReason::BudgetExhausted {
304                needed: 9_000,
305                remaining: 90
306            })
307        ));
308        // An item that was never considered is distinguishable from one refused.
309        assert_eq!(ctx.why_evicted(&Digest::of(b"never seen")), None);
310    }
311
312    #[test]
313    fn reports_headroom_and_evicted_volume() {
314        let ctx = Context::new(
315            vec![item(30, b"a")],
316            vec![eviction(70, b"b", EvictionReason::NotDeclared)],
317            TokenBudget(100),
318        )
319        .unwrap();
320        assert_eq!(ctx.admitted_tokens(), 30);
321        assert_eq!(ctx.headroom(), 70);
322        assert_eq!(ctx.evicted_tokens(), 70);
323    }
324
325    #[test]
326    fn empty_context_is_within_any_budget() {
327        let ctx = Context::empty(TokenBudget(0));
328        assert_eq!(ctx.admitted_tokens(), 0);
329        assert_eq!(ctx.headroom(), 0);
330    }
331
332    #[test]
333    fn round_trips_and_has_a_stable_digest() {
334        let ctx = Context::new(
335            vec![item(10, b"a")],
336            vec![eviction(5, b"b", EvictionReason::NotDeclared)],
337            TokenBudget(100),
338        )
339        .unwrap();
340        let bytes = crate::to_canonical_cbor(&ctx).unwrap();
341        let back: Context = crate::from_canonical_cbor(&bytes).unwrap();
342        assert_eq!(ctx, back);
343        assert_eq!(ctx.digest().unwrap(), back.digest().unwrap());
344    }
345}