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}