Skip to main content

lean_ctx/http_server/
savings_summary.rs

1//! `GET /v1/savings/summary` — the team savings roll-up (the customer-facing
2//! "team usage visibility" surface, powering the account ROI dashboard).
3//!
4//! The savings store holds one append-only JSONL file per signer
5//! (`savings_<pubkey>.jsonl`); each line is a [`SignedSavingsBatchV1`] snapshot
6//! of that signer's **whole** local ledger (`period = "all"`). Successive batches
7//! from the same signer are therefore cumulative re-snapshots, **not** increments
8//! — so the honest team total is the sum of each signer's *latest* batch, never
9//! the sum of every batch (which would multiply-count). Integrity is enforced at
10//! ingest ([`super::savings_ingest`] verifies the Ed25519 signature before
11//! storing), so this read path trusts the stored snapshots and parses defensively.
12//!
13//! Because every snapshot carries its own `created_at`, the cumulative history can
14//! be replayed into a **daily time series**: for each signer, the value on a given
15//! day is its most recent snapshot on or before that day (carry-forward); summing
16//! across signers yields the team's cumulative ROI curve over the trailing window.
17//! This is real reported data — no interpolation, no synthetic points.
18//!
19//! Authorisation: gated by [`TeamScope::Audit`](super::team) in the team auth
20//! middleware (owner/admin only) — aggregate savings is sensitive team data.
21
22use std::collections::HashMap;
23use std::path::Path;
24
25use axum::{
26    extract::{Path as AxumPath, State},
27    http::StatusCode,
28    response::IntoResponse,
29    Json,
30};
31use chrono::{Days, NaiveDate, Utc};
32use serde::Serialize;
33
34use crate::core::savings_ledger::SignedSavingsBatchV1;
35
36use super::team::TeamAppState;
37
38/// Trailing window (days) for the cumulative savings time series.
39const SERIES_WINDOW_DAYS: u32 = 90;
40/// Cap on per-model / per-tool rows surfaced to the dashboard.
41const MAX_BREAKDOWN_ROWS: usize = 10;
42
43/// Team-wide savings roll-up, aggregated from each member's latest signed batch.
44#[derive(Debug, Default, Serialize)]
45pub struct TeamSavingsSummary {
46    pub schema_version: u32,
47    pub generated_at: String,
48    /// Distinct signers (≈ developers/agents) that have reported savings.
49    pub member_count: usize,
50    pub totals: SavingsTotals,
51    /// One row per signer, descending by net saved tokens.
52    pub by_member: Vec<MemberSavings>,
53    /// Cross-team model breakdown (summed over each member's latest batch).
54    pub by_model: Vec<ModelRow>,
55    /// Cross-team tool breakdown (summed over each member's latest batch).
56    pub by_tool: Vec<ToolRow>,
57    /// Trailing-window cumulative daily series (oldest → newest). Empty until at
58    /// least one timestamped batch exists.
59    pub series: Vec<SeriesPoint>,
60    /// Length of the series window in days (for client-side labelling).
61    pub window_days: u32,
62}
63
64#[derive(Debug, Default, Serialize)]
65pub struct SavingsTotals {
66    /// Gross saved tokens (before bounce adjustment).
67    pub saved_tokens: u64,
68    /// Net saved tokens (gross minus compressed→full re-read bounce).
69    pub net_saved_tokens: u64,
70    /// Conservative USD upper bound (ignores prompt-cache discounts).
71    pub saved_usd: f64,
72    /// Measured agent actions across the team (sum of each signer's latest batch).
73    pub total_events: u64,
74}
75
76#[derive(Debug, Serialize)]
77pub struct MemberSavings {
78    /// Truncated signer public key — a stable, privacy-preserving member id.
79    pub signer: String,
80    pub agent_id: String,
81    pub saved_tokens: u64,
82    pub net_saved_tokens: u64,
83    pub saved_usd: f64,
84    /// Measured agent actions for this signer (latest batch).
85    pub total_events: u64,
86    /// `created_at` of the member's most recent batch (RFC 3339).
87    pub last_reported: String,
88}
89
90#[derive(Debug, Serialize)]
91pub struct ModelRow {
92    pub model: String,
93    pub saved_tokens: u64,
94    pub saved_usd: f64,
95}
96
97#[derive(Debug, Serialize)]
98pub struct ToolRow {
99    pub tool: String,
100    pub saved_tokens: u64,
101}
102
103/// One day of the cumulative team series. Values are team-wide cumulative totals
104/// as of the end of `date` (UTC), reconstructed by carrying each signer's latest
105/// snapshot forward.
106#[derive(Debug, Clone, PartialEq, Serialize)]
107pub struct SeriesPoint {
108    /// `YYYY-MM-DD` (UTC).
109    pub date: String,
110    pub net_saved_tokens: u64,
111    pub saved_usd: f64,
112    pub total_events: u64,
113}
114
115/// A single signer's cumulative snapshot on a given day.
116#[derive(Debug, Clone, Copy)]
117struct DayPoint {
118    date: NaiveDate,
119    net_saved_tokens: u64,
120    saved_usd: f64,
121    total_events: u64,
122}
123
124/// Per-member drilldown (GL #389) — one signer's full picture: latest totals,
125/// model/tool breakdowns from the latest batch, and a 90-day cumulative series
126/// replayed from that signer's snapshot history alone.
127#[derive(Debug, Serialize)]
128pub struct MemberDrilldown {
129    pub schema_version: u32,
130    pub generated_at: String,
131    /// Truncated signer public key — matches `by_member[].signer` in the summary.
132    pub signer: String,
133    pub agent_id: String,
134    /// `created_at` of the member's most recent batch (RFC 3339).
135    pub last_reported: String,
136    pub totals: SavingsTotals,
137    /// This member's model breakdown (latest batch, top rows).
138    pub by_model: Vec<ModelRow>,
139    /// This member's tool breakdown (latest batch, top rows).
140    pub by_tool: Vec<ToolRow>,
141    /// Trailing-window cumulative daily series for this member only.
142    pub series: Vec<SeriesPoint>,
143    pub window_days: u32,
144}
145
146pub async fn v1_savings_summary(State(state): State<TeamAppState>) -> impl IntoResponse {
147    let dir = state.team.savings_store_dir.lock().await.clone();
148    let summary = tokio::task::spawn_blocking(move || aggregate(&dir))
149        .await
150        .unwrap_or_default();
151    (StatusCode::OK, Json(summary))
152}
153
154/// `GET /v1/savings/member/{signer}` — drilldown for one member (GL #389).
155/// `signer` is the truncated public key from `by_member[].signer`. Audit-scoped
156/// like the summary (same sensitivity class). 404 when the signer has never
157/// reported; 400 when the id can't be a signer prefix (defense-in-depth: the
158/// id is also used to derive a store filename).
159pub async fn v1_savings_member(
160    State(state): State<TeamAppState>,
161    AxumPath(signer): AxumPath<String>,
162) -> axum::response::Response {
163    if !is_valid_signer_prefix(&signer) {
164        return super::json_error(
165            StatusCode::BAD_REQUEST,
166            "invalid_signer",
167            "signer must be 1-64 chars of [A-Za-z0-9_-]",
168        );
169    }
170    let dir = state.team.savings_store_dir.lock().await.clone();
171    let drill = tokio::task::spawn_blocking(move || member_drilldown(&dir, &signer))
172        .await
173        .ok()
174        .flatten();
175    match drill {
176        Some(d) => (StatusCode::OK, Json(d)).into_response(),
177        None => super::json_error(
178            StatusCode::NOT_FOUND,
179            "unknown_member",
180            "no savings batches reported for this signer",
181        ),
182    }
183}
184
185/// Signer ids are truncated Ed25519 public keys (hex or base64url) — anything
186/// outside `[A-Za-z0-9_-]{1,64}` is rejected before touching the filesystem.
187fn is_valid_signer_prefix(s: &str) -> bool {
188    !s.is_empty()
189        && s.len() <= 64
190        && s.bytes()
191            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
192}
193
194/// Build the drilldown for one signer from its JSONL snapshot history.
195/// Returns `None` when the signer file doesn't exist or holds no parseable batch.
196pub(super) fn member_drilldown(dir: &Path, signer: &str) -> Option<MemberDrilldown> {
197    // Ingest stores files under the *truncated* signer key, so the id from
198    // `by_member[].signer` maps 1:1 onto a filename.
199    let truncated: String = signer.chars().take(16).collect();
200    let path = dir.join(format!("savings_{truncated}.jsonl"));
201
202    let batches = read_all_batches(&path);
203    let latest = batches.last()?;
204
205    let mut by_model: Vec<ModelRow> = latest
206        .totals
207        .by_model
208        .iter()
209        .map(|(model, tokens, usd)| ModelRow {
210            model: model.clone(),
211            saved_tokens: *tokens,
212            saved_usd: round_usd(*usd),
213        })
214        .collect();
215    by_model.sort_by_key(|r| std::cmp::Reverse(r.saved_tokens));
216    by_model.truncate(MAX_BREAKDOWN_ROWS);
217
218    let mut by_tool: Vec<ToolRow> = latest
219        .totals
220        .by_tool
221        .iter()
222        .map(|(tool, tokens)| ToolRow {
223            tool: tool.clone(),
224            saved_tokens: *tokens,
225        })
226        .collect();
227    by_tool.sort_by_key(|r| std::cmp::Reverse(r.saved_tokens));
228    by_tool.truncate(MAX_BREAKDOWN_ROWS);
229
230    let mut points: Vec<DayPoint> = batches
231        .iter()
232        .filter_map(|b| {
233            parse_date(&b.created_at).map(|date| DayPoint {
234                date,
235                net_saved_tokens: b.totals.net_saved_tokens,
236                saved_usd: b.totals.saved_usd,
237                total_events: b.totals.total_events as u64,
238            })
239        })
240        .collect();
241    points.sort_by_key(|p| p.date);
242    let series = build_series(
243        std::slice::from_ref(&points),
244        Utc::now().date_naive(),
245        SERIES_WINDOW_DAYS,
246    );
247
248    Some(MemberDrilldown {
249        schema_version: 1,
250        generated_at: Utc::now().to_rfc3339(),
251        signer: truncated,
252        agent_id: latest.agent_id.clone(),
253        last_reported: latest.created_at.clone(),
254        totals: SavingsTotals {
255            saved_tokens: latest.totals.saved_tokens,
256            net_saved_tokens: latest.totals.net_saved_tokens,
257            saved_usd: round_usd(latest.totals.saved_usd),
258            total_events: latest.totals.total_events as u64,
259        },
260        by_model,
261        by_tool,
262        series,
263        window_days: SERIES_WINDOW_DAYS,
264    })
265}
266
267/// Aggregate the savings store: latest batch per signer (totals/breakdowns) plus
268/// a carry-forward daily series replayed from every signer's full snapshot history.
269/// Also feeds the `/v1/usage` snapshot ([`super::team_billing`]).
270pub(super) fn aggregate(dir: &Path) -> TeamSavingsSummary {
271    let mut members: Vec<MemberSavings> = Vec::new();
272    let mut model_totals: HashMap<String, (u64, f64)> = HashMap::new();
273    let mut tool_totals: HashMap<String, u64> = HashMap::new();
274    let mut totals = SavingsTotals::default();
275    let mut signer_points: Vec<Vec<DayPoint>> = Vec::new();
276
277    let Ok(entries) = std::fs::read_dir(dir) else {
278        return finalize(totals, members, model_totals, tool_totals, &signer_points);
279    };
280
281    for entry in entries.flatten() {
282        let path = entry.path();
283        let named_savings = path
284            .file_name()
285            .and_then(|n| n.to_str())
286            .is_some_and(|n| n.starts_with("savings_"));
287        let is_jsonl = path
288            .extension()
289            .is_some_and(|e| e.eq_ignore_ascii_case("jsonl"));
290        if !(named_savings && is_jsonl) {
291            continue;
292        }
293
294        let batches = read_all_batches(&path);
295        let Some(batch) = batches.last() else {
296            continue;
297        };
298
299        totals.saved_tokens = totals
300            .saved_tokens
301            .saturating_add(batch.totals.saved_tokens);
302        totals.net_saved_tokens = totals
303            .net_saved_tokens
304            .saturating_add(batch.totals.net_saved_tokens);
305        totals.saved_usd += batch.totals.saved_usd;
306        totals.total_events = totals
307            .total_events
308            .saturating_add(batch.totals.total_events as u64);
309
310        for (model, tokens, usd) in &batch.totals.by_model {
311            let acc = model_totals.entry(model.clone()).or_default();
312            acc.0 = acc.0.saturating_add(*tokens);
313            acc.1 += *usd;
314        }
315        for (tool, tokens) in &batch.totals.by_tool {
316            let acc = tool_totals.entry(tool.clone()).or_default();
317            *acc = acc.saturating_add(*tokens);
318        }
319
320        let signer = batch.signer_public_key.as_deref().unwrap_or("unknown");
321        members.push(MemberSavings {
322            signer: signer.chars().take(16).collect(),
323            agent_id: batch.agent_id.clone(),
324            saved_tokens: batch.totals.saved_tokens,
325            net_saved_tokens: batch.totals.net_saved_tokens,
326            saved_usd: round_usd(batch.totals.saved_usd),
327            total_events: batch.totals.total_events as u64,
328            last_reported: batch.created_at.clone(),
329        });
330
331        let mut points: Vec<DayPoint> = batches
332            .iter()
333            .filter_map(|b| {
334                parse_date(&b.created_at).map(|date| DayPoint {
335                    date,
336                    net_saved_tokens: b.totals.net_saved_tokens,
337                    saved_usd: b.totals.saved_usd,
338                    total_events: b.totals.total_events as u64,
339                })
340            })
341            .collect();
342        points.sort_by_key(|p| p.date);
343        signer_points.push(points);
344    }
345
346    finalize(totals, members, model_totals, tool_totals, &signer_points)
347}
348
349fn finalize(
350    mut totals: SavingsTotals,
351    mut members: Vec<MemberSavings>,
352    model_totals: HashMap<String, (u64, f64)>,
353    tool_totals: HashMap<String, u64>,
354    signer_points: &[Vec<DayPoint>],
355) -> TeamSavingsSummary {
356    totals.saved_usd = round_usd(totals.saved_usd);
357    members.sort_by_key(|m| std::cmp::Reverse(m.net_saved_tokens));
358
359    let mut by_model: Vec<ModelRow> = model_totals
360        .into_iter()
361        .map(|(model, (saved_tokens, usd))| ModelRow {
362            model,
363            saved_tokens,
364            saved_usd: round_usd(usd),
365        })
366        .collect();
367    by_model.sort_by_key(|r| std::cmp::Reverse(r.saved_tokens));
368    by_model.truncate(MAX_BREAKDOWN_ROWS);
369
370    let mut by_tool: Vec<ToolRow> = tool_totals
371        .into_iter()
372        .map(|(tool, saved_tokens)| ToolRow { tool, saved_tokens })
373        .collect();
374    by_tool.sort_by_key(|r| std::cmp::Reverse(r.saved_tokens));
375    by_tool.truncate(MAX_BREAKDOWN_ROWS);
376
377    let series = build_series(signer_points, Utc::now().date_naive(), SERIES_WINDOW_DAYS);
378
379    TeamSavingsSummary {
380        schema_version: 2,
381        generated_at: Utc::now().to_rfc3339(),
382        member_count: members.len(),
383        totals,
384        by_member: members,
385        by_model,
386        by_tool,
387        series,
388        window_days: SERIES_WINDOW_DAYS,
389    }
390}
391
392/// Replay each signer's snapshot history into a team-wide cumulative daily series
393/// over the trailing `window_days` ending at `today`. For each day, a signer
394/// contributes its most recent snapshot on or before that day (carry-forward);
395/// the per-day team value is the sum across signers. Returns an empty series when
396/// no signer has any timestamped batch.
397fn build_series(
398    signer_points: &[Vec<DayPoint>],
399    today: NaiveDate,
400    window_days: u32,
401) -> Vec<SeriesPoint> {
402    if window_days == 0 || signer_points.iter().all(Vec::is_empty) {
403        return Vec::new();
404    }
405    let start = today - Days::new(u64::from(window_days.saturating_sub(1)));
406
407    // Per-signer cursor into its (date-ascending) snapshot list and the
408    // carried-forward cumulative value as of the current day.
409    let mut cursor = vec![0usize; signer_points.len()];
410    let mut carried = vec![(0u64, 0f64, 0u64); signer_points.len()];
411
412    let mut out: Vec<SeriesPoint> = Vec::with_capacity(window_days as usize);
413    let mut day = start;
414    while day <= today {
415        let mut net = 0u64;
416        let mut usd = 0f64;
417        let mut events = 0u64;
418        for (si, points) in signer_points.iter().enumerate() {
419            while cursor[si] < points.len() && points[cursor[si]].date <= day {
420                let p = points[cursor[si]];
421                carried[si] = (p.net_saved_tokens, p.saved_usd, p.total_events);
422                cursor[si] += 1;
423            }
424            net = net.saturating_add(carried[si].0);
425            usd += carried[si].1;
426            events = events.saturating_add(carried[si].2);
427        }
428        out.push(SeriesPoint {
429            date: day.format("%Y-%m-%d").to_string(),
430            net_saved_tokens: net,
431            saved_usd: round_usd(usd),
432            total_events: events,
433        });
434        match day.succ_opt() {
435            Some(next) => day = next,
436            None => break,
437        }
438    }
439    out
440}
441
442/// All parseable batches in a signer's JSONL file, in file (append/chronological)
443/// order. The last element is the signer's latest cumulative snapshot.
444fn read_all_batches(path: &Path) -> Vec<SignedSavingsBatchV1> {
445    let Ok(content) = std::fs::read_to_string(path) else {
446        return Vec::new();
447    };
448    content
449        .lines()
450        .filter_map(|line| {
451            let line = line.trim();
452            if line.is_empty() {
453                return None;
454            }
455            serde_json::from_str::<SignedSavingsBatchV1>(line).ok()
456        })
457        .collect()
458}
459
460/// Parse an RFC 3339 `created_at` into a UTC calendar date.
461fn parse_date(created_at: &str) -> Option<NaiveDate> {
462    chrono::DateTime::parse_from_rfc3339(created_at)
463        .ok()
464        .map(|dt| dt.with_timezone(&Utc).date_naive())
465}
466
467fn round_usd(v: f64) -> f64 {
468    (v * 1_000_000.0).round() / 1_000_000.0
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use crate::core::savings_ledger::signed_batch::BatchTotals;
475
476    fn batch(signer: &str, net: u64, usd: f64, created_at: &str) -> SignedSavingsBatchV1 {
477        SignedSavingsBatchV1 {
478            schema_version: 1,
479            kind: "lean-ctx.savings-batch".into(),
480            created_at: created_at.into(),
481            lean_ctx_version: "test".into(),
482            agent_id: format!("agent-{signer}"),
483            period: "all".into(),
484            first_entry_hash: "genesis".into(),
485            last_entry_hash: "head".into(),
486            chain_valid: true,
487            totals: BatchTotals {
488                total_events: 1,
489                saved_tokens: net,
490                net_saved_tokens: net,
491                saved_usd: usd,
492                bounce_tokens: 0,
493                bounce_events: 0,
494                tokenizers: vec!["o200k_base".into()],
495                by_model: vec![("claude-opus".into(), net, usd)],
496                by_tool: vec![("ctx_read".into(), net)],
497            },
498            signer_public_key: Some(signer.into()),
499            signature: Some("sig".into()),
500        }
501    }
502
503    fn write_lines(dir: &Path, file: &str, batches: &[SignedSavingsBatchV1]) {
504        let body = batches
505            .iter()
506            .map(|b| serde_json::to_string(b).unwrap())
507            .collect::<Vec<_>>()
508            .join("\n");
509        std::fs::write(dir.join(file), body + "\n").unwrap();
510    }
511
512    fn temp_dir(tag: &str) -> std::path::PathBuf {
513        let d = std::env::temp_dir().join(format!(
514            "leanctx_savings_summary_{tag}_{}",
515            std::process::id()
516        ));
517        let _ = std::fs::remove_dir_all(&d);
518        std::fs::create_dir_all(&d).unwrap();
519        d
520    }
521
522    fn day(s: &str) -> NaiveDate {
523        NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
524    }
525
526    fn points(raw: &[(&str, u64, f64, u64)]) -> Vec<DayPoint> {
527        raw.iter()
528            .map(|(d, net, usd, ev)| DayPoint {
529                date: day(d),
530                net_saved_tokens: *net,
531                saved_usd: *usd,
532                total_events: *ev,
533            })
534            .collect()
535    }
536
537    #[test]
538    fn latest_batch_per_signer_is_not_double_counted() {
539        let dir = temp_dir("nodouble");
540        // Signer A re-snapshots twice (1000 → 3000); only the latest must count.
541        write_lines(
542            &dir,
543            "savings_aaaaaaaaaaaaaaaa.jsonl",
544            &[
545                batch("aaaaaaaaaaaaaaaa", 1000, 0.01, "2026-06-01T00:00:00Z"),
546                batch("aaaaaaaaaaaaaaaa", 3000, 0.03, "2026-06-08T00:00:00Z"),
547            ],
548        );
549        // Signer B has a single snapshot.
550        write_lines(
551            &dir,
552            "savings_bbbbbbbbbbbbbbbb.jsonl",
553            &[batch(
554                "bbbbbbbbbbbbbbbb",
555                2000,
556                0.02,
557                "2026-06-07T00:00:00Z",
558            )],
559        );
560
561        let s = aggregate(&dir);
562        assert_eq!(s.schema_version, 2);
563        assert_eq!(s.member_count, 2);
564        // 3000 (A latest) + 2000 (B) = 5000 — NOT 1000+3000+2000.
565        assert_eq!(s.totals.net_saved_tokens, 5000);
566        // total_events = 1 (A latest) + 1 (B) = 2.
567        assert_eq!(s.totals.total_events, 2);
568        // by_member sorted descending by net tokens.
569        assert_eq!(s.by_member[0].net_saved_tokens, 3000);
570        assert_eq!(s.by_member[1].net_saved_tokens, 2000);
571        assert_eq!(s.by_member[0].total_events, 1);
572        // model + tool breakdowns summed over members' latest batches.
573        assert_eq!(s.by_model[0].model, "claude-opus");
574        assert_eq!(s.by_model[0].saved_tokens, 5000);
575        assert_eq!(s.by_tool[0].tool, "ctx_read");
576        assert_eq!(s.by_tool[0].saved_tokens, 5000);
577
578        let _ = std::fs::remove_dir_all(&dir);
579    }
580
581    #[test]
582    fn empty_or_missing_store_is_zeroed() {
583        let missing = std::env::temp_dir().join("leanctx_savings_summary_does_not_exist_xyz");
584        let _ = std::fs::remove_dir_all(&missing);
585        let s = aggregate(&missing);
586        assert_eq!(s.member_count, 0);
587        assert_eq!(s.totals.net_saved_tokens, 0);
588        assert!(s.by_member.is_empty());
589        assert!(s.series.is_empty());
590        assert_eq!(s.window_days, SERIES_WINDOW_DAYS);
591    }
592
593    #[test]
594    fn non_savings_files_are_ignored() {
595        let dir = temp_dir("ignore");
596        std::fs::write(dir.join("audit.jsonl"), "{\"not\":\"a batch\"}\n").unwrap();
597        std::fs::write(dir.join("README.md"), "hello\n").unwrap();
598        write_lines(
599            &dir,
600            "savings_cccccccccccccccc.jsonl",
601            &[batch(
602                "cccccccccccccccc",
603                700,
604                0.007,
605                "2026-06-08T00:00:00Z",
606            )],
607        );
608        let s = aggregate(&dir);
609        assert_eq!(s.member_count, 1);
610        assert_eq!(s.totals.net_saved_tokens, 700);
611        let _ = std::fs::remove_dir_all(&dir);
612    }
613
614    #[test]
615    fn series_carries_each_signer_snapshot_forward_and_sums() {
616        // A: 1000 on day 1, re-snapshots to 3000 on day 3.
617        // B: 2000 on day 2 (single snapshot).
618        let a = points(&[
619            ("2026-06-01", 1000, 0.01, 10),
620            ("2026-06-03", 3000, 0.03, 30),
621        ]);
622        let b = points(&[("2026-06-02", 2000, 0.02, 20)]);
623        let series = build_series(&[a, b], day("2026-06-04"), 4);
624
625        // 4-day window: 06-01 .. 06-04.
626        assert_eq!(series.len(), 4);
627        // day 1: A=1000, B=0 → 1000.
628        assert_eq!(series[0].date, "2026-06-01");
629        assert_eq!(series[0].net_saved_tokens, 1000);
630        assert_eq!(series[0].total_events, 10);
631        // day 2: A=1000 (carried), B=2000 → 3000.
632        assert_eq!(series[1].net_saved_tokens, 3000);
633        assert_eq!(series[1].total_events, 30);
634        // day 3: A=3000 (re-snapshot), B=2000 → 5000.
635        assert_eq!(series[2].net_saved_tokens, 5000);
636        // day 4: both carried forward → 5000.
637        assert_eq!(series[3].net_saved_tokens, 5000);
638        assert_eq!(series[3].total_events, 50);
639        assert!((series[3].saved_usd - 0.05).abs() < 1e-9);
640    }
641
642    #[test]
643    fn series_window_clips_to_recent_days_only() {
644        // A snapshot well before the window must still be carried in as the
645        // opening value (not dropped) so the curve starts at the true baseline.
646        let a = points(&[("2026-01-01", 5000, 0.5, 100)]);
647        let series = build_series(&[a], day("2026-06-03"), 3);
648        assert_eq!(series.len(), 3);
649        assert_eq!(series[0].date, "2026-06-01");
650        // Carried forward from January.
651        assert_eq!(series[0].net_saved_tokens, 5000);
652        assert_eq!(series[2].net_saved_tokens, 5000);
653    }
654
655    #[test]
656    fn series_is_empty_without_points() {
657        let series = build_series(&[Vec::new(), Vec::new()], day("2026-06-03"), 30);
658        assert!(series.is_empty());
659    }
660
661    #[test]
662    fn member_drilldown_returns_latest_breakdowns_and_own_series() {
663        let dir = temp_dir("drill");
664        // Two snapshots: drilldown totals/breakdowns must come from the LATEST,
665        // the series from the full history (1000 → 3000).
666        write_lines(
667            &dir,
668            "savings_aaaaaaaaaaaaaaaa.jsonl",
669            &[
670                batch("aaaaaaaaaaaaaaaa", 1000, 0.01, "2026-06-01T00:00:00Z"),
671                batch("aaaaaaaaaaaaaaaa", 3000, 0.03, "2026-06-03T00:00:00Z"),
672            ],
673        );
674        // A second signer must NOT leak into A's drilldown.
675        write_lines(
676            &dir,
677            "savings_bbbbbbbbbbbbbbbb.jsonl",
678            &[batch(
679                "bbbbbbbbbbbbbbbb",
680                9999,
681                0.99,
682                "2026-06-02T00:00:00Z",
683            )],
684        );
685
686        let d = member_drilldown(&dir, "aaaaaaaaaaaaaaaa").expect("drilldown");
687        assert_eq!(d.signer, "aaaaaaaaaaaaaaaa");
688        assert_eq!(d.agent_id, "agent-aaaaaaaaaaaaaaaa");
689        assert_eq!(d.totals.net_saved_tokens, 3000);
690        assert_eq!(d.last_reported, "2026-06-03T00:00:00Z");
691        assert_eq!(d.by_model.len(), 1);
692        assert_eq!(d.by_model[0].model, "claude-opus");
693        assert_eq!(d.by_model[0].saved_tokens, 3000);
694        assert_eq!(d.by_tool[0].tool, "ctx_read");
695        assert_eq!(d.window_days, SERIES_WINDOW_DAYS);
696        // Series is member-only: its last value equals the member's latest
697        // snapshot, not the team total (which would include signer B).
698        let last = d.series.last().expect("series");
699        assert_eq!(last.net_saved_tokens, 3000);
700        assert_eq!(last.total_events, 1);
701
702        let _ = std::fs::remove_dir_all(&dir);
703    }
704
705    #[test]
706    fn member_drilldown_unknown_signer_is_none() {
707        let dir = temp_dir("drillmissing");
708        assert!(member_drilldown(&dir, "cccccccccccccccc").is_none());
709        let _ = std::fs::remove_dir_all(&dir);
710    }
711
712    #[test]
713    fn signer_prefix_validation_rejects_path_chars() {
714        assert!(is_valid_signer_prefix("aaaaaaaaaaaaaaaa"));
715        assert!(is_valid_signer_prefix("AbC123_-"));
716        assert!(!is_valid_signer_prefix(""));
717        assert!(!is_valid_signer_prefix("../../etc/passwd"));
718        assert!(!is_valid_signer_prefix("a/b"));
719        assert!(!is_valid_signer_prefix("a.b"));
720        assert!(!is_valid_signer_prefix(&"a".repeat(65)));
721    }
722}