Skip to main content

tokenfold_core/
stats.rs

1//! F-046: savings ledger, stats aggregation, and JSON/CSV export (`roadmap.md` F-046,
2//! `interfaces.md` §7.1 "Stats and Analytics JSON").
3//!
4//! `StatsSummary`/`LedgerRecord` mirror `interfaces.md`'s documented JSON shapes exactly.
5//! `aggregate()` is the one pure aggregation path shared by the CLI's `stats`/`gain`/`session`
6//! subcommands (each just calls it and then tweaks framing fields like `scope`/`window`).
7//!
8//! Ledger storage format: `tokenfold.toml`'s `[analytics].ledger_db` path is documented as
9//! ending in `.db`, but this pass stores newline-delimited JSON (JSONL) inside it rather than
10//! embedding a sqlite dependency — there is no sqlite crate anywhere in this workspace's
11//! dependency graph. The `.db` extension is only what the config schema names the path; the
12//! format inside is plain JSONL. Upgrade path is a real embedded database (sqlite or similar)
13//! if this ever needs concurrent-writer safety; a single local CLI process appending its own
14//! ledger doesn't need that yet.
15//!
16//! Fields with no honest data source yet are zero-filled rather than invented, each with a
17//! comment at its computation site: `cache` (no cache subsystem exists anywhere in this
18//! codebase), `retrieval.hits`/`misses`/`expired` (only store-time marker counts are tracked,
19//! not later retrieval-attempt outcomes), `latency` (no per-request timing is threaded through
20//! `CompressionReport`/`LedgerRecord` yet — every `TransformReport.elapsed_micros` in this
21//! codebase is already always `None`), and `untrusted_filter_count` (the F-047 filter registry
22//! doesn't exist yet).
23
24use std::path::{Path, PathBuf};
25use std::time::{SystemTime, UNIX_EPOCH};
26
27use serde::{Deserialize, Serialize};
28
29use crate::errors::TokenFoldError;
30use crate::report::CompressionReport;
31use crate::status::Status;
32
33pub const SCHEMA_VERSION: &str = "1.0";
34
35#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
36pub struct RetrievalStats {
37    pub markers: usize,
38    pub hits: usize,
39    pub misses: usize,
40    pub expired: usize,
41}
42
43#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
44pub struct CacheStats {
45    pub hits: usize,
46    pub misses: usize,
47}
48
49#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
50pub struct LatencyStats {
51    pub p50_ms: f64,
52    pub p95_ms: f64,
53}
54
55/// Matches `interfaces.md` §7.1's full `StatsSummary` JSON shape verbatim.
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57pub struct StatsSummary {
58    pub schema_version: String,
59    pub scope: String,
60    pub window: String,
61    pub project: Option<String>,
62    pub requests: usize,
63    pub commands: usize,
64    pub wrapped_commands: usize,
65    pub raw_commands: usize,
66    pub bypass_count: usize,
67    pub raw_tokens: usize,
68    pub compressed_tokens: usize,
69    pub saved_tokens: usize,
70    pub savings_pct: f64,
71    pub estimated_lost_tokens: usize,
72    pub coverage_pct: f64,
73    pub untrusted_filter_count: usize,
74    pub retrieval: RetrievalStats,
75    pub cache: CacheStats,
76    pub latency: LatencyStats,
77    pub recent_requests: Vec<LedgerRecord>,
78}
79
80/// Matches `interfaces.md` §7.1's redacted `recent_requests[]` item shape verbatim. This is
81/// also the exact shape persisted (one per line, as JSON) by [`LedgerStore`] — there is no
82/// separate "storage" representation, so no raw prompt/response/command-arg/path/header bytes
83/// can ever end up on disk through this type.
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
85pub struct LedgerRecord {
86    pub request_id: String,
87    pub timestamp: String,
88    pub surface: String,
89    pub format: String,
90    pub mode: String,
91    pub status: String,
92    pub original_tokens: usize,
93    pub compressed_tokens: usize,
94    pub saved_tokens: usize,
95    pub savings_pct: f64,
96    pub bypass_reason: Option<String>,
97    pub project_hash: Option<String>,
98}
99
100/// Whether a savings/tokens figure came from an exact estimator (`"measured"`), the byte
101/// heuristic (`"heuristic"`), or is a derived extrapolation like `estimated_lost_tokens`
102/// (`"estimated"`) rather than a directly observed value. There is no per-provider pricing data
103/// anywhere in this codebase, so this labels token-count provenance, not dollar cost.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum SavingsProvenance {
106    Measured,
107    Heuristic,
108    Estimated,
109}
110
111impl SavingsProvenance {
112    pub fn as_str(self) -> &'static str {
113        match self {
114            SavingsProvenance::Measured => "measured",
115            SavingsProvenance::Heuristic => "heuristic",
116            SavingsProvenance::Estimated => "estimated",
117        }
118    }
119}
120
121/// Labels a directly-observed token count by estimator exactness (see `EstimatorInfo.is_exact`).
122/// Derived/extrapolated figures (e.g. `estimated_lost_tokens`) are always
123/// `SavingsProvenance::Estimated` by construction, independent of this function.
124pub fn savings_provenance(is_exact: bool) -> SavingsProvenance {
125    if is_exact {
126        SavingsProvenance::Measured
127    } else {
128        SavingsProvenance::Heuristic
129    }
130}
131
132/// Builds the `LedgerRecord` for one `CompressionReport`, used both by the CLI's post-run
133/// ledger-recording hook and by ad-hoc `tokenfold stats <report-glob>` aggregation (turning a
134/// standalone report file into the same shape). `surface` is derived from the report itself
135/// (`"wrap"` when `CommandReport` is present, `"cli"` otherwise) rather than passed in, so both
136/// callers classify it identically.
137pub fn record_from_report(
138    report: &CompressionReport,
139    request_id: String,
140    timestamp: String,
141    project_hash: Option<String>,
142) -> LedgerRecord {
143    let surface = if report.command.is_some() {
144        "wrap"
145    } else {
146        "cli"
147    }
148    .to_string();
149
150    // A wrap invocation whose `never_worse` guard fell back to raw output is a genuine,
151    // already-recorded "why compression didn't apply here" reason; `report.bypass` (F-047) is
152    // the future general-purpose source once the filter registry exists, but nothing sets it
153    // yet, so this `or_else` currently never fires.
154    let bypass_reason = report
155        .command
156        .as_ref()
157        .filter(|c| c.never_worse_applied)
158        .map(|_| "would_increase_tokens".to_string())
159        .or_else(|| report.bypass.as_ref().map(|b| b.reason.clone()));
160
161    LedgerRecord {
162        request_id,
163        timestamp,
164        surface,
165        format: report.format.clone(),
166        mode: report.mode.clone(),
167        status: status_label(&report.status),
168        original_tokens: report.original_tokens,
169        compressed_tokens: report.compressed_tokens,
170        saved_tokens: report.saved_tokens,
171        savings_pct: report.savings_pct,
172        bypass_reason,
173        project_hash,
174    }
175}
176
177fn status_label(status: &Status) -> String {
178    serde_json::to_value(status)
179        .ok()
180        .and_then(|v| v.as_str().map(str::to_string))
181        .unwrap_or_else(|| "unknown".to_string())
182}
183
184/// The one shared aggregation path: pure, total-order-independent, takes only ledger-shaped
185/// records. `scope`/`window`/`project` are generic defaults here — callers (the CLI's
186/// `stats`/`gain`/`session` entry points) overwrite those framing fields on the returned
187/// `StatsSummary` to suit each command's emphasis; the underlying counts never change.
188pub fn aggregate(records: &[LedgerRecord]) -> StatsSummary {
189    let requests = records.len();
190
191    let is_wrap = |r: &&LedgerRecord| r.surface == "wrap";
192    let commands: usize = records.iter().filter(is_wrap).count();
193    let wrapped_commands = records
194        .iter()
195        .filter(is_wrap)
196        .filter(|r| r.bypass_reason.is_none())
197        .count();
198    let raw_commands = commands - wrapped_commands;
199    let bypass_count = records.iter().filter(|r| r.bypass_reason.is_some()).count();
200
201    let raw_tokens: usize = records.iter().map(|r| r.original_tokens).sum();
202    let compressed_tokens: usize = records.iter().map(|r| r.compressed_tokens).sum();
203    let saved_tokens = raw_tokens.saturating_sub(compressed_tokens);
204    let savings_pct = if raw_tokens == 0 {
205        0.0
206    } else {
207        saved_tokens as f64 / raw_tokens as f64 * 100.0
208    };
209
210    // estimated_lost_tokens/coverage_pct: extrapolated from the measured average savings ratio
211    // of wrapped (actually-compressed) commands, applied to raw (bypassed / never-worse
212    // fallback) commands' own token counts. This is the honest "straightforward" computation
213    // available today per ROADMAP.md's F-046 note: there is no filter registry (F-047) yet to
214    // report *why* coverage is incomplete, only that it is.
215    let wrapped_raw_tokens: usize = records
216        .iter()
217        .filter(is_wrap)
218        .filter(|r| r.bypass_reason.is_none())
219        .map(|r| r.original_tokens)
220        .sum();
221    let wrapped_saved_tokens: usize = records
222        .iter()
223        .filter(is_wrap)
224        .filter(|r| r.bypass_reason.is_none())
225        .map(|r| r.saved_tokens)
226        .sum();
227    let raw_command_tokens: usize = records
228        .iter()
229        .filter(is_wrap)
230        .filter(|r| r.bypass_reason.is_some())
231        .map(|r| r.original_tokens)
232        .sum();
233    let estimated_lost_tokens = if wrapped_raw_tokens == 0 {
234        0
235    } else {
236        let ratio = wrapped_saved_tokens as f64 / wrapped_raw_tokens as f64;
237        (raw_command_tokens as f64 * ratio).round() as usize
238    };
239    let coverage_pct = if commands == 0 {
240        0.0
241    } else {
242        wrapped_commands as f64 / commands as f64 * 100.0
243    };
244
245    // Lexicographic order matches chronological order for the fixed-width, zero-padded
246    // `format_unix_timestamp` shape, so no timestamp parsing is needed just to sort.
247    let mut recent_requests = records.to_vec();
248    recent_requests.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
249
250    StatsSummary {
251        schema_version: SCHEMA_VERSION.to_string(),
252        scope: "aggregate".to_string(),
253        window: "all".to_string(),
254        project: None,
255        requests,
256        commands,
257        wrapped_commands,
258        raw_commands,
259        bypass_count,
260        raw_tokens,
261        compressed_tokens,
262        saved_tokens,
263        savings_pct,
264        estimated_lost_tokens,
265        coverage_pct,
266        // ponytail: no filter registry exists yet (ROADMAP.md F-047); real counts land with it.
267        untrusted_filter_count: 0,
268        // ponytail: no historical record of individual `tokenfold retrieve` outcomes exists —
269        // `RetrievalReport` only carries store-time marker counts, not later hit/miss/expiry —
270        // so those three stay zero; a real `markers` count is filled in by ad-hoc report-glob
271        // aggregation (see `crates/tokenfold-cli/src/main.rs::cmd_stats`), which has direct
272        // access to each `CompressionReport.retrieval.marker_count`.
273        retrieval: RetrievalStats::default(),
274        // ponytail: no cache subsystem exists anywhere in this codebase yet; zero is honest,
275        // not a placeholder for a feature this pass should build.
276        cache: CacheStats::default(),
277        // ponytail: no per-request latency is threaded through `CompressionReport`/
278        // `LedgerRecord` yet (every `TransformReport.elapsed_micros` in this codebase is
279        // already always `None`); zero is honest, not measured.
280        latency: LatencyStats::default(),
281        recent_requests,
282    }
283}
284
285/// Parses a duration shorthand like `"30d"`, `"24h"`, `"90m"`, `"120s"`, or a bare integer
286/// (seconds) — used by `tokenfold gain --since`.
287pub fn parse_duration_secs(input: &str) -> Result<u64, TokenFoldError> {
288    let trimmed = input.trim();
289    let invalid = || {
290        TokenFoldError::InvalidInput(format!(
291            "invalid duration {input:?}; expected e.g. \"30d\", \"24h\", \"90m\", \"120s\", or a bare integer of seconds"
292        ))
293    };
294    if trimmed.is_empty() {
295        return Err(invalid());
296    }
297    let (digits, unit_secs) = match trimmed.chars().last().unwrap() {
298        'd' => (&trimmed[..trimmed.len() - 1], 86_400u64),
299        'h' => (&trimmed[..trimmed.len() - 1], 3_600u64),
300        'm' => (&trimmed[..trimmed.len() - 1], 60u64),
301        's' => (&trimmed[..trimmed.len() - 1], 1u64),
302        _ => (trimmed, 1u64),
303    };
304    let count: u64 = digits.trim().parse().map_err(|_| invalid())?;
305    Ok(count.saturating_mul(unit_secs))
306}
307
308/// Keeps only records whose `timestamp` is within `window_secs` of `now` (both in Unix
309/// seconds). Records with an unparsable timestamp are dropped — unlike [`LedgerStore::gc`],
310/// which fails safe by keeping what it can't confidently date, a `--since` view is explicitly
311/// asking for "recent", so an undatable record can't honestly satisfy that.
312pub fn filter_since(records: &[LedgerRecord], now: u64, window_secs: u64) -> Vec<LedgerRecord> {
313    records
314        .iter()
315        .filter(|r| match parse_timestamp_to_unix(&r.timestamp) {
316            Some(ts) => now.saturating_sub(ts) <= window_secs,
317            None => false,
318        })
319        .cloned()
320        .collect()
321}
322
323/// Renders `summary` as two CSV sections separated by a blank line: a one-row summary table,
324/// then the `recent_requests` table. A manual writer (no new dependency) is enough for this
325/// shape — every field is a plain number or a short known-alphabet string, so the only escaping
326/// that can ever matter is on `project`/`project_hash`/`bypass_reason`, handled by [`csv_field`].
327pub fn to_csv(summary: &StatsSummary) -> String {
328    let mut out = String::new();
329    out.push_str(
330        "schema_version,scope,window,project,requests,commands,wrapped_commands,raw_commands,\
331         bypass_count,raw_tokens,compressed_tokens,saved_tokens,savings_pct,\
332         estimated_lost_tokens,coverage_pct,untrusted_filter_count,retrieval_markers,\
333         retrieval_hits,retrieval_misses,retrieval_expired,cache_hits,cache_misses,\
334         latency_p50_ms,latency_p95_ms\n",
335    );
336    out.push_str(&format!(
337        "{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}\n",
338        csv_field(&summary.schema_version),
339        csv_field(&summary.scope),
340        csv_field(&summary.window),
341        csv_field(summary.project.as_deref().unwrap_or("")),
342        summary.requests,
343        summary.commands,
344        summary.wrapped_commands,
345        summary.raw_commands,
346        summary.bypass_count,
347        summary.raw_tokens,
348        summary.compressed_tokens,
349        summary.saved_tokens,
350        summary.savings_pct,
351        summary.estimated_lost_tokens,
352        summary.coverage_pct,
353        summary.untrusted_filter_count,
354        summary.retrieval.markers,
355        summary.retrieval.hits,
356        summary.retrieval.misses,
357        summary.retrieval.expired,
358        summary.cache.hits,
359        summary.cache.misses,
360        summary.latency.p50_ms,
361        summary.latency.p95_ms,
362    ));
363    out.push('\n');
364    out.push_str(
365        "request_id,timestamp,surface,format,mode,status,original_tokens,compressed_tokens,\
366         saved_tokens,savings_pct,bypass_reason,project_hash\n",
367    );
368    for r in &summary.recent_requests {
369        out.push_str(&format!(
370            "{},{},{},{},{},{},{},{},{},{},{},{}\n",
371            csv_field(&r.request_id),
372            csv_field(&r.timestamp),
373            csv_field(&r.surface),
374            csv_field(&r.format),
375            csv_field(&r.mode),
376            csv_field(&r.status),
377            r.original_tokens,
378            r.compressed_tokens,
379            r.saved_tokens,
380            r.savings_pct,
381            csv_field(r.bypass_reason.as_deref().unwrap_or("")),
382            csv_field(r.project_hash.as_deref().unwrap_or("")),
383        ));
384    }
385    out
386}
387
388fn csv_field(value: &str) -> String {
389    if value.contains(',') || value.contains('"') || value.contains('\n') || value.contains('\r') {
390        format!("\"{}\"", value.replace('"', "\"\""))
391    } else {
392        value.to_string()
393    }
394}
395
396/// A short synthetic ID in the documented `tc-XXXXXXXX` shape (8 lowercase hex chars), derived
397/// from the current time and process ID. Good enough for a human-scannable, practically-unique
398/// local identifier; not a security-sensitive value, so `DefaultHasher` (not a cryptographic
399/// hash) is an appropriate, dependency-free choice.
400pub fn generate_request_id() -> String {
401    use std::hash::{Hash, Hasher};
402    let mut hasher = std::collections::hash_map::DefaultHasher::new();
403    let nanos = SystemTime::now()
404        .duration_since(UNIX_EPOCH)
405        .unwrap_or_default()
406        .as_nanos();
407    nanos.hash(&mut hasher);
408    std::process::id().hash(&mut hasher);
409    format!("tc-{:08x}", hasher.finish() as u32)
410}
411
412pub fn now_unix() -> u64 {
413    SystemTime::now()
414        .duration_since(UNIX_EPOCH)
415        .map(|d| d.as_secs())
416        .unwrap_or(0)
417}
418
419/// Formats Unix seconds as an RFC3339 UTC timestamp (`YYYY-MM-DDTHH:MM:SSZ`) without pulling in
420/// a date/time crate: this is the one place in the codebase that needs calendar math, so a
421/// compact civil-from-days conversion (Howard Hinnant's well-known `civil_from_days` algorithm,
422/// see http://howardhinnant.github.io/date_algorithms.html) is a fair ponytail trade against
423/// adding `chrono`/`time` as a dependency for a couple of call sites.
424pub fn format_unix_timestamp(unix_secs: u64) -> String {
425    let days = (unix_secs / 86_400) as i64;
426    let rem = unix_secs % 86_400;
427    let (hour, minute, second) = (rem / 3600, (rem % 3600) / 60, rem % 60);
428    let (y, m, d) = civil_from_days(days);
429    format!("{y:04}-{m:02}-{d:02}T{hour:02}:{minute:02}:{second:02}Z")
430}
431
432fn civil_from_days(z: i64) -> (i64, u32, u32) {
433    let z = z + 719_468;
434    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
435    let doe = z - era * 146_097; // [0, 146096]
436    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
437    let y = yoe + era * 400;
438    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
439    let mp = (5 * doy + 2) / 153; // [0, 11]
440    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
441    let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; // [1, 12]
442    (if m <= 2 { y + 1 } else { y }, m, d)
443}
444
445fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
446    let y = if m <= 2 { y - 1 } else { y };
447    let era = if y >= 0 { y } else { y - 399 } / 400;
448    let yoe = y - era * 400; // [0, 399]
449    let mp = if m > 2 { m as i64 - 3 } else { m as i64 + 9 }; // [0, 11]
450    let doy = (153 * mp + 2) / 5 + d as i64 - 1; // [0, 365]
451    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
452    era * 146_097 + doe - 719_468
453}
454
455fn parse_timestamp_to_unix(ts: &str) -> Option<u64> {
456    let ts = ts.strip_suffix('Z')?;
457    let (date, time) = ts.split_once('T')?;
458    let mut date_parts = date.splitn(3, '-');
459    let year: i64 = date_parts.next()?.parse().ok()?;
460    let month: u32 = date_parts.next()?.parse().ok()?;
461    let day: u32 = date_parts.next()?.parse().ok()?;
462    let mut time_parts = time.splitn(3, ':');
463    let hour: u64 = time_parts.next()?.parse().ok()?;
464    let minute: u64 = time_parts.next()?.parse().ok()?;
465    let second: u64 = time_parts.next()?.parse().ok()?;
466    let days = days_from_civil(year, month, day);
467    if days < 0 {
468        return None;
469    }
470    Some(days as u64 * 86_400 + hour * 3_600 + minute * 60 + second)
471}
472
473/// F-046's optional local ledger: appends/reads/garbage-collects redacted `LedgerRecord`
474/// metadata at a JSONL file path (see the module doc for the `.db`-named-but-JSONL decision).
475pub struct LedgerStore {
476    path: PathBuf,
477}
478
479#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
480pub struct LedgerGcOutcome {
481    pub kept: usize,
482    pub removed: usize,
483}
484
485impl LedgerStore {
486    pub fn new(path: impl Into<PathBuf>) -> Self {
487        LedgerStore { path: path.into() }
488    }
489
490    /// `$XDG_DATA_HOME/tokenfold/ledger.db`, falling back to
491    /// `<home>/.local/share/tokenfold/ledger.db` — mirrors
492    /// `retrieval_store::default_store_path`'s HOME/USERPROFILE fallback.
493    pub fn default_path() -> PathBuf {
494        if let Some(dir) = std::env::var_os("XDG_DATA_HOME") {
495            return PathBuf::from(dir).join("tokenfold").join("ledger.db");
496        }
497        let home = home_dir().unwrap_or_else(|| PathBuf::from("."));
498        home.join(".local")
499            .join("share")
500            .join("tokenfold")
501            .join("ledger.db")
502    }
503
504    pub fn path(&self) -> &Path {
505        &self.path
506    }
507
508    pub fn append(&self, record: &LedgerRecord) -> Result<(), TokenFoldError> {
509        if let Some(parent) = self.path.parent() {
510            std::fs::create_dir_all(parent)?;
511        }
512        let mut line = serde_json::to_string(record).map_err(|e| {
513            TokenFoldError::InternalError(format!("failed to encode ledger record: {e}"))
514        })?;
515        line.push('\n');
516        use std::io::Write;
517        let mut file = std::fs::OpenOptions::new()
518            .create(true)
519            .append(true)
520            .open(&self.path)?;
521        file.write_all(line.as_bytes())?;
522        Ok(())
523    }
524
525    /// Reads every well-formed record. A line that fails to parse (e.g. a partial write left by
526    /// a crash mid-append) is skipped rather than failing the whole read. A missing file reads
527    /// as an empty ledger, not an error (an analytics-enabled run before any record exists yet
528    /// is the common case, not a failure).
529    pub fn read_all(&self) -> Result<Vec<LedgerRecord>, TokenFoldError> {
530        let Ok(text) = std::fs::read_to_string(&self.path) else {
531            return Ok(Vec::new());
532        };
533        Ok(text
534            .lines()
535            .filter(|line| !line.trim().is_empty())
536            .filter_map(|line| serde_json::from_str::<LedgerRecord>(line).ok())
537            .collect())
538    }
539
540    /// Rewrites the ledger file keeping only records whose `timestamp` is within
541    /// `retention_days` of now; records with an unparsable timestamp are kept (fail safe rather
542    /// than silently discarding data this can't confidently date). Survivors keep their original
543    /// relative order — nothing about a kept record is touched or reordered.
544    pub fn gc(&self, retention_days: u64) -> Result<LedgerGcOutcome, TokenFoldError> {
545        let records = self.read_all()?;
546        let cutoff_secs = retention_days.saturating_mul(86_400);
547        let now = now_unix();
548
549        let mut kept = Vec::with_capacity(records.len());
550        let mut removed = 0usize;
551        for record in records {
552            let within_retention = match parse_timestamp_to_unix(&record.timestamp) {
553                Some(ts) => now.saturating_sub(ts) <= cutoff_secs,
554                None => true,
555            };
556            if within_retention {
557                kept.push(record);
558            } else {
559                removed += 1;
560            }
561        }
562
563        let mut out = String::new();
564        for record in &kept {
565            let line = serde_json::to_string(record).map_err(|e| {
566                TokenFoldError::InternalError(format!("failed to encode ledger record: {e}"))
567            })?;
568            out.push_str(&line);
569            out.push('\n');
570        }
571        if let Some(parent) = self.path.parent() {
572            std::fs::create_dir_all(parent)?;
573        }
574        std::fs::write(&self.path, out)?;
575
576        Ok(LedgerGcOutcome {
577            kept: kept.len(),
578            removed,
579        })
580    }
581}
582
583fn home_dir() -> Option<PathBuf> {
584    std::env::var_os("HOME")
585        .or_else(|| std::env::var_os("USERPROFILE"))
586        .map(PathBuf::from)
587}
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592    use crate::report::{BypassReport, CommandReport, EstimatorInfo, RetrievalReport};
593    use std::sync::atomic::{AtomicU64, Ordering};
594
595    fn temp_ledger_path(tag: &str) -> PathBuf {
596        static COUNTER: AtomicU64 = AtomicU64::new(0);
597        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
598        std::env::temp_dir().join(format!(
599            "tokenfold_stats_test_{tag}_{}_{n}.db",
600            std::process::id()
601        ))
602    }
603
604    fn exact_estimator() -> EstimatorInfo {
605        EstimatorInfo {
606            backend: "tiktoken".to_string(),
607            model: Some("o200k_base".to_string()),
608            is_exact: true,
609        }
610    }
611
612    fn heuristic_estimator() -> EstimatorInfo {
613        EstimatorInfo {
614            backend: "heuristic".to_string(),
615            model: None,
616            is_exact: false,
617        }
618    }
619
620    fn cli_report(
621        original: usize,
622        compressed: usize,
623        format: &str,
624        status: Status,
625        estimator: EstimatorInfo,
626    ) -> CompressionReport {
627        CompressionReport::new(
628            original,
629            compressed,
630            estimator,
631            status,
632            "balanced".to_string(),
633            format.to_string(),
634            "general".to_string(),
635            vec![],
636            vec![],
637        )
638    }
639
640    // --- timestamp round trip -------------------------------------------------------------
641
642    #[test]
643    fn format_unix_timestamp_matches_known_epoch_values() {
644        assert_eq!(format_unix_timestamp(0), "1970-01-01T00:00:00Z");
645        assert_eq!(format_unix_timestamp(86_400), "1970-01-02T00:00:00Z");
646    }
647
648    #[test]
649    fn timestamp_round_trips_through_parse_and_format() {
650        for secs in [0u64, 1, 86_399, 86_400, 1_000_000_000, 1_752_000_000] {
651            let formatted = format_unix_timestamp(secs);
652            assert_eq!(
653                parse_timestamp_to_unix(&formatted),
654                Some(secs),
655                "{formatted}"
656            );
657        }
658    }
659
660    #[test]
661    fn parse_duration_secs_supports_documented_suffixes() {
662        assert_eq!(parse_duration_secs("30d").unwrap(), 30 * 86_400);
663        assert_eq!(parse_duration_secs("24h").unwrap(), 24 * 3_600);
664        assert_eq!(parse_duration_secs("90m").unwrap(), 90 * 60);
665        assert_eq!(parse_duration_secs("120s").unwrap(), 120);
666        assert_eq!(parse_duration_secs("45").unwrap(), 45);
667        assert!(parse_duration_secs("nonsense").is_err());
668    }
669
670    // --- record_from_report ----------------------------------------------------------------
671
672    #[test]
673    fn record_from_report_uses_cli_surface_when_no_command_report() {
674        let report = cli_report(
675            1000,
676            600,
677            "openai_json",
678            Status::Compressed,
679            exact_estimator(),
680        );
681        let record = record_from_report(
682            &report,
683            "tc-00000001".to_string(),
684            "2026-01-01T00:00:00Z".to_string(),
685            Some("sha256:abc".to_string()),
686        );
687        assert_eq!(record.surface, "cli");
688        assert_eq!(record.status, "compressed");
689        assert_eq!(record.original_tokens, 1000);
690        assert_eq!(record.compressed_tokens, 600);
691        assert_eq!(record.saved_tokens, 400);
692        assert!(record.bypass_reason.is_none());
693        assert_eq!(record.project_hash, Some("sha256:abc".to_string()));
694    }
695
696    #[test]
697    fn record_from_report_uses_wrap_surface_and_flags_never_worse_as_bypass() {
698        let mut report = cli_report(
699            100,
700            120,
701            "command_output",
702            Status::BestEffort,
703            heuristic_estimator(),
704        );
705        report.command = Some(CommandReport {
706            command_family: None,
707            child_exit_code: Some(0),
708            duration_ms: 5,
709            raw_output_bytes: 100,
710            stdout_bytes: 100,
711            stderr_bytes: 0,
712            stderr_mode: "captured".to_string(),
713            stderr_truncated: false,
714            compressed_output_bytes: 100,
715            filter_pack_id: None,
716            filter_version: None,
717            never_worse_applied: true,
718            bypass_reason: None,
719        });
720        let record = record_from_report(
721            &report,
722            "tc-00000002".to_string(),
723            "2026-01-01T00:00:00Z".to_string(),
724            None,
725        );
726        assert_eq!(record.surface, "wrap");
727        assert_eq!(
728            record.bypass_reason,
729            Some("would_increase_tokens".to_string())
730        );
731    }
732
733    #[test]
734    fn record_from_report_falls_back_to_bypass_report_reason() {
735        let mut report = cli_report(50, 50, "plain_text", Status::Passthrough, exact_estimator());
736        report.bypass = Some(BypassReport {
737            reason: "env".to_string(),
738            source: "cli".to_string(),
739        });
740        let record = record_from_report(
741            &report,
742            "tc-00000003".to_string(),
743            "2026-01-01T00:00:00Z".to_string(),
744            None,
745        );
746        assert_eq!(record.bypass_reason, Some("env".to_string()));
747    }
748
749    // --- aggregate: engineering.md "stats.rs (v0.2 parity surface)" bullets ----------------
750
751    #[test]
752    fn aggregates_fixture_reports_by_transform_format_estimator_status_and_project() {
753        // Three heterogeneous fixture CompressionReports: different format, estimator
754        // exactness, status, and project attribution.
755        let openai = cli_report(
756            2000,
757            1000,
758            "openai_json",
759            Status::Compressed,
760            exact_estimator(),
761        );
762        let text = cli_report(
763            500,
764            500,
765            "plain_text",
766            Status::Passthrough,
767            heuristic_estimator(),
768        );
769        let mut diff = cli_report(300, 200, "git_diff", Status::BestEffort, exact_estimator());
770        diff.transforms.push(crate::report::TransformReport {
771            id: "diff_compaction".to_string(),
772            version: "1.0.0".to_string(),
773            tokens_before: 300,
774            tokens_after: 200,
775            saved_tokens: 100,
776            savings_ratio: 0.333,
777            elapsed_micros: None,
778            status: crate::report::TransformStatus::Applied,
779            skipped_reason: None,
780            warnings: vec![],
781        });
782
783        let records = vec![
784            record_from_report(
785                &openai,
786                "tc-a".to_string(),
787                "2026-01-01T00:00:00Z".to_string(),
788                Some("sha256:proj-a".to_string()),
789            ),
790            record_from_report(
791                &text,
792                "tc-b".to_string(),
793                "2026-01-02T00:00:00Z".to_string(),
794                Some("sha256:proj-b".to_string()),
795            ),
796            record_from_report(
797                &diff,
798                "tc-c".to_string(),
799                "2026-01-03T00:00:00Z".to_string(),
800                Some("sha256:proj-c".to_string()),
801            ),
802        ];
803
804        let summary = aggregate(&records);
805        assert_eq!(summary.requests, 3);
806        assert_eq!(summary.raw_tokens, 2000 + 500 + 300);
807        assert_eq!(summary.compressed_tokens, 1000 + 500 + 200);
808        assert_eq!(summary.saved_tokens, 1000 + 100);
809        assert_eq!(summary.recent_requests.len(), 3);
810        let project_hashes: Vec<_> = summary
811            .recent_requests
812            .iter()
813            .filter_map(|r| r.project_hash.clone())
814            .collect();
815        assert!(project_hashes.contains(&"sha256:proj-a".to_string()));
816        assert!(project_hashes.contains(&"sha256:proj-b".to_string()));
817        assert!(project_hashes.contains(&"sha256:proj-c".to_string()));
818        // Sorted newest-timestamp-first.
819        assert_eq!(summary.recent_requests[0].request_id, "tc-c");
820    }
821
822    #[test]
823    fn aggregate_splits_wrapped_vs_raw_commands_and_computes_coverage() {
824        let wrapped = {
825            let mut r = cli_report(
826                1000,
827                400,
828                "command_output",
829                Status::BestEffort,
830                exact_estimator(),
831            );
832            r.command = Some(CommandReport {
833                command_family: None,
834                child_exit_code: Some(0),
835                duration_ms: 1,
836                raw_output_bytes: 1000,
837                stdout_bytes: 1000,
838                stderr_bytes: 0,
839                stderr_mode: "captured".to_string(),
840                stderr_truncated: false,
841                compressed_output_bytes: 400,
842                filter_pack_id: None,
843                filter_version: None,
844                never_worse_applied: false,
845                bypass_reason: None,
846            });
847            r
848        };
849        let raw = {
850            let mut r = cli_report(
851                1000,
852                1000,
853                "command_output",
854                Status::BestEffort,
855                exact_estimator(),
856            );
857            r.command = Some(CommandReport {
858                command_family: None,
859                child_exit_code: Some(0),
860                duration_ms: 1,
861                raw_output_bytes: 1000,
862                stdout_bytes: 1000,
863                stderr_bytes: 0,
864                stderr_mode: "captured".to_string(),
865                stderr_truncated: false,
866                compressed_output_bytes: 1000,
867                filter_pack_id: None,
868                filter_version: None,
869                never_worse_applied: true,
870                bypass_reason: None,
871            });
872            r
873        };
874
875        let records = vec![
876            record_from_report(
877                &wrapped,
878                "tc-w".to_string(),
879                "2026-01-01T00:00:00Z".to_string(),
880                None,
881            ),
882            record_from_report(
883                &raw,
884                "tc-r".to_string(),
885                "2026-01-01T00:00:01Z".to_string(),
886                None,
887            ),
888        ];
889        let summary = aggregate(&records);
890        assert_eq!(summary.commands, 2);
891        assert_eq!(summary.wrapped_commands, 1);
892        assert_eq!(summary.raw_commands, 1);
893        assert_eq!(summary.bypass_count, 1);
894        assert_eq!(summary.coverage_pct, 50.0);
895        // wrapped ratio is 60% (400/1000 saved); extrapolated onto raw's 1000 original tokens.
896        assert_eq!(summary.estimated_lost_tokens, 600);
897    }
898
899    #[test]
900    fn aggregate_on_empty_records_never_divides_by_zero() {
901        let summary = aggregate(&[]);
902        assert_eq!(summary.requests, 0);
903        assert_eq!(summary.savings_pct, 0.0);
904        assert_eq!(summary.coverage_pct, 0.0);
905        assert_eq!(summary.estimated_lost_tokens, 0);
906    }
907
908    #[test]
909    fn json_and_csv_outputs_are_schema_stable_and_carry_no_raw_payload_bytes() {
910        let report = cli_report(
911            18_400,
912            11_900,
913            "openai_json",
914            Status::Compressed,
915            exact_estimator(),
916        );
917        let record = record_from_report(
918            &report,
919            "tc-7f3a2b1c".to_string(),
920            "2026-07-08T12:00:00Z".to_string(),
921            Some("sha256:deadbeef".to_string()),
922        );
923        let summary = aggregate(&[record]);
924
925        let json = serde_json::to_value(&summary).unwrap();
926        for key in [
927            "schema_version",
928            "scope",
929            "window",
930            "project",
931            "requests",
932            "commands",
933            "wrapped_commands",
934            "raw_commands",
935            "bypass_count",
936            "raw_tokens",
937            "compressed_tokens",
938            "saved_tokens",
939            "savings_pct",
940            "estimated_lost_tokens",
941            "coverage_pct",
942            "untrusted_filter_count",
943            "retrieval",
944            "cache",
945            "latency",
946            "recent_requests",
947        ] {
948            assert!(json.get(key).is_some(), "missing key {key}");
949        }
950        assert_eq!(json["retrieval"]["markers"], 0);
951        assert_eq!(json["cache"]["hits"], 0);
952        assert_eq!(json["recent_requests"][0]["request_id"], "tc-7f3a2b1c");
953
954        // No arbitrary payload text ever enters the summary: every string in it is drawn from
955        // a fixed short vocabulary (ids, ISO timestamps, format/mode/status labels, hashes).
956        let serialized = serde_json::to_string(&summary).unwrap();
957        assert!(!serialized.contains("hello world"));
958
959        let csv = to_csv(&summary);
960        assert!(csv.contains("schema_version,scope,window"));
961        assert!(csv.contains("request_id,timestamp,surface"));
962        assert!(csv.contains("tc-7f3a2b1c"));
963        assert!(csv.contains("sha256:deadbeef"));
964    }
965
966    #[test]
967    fn csv_escapes_fields_containing_commas_or_quotes() {
968        assert_eq!(csv_field("plain"), "plain");
969        assert_eq!(csv_field("a,b"), "\"a,b\"");
970        assert_eq!(csv_field("a\"b"), "\"a\"\"b\"");
971    }
972
973    #[test]
974    fn savings_provenance_maps_estimator_exactness() {
975        assert_eq!(savings_provenance(true), SavingsProvenance::Measured);
976        assert_eq!(savings_provenance(false), SavingsProvenance::Heuristic);
977        assert_eq!(SavingsProvenance::Measured.as_str(), "measured");
978        assert_eq!(SavingsProvenance::Heuristic.as_str(), "heuristic");
979        assert_eq!(SavingsProvenance::Estimated.as_str(), "estimated");
980    }
981
982    // --- LedgerStore -------------------------------------------------------------------------
983
984    fn sample_record(id: &str, timestamp: &str) -> LedgerRecord {
985        LedgerRecord {
986            request_id: id.to_string(),
987            timestamp: timestamp.to_string(),
988            surface: "cli".to_string(),
989            format: "plain_text".to_string(),
990            mode: "balanced".to_string(),
991            status: "compressed".to_string(),
992            original_tokens: 100,
993            compressed_tokens: 60,
994            saved_tokens: 40,
995            savings_pct: 40.0,
996            bypass_reason: None,
997            project_hash: None,
998        }
999    }
1000
1001    #[test]
1002    fn append_then_read_all_round_trips_records_in_order() {
1003        let path = temp_ledger_path("append_read");
1004        let store = LedgerStore::new(&path);
1005        store
1006            .append(&sample_record("tc-1", "2026-01-01T00:00:00Z"))
1007            .unwrap();
1008        store
1009            .append(&sample_record("tc-2", "2026-01-02T00:00:00Z"))
1010            .unwrap();
1011
1012        let records = store.read_all().unwrap();
1013        assert_eq!(records.len(), 2);
1014        assert_eq!(records[0].request_id, "tc-1");
1015        assert_eq!(records[1].request_id, "tc-2");
1016
1017        std::fs::remove_file(&path).ok();
1018    }
1019
1020    #[test]
1021    fn read_all_on_missing_file_is_an_empty_ledger_not_an_error() {
1022        let path = temp_ledger_path("missing");
1023        let store = LedgerStore::new(&path);
1024        assert_eq!(store.read_all().unwrap(), Vec::new());
1025    }
1026
1027    #[test]
1028    fn read_all_skips_malformed_lines_without_failing() {
1029        let path = temp_ledger_path("malformed");
1030        std::fs::write(
1031            &path,
1032            format!(
1033                "{}\nnot json at all\n{}\n",
1034                serde_json::to_string(&sample_record("tc-1", "2026-01-01T00:00:00Z")).unwrap(),
1035                serde_json::to_string(&sample_record("tc-2", "2026-01-02T00:00:00Z")).unwrap(),
1036            ),
1037        )
1038        .unwrap();
1039        let store = LedgerStore::new(&path);
1040        let records = store.read_all().unwrap();
1041        assert_eq!(records.len(), 2);
1042
1043        std::fs::remove_file(&path).ok();
1044    }
1045
1046    #[test]
1047    fn gc_deletes_only_records_older_than_retention_and_leaves_active_records_untouched() {
1048        let path = temp_ledger_path("gc");
1049        let store = LedgerStore::new(&path);
1050
1051        let now = now_unix();
1052        let old_ts = format_unix_timestamp(now.saturating_sub(200 * 86_400));
1053        let recent_ts = format_unix_timestamp(now.saturating_sub(86_400));
1054        store.append(&sample_record("tc-old", &old_ts)).unwrap();
1055        store
1056            .append(&sample_record("tc-recent", &recent_ts))
1057            .unwrap();
1058
1059        let outcome = store.gc(90).unwrap();
1060        assert_eq!(outcome.removed, 1);
1061        assert_eq!(outcome.kept, 1);
1062
1063        let remaining = store.read_all().unwrap();
1064        assert_eq!(remaining.len(), 1);
1065        assert_eq!(remaining[0].request_id, "tc-recent");
1066        // The surviving record must be byte-identical metadata, not just present.
1067        assert_eq!(remaining[0], sample_record("tc-recent", &recent_ts));
1068
1069        std::fs::remove_file(&path).ok();
1070    }
1071
1072    #[test]
1073    fn gc_keeps_records_with_unparsable_timestamps_fail_safe() {
1074        let path = temp_ledger_path("gc_unparsable");
1075        let store = LedgerStore::new(&path);
1076        store
1077            .append(&sample_record("tc-weird", "not-a-timestamp"))
1078            .unwrap();
1079
1080        let outcome = store.gc(1).unwrap();
1081        assert_eq!(outcome.removed, 0);
1082        assert_eq!(outcome.kept, 1);
1083
1084        std::fs::remove_file(&path).ok();
1085    }
1086
1087    #[test]
1088    fn filter_since_drops_older_and_unparsable_records() {
1089        let now = now_unix();
1090        let recent_ts = format_unix_timestamp(now.saturating_sub(60));
1091        let old_ts = format_unix_timestamp(now.saturating_sub(90 * 86_400));
1092        let records = vec![
1093            sample_record("tc-recent", &recent_ts),
1094            sample_record("tc-old", &old_ts),
1095            sample_record("tc-weird", "garbage"),
1096        ];
1097        let filtered = filter_since(&records, now, 3_600);
1098        assert_eq!(filtered.len(), 1);
1099        assert_eq!(filtered[0].request_id, "tc-recent");
1100    }
1101
1102    #[test]
1103    fn retrieval_report_used_by_glob_aggregation_carries_marker_count() {
1104        // Sanity check that CompressionReport.retrieval is the honest source for retrieval
1105        // marker totals during ad-hoc report-glob aggregation (see module doc); aggregate()
1106        // itself never touches it since LedgerRecord doesn't carry retrieval fields.
1107        let mut report = cli_report(100, 80, "plain_text", Status::Compressed, exact_estimator());
1108        report.retrieval = Some(RetrievalReport {
1109            store_namespace: "default".to_string(),
1110            hash_algorithm: "sha256".to_string(),
1111            marker_count: 1,
1112            ttl_seconds: Some(3600),
1113            persisted_original_bytes: 100,
1114            skipped_original_bytes: 0,
1115        });
1116        assert_eq!(report.retrieval.as_ref().unwrap().marker_count, 1);
1117    }
1118}