contextgraph_types/usage.rs
1//! Usage reports — a per-request roll-up of context cost
2//! (`docs/context-reuse.md` §2).
3//!
4//! Every frame carries an honest `token_cost` (§protocol-surface B1), but the
5//! protocol otherwise stops at the frame. A host that meters context into a
6//! billing system — the usage-events → ClickHouse → Stripe loop platforms run —
7//! needs an *aggregate*: which providers served how many frames, at what token
8//! cost, against which budget. Left unspecified, every host invents that shape
9//! independently and context cost stays unauditable one level up from the wire
10//! — the blob-pipe problem reborn at the accounting layer.
11//!
12//! A [`UsageReport`] is that aggregate. It is a **host-side artifact**, not a
13//! wire message: it rides no new envelope variant and no new required field, so
14//! a provider implements nothing to make one possible. The reference host
15//! produces it from a fan-out
16//! ([`FanOut::usage_report`](https://docs.rs/contextgraph-host/latest/contextgraph_host/host/struct.FanOut.html#method.usage_report)).
17//!
18//! Each served frame is recorded as a [`ServedFrame`]: its stable
19//! [`FrameId`](crate::FrameId) identity *and* the `token_cost` the provider
20//! declared for it. Storing the pair is what lets an auditor walk from a billed
21//! total back to the exact frames behind it — and it makes the report
22//! **self-verifying**: the per-provider and request totals re-sum from these
23//! entries, so a corrupted total is a checkable arithmetic failure, not a
24//! silent misbill (the conformance case in §2).
25
26use serde::{Deserialize, Serialize};
27
28use crate::identity::FrameId;
29
30/// One served frame's contribution to a [`UsageReport`]: its stable identity
31/// and the `token_cost` its provider declared for it.
32///
33/// The identity is the auditor's walk-back handle — from a billed line to the
34/// exact `(provider id, frame id, content digest)` behind it — and the paired
35/// `token_cost` is what the request total re-sums from, so the report needs no
36/// out-of-band lookup to be checked.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct ServedFrame {
39 /// The frame's stable identity (`docs/context-reuse.md` §1).
40 pub frame: FrameId,
41 /// The `token_cost` the provider declared for this frame — the value that
42 /// was budget-audited before the frame was accepted.
43 pub token_cost: u32,
44}
45
46/// One provider's usage within a single query: how many frames it served, how
47/// many were rejected, the summed token cost, and the served frames'
48/// identities.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct ProviderUsage {
51 /// The host-facing id of the provider (the routing/consent key).
52 pub provider_id: String,
53 /// Frames the host accepted from this provider (passed consent, timeout,
54 /// and the budget-honesty audit). Equal to `served_frames.len()`.
55 pub frames_served: u32,
56 /// Frames the provider offered that the host rejected — a provider that
57 /// blew the budget has its frames dropped as a `token_cost` lie
58 /// (§protocol-surface B2), and once [verification](crate::verify) is wired
59 /// a frame verified `stale`/`gone` is rejected too.
60 pub frames_rejected: u32,
61 /// Summed `token_cost` of the served frames — the provider's contribution
62 /// to the request's consumed budget.
63 pub token_cost: u64,
64 /// The served frames, each by stable identity + declared cost, so an
65 /// auditor can walk from this provider's total to the exact frames.
66 #[serde(default, skip_serializing_if = "Vec::is_empty")]
67 pub served_frames: Vec<ServedFrame>,
68}
69
70impl ProviderUsage {
71 /// Re-sum the served frames' declared costs. Equals [`token_cost`](Self::token_cost)
72 /// for a consistently-built report — the arithmetic identity a metering
73 /// pipeline checks before trusting the total (§2).
74 pub fn served_token_cost(&self) -> u64 {
75 self.served_frames
76 .iter()
77 .map(|served| served.token_cost as u64)
78 .sum()
79 }
80
81 /// Whether this provider's aggregate agrees with its itemized frames:
82 /// `frames_served == served_frames.len()` and
83 /// `token_cost == served_token_cost()`.
84 pub fn is_consistent(&self) -> bool {
85 self.frames_served as usize == self.served_frames.len()
86 && self.token_cost == self.served_token_cost()
87 }
88}
89
90/// A per-request roll-up of context cost across every provider a query fanned
91/// out to (`docs/context-reuse.md` §2).
92///
93/// This is the shape a host maps into a metering pipeline: one row per
94/// `(request, provider)` with a frame-cited token total, plus the request-level
95/// budget requested vs. consumed and the accounting snapshot time.
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97pub struct UsageReport {
98 /// The query's `max_tokens` — the budget the host asked providers to
99 /// respect.
100 pub budget_requested: u32,
101 /// The summed `token_cost` of every served frame across all providers —
102 /// what the request actually consumed.
103 pub budget_consumed: u64,
104 /// The accounting snapshot time (RFC 3339), supplied by the host that
105 /// produced the report. This is the *report's* as-of, distinct from a
106 /// query's bi-temporal `as_of` retrieval pin.
107 pub as_of: String,
108 /// Per-provider usage, one entry per provider the query fanned out to.
109 #[serde(default, skip_serializing_if = "Vec::is_empty")]
110 pub providers: Vec<ProviderUsage>,
111}
112
113impl UsageReport {
114 /// Re-sum every provider's `token_cost`. Equals [`budget_consumed`](Self::budget_consumed)
115 /// for a consistently-built report.
116 pub fn total_provider_cost(&self) -> u64 {
117 self.providers.iter().map(|p| p.token_cost).sum()
118 }
119
120 /// The number of frames served across all providers.
121 pub fn total_frames_served(&self) -> u64 {
122 self.providers.iter().map(|p| p.frames_served as u64).sum()
123 }
124
125 /// The core arithmetic identity a metering pipeline checks before trusting
126 /// the report: the consumed total equals the summed cost of every served
127 /// frame, and each provider's aggregate agrees with its itemized frames.
128 /// A report that fails this is a corrupted total, never a silent misbill
129 /// (§2 conformance).
130 pub fn is_consistent(&self) -> bool {
131 self.budget_consumed == self.total_provider_cost()
132 && self.providers.iter().all(ProviderUsage::is_consistent)
133 }
134
135 /// Whether the request stayed within its requested budget. A conformant
136 /// host drops budget-lying providers *before* they reach a report, so a
137 /// report built by such a host always satisfies this.
138 pub fn within_budget(&self) -> bool {
139 self.budget_consumed <= self.budget_requested as u64
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 fn served(provider: &str, frame: &str, digest: &str, cost: u32) -> ServedFrame {
148 ServedFrame {
149 frame: FrameId::new(provider, frame, Some(digest.into())),
150 token_cost: cost,
151 }
152 }
153
154 fn sample_report() -> UsageReport {
155 let graph = ProviderUsage {
156 provider_id: "repo-graph".into(),
157 frames_served: 2,
158 frames_rejected: 0,
159 token_cost: 64,
160 served_frames: vec![
161 served("repo-graph", "retry-doc", "sha256:aa", 41),
162 served("repo-graph", "retry-sym", "sha256:bb", 23),
163 ],
164 };
165 let liar = ProviderUsage {
166 provider_id: "cloud-docs".into(),
167 frames_served: 0,
168 frames_rejected: 3,
169 token_cost: 0,
170 served_frames: vec![],
171 };
172 UsageReport {
173 budget_requested: 1024,
174 budget_consumed: 64,
175 as_of: "2026-07-21T00:00:00Z".into(),
176 providers: vec![graph, liar],
177 }
178 }
179
180 #[test]
181 fn usage_report_roundtrips_through_json() {
182 let report = sample_report();
183 let json = serde_json::to_string(&report).unwrap();
184 let back: UsageReport = serde_json::from_str(&json).unwrap();
185 assert_eq!(back, report);
186 }
187
188 #[test]
189 fn a_consistent_report_re_sums_from_its_served_frames() {
190 let report = sample_report();
191 assert!(report.is_consistent());
192 assert_eq!(report.total_provider_cost(), 64);
193 assert_eq!(report.budget_consumed, report.total_provider_cost());
194 assert_eq!(report.total_frames_served(), 2);
195 assert!(report.within_budget());
196 }
197
198 #[test]
199 fn a_tampered_total_fails_the_arithmetic_identity() {
200 // Inflate the consumed total without touching the served frames: the
201 // report no longer re-sums, and the check catches it — exactly the
202 // misbill a metering pipeline must refuse.
203 let mut report = sample_report();
204 report.budget_consumed = 9_999;
205 assert!(!report.is_consistent());
206
207 // A per-provider aggregate that disagrees with its frames also fails.
208 let mut report = sample_report();
209 report.providers[0].token_cost = 100; // frames still sum to 64
210 assert!(!report.is_consistent());
211 assert!(!report.providers[0].is_consistent());
212 }
213
214 #[test]
215 fn served_frames_carry_the_stable_identity_for_audit_walk_back() {
216 let report = sample_report();
217 let first = &report.providers[0].served_frames[0];
218 assert_eq!(first.frame.provider_id, "repo-graph");
219 assert_eq!(first.frame.frame_id, "retry-doc");
220 assert_eq!(first.frame.content_digest.as_deref(), Some("sha256:aa"));
221 assert_eq!(first.token_cost, 41);
222 }
223
224 #[test]
225 fn empty_provider_and_served_lists_are_omitted_when_absent() {
226 let report = UsageReport {
227 budget_requested: 100,
228 budget_consumed: 0,
229 as_of: "2026-07-21T00:00:00Z".into(),
230 providers: vec![],
231 };
232 let json = serde_json::to_string(&report).unwrap();
233 assert!(!json.contains("providers"));
234 let back: UsageReport = serde_json::from_str(&json).unwrap();
235 assert_eq!(back, report);
236 }
237}