Skip to main content

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}