storage/attribution/report.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Where the money went. Four independent cuts of
7//! one authoritative total: by Run, by actor, by prefix segment, by
8//! tool.
9//!
10//! The invariant that makes the report trustworthy (A20) is that each
11//! cut sums to exactly the billed total — not approximately, not after
12//! rounding. Percentages cannot promise that, so nothing here is a
13//! percentage: shares are split by the largest-remainder method, which
14//! distributes the floor divisions and then hands the leftover units
15//! out one at a time, in a fixed order. What is billed is what is
16//! attributed, to the microdollar.
17//!
18//! The total comes from the `billed_usd_micros` a provider reported for
19//! a call (`model_returned`) or a keep-warm renewal (`cache_renewed`),
20//! and nowhere else. This module never prices a call — that authority is
21//! `gateway::cost`, and a second one would be a second answer.
22//!
23//! Specified by `crates/storage/spec/Attribution.lean` §8-7.
24
25//! Attribution reports: what each wave billed.
26
27use super::split::{add, quantify, segment_weights, split};
28
29/// The bucket a call falls into when the ledger gives no basis for a
30/// finer split — an honest "unsplit", never a silent drop.
31pub(crate) const NO_TOOL: &str = "no_tool";
32pub(crate) const NO_SKILL: &str = "no_skill";
33pub(crate) const NO_SEGMENT: &str = "unattributed";
34pub(crate) const WINDOW_SLOT: &str = "window";
35/// The bucket a keep-warm renewal's bill takes in every cut but
36/// `by_actor`: a renewal belongs to no run and has no segment basis,
37/// tool wave or skill, and a named bucket keeps its money apart from
38/// the honest buckets of calls that do (storage D36).
39pub(crate) const KEEP_WARM: &str = "keep_warm";
40
41use std::collections::BTreeMap;
42
43use kernel::event::record::{CacheRenewed, RenewalOutcome};
44use kernel::{Address, EventKind, EventRecord, RunId, UsdMicros};
45use serde_json::Value;
46
47use crate::error::StorageError;
48
49#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
50pub struct Attribution {
51 by_run: BTreeMap<String, u64>,
52 by_actor: BTreeMap<String, u64>,
53 by_segment: BTreeMap<String, u64>,
54 by_tool: BTreeMap<String, u64>,
55 by_skill: BTreeMap<String, u64>,
56 total: u64,
57 /// Basis for the next model_returned: each run's most recent prefix
58 /// shape, and the tool bytes returned since the previous call. The
59 /// shape is keyed by run because a run records its prompt once, so
60 /// the latest `prompt_assembled` in the ledger may be another run's.
61 segment_weights: BTreeMap<RunId, Vec<(String, u64)>>,
62 tool_weights: BTreeMap<String, u64>,
63 skill_weights: BTreeMap<String, u64>,
64 unpriced: Unpriced,
65 #[serde(default)]
66 renewals: Renewals,
67}
68
69pub struct AttributionReport {
70 pub total: UsdMicros,
71 pub by_run: Vec<(String, UsdMicros)>,
72 pub by_actor: Vec<(String, UsdMicros)>,
73 pub by_segment: Vec<(String, UsdMicros)>,
74 pub by_tool: Vec<(String, UsdMicros)>,
75 pub by_skill: Vec<(String, UsdMicros)>,
76 pub unpriced: Unpriced,
77 pub renewals: Renewals,
78}
79
80/// The model calls that came back with no authoritative amount. They
81/// add nothing to `total`, so without this count a city whose provider
82/// never prices a call reads the same as a city that never ran one; the
83/// tokens are the one measure of their use the ledger does hold.
84#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
85pub struct Unpriced {
86 pub calls: u64,
87 pub tokens: u64,
88}
89
90impl Unpriced {
91 /// Counts one call that came back with no amount, by the usage its
92 /// line `data` carries.
93 fn count(&mut self, data: &serde_json::Map<String, Value>) {
94 self.calls = self.calls.saturating_add(1);
95 self.tokens = self.tokens.saturating_add(usage_tokens(data));
96 }
97}
98
99/// The keep-warm renewals the ledger holds: what the priced ones were
100/// billed, an amount `total` and every cut already hold, so a cost view
101/// draws it as a line inside the total and never adds it again (storage
102/// D36); and how many showed what about the cache each was sent to keep
103/// (`RenewalOutcome`), which is how a cost view says how many arrived
104/// too late (storage D35).
105#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
106pub struct Renewals {
107 #[serde(default)]
108 pub billed: UsdMicros,
109 pub read: u64,
110 pub late: u64,
111 pub unreported: u64,
112 pub refused: u64,
113}
114
115impl Renewals {
116 fn count(&mut self, outcome: RenewalOutcome) {
117 let slot = match outcome {
118 RenewalOutcome::Read => &mut self.read,
119 RenewalOutcome::Late => &mut self.late,
120 RenewalOutcome::Unreported => &mut self.unreported,
121 RenewalOutcome::Refused => &mut self.refused,
122 };
123 *slot = slot.saturating_add(1);
124 }
125}
126
127impl Attribution {
128 pub fn new() -> Attribution {
129 Attribution::default()
130 }
131
132 /// Folds one record in. `model_returned` and `cache_renewed` move
133 /// money; two other kinds set the basis on which the next call is
134 /// split, and `run_frozen` drops the basis of a run that makes no
135 /// further call.
136 #[expect(
137 clippy::wildcard_enum_match_arm,
138 reason = "five kinds carry money, its basis or a renewal; the rest of the vocabulary is silent here"
139 )]
140 pub fn apply(&mut self, record: &EventRecord) -> Result<(), StorageError> {
141 match record.kind() {
142 EventKind::CacheRenewed => self.renewed(record),
143 EventKind::PromptAssembled => {
144 self.segment_weights
145 .insert(record.run(), segment_weights(record.data().as_map()));
146 }
147 EventKind::RunFrozen => {
148 self.segment_weights.remove(&record.run());
149 }
150 EventKind::ToolResult => {
151 let name = record
152 .data()
153 .as_map()
154 .get("name")
155 .and_then(Value::as_str)
156 .unwrap_or("unnamed")
157 .to_owned();
158 let bytes = record
159 .data()
160 .as_map()
161 .get("bytes")
162 .and_then(Value::as_u64)
163 .unwrap_or(1);
164 let slot = self.tool_weights.entry(name).or_insert(0);
165 *slot = slot.saturating_add(bytes);
166 // The SKILL a call was made under, when one was; a call
167 // without one goes to the `no_skill` bucket.
168 let skill = record
169 .data()
170 .as_map()
171 .get("skill")
172 .and_then(Value::as_str)
173 .unwrap_or(NO_SKILL)
174 .to_owned();
175 let slot = self.skill_weights.entry(skill).or_insert(0);
176 *slot = slot.saturating_add(bytes);
177 }
178 EventKind::ModelReturned => {
179 let Some(billed) = record
180 .data()
181 .as_map()
182 .get("billed_usd_micros")
183 .and_then(Value::as_u64)
184 else {
185 // A call with no authoritative amount attributes
186 // nothing. Estimating here would invent money.
187 self.unpriced.count(record.data().as_map());
188 self.tool_weights.clear();
189 self.skill_weights.clear();
190 return Ok(());
191 };
192 self.total = self.total.saturating_add(billed);
193 add(&mut self.by_run, &record.run().to_string(), billed);
194 add(&mut self.by_actor, record.who(), billed);
195 let segment_basis = self
196 .segment_weights
197 .get(&record.run())
198 .map_or(&[][..], Vec::as_slice);
199 for (bucket, share) in split(billed, segment_basis, NO_SEGMENT) {
200 add(&mut self.by_segment, &bucket, share);
201 }
202 let tool_basis: Vec<(String, u64)> = self
203 .tool_weights
204 .iter()
205 .map(|(k, v)| (k.clone(), *v))
206 .collect();
207 for (bucket, share) in split(billed, &tool_basis, NO_TOOL) {
208 add(&mut self.by_tool, &bucket, share);
209 }
210 let skill_basis: Vec<(String, u64)> = self
211 .skill_weights
212 .iter()
213 .map(|(k, v)| (k.clone(), *v))
214 .collect();
215 for (bucket, share) in split(billed, &skill_basis, NO_SKILL) {
216 add(&mut self.by_skill, &bucket, share);
217 }
218 // The wave is settled; the next call starts a new basis.
219 self.tool_weights.clear();
220 self.skill_weights.clear();
221 }
222 _ => {}
223 }
224 Ok(())
225 }
226
227 /// Folds one `cache_renewed` line: counted by what it showed, and
228 /// its bill, when a provider gave one, added to the total, to the
229 /// room it kept warm in `by_actor`, and to the [`KEEP_WARM`] bucket
230 /// of the other four cuts (storage D36).
231 ///
232 /// A line this build cannot read still records a renewal that was
233 /// sent; what cannot be read is its usage and its bill, so it counts
234 /// as `unreported` and moves no money (storage D35). A renewal
235 /// leaves the tool and skill waves alone: it is no call of the run
236 /// whose wave is open.
237 fn renewed(&mut self, record: &EventRecord) {
238 let line = match record.data().read::<CacheRenewed>() {
239 Ok(line) => line,
240 Err(_unreadable) => {
241 self.renewals.count(RenewalOutcome::Unreported);
242 return;
243 }
244 };
245 self.renewals.count(line.outcome());
246 match line {
247 CacheRenewed::Refused { .. } => {}
248 CacheRenewed::Answered {
249 billed_usd_micros: None,
250 ..
251 } => self.unpriced.count(record.data().as_map()),
252 CacheRenewed::Answered {
253 billed_usd_micros: Some(billed),
254 ..
255 } => {
256 let micros = billed.get();
257 self.total = self.total.saturating_add(micros);
258 self.renewals.billed =
259 UsdMicros::new(self.renewals.billed.get().saturating_add(micros));
260 let room = record.addr().map_or(record.who(), Address::as_str);
261 add(&mut self.by_actor, room, micros);
262 for cut in [
263 &mut self.by_run,
264 &mut self.by_segment,
265 &mut self.by_tool,
266 &mut self.by_skill,
267 ] {
268 add(cut, KEEP_WARM, micros);
269 }
270 }
271 }
272 }
273
274 /// What `run` was billed, when this fold holds a row for it: one
275 /// lookup rather than a whole `report()` per question.
276 pub fn billed_to(&self, run: &RunId) -> Option<UsdMicros> {
277 self.by_run
278 .get(&run.to_string())
279 .copied()
280 .map(UsdMicros::new)
281 }
282
283 /// Drops the `by_run` row of every run `keep` refuses. `total` and
284 /// the other four cuts keep the money, so they still sum to `total`
285 /// while `by_run` shrinks to the runs a caller still holds warm.
286 pub fn retain_runs(&mut self, keep: impl Fn(&RunId) -> bool) {
287 self.by_run
288 .retain(|name, _| RunId::parse(name).map_or(true, |run| keep(&run)));
289 }
290
291 pub fn report(&self) -> AttributionReport {
292 AttributionReport {
293 total: UsdMicros::new(self.total),
294 by_run: quantify(&self.by_run),
295 by_actor: quantify(&self.by_actor),
296 by_segment: quantify(&self.by_segment),
297 by_tool: quantify(&self.by_tool),
298 by_skill: quantify(&self.by_skill),
299 unpriced: self.unpriced,
300 renewals: self.renewals,
301 }
302 }
303}
304
305/// The tokens of one call's `usage`: input and output, the only two
306/// disjoint counts — the dialect layer normalizes `input_tokens` to
307/// the whole prompt, cache reads and writes included
308/// (`crates/gateway/spec/Dialect.lean` §8-1), so adding the cache counts
309/// again would count the cached part twice. A call that reported no
310/// usage used no tokens anyone measured.
311fn usage_tokens(data: &serde_json::Map<String, Value>) -> u64 {
312 let usage = data.get("usage");
313 ["input_tokens", "output_tokens"]
314 .into_iter()
315 .filter_map(|field| usage.and_then(|u| u.get(field)).and_then(Value::as_u64))
316 .fold(0, u64::saturating_add)
317}
318
319#[cfg(test)]
320mod tests;