use serde::{Deserialize, Serialize};
use crate::identity::FrameId;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServedFrame {
pub frame: FrameId,
pub token_cost: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProviderUsage {
pub provider_id: String,
pub frames_served: u32,
pub frames_rejected: u32,
pub token_cost: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub served_frames: Vec<ServedFrame>,
}
impl ProviderUsage {
pub fn served_token_cost(&self) -> u64 {
self.served_frames
.iter()
.map(|served| served.token_cost as u64)
.sum()
}
pub fn is_consistent(&self) -> bool {
self.frames_served as usize == self.served_frames.len()
&& self.token_cost == self.served_token_cost()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsageReport {
pub budget_requested: u32,
pub budget_consumed: u64,
pub as_of: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub providers: Vec<ProviderUsage>,
}
impl UsageReport {
pub fn total_provider_cost(&self) -> u64 {
self.providers.iter().map(|p| p.token_cost).sum()
}
pub fn total_frames_served(&self) -> u64 {
self.providers.iter().map(|p| p.frames_served as u64).sum()
}
pub fn is_consistent(&self) -> bool {
self.budget_consumed == self.total_provider_cost()
&& self.providers.iter().all(ProviderUsage::is_consistent)
}
pub fn within_budget(&self) -> bool {
self.budget_consumed <= self.budget_requested as u64
}
}
#[cfg(test)]
mod tests {
use super::*;
fn served(provider: &str, frame: &str, digest: &str, cost: u32) -> ServedFrame {
ServedFrame {
frame: FrameId::new(provider, frame, Some(digest.into())),
token_cost: cost,
}
}
fn sample_report() -> UsageReport {
let graph = ProviderUsage {
provider_id: "repo-graph".into(),
frames_served: 2,
frames_rejected: 0,
token_cost: 64,
served_frames: vec![
served("repo-graph", "retry-doc", "sha256:aa", 41),
served("repo-graph", "retry-sym", "sha256:bb", 23),
],
};
let liar = ProviderUsage {
provider_id: "cloud-docs".into(),
frames_served: 0,
frames_rejected: 3,
token_cost: 0,
served_frames: vec![],
};
UsageReport {
budget_requested: 1024,
budget_consumed: 64,
as_of: "2026-07-21T00:00:00Z".into(),
providers: vec![graph, liar],
}
}
#[test]
fn usage_report_roundtrips_through_json() {
let report = sample_report();
let json = serde_json::to_string(&report).unwrap();
let back: UsageReport = serde_json::from_str(&json).unwrap();
assert_eq!(back, report);
}
#[test]
fn a_consistent_report_re_sums_from_its_served_frames() {
let report = sample_report();
assert!(report.is_consistent());
assert_eq!(report.total_provider_cost(), 64);
assert_eq!(report.budget_consumed, report.total_provider_cost());
assert_eq!(report.total_frames_served(), 2);
assert!(report.within_budget());
}
#[test]
fn a_tampered_total_fails_the_arithmetic_identity() {
let mut report = sample_report();
report.budget_consumed = 9_999;
assert!(!report.is_consistent());
let mut report = sample_report();
report.providers[0].token_cost = 100; assert!(!report.is_consistent());
assert!(!report.providers[0].is_consistent());
}
#[test]
fn served_frames_carry_the_stable_identity_for_audit_walk_back() {
let report = sample_report();
let first = &report.providers[0].served_frames[0];
assert_eq!(first.frame.provider_id, "repo-graph");
assert_eq!(first.frame.frame_id, "retry-doc");
assert_eq!(first.frame.content_digest.as_deref(), Some("sha256:aa"));
assert_eq!(first.token_cost, 41);
}
#[test]
fn empty_provider_and_served_lists_are_omitted_when_absent() {
let report = UsageReport {
budget_requested: 100,
budget_consumed: 0,
as_of: "2026-07-21T00:00:00Z".into(),
providers: vec![],
};
let json = serde_json::to_string(&report).unwrap();
assert!(!json.contains("providers"));
let back: UsageReport = serde_json::from_str(&json).unwrap();
assert_eq!(back, report);
}
}