Files
buzz/desktop/src-tauri/src/archive/agent_usage.rs
Will Pfleger 5e4c05f90b feat(desktop): NIP-AM agent-usage backend — P2 emission/transport/archive + P4a aggregation/D6 (#4000)
## What

Implements Phases 2 and 4a of the Usage v2 plan (plan events
`d0268cd0`/`0e95b035`), extending the archive backend to emit,
transport, archive, and aggregate both cache categories and billing
identity fail-closed.

### P2 — emission, transport, archive

**Tri-state accumulators** (`Unseen`/`Exact`/`Unknown`) for cache-read
and cache-write in `buzz-agent` turn and session state. Absent field =
Unknown (never zero) through the full pipeline. No `unwrap_or(0)` on the
cache path. Both cache folds are gated on usage-bearing responses (same
gate as the total-state and identity folds) — a response with no usage
at all must not poison either accumulator.

**Overflow-aware input token parsing and accumulation** — closed
end-to-end from parse through wire to ACP:
- `sum_usage()` returns `SumUsageResult` (`Exact(u64)` | `Overflow`) —
checked arithmetic, never clamps. `anthropic_input_tokens()` returns
`Option<SumUsageResult>` since it sums three fields (`input_tokens +
cache_read_input_tokens + cache_creation_input_tokens`) that can
collectively overflow. Single-field callers (`prompt_tokens`,
`completion_tokens`, etc.) convert via `.into_exact()` — their
single-field sums cannot overflow.
- `LlmResponse.input_tokens_overflowed: bool` propagates the parse-layer
signal into the run loop. When set, `input_tokens` is `None` (clamped
value discarded), the context-gate baseline
(`last_request_input_tokens`) is frozen at its prior reading, and
`turn_input_tokens` is poisoned to `TurnIOState::Poisoned` before any
emission — including mid-turn `emit_usage_update` calls. A dedicated
enum on `LlmResponse.input_tokens` would ripple into ~20 existing test
assertions on `r.input_tokens == Some(...)`; the bool flag confines the
change to the two call sites that check it.
- `TurnIOState` (`Unseen`/`Exact`/`Poisoned`) for input and output:
per-round fold uses `checked_add`; overflow poisons permanently at turn
and session level, no healing. Absence does not poison (pass-2-cleared
contract unchanged). Wire emission omits
`accumulatedInputTokens`/`accumulatedOutputTokens` when poisoned — never
null, never `u64::MAX`. ACP treats absent = publisher-poisoned:
`delta_reliable: false`, null turn fields, null cumulative for that
category; session cumulative stays unknown for all subsequent turns once
poisoned.

**Conditional wire emission** for `accumulatedCachedInputTokens` and new
`accumulatedCacheWriteTokens`: fields are omitted when the cumulative is
Unseen or Unknown. ACP `_goose/unstable/session/update` contract
documented next to the payload with tests for all absence/zero variants.

**`PricingIdentity` stamping (publisher-side)**:
- `pricing_authority()`: canonical parsed-URL endpoint comparison
against the official allowlist — HTTPS only, exact allowlisted host
(lookalike-safe), default port (omitted or explicit :443), required API
base path, rejects userinfo/query/fragment/path-prefix lookalikes.
- Model: the actually-requested `request_model` after mesh/auto
resolution (not `effective_model_str`).
- Turn discipline: identity retained only while ALL usage in the current
turn carries one identical proven identity; any mismatch,
unproven-usage-bearing response, or unpaired cumulative snapshot poisons
to absent; a later matching notification does not heal a mixed turn.

**ACP `UsageTracker` identity fold**: per-in-flight-turn tri-state
identity accumulator replacing last-update-wins. Any absent identity on
a token-advancing notification or exact mismatch poisons to absent;
poison survives later updates; reset in `begin_turn()`/`take()`; reset
also when a request fails (baseline cleared so preflight gate cannot
stay frozen sub-threshold on retries).

**M3 migration**: adds `turn_cache_write_tokens`,
`cumulative_cache_write_tokens`, `pricing_authority`, `pricing_model`,
`pricing_cache_class` to `agent_metric_index`. Additive, idempotent,
guarded per-column by marker. M2 migration also guarded per-column (turn
and cumulative cache-read columns checked and added independently;
marker commits only after both are present). Fresh-DB schema includes
all columns.

**First-turn baselines**: `seed_zero_baseline` seeds `last_input:
Some(0)`, `last_output: Some(0)`, `last_cached_input: Some(0)`,
`last_cache_write: Some(0)`, and `last_total: Some(0)` — all have the
known-zero-at-spawn argument. Absent fields from incoming snapshots
still produce unknown (tri-state unchanged). Sessions buzz-acp did not
spawn (no seed) remain fail-closed on turn one.

**`ReportedUsage` TS mirror**: `cacheReadTokens`, `cacheWriteTokens`,
`freshInputTokens` added to `tauriArchive.ts` as `UsageField` members,
field-for-field with the Rust struct.

### P4a — aggregation layer

**Extended S-1 ladder** to cache-read and cache-write via the same
`ladder_token` path as the existing token fields.

**`freshInputTokens` derivation**: checked arithmetic, fail-closed —
absent cache fields produce Unknown (not zero), overflow and
`cacheRead+cacheWrite > input` both produce `incomplete: true`.
Aggregated as a `UsageField`.

**D6 comparator**: `sort_value()` = provider total when known, else
`input+output` when both known, else `None` (unknown-last). Replaces the
prior total-only comparator for both agent-level and model-level sort.
Ships a pinned test vector that the TS render layer (P5) must match.

## Test coverage

- `buzz-agent`: 440 lib + 15 integration (golden_transcripts) — includes
13 new `cache_total_state_tests`; 14 new `turn_io_state_tests`; 3 new
`sum_usage_*` tests (exact single-field, exact two-field, overflow
signals correctly); 3 new `parse_anthropic_*` tests (overflow flag set +
value cleared, normal sum no flag, absent usage no flag); end-to-end
golden transcript drives real subprocess with Anthropic-shaped
`input_tokens: u64::MAX, cache_read: 1` response and asserts
`accumulatedInputTokens` absent from the emitted `usage_update` — no
logic duplication; 3 wire pin tests; 4 `fold_pricing_identity_*` tests;
`pricing_authority()` explicit-:443 acceptance
- `buzz-acp`: 700 tests (691 lib + 9 integration) — 4 new usage tests
(absent input → unreliable+null; absent output → unreliable+null;
goose-shaped both present unchanged; poison mid-session); 3 ACP behavior
tests; 7 pool lifecycle tests
- Desktop (Rust): 2259+ tests — 14 new P4a pinned tests; 2 M3 round-trip
tests; 1 serde key-shape test; 2 M2 partial-schema migration tests;
first-turn cache round-trip test

## Related PRs

- P1 NIP-AM spec: [#4632](https://github.com/block/buzz/pull/4632)
- P3 pricing table: [#4629](https://github.com/block/buzz/pull/4629)
- UI (P5): [#4001](https://github.com/block/buzz/pull/4001)

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-10 10:47:37 -04:00

887 lines
33 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Pure NIP-AM usage accounting: request validation, wire types, and the
//! per-field cumulative/direct-fallback ladder.
//!
//! No Tauri or filesystem dependency — every function here takes already
//! loaded rows (or plain values) and returns plain values. The caller
//! (`mod.rs`'s `get_agent_usage_series` command, Phase 2) owns SQLite access
//! (`metric_store.rs`) and glues the two together: load window rows, compute
//! [`window_probe_keys`], load those exact-key rows, then call
//! [`compute_series`].
//!
//! Accounting contract (Rev 3, frozen plan + amendments A1/A2/A4/A9/A11–A13):
//! see `docs/nips/NIP-AM.md:119-160` for the NIP itself.
use std::collections::{HashMap, HashSet};
use serde::{Deserialize, Serialize};
use super::metric_store::AgentMetricIndexRow;
// ── Request ──────────────────────────────────────────────────────────────────
/// Request for [`compute_series`]'s caller. `bucket_boundaries` are exact
/// local-midnight Unix-second boundaries built by the frontend (inclusive
/// start / exclusive end per adjacent pair): N entries = N - 1 buckets, so
/// 2 entries = 1 bucket (`1d`), 8 = 7 buckets, 31 = 30 buckets, and an
/// arbitrary custom range falls anywhere in between up to
/// [`MAX_BOUNDARIES`].
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentUsageSeriesRequest {
pub bucket_boundaries: Vec<i64>,
pub agent_pubkey: Option<String>,
}
/// Widest interval NIP-AM query validation admits (A9): wide enough to admit
/// every real civil-day transition (ordinary DST, 30-minute-offset zones,
/// historical calendar skips) while still rejecting arbitrary bins.
const MAX_INTERVAL_SECS: i64 = 48 * 3600;
/// Largest boundary count a request may carry: 367 boundaries = 366 daily
/// buckets = one leap year, the product ceiling on the custom date-range
/// picker. Bounds the SQLite window scan and keeps the rendered bar chart
/// legible; the frontend clamps the picker to the same span so a user never
/// reaches this check, which stays as fail-closed defense in depth.
const MAX_BOUNDARIES: usize = 367;
/// Smallest boundary count that describes a real window: 2 boundaries =
/// 1 bucket, the `1d` case.
const MIN_BOUNDARIES: usize = 2;
/// Validate a request per the frozen contract + A9 (drops the 23–25h band
/// for a `> 0 && <= 48h` sanity band) and A13 pubkey normalization.
///
/// Fails closed before any SQLite work. Returns the normalized (lowercased)
/// agent pubkey, if one was supplied.
pub(super) fn validate_request(req: &AgentUsageSeriesRequest) -> Result<Option<String>, String> {
let n = req.bucket_boundaries.len();
if !(MIN_BOUNDARIES..=MAX_BOUNDARIES).contains(&n) {
return Err(format!(
"bucket_boundaries must have between {MIN_BOUNDARIES} and {MAX_BOUNDARIES} entries, got {n}"
));
}
for i in 0..n - 1 {
let (a, b) = (req.bucket_boundaries[i], req.bucket_boundaries[i + 1]);
if b <= a {
return Err(format!(
"bucket_boundaries must be strictly increasing (index {i}: {a} >= {b})"
));
}
let interval = b - a;
if interval > MAX_INTERVAL_SECS {
return Err(format!(
"bucket_boundaries interval at index {i} is {interval}s, exceeds {MAX_INTERVAL_SECS}s"
));
}
}
// Finite Unix-second bounds: must be representable as an RFC 3339
// instant (chrono's timestamp range), independent of local timezone.
for &t in &req.bucket_boundaries {
if chrono::DateTime::from_timestamp(t, 0).is_none() {
return Err(format!("bucket boundary {t} is out of representable range"));
}
}
let normalized_pubkey = match &req.agent_pubkey {
None => None,
Some(pk) => {
if pk.len() != 64 || !pk.chars().all(|c| c.is_ascii_hexdigit()) {
return Err("agent_pubkey must be exactly 64 hex characters".to_string());
}
Some(pk.to_lowercase())
}
};
Ok(normalized_pubkey)
}
// ── Wire types ───────────────────────────────────────────────────────────────
/// Per-field completeness (A2): `value: null` means no known increment in
/// scope; `incomplete: true` on a non-null value means the value is a
/// reported lower bound, not full coverage.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageField {
pub value: Option<String>,
pub incomplete: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CostField {
pub value: Option<f64>,
pub incomplete: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReportedUsage {
pub input_tokens: UsageField,
pub output_tokens: UsageField,
pub total_tokens: UsageField,
pub estimated_cost_usd: CostField,
/// Cache-read (served) token count. `None` value + `incomplete: false`
/// means no events in this scope reported the field; in practice this
/// only happens for empty scopes. A non-empty scope where ALL events had
/// absent `turn_cache_read_tokens` (old harness) produces `incomplete: true`
/// (unknown, not zero).
pub cache_read_tokens: UsageField,
/// Cache-write (creation) token count. Same absence semantics as
/// `cache_read_tokens`.
pub cache_write_tokens: UsageField,
/// Input tokens minus cache-served and cache-write subsets. Computed only
/// when all three inputs are complete and the arithmetic succeeds
/// (`cacheRead + cacheWrite ≤ input`). Otherwise `incomplete: true`.
pub fresh_input_tokens: UsageField,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SeriesBucket {
pub start: i64,
pub end: i64,
pub usage: ReportedUsage,
pub report_count: i64,
pub has_unknown_usage: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelUsage {
pub harness: Option<String>,
pub model: Option<String>,
pub usage: ReportedUsage,
pub report_count: i64,
pub has_unknown_usage: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentUsage {
pub agent_pubkey: String,
pub usage: ReportedUsage,
pub buckets: Vec<SeriesBucket>,
pub models: Vec<ModelUsage>,
pub report_count: i64,
pub has_unknown_usage: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Coverage {
pub first_archived_at: Option<i64>,
pub last_archived_at: Option<i64>,
pub first_reported_at: Option<i64>,
pub last_reported_at: Option<i64>,
pub report_count: i64,
pub invalid_report_count: i64,
pub has_unknown_usage: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentUsageSeries {
pub collection_enabled: bool,
pub buckets: Vec<SeriesBucket>,
pub agents: Vec<AgentUsage>,
pub coverage: Coverage,
/// A13: `null` when the request had no `agentPubkey` filter; otherwise
/// `true` iff at least one surviving `agent_metric_index` row (either
/// `parse_status`) exists for that author, with no bucket-boundary
/// restriction. Callers compute this (DB access) and pass it through.
pub has_archived_evidence: Option<bool>,
}
// ── Per-event field ladder (A1, A4, A11, A12) ───────────────────────────────
#[derive(Debug, Clone, Copy)]
enum FieldValue<T> {
Known(T),
Unknown,
}
struct EventOutcome {
input: FieldValue<u64>,
output: FieldValue<u64>,
total: FieldValue<u64>,
cost: FieldValue<f64>,
cache_read: FieldValue<u64>,
cache_write: FieldValue<u64>,
}
/// Per-field ladder for token counters (A1): adjacent nondecreasing cumulative
/// pair → diff; adjacent decreasing pair → unknown, terminal, no fallback;
/// no usable baseline for this field → `deltaReliable` direct value or
/// unknown.
fn ladder_token(
baseline_cumulative: Option<u64>,
current_cumulative: Option<u64>,
current_turn: Option<u64>,
delta_reliable: bool,
) -> FieldValue<u64> {
if let (Some(prev), Some(cur)) = (baseline_cumulative, current_cumulative) {
return if cur >= prev {
FieldValue::Known(cur - prev)
} else {
FieldValue::Unknown
};
}
if delta_reliable {
if let Some(v) = current_turn {
return FieldValue::Known(v);
}
}
FieldValue::Unknown
}
/// Same ladder for `costUsd` (f64).
fn ladder_cost(
baseline_cumulative: Option<f64>,
current_cumulative: Option<f64>,
current_turn: Option<f64>,
delta_reliable: bool,
) -> FieldValue<f64> {
if let (Some(prev), Some(cur)) = (baseline_cumulative, current_cumulative) {
return if cur >= prev {
FieldValue::Known(cur - prev)
} else {
FieldValue::Unknown
};
}
if delta_reliable {
if let Some(v) = current_turn {
return FieldValue::Known(v);
}
}
FieldValue::Unknown
}
/// Resolve the exact-`S-1` baseline row for `row`, or `None` if no usable
/// baseline exists (A11/A12): missing session/sequence key, duplicate row at
/// `row`'s own sequence (A4 — no cumulative delta for any row at a
/// duplicated sequence), sequence `0` (no predecessor, `checked_sub`
/// underflow), no predecessor row (gap), or duplicate rows at the
/// predecessor sequence (ambiguous baseline).
///
/// `probe_by_key` must contain, for every key queried, ALL valid rows at
/// that exact `(agent, session, turnSeq)` — used for both this baseline
/// lookup and the A4/A11 duplicate-cardinality check, which is why absence
/// of a key from the map is treated identically to an empty group.
fn resolve_baseline<'a>(
row: &AgentMetricIndexRow,
probe_by_key: &HashMap<(String, String, u64), Vec<&'a AgentMetricIndexRow>>,
) -> Option<&'a AgentMetricIndexRow> {
let (agent, session, seq) = row.accounting_key()?;
let own_group = probe_by_key.get(&(agent.clone(), session.clone(), seq))?;
if own_group.len() > 1 {
return None; // A4: duplicate at own sequence — no cumulative delta.
}
let pred_seq = seq.checked_sub(1)?; // A12: seq == 0 has no baseline.
let pred_group = probe_by_key.get(&(agent, session, pred_seq))?;
if pred_group.len() != 1 {
return None; // Missing (gap) or ambiguous (duplicate) predecessor.
}
Some(pred_group[0])
}
fn compute_event_outcome(
row: &AgentMetricIndexRow,
probe_by_key: &HashMap<(String, String, u64), Vec<&AgentMetricIndexRow>>,
) -> EventOutcome {
let baseline = resolve_baseline(row, probe_by_key);
let delta_reliable = row.delta_reliable.unwrap_or(false);
EventOutcome {
input: ladder_token(
baseline.and_then(|b| b.cumulative_input_tokens),
row.cumulative_input_tokens,
row.turn_input_tokens,
delta_reliable,
),
output: ladder_token(
baseline.and_then(|b| b.cumulative_output_tokens),
row.cumulative_output_tokens,
row.turn_output_tokens,
delta_reliable,
),
total: ladder_token(
baseline.and_then(|b| b.cumulative_total_tokens),
row.cumulative_total_tokens,
row.turn_total_tokens,
delta_reliable,
),
cost: ladder_cost(
baseline.and_then(|b| b.cumulative_cost_usd),
row.cumulative_cost_usd,
row.turn_cost_usd,
delta_reliable,
),
cache_read: ladder_token(
baseline.and_then(|b| b.cumulative_cache_read_tokens),
row.cumulative_cache_read_tokens,
row.turn_cache_read_tokens,
delta_reliable,
),
cache_write: ladder_token(
baseline.and_then(|b| b.cumulative_cache_write_tokens),
row.cumulative_cache_write_tokens,
row.turn_cache_write_tokens,
delta_reliable,
),
}
}
/// The exact `(agent, session, turnSeq)` keys the caller must load via
/// `metric_store::load_rows_at_exact_keys` before calling [`compute_series`]
/// (A11): each in-window row's own key, plus its checked predecessor key
/// (`turnSeq - 1`) when one exists. Pure and DB-free so it is unit-testable
/// without SQLite.
pub(super) fn window_probe_keys(
window_rows: &[AgentMetricIndexRow],
) -> HashSet<(String, String, u64)> {
let mut keys = HashSet::new();
for row in window_rows {
if let Some((agent, session, seq)) = row.accounting_key() {
if let Some(pred) = seq.checked_sub(1) {
keys.insert((agent.clone(), session.clone(), pred));
}
keys.insert((agent, session, seq));
}
}
keys
}
// ── Accumulators ─────────────────────────────────────────────────────────────
/// Sums known per-event increments with `checked_add`; an event with an
/// unknown value, or an overflow, marks the scope `incomplete` (A2) without
/// ever wrapping (overflow freezes the sum at its last valid value).
#[derive(Debug, Default, Clone)]
struct TokenAccumulator {
value: Option<u64>,
incomplete: bool,
overflowed: bool,
}
impl TokenAccumulator {
fn add(&mut self, v: FieldValue<u64>) {
match v {
FieldValue::Unknown => self.incomplete = true,
FieldValue::Known(x) => {
if self.overflowed {
self.incomplete = true;
return;
}
self.value = Some(match self.value {
None => x,
Some(cur) => match cur.checked_add(x) {
Some(sum) => sum,
None => {
self.overflowed = true;
self.incomplete = true;
cur
}
},
});
}
}
}
fn has_unknown(&self) -> bool {
self.incomplete
}
fn finish(self) -> UsageField {
UsageField {
value: self.value.map(|v| v.to_string()),
incomplete: self.incomplete,
}
}
}
/// Same contract as [`TokenAccumulator`] for `f64` costs: "checked finite
/// addition" means a sum that would become non-finite is rejected and the
/// scope freezes at its last valid value, flagged incomplete.
#[derive(Debug, Default, Clone)]
struct CostAccumulator {
value: Option<f64>,
incomplete: bool,
overflowed: bool,
}
impl CostAccumulator {
fn add(&mut self, v: FieldValue<f64>) {
match v {
FieldValue::Unknown => self.incomplete = true,
FieldValue::Known(x) => {
if self.overflowed {
self.incomplete = true;
return;
}
let candidate = match self.value {
None => x,
Some(cur) => cur + x,
};
if candidate.is_finite() {
self.value = Some(candidate);
} else {
self.overflowed = true;
self.incomplete = true;
}
}
}
}
fn has_unknown(&self) -> bool {
self.incomplete
}
fn finish(self) -> CostField {
CostField {
value: self.value,
incomplete: self.incomplete,
}
}
}
#[derive(Debug, Default, Clone)]
struct UsageAccumulator {
input: TokenAccumulator,
output: TokenAccumulator,
total: TokenAccumulator,
cost: CostAccumulator,
cache_read: TokenAccumulator,
cache_write: TokenAccumulator,
}
impl UsageAccumulator {
fn add(&mut self, outcome: &EventOutcome) {
self.input.add(outcome.input);
self.output.add(outcome.output);
self.total.add(outcome.total);
self.cost.add(outcome.cost);
self.cache_read.add(outcome.cache_read);
self.cache_write.add(outcome.cache_write);
}
fn has_unknown(&self) -> bool {
self.input.has_unknown()
|| self.output.has_unknown()
|| self.total.has_unknown()
|| self.cost.has_unknown()
|| self.cache_read.has_unknown()
|| self.cache_write.has_unknown()
|| self.fresh_input_incomplete()
}
/// Returns true when `derive_fresh_input()` would produce `incomplete: true`.
///
/// Mirrors the fail-closed conditions of `derive_fresh_input()` without
/// consuming `self`, so `has_unknown()` can be called before `finish()` at
/// all six call sites. The two functions must stay in sync.
fn fresh_input_incomplete(&self) -> bool {
// Input unknown or incomplete → fresh_input incomplete.
if self.input.incomplete {
return true;
}
// No input events at all (value = None, not incomplete) → no fresh_input
// to derive; not a failure, not incomplete.
let Some(input) = self.input.value else {
return false;
};
// Any cache accumulator incomplete → fresh_input incomplete.
if self.cache_read.incomplete || self.cache_write.incomplete {
return true;
}
let cache_read = self.cache_read.value.unwrap_or(0);
let cache_write = self.cache_write.value.unwrap_or(0);
// Subset sum overflow → incomplete.
let Some(subset_sum) = cache_read.checked_add(cache_write) else {
return true;
};
// Subsets exceed input → incomplete.
input < subset_sum
}
/// D6 sort value: provider `totalTokens` when known, else `input+output`
/// when BOTH are known (no overflow), else `None` (unknown-last). This is
/// the sole sort key; provenance (whether total came from a single field vs
/// two) is not distinguished — both are "known" and rank before unknown.
fn sort_value(&self) -> Option<u64> {
// Prefer the reported total if present and exact.
if let Some(t) = self.total.value {
if !self.total.incomplete {
return Some(t);
}
}
// Fall back to input+output when both are complete and neither
// overflowed. An incomplete accumulator may still carry a partial
// value; we must not use it (treat as unknown).
match (self.input.value, self.output.value) {
(Some(i), Some(o)) if !self.input.incomplete && !self.output.incomplete => {
i.checked_add(o)
}
_ => None,
}
}
/// Derive `freshInputTokens` from accumulated input, cache-read, and
/// cache-write: checked arithmetic — fail-closed on any unknown input or
/// cache delta, overflow, or subsets-exceed-input condition.
///
/// Cache-read and cache-write follow the same Unknown semantics as the
/// main token ladder: a turn or cumulative value that was absent (not
/// reported by the harness) produces `FieldValue::Unknown` in
/// `compute_event_outcome`, which poisons the accumulator as `incomplete`.
/// An absent cache field is therefore "unknown, not zero," and always
/// produces `incomplete: true` here.
///
/// The only case where the cache accumulator can be `{ value: None,
/// incomplete: false }` without any events is an empty scope, which
/// cannot occur in practice (scopes are created on first event).
///
/// Conditions that produce `incomplete: true`:
/// - input is unknown (None value or incomplete = true)
/// - any cache category's accumulator is incomplete
/// - checked_sub(input - (cacheRead + cacheWrite)) would underflow
/// (cacheRead + cacheWrite > input)
/// - overflow in cacheRead + cacheWrite sum
fn derive_fresh_input(&self) -> UsageField {
// If input is unknown or incomplete, fresh_input is incomplete too.
if self.input.incomplete {
return UsageField {
value: None,
incomplete: true,
};
}
let input = match self.input.value {
Some(v) => v,
None => {
// No input events observed — no fresh_input to compute.
return UsageField {
value: None,
incomplete: false,
};
}
};
// Cache-read and cache-write: absent means zero (no provider reported it),
// but incomplete (unknown delta) means fail-closed.
if self.cache_read.incomplete || self.cache_write.incomplete {
return UsageField {
value: None,
incomplete: true,
};
}
let cache_read = self.cache_read.value.unwrap_or(0);
let cache_write = self.cache_write.value.unwrap_or(0);
// Sum of subsets must not overflow and must not exceed input.
let subset_sum = match cache_read.checked_add(cache_write) {
Some(s) => s,
None => {
return UsageField {
value: None,
incomplete: true,
};
}
};
match input.checked_sub(subset_sum) {
Some(fresh) => UsageField {
value: Some(fresh.to_string()),
incomplete: false,
},
None => UsageField {
value: None,
incomplete: true,
},
}
}
fn finish(self) -> ReportedUsage {
let fresh_input = self.derive_fresh_input();
ReportedUsage {
input_tokens: self.input.finish(),
output_tokens: self.output.finish(),
total_tokens: self.total.finish(),
estimated_cost_usd: self.cost.finish(),
cache_read_tokens: self.cache_read.finish(),
cache_write_tokens: self.cache_write.finish(),
fresh_input_tokens: fresh_input,
}
}
}
// ── Bucket assignment ────────────────────────────────────────────────────────
/// Which `[boundaries[i], boundaries[i+1])` bucket `t` falls in, or `None`
/// if outside every bucket (defensive; callers scope their row query to
/// `[boundaries[0], boundaries[last])` so this should never miss).
fn assign_bucket_index(boundaries: &[i64], t: i64) -> Option<usize> {
(0..boundaries.len().saturating_sub(1)).find(|&i| t >= boundaries[i] && t < boundaries[i + 1])
}
// ── Sort helpers ─────────────────────────────────────────────────────────────
/// D6 sort comparator: known values descending, unknown (`None`) last; equal
/// within unknowns (callers chain further tiebreaks). Value is derived by
/// [`UsageAccumulator::sort_value`]: provider total when available, else
/// `input+output`, else `None`.
fn cmp_sort_value(a: Option<u64>, b: Option<u64>) -> std::cmp::Ordering {
match (a, b) {
(Some(av), Some(bv)) => bv.cmp(&av), // descending
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
}
/// Ordinal (byte-order) comparison of two optional strings, with `None`
/// sorting after any `Some` value — used as a stable tiebreak for harness and
/// model names so ordering is locale-independent and matches the Rust backend.
fn cmp_option_str_none_last(a: &Option<String>, b: &Option<String>) -> std::cmp::Ordering {
match (a, b) {
(Some(av), Some(bv)) => av.cmp(bv),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
}
// ── compute_series ───────────────────────────────────────────────────────────
/// Per-agent accumulation scope, built while walking `window_rows` once.
struct AgentScope {
buckets: Vec<UsageAccumulator>,
bucket_counts: Vec<i64>,
total: UsageAccumulator,
report_count: i64,
/// Keyed by `(harness, model)` — same model via two harnesses → two rows.
models: HashMap<(Option<String>, Option<String>), (UsageAccumulator, i64)>,
}
/// Compute the full [`AgentUsageSeries`] from already-loaded rows.
///
/// - `window_rows`: valid rows with `reported_at` in `[boundaries[0],
/// boundaries[last])` (optionally pre-filtered to one agent), from
/// `metric_store::load_window_valid_rows`.
/// - `probe_rows`: valid rows at the exact keys from [`window_probe_keys`],
/// from `metric_store::load_rows_at_exact_keys` — used for baseline
/// resolution and A4/A11 duplicate-cardinality checks, unrestricted by the
/// window.
/// - `invalid_report_count`: from `metric_store::count_invalid_rows_in_window`.
/// - `has_archived_evidence`: from `metric_store::has_archived_evidence`,
/// already resolved to `None` when the request has no `agentPubkey` filter
/// (A13) — this function does not decide that; it only carries the value.
pub(super) fn compute_series(
window_rows: &[AgentMetricIndexRow],
probe_rows: &[AgentMetricIndexRow],
invalid_report_count: i64,
boundaries: &[i64],
has_archived_evidence: Option<bool>,
collection_enabled: bool,
) -> AgentUsageSeries {
let bucket_count = boundaries.len().saturating_sub(1);
let mut probe_by_key: HashMap<(String, String, u64), Vec<&AgentMetricIndexRow>> =
HashMap::new();
for r in probe_rows {
if let Some(key) = r.accounting_key() {
probe_by_key.entry(key).or_default().push(r);
}
}
let mut overall_buckets: Vec<UsageAccumulator> = (0..bucket_count)
.map(|_| UsageAccumulator::default())
.collect();
let mut overall_bucket_counts: Vec<i64> = vec![0; bucket_count];
let mut agents: HashMap<String, AgentScope> = HashMap::new();
let mut first_reported_at: Option<i64> = None;
let mut last_reported_at: Option<i64> = None;
let mut first_archived_at: Option<i64> = None;
let mut last_archived_at: Option<i64> = None;
for row in window_rows {
// Defensive: the loader already scopes to reported_at in-window and
// parse_status = 'valid'; a miss here means the caller passed rows
// it should not have, so skip rather than panic or miscount.
let Some(reported_at) = row.reported_at else {
continue;
};
let Some(bucket_idx) = assign_bucket_index(boundaries, reported_at) else {
continue;
};
first_reported_at = Some(first_reported_at.map_or(reported_at, |v| v.min(reported_at)));
last_reported_at = Some(last_reported_at.map_or(reported_at, |v| v.max(reported_at)));
first_archived_at =
Some(first_archived_at.map_or(row.archived_at, |v| v.min(row.archived_at)));
last_archived_at =
Some(last_archived_at.map_or(row.archived_at, |v| v.max(row.archived_at)));
let outcome = compute_event_outcome(row, &probe_by_key);
overall_buckets[bucket_idx].add(&outcome);
overall_bucket_counts[bucket_idx] += 1;
let scope = agents
.entry(row.agent_pubkey.clone())
.or_insert_with(|| AgentScope {
buckets: (0..bucket_count)
.map(|_| UsageAccumulator::default())
.collect(),
bucket_counts: vec![0; bucket_count],
total: UsageAccumulator::default(),
report_count: 0,
models: HashMap::new(),
});
scope.buckets[bucket_idx].add(&outcome);
scope.bucket_counts[bucket_idx] += 1;
scope.total.add(&outcome);
scope.report_count += 1;
let model_entry = scope
.models
.entry((row.harness.clone(), row.model.clone()))
.or_insert_with(|| (UsageAccumulator::default(), 0i64));
model_entry.0.add(&outcome);
model_entry.1 += 1;
}
let overall_report_count: i64 = overall_bucket_counts.iter().sum();
let buckets: Vec<SeriesBucket> = overall_buckets
.into_iter()
.zip(overall_bucket_counts)
.enumerate()
.map(|(i, (acc, count))| {
let has_unknown_usage = acc.has_unknown();
SeriesBucket {
start: boundaries[i],
end: boundaries[i + 1],
usage: acc.finish(),
report_count: count,
has_unknown_usage,
}
})
.collect();
let any_overall_bucket_unknown = buckets.iter().any(|b| b.has_unknown_usage);
// Build agent rows, then apply the D6 ranking rule: known sort value
// (provider total, else input+output) descending, unknown-value agents
// after, pubkey as the final tiebreak for determinism.
let mut agent_rows: Vec<(Option<u64>, String, AgentUsage)> = agents
.into_iter()
.map(|(agent_pubkey, scope)| {
let sort_value = scope.total.sort_value();
let has_unknown_usage = scope.total.has_unknown();
let buckets: Vec<SeriesBucket> = scope
.buckets
.into_iter()
.zip(scope.bucket_counts)
.enumerate()
.map(|(i, (acc, count))| {
let has_unknown_usage = acc.has_unknown();
SeriesBucket {
start: boundaries[i],
end: boundaries[i + 1],
usage: acc.finish(),
report_count: count,
has_unknown_usage,
}
})
.collect();
// Named sort key so the 4-tuple doesn't exceed clippy's type_complexity
// threshold and the ordering intent reads at a glance.
struct ModelSortKey {
sort_val: Option<u64>,
harness: Option<String>,
model: Option<String>,
usage: ModelUsage,
}
let mut model_rows: Vec<ModelSortKey> = scope
.models
.into_iter()
.map(|((harness, model), (acc, count))| {
let sort_val = acc.sort_value();
let has_unknown_usage = acc.has_unknown();
ModelSortKey {
sort_val,
harness: harness.clone(),
model: model.clone(),
usage: ModelUsage {
harness,
model,
usage: acc.finish(),
report_count: count,
has_unknown_usage,
},
}
})
.collect();
// D6 ranking: known sort value (provider total, else input+output)
// descending, unknown-value rows after; tiebreak: harness ascending
// (None last), then model ascending (None last), for determinism.
model_rows.sort_by(|a, b| {
cmp_sort_value(a.sort_val, b.sort_val)
.then_with(|| cmp_option_str_none_last(&a.harness, &b.harness))
.then_with(|| cmp_option_str_none_last(&a.model, &b.model))
});
let models = model_rows.into_iter().map(|k| k.usage).collect();
(
sort_value,
agent_pubkey.clone(),
AgentUsage {
agent_pubkey,
usage: scope.total.finish(),
buckets,
models,
report_count: scope.report_count,
has_unknown_usage,
},
)
})
.collect();
agent_rows.sort_by(|a, b| cmp_sort_value(a.0, b.0).then_with(|| a.1.cmp(&b.1)));
let any_agent_unknown = agent_rows.iter().any(|(_, _, a)| a.has_unknown_usage);
let agents: Vec<AgentUsage> = agent_rows.into_iter().map(|(_, _, a)| a).collect();
AgentUsageSeries {
collection_enabled,
buckets,
agents,
coverage: Coverage {
first_archived_at,
last_archived_at,
first_reported_at,
last_reported_at,
report_count: overall_report_count,
invalid_report_count,
has_unknown_usage: any_overall_bucket_unknown
|| any_agent_unknown
|| invalid_report_count > 0,
},
has_archived_evidence,
}
}
// ── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
#[path = "agent_usage_tests.rs"]
mod agent_usage_tests;
#[cfg(test)]
#[path = "agent_usage_p4a_tests.rs"]
mod agent_usage_p4a_tests;