1use serde::{Deserialize, Serialize};
12
13use crate::core::savings_ledger::{RoiReport, roi_report};
14
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19pub struct Usage {
20 pub schema_version: u32,
22 pub period: String,
24 pub created_at: String,
26 pub agent_id: String,
28
29 pub metered_events: usize,
32 pub net_saved_tokens: u64,
34 pub saved_usd: f64,
36
37 pub last_entry_hash: String,
40 pub chain_valid: bool,
42 pub signed: bool,
44}
45
46impl Usage {
47 pub const SCHEMA_VERSION: u32 = 1;
49
50 #[must_use]
52 pub fn from_roi(roi: &RoiReport) -> Self {
53 Self {
54 schema_version: Self::SCHEMA_VERSION,
55 period: roi.period.clone(),
56 created_at: roi.created_at.clone(),
57 agent_id: roi.agent_id.clone(),
58 metered_events: roi.total_events,
59 net_saved_tokens: roi.net_saved_tokens,
60 saved_usd: roi.saved_usd,
61 last_entry_hash: roi.last_entry_hash.clone(),
62 chain_valid: roi.chain_valid,
63 signed: roi.signed,
64 }
65 }
66
67 #[must_use]
73 pub fn is_billable(&self) -> bool {
74 self.signed && self.chain_valid
75 }
76
77 #[must_use]
79 pub fn source_integrity_verified(&self) -> bool {
80 self.is_billable()
81 }
82
83 #[must_use]
85 pub fn headline(&self) -> String {
86 format!(
87 "Usage[{}]: {} events, {} net tokens, ${:.4} ({}, {})",
88 self.period,
89 self.metered_events,
90 self.net_saved_tokens,
91 self.saved_usd,
92 if self.chain_valid {
93 "chain valid"
94 } else {
95 "chain BROKEN"
96 },
97 if self.source_integrity_verified() {
98 "source integrity verified"
99 } else {
100 "source integrity unverified"
101 },
102 )
103 }
104}
105
106#[must_use]
110pub fn metered_usage(agent_id: &str) -> Usage {
111 Usage::from_roi(&roi_report(agent_id))
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use crate::core::savings_ledger::signed_batch::{BatchTotals, SignedSavingsBatchV1};
118
119 fn roi(events: usize, net: u64, usd: f64, signed: bool, chain_valid: bool) -> RoiReport {
120 let batch = SignedSavingsBatchV1 {
121 schema_version: 1,
122 kind: "lean-ctx.savings-batch".to_string(),
123 created_at: "2026-01-01T00:00:00Z".to_string(),
124 lean_ctx_version: "test".to_string(),
125 agent_id: "agent-1".to_string(),
126 period: "all".to_string(),
127 first_entry_hash: "genesis".to_string(),
128 last_entry_hash: "deadbeef".to_string(),
129 chain_valid,
130 totals: BatchTotals {
131 total_events: events,
132 saved_tokens: net + 10,
133 net_saved_tokens: net,
134 saved_usd: usd,
135 bounce_tokens: 10,
136 bounce_events: 1,
137 tokenizers: vec!["o200k_base".to_string()],
138 by_model: vec![("gpt".to_string(), net, usd)],
139 by_tool: vec![("ctx_read".to_string(), net)],
140 by_mechanism: vec![("compression".to_string(), net, usd)],
141 },
142 signer_public_key: signed.then(|| "pubkey".to_string()),
143 signature: signed.then(|| "sig".to_string()),
144 };
145 RoiReport::from_signed_batch(&batch)
146 }
147
148 #[test]
149 fn usage_mirrors_roi_aggregates() {
150 let u = Usage::from_roi(&roi(7, 7000, 0.14, true, true));
151 assert_eq!(u.metered_events, 7);
152 assert_eq!(u.net_saved_tokens, 7000);
153 assert!((u.saved_usd - 0.14).abs() < 1e-9);
154 assert_eq!(u.last_entry_hash, "deadbeef");
155 assert_eq!(u.schema_version, Usage::SCHEMA_VERSION);
156 }
157
158 #[test]
159 fn only_signed_intact_chains_are_billable() {
160 let usage = Usage::from_roi(&roi(1, 1, 0.0, true, true));
161 assert!(usage.is_billable());
162 assert!(usage.source_integrity_verified());
163 assert!(!Usage::from_roi(&roi(1, 1, 0.0, false, true)).is_billable());
164 assert!(!Usage::from_roi(&roi(1, 1, 0.0, true, false)).is_billable());
165 }
166
167 #[test]
168 fn usage_is_privacy_preserving() {
169 let json = serde_json::to_string(&Usage::from_roi(&roi(2, 100, 0.01, true, true))).unwrap();
170 for forbidden in ["path", "prompt", "content", "cwd", "\"file\""] {
171 assert!(!json.contains(forbidden), "usage leaked '{forbidden}'");
172 }
173 }
174}