pub mod event;
pub mod push;
pub mod roi;
pub mod signed_batch;
pub mod store;
pub use event::SavingsEvent;
pub use roi::{roi_report, RoiReport};
pub use signed_batch::{BatchVerifyResult, SignedSavingsBatchV1};
pub use store::{LedgerSummary, VerifyResult};
use std::sync::OnceLock;
fn enabled() -> bool {
enabled_from(std::env::var("LEAN_CTX_SAVINGS_LEDGER").ok().as_deref())
}
fn enabled_from(value: Option<&str>) -> bool {
match value {
Some(v) => !matches!(
v.trim().to_lowercase().as_str(),
"off" | "0" | "false" | "no"
),
None => true,
}
}
fn model_and_price() -> &'static (String, f64) {
static CACHE: OnceLock<(String, f64)> = OnceLock::new();
CACHE.get_or_init(|| {
let env_model = std::env::var("LEAN_CTX_MODEL")
.or_else(|_| std::env::var("LCTX_MODEL"))
.ok();
let quote =
crate::core::gain::model_pricing::ModelPricing::load().quote(env_model.as_deref());
(quote.model_key, quote.cost.input_per_m)
})
}
fn repo_hash() -> &'static str {
static CACHE: OnceLock<String> = OnceLock::new();
CACHE.get_or_init(|| {
use sha2::{Digest, Sha256};
let cwd = std::env::current_dir()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let mut hasher = Sha256::new();
hasher.update(cwd.as_bytes());
let hex = format!("{:x}", hasher.finalize());
hex.get(..16).unwrap_or(&hex).to_string()
})
}
fn agent_id() -> &'static str {
crate::core::agent_identity::current_agent_id()
}
fn tokenizer() -> &'static str {
static CACHE: OnceLock<String> = OnceLock::new();
CACHE.get_or_init(crate::core::tokens::counting_family_label)
}
fn new_event(tool: &str) -> SavingsEvent {
let (model_id, price_per_m) = model_and_price();
SavingsEvent {
ts: chrono::Utc::now().to_rfc3339(),
tool: tool.to_string(),
model_id: model_id.clone(),
tokenizer: tokenizer().to_string(),
baseline_tokens: 0,
actual_tokens: 0,
saved_tokens: 0,
bounce_adjustment: 0,
unit_price_per_m_usd: *price_per_m,
saved_usd: 0.0,
repo_hash: repo_hash().to_string(),
agent_id: agent_id().to_string(),
prev_hash: String::new(),
entry_hash: String::new(),
}
}
pub fn record_read_event(original_tokens: usize, saved_tokens: usize) {
record_tool_event(
"ctx_read",
original_tokens,
original_tokens.saturating_sub(saved_tokens),
);
}
pub fn record_tool_event(tool: &str, baseline_tokens: usize, actual_tokens: usize) {
let saved = baseline_tokens.saturating_sub(actual_tokens);
if saved == 0 || !enabled() {
return;
}
let Some(path) = store::default_path() else {
return;
};
let mut event = new_event(tool);
event.baseline_tokens = baseline_tokens as u64;
event.actual_tokens = actual_tokens as u64;
event.saved_tokens = saved as u64;
event.saved_usd = saved as f64 / 1_000_000.0 * event.unit_price_per_m_usd;
let _ = store::append(&path, event);
}
pub fn record_bounce_event(wasted_tokens: usize) {
if wasted_tokens == 0 || !enabled() {
return;
}
let Some(path) = store::default_path() else {
return;
};
let wasted = wasted_tokens as u64;
let mut event = new_event("bounce");
event.baseline_tokens = wasted;
event.actual_tokens = wasted;
event.bounce_adjustment = wasted;
event.saved_usd = -(wasted as f64 / 1_000_000.0 * event.unit_price_per_m_usd);
let _ = store::append(&path, event);
}
pub fn bounce_tokens(days: Option<u32>) -> u64 {
let Some(path) = store::default_path() else {
return 0;
};
store::bounce_tokens_since(&path, days)
}
pub fn summary() -> LedgerSummary {
store::default_path()
.map(|p| store::summarize(&p))
.unwrap_or_default()
}
pub fn daily_bounce_trend(days: u32) -> Vec<(String, u64, u64)> {
store::default_path()
.map(|p| store::daily_bounce_trend(&p, days))
.unwrap_or_default()
}
pub fn verify() -> VerifyResult {
store::default_path().map_or_else(VerifyResult::empty, |p| store::verify(&p))
}
pub fn rechain() -> std::io::Result<usize> {
match store::default_path() {
Some(p) if p.exists() => store::rechain(&p),
_ => Ok(0),
}
}
pub fn all_events() -> Vec<SavingsEvent> {
store::default_path()
.map(|p| store::load(&p))
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn opt_out_logic_is_correct() {
assert!(enabled_from(None), "enabled by default when unset");
assert!(enabled_from(Some("on")));
assert!(enabled_from(Some("1")));
assert!(!enabled_from(Some("off")));
assert!(!enabled_from(Some("0")));
assert!(!enabled_from(Some("false")));
assert!(!enabled_from(Some(" No ")), "trim + case-insensitive");
}
#[test]
fn repo_hash_is_truncated_hex() {
let h = repo_hash();
assert_eq!(h.len(), 16);
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn record_tool_event_skips_zero_and_inverted_savings() {
record_tool_event("cli_shell", 100, 100);
record_tool_event("ctx_search", 50, 80);
record_tool_event("cli_shell", 0, 0);
}
#[test]
fn record_tool_event_appends_measured_event() {
let dir = std::env::temp_dir().join(format!("lctx-ledger-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("mkdir");
std::env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
record_tool_event("cli_shell", 5000, 800);
let ledger = dir.join("savings").join("ledger.jsonl");
let content = std::fs::read_to_string(&ledger).expect("ledger written");
std::env::remove_var("LEAN_CTX_DATA_DIR");
let _ = std::fs::remove_dir_all(&dir);
let last = content.lines().last().expect("one event");
let ev: SavingsEvent = serde_json::from_str(last).expect("valid event JSON");
assert_eq!(ev.tool, "cli_shell");
assert_eq!(ev.baseline_tokens, 5000, "raw baseline, no estimate factor");
assert_eq!(ev.actual_tokens, 800);
assert_eq!(ev.saved_tokens, 4200);
}
}