use serde::{Deserialize, Serialize};
use crate::digest::Digest;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct TokenBudget(pub u32);
impl TokenBudget {
pub const fn tokens(self) -> u32 {
self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ContextSource {
Prompt,
Condensate,
Retrieval,
Evidence,
Other(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdmittedItem {
pub source: ContextSource,
pub digest: Digest,
pub tokens: u32,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EvictionReason {
BudgetExhausted {
needed: u32,
remaining: u32,
},
NotDeclared,
Superseded {
by: Digest,
},
PolicyDenied {
policy: String,
},
Other(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EvictionRecord {
pub source: ContextSource,
pub digest: Digest,
pub tokens: u32,
pub reason: EvictionReason,
}
#[derive(Debug, thiserror::Error)]
#[error(
"context exceeds its token budget: {admitted} tokens admitted against a ceiling of {budget}"
)]
pub struct ContextOverBudget {
pub admitted: u32,
pub budget: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Context {
pub items: Vec<AdmittedItem>,
pub evicted: Vec<EvictionRecord>,
pub budget: TokenBudget,
}
impl Context {
pub fn new(
items: Vec<AdmittedItem>,
evicted: Vec<EvictionRecord>,
budget: TokenBudget,
) -> Result<Self, ContextOverBudget> {
let admitted: u32 = items.iter().map(|i| i.tokens).sum();
if admitted > budget.tokens() {
return Err(ContextOverBudget {
admitted,
budget: budget.tokens(),
});
}
Ok(Self {
items,
evicted,
budget,
})
}
pub fn empty(budget: TokenBudget) -> Self {
Self {
items: Vec::new(),
evicted: Vec::new(),
budget,
}
}
pub fn admitted_tokens(&self) -> u32 {
self.items.iter().map(|i| i.tokens).sum()
}
pub fn headroom(&self) -> u32 {
self.budget.tokens().saturating_sub(self.admitted_tokens())
}
pub fn evicted_tokens(&self) -> u32 {
self.evicted.iter().map(|e| e.tokens).sum()
}
pub fn why_evicted(&self, digest: &Digest) -> Option<&EvictionReason> {
self.evicted
.iter()
.find(|e| &e.digest == digest)
.map(|e| &e.reason)
}
pub fn admitted(&self, digest: &Digest) -> bool {
self.items.iter().any(|i| &i.digest == digest)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::canonical::Canonical;
fn item(tokens: u32, tag: &[u8]) -> AdmittedItem {
AdmittedItem {
source: ContextSource::Prompt,
digest: Digest::of(tag),
tokens,
reason: "declared".into(),
}
}
fn eviction(tokens: u32, tag: &[u8], reason: EvictionReason) -> EvictionRecord {
EvictionRecord {
source: ContextSource::Retrieval,
digest: Digest::of(tag),
tokens,
reason,
}
}
#[test]
fn carries_items_evicted_and_budget() {
let ctx = Context::new(
vec![item(10, b"a")],
vec![eviction(50, b"b", EvictionReason::NotDeclared)],
TokenBudget(100),
)
.unwrap();
assert_eq!(ctx.items.len(), 1);
assert_eq!(ctx.evicted.len(), 1);
assert_eq!(ctx.budget, TokenBudget(100));
}
#[test]
fn admitted_items_record_source_digest_tokens_and_reason() {
let ctx = Context::new(vec![item(10, b"a")], vec![], TokenBudget(100)).unwrap();
let i = &ctx.items[0];
assert_eq!(i.source, ContextSource::Prompt);
assert_eq!(i.digest, Digest::of(b"a"));
assert_eq!(i.tokens, 10);
assert_eq!(i.reason, "declared");
}
#[test]
fn exceeding_the_budget_is_a_typed_error_not_a_truncation() {
let err = Context::new(vec![item(101, b"a")], vec![], TokenBudget(100)).unwrap_err();
assert_eq!(err.admitted, 101);
assert_eq!(err.budget, 100);
}
#[test]
fn exactly_at_the_budget_is_allowed() {
assert!(Context::new(vec![item(100, b"a")], vec![], TokenBudget(100)).is_ok());
}
#[test]
fn answers_why_the_agent_did_not_know_something() {
let missing = Digest::of(b"the missing document");
let ctx = Context::new(
vec![item(10, b"a")],
vec![eviction(
9_000,
b"the missing document",
EvictionReason::BudgetExhausted {
needed: 9_000,
remaining: 90,
},
)],
TokenBudget(100),
)
.unwrap();
assert!(!ctx.admitted(&missing));
assert!(matches!(
ctx.why_evicted(&missing),
Some(EvictionReason::BudgetExhausted {
needed: 9_000,
remaining: 90
})
));
assert_eq!(ctx.why_evicted(&Digest::of(b"never seen")), None);
}
#[test]
fn reports_headroom_and_evicted_volume() {
let ctx = Context::new(
vec![item(30, b"a")],
vec![eviction(70, b"b", EvictionReason::NotDeclared)],
TokenBudget(100),
)
.unwrap();
assert_eq!(ctx.admitted_tokens(), 30);
assert_eq!(ctx.headroom(), 70);
assert_eq!(ctx.evicted_tokens(), 70);
}
#[test]
fn empty_context_is_within_any_budget() {
let ctx = Context::empty(TokenBudget(0));
assert_eq!(ctx.admitted_tokens(), 0);
assert_eq!(ctx.headroom(), 0);
}
#[test]
fn round_trips_and_has_a_stable_digest() {
let ctx = Context::new(
vec![item(10, b"a")],
vec![eviction(5, b"b", EvictionReason::NotDeclared)],
TokenBudget(100),
)
.unwrap();
let bytes = crate::to_canonical_cbor(&ctx).unwrap();
let back: Context = crate::from_canonical_cbor(&bytes).unwrap();
assert_eq!(ctx, back);
assert_eq!(ctx.digest().unwrap(), back.digest().unwrap());
}
}