Skip to main content

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;