use serde::Serialize;
use crate::core::benchmark::{self, FileMeasurement};
use crate::core::gain::model_pricing::{ModelCost, ModelPricing};
const PRICED_MODELS: &[&str] = &[
"claude-opus-4.5", "claude-sonnet-4.5", "gpt-5.4", "gemini-2.5-pro", "fallback-blended", ];
#[derive(Debug, Clone, Copy)]
struct Turn {
raw: u64,
lean: u64,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ArmCost {
pub arm: String,
pub input_tokens: u64,
pub cache_read_tokens: u64,
pub cache_write_tokens: u64,
pub cost_usd: f64,
}
#[derive(Debug, Clone, Serialize)]
pub struct DualArmResult {
pub model_key: String,
pub cache_priced: bool,
pub phase_isolated: ArmCost,
pub long_lived_cache_aware: ArmCost,
pub net_savings_usd: f64,
pub savings_pct: f64,
}
#[derive(Debug, Clone, Serialize)]
pub struct DualArmScorecard {
pub schema_version: u32,
pub tokenizer: String,
pub scenario: String,
pub turns: usize,
pub total_raw_input_tokens: u64,
pub total_lean_prefix_tokens: u64,
pub cache_preservation_ratio: f64,
pub determinism_digest: String,
pub results: Vec<DualArmResult>,
}
fn build_session_plan(files: &[FileMeasurement]) -> Vec<Turn> {
let mut turns = Vec::with_capacity(files.len() + files.len() / 2);
for f in files {
let raw = f.raw_tokens as u64;
let map = f
.modes
.iter()
.find(|m| m.mode == "map")
.map_or(raw, |m| (m.tokens as u64).clamp(1, raw.max(1)));
turns.push(Turn { raw, lean: map });
}
let reread_count = files.len() / 2;
for f in files.iter().take(reread_count) {
let raw = f.raw_tokens as u64;
let stub = f
.modes
.iter()
.find(|m| m.mode == "cache_hit")
.map_or(raw, |m| (m.tokens as u64).clamp(1, raw.max(1)));
turns.push(Turn { raw, lean: stub });
}
turns
}
struct ArmTokens {
arm_a_input: u64,
arm_b_cache_read: u64,
arm_b_cache_write: u64,
total_lean_prefix: u64,
}
fn accumulate(turns: &[Turn]) -> ArmTokens {
let mut prefix_raw = 0u64;
let mut prefix_lean = 0u64;
let mut t = ArmTokens {
arm_a_input: 0,
arm_b_cache_read: 0,
arm_b_cache_write: 0,
total_lean_prefix: 0,
};
for turn in turns {
t.arm_b_cache_read += prefix_lean;
t.arm_b_cache_write += turn.lean;
prefix_raw += turn.raw;
prefix_lean += turn.lean;
t.arm_a_input += prefix_raw;
t.total_lean_prefix += prefix_lean;
}
t
}
fn price_result(model_key: &str, cost: &ModelCost, tokens: &ArmTokens) -> DualArmResult {
let phase_isolated = ArmCost {
arm: "phase_isolated".to_string(),
input_tokens: tokens.arm_a_input,
cache_read_tokens: 0,
cache_write_tokens: 0,
cost_usd: cost.estimate_usd(tokens.arm_a_input, 0, 0, 0),
};
let long_lived_cache_aware = ArmCost {
arm: "long_lived_cache_aware".to_string(),
input_tokens: 0,
cache_read_tokens: tokens.arm_b_cache_read,
cache_write_tokens: tokens.arm_b_cache_write,
cost_usd: cost.estimate_usd(0, 0, tokens.arm_b_cache_write, tokens.arm_b_cache_read),
};
let net = phase_isolated.cost_usd - long_lived_cache_aware.cost_usd;
let pct = if phase_isolated.cost_usd > 0.0 {
net / phase_isolated.cost_usd * 100.0
} else {
0.0
};
DualArmResult {
model_key: model_key.to_string(),
cache_priced: cost.cache_read_per_m < cost.input_per_m,
phase_isolated,
long_lived_cache_aware,
net_savings_usd: round4(net),
savings_pct: round2(pct),
}
}
fn compute_digest(
scenario: &str,
turns: usize,
tokens: &ArmTokens,
results: &[DualArmResult],
) -> String {
let mut parts = vec![format!(
"{scenario}|turns={turns}|a_in={}|b_cr={}|b_cw={}",
tokens.arm_a_input, tokens.arm_b_cache_read, tokens.arm_b_cache_write
)];
for r in results {
parts.push(format!(
"{}:a={:.6};b={:.6};net={:.6}",
r.model_key,
r.phase_isolated.cost_usd,
r.long_lived_cache_aware.cost_usd,
r.net_savings_usd
));
}
crate::core::hasher::hash_short(&parts.join(";"))
}
fn run_for_dir(scenario: &str, root: &std::path::Path) -> DualArmScorecard {
let bench = benchmark::run_project_benchmark(&root.to_string_lossy());
let turns = build_session_plan(&bench.file_results);
let tokens = accumulate(&turns);
let pricing = ModelPricing::embedded();
let results: Vec<DualArmResult> = PRICED_MODELS
.iter()
.map(|key| {
let quote = pricing.quote(Some(key));
price_result(key, "e.cost, &tokens)
})
.collect();
let determinism_digest = compute_digest(scenario, turns.len(), &tokens, &results);
DualArmScorecard {
schema_version: 1,
tokenizer: crate::core::tokens::counting_family_label(),
scenario: scenario.to_string(),
turns: turns.len(),
total_raw_input_tokens: tokens.arm_a_input,
total_lean_prefix_tokens: tokens.total_lean_prefix,
cache_preservation_ratio: cache_preservation_ratio(&tokens),
determinism_digest,
results,
}
}
fn cache_preservation_ratio(t: &ArmTokens) -> f64 {
let billed = t.arm_b_cache_read + t.arm_b_cache_write;
if billed == 0 {
return 0.0;
}
round4(t.arm_b_cache_read as f64 / billed as f64)
}
pub fn run_dual_arm() -> std::io::Result<DualArmScorecard> {
let dir = tempfile::TempDir::new()?;
let scenario = super::scenarios::medium_scenario();
super::scenarios::materialize(scenario, dir.path())?;
Ok(run_for_dir(scenario.name, dir.path()))
}
impl DualArmScorecard {
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".to_string())
}
pub fn to_human(&self) -> String {
let mut out = String::new();
out.push_str("lean-ctx dual-arm self-verify (input-side, output held equal)\n");
out.push_str(&format!(
"scenario: {} ({} turns)\n",
self.scenario, self.turns
));
out.push_str(&format!("tokenizer: {}\n", self.tokenizer));
out.push_str(&format!("digest: {}\n", self.determinism_digest));
out.push_str(&format!(
"workload: {} raw input tok (phase-isolated) vs {} lean prefix tok (long-lived)\n",
self.total_raw_input_tokens, self.total_lean_prefix_tokens
));
out.push_str(&format!(
"cache: {:.1}% of the carried context is billed from cache (preservation ratio)\n\n",
self.cache_preservation_ratio * 100.0
));
out.push_str(
"model cache? phase-isolated $ long-lived $ saved $ saved%\n",
);
out.push_str("------------------------------------------------------------------------------------\n");
for r in &self.results {
out.push_str(&format!(
"{:<18} {:<6} {:>16.6} {:>13.6} {:>9.6} {:>6.1}\n",
r.model_key,
if r.cache_priced { "yes" } else { "no" },
r.phase_isolated.cost_usd,
r.long_lived_cache_aware.cost_usd,
r.net_savings_usd,
r.savings_pct,
));
}
out.push_str("------------------------------------------------------------------------------------\n");
out.push_str(
"Arm B (long-lived + cache-aware) is the lean-ctx proxy rail; Arm A is a stateless,\n\
phase-isolated session. Cache-priced models show a strict win; non-caching models\n\
still win on compression + read-cache, never worse than break-even.\n",
);
out
}
}
fn round2(v: f64) -> f64 {
(v * 100.0).round() / 100.0
}
fn round4(v: f64) -> f64 {
(v * 10_000.0).round() / 10_000.0
}
#[cfg(test)]
mod tests {
use super::*;
fn scorecard() -> DualArmScorecard {
run_dual_arm().expect("dual-arm bench runs")
}
#[test]
fn long_lived_never_loses_to_phase_isolated() {
let sc = scorecard();
assert!(!sc.results.is_empty());
for r in &sc.results {
assert!(
r.long_lived_cache_aware.cost_usd <= r.phase_isolated.cost_usd + 1e-9,
"{}: long-lived {} must not exceed phase-isolated {}",
r.model_key,
r.long_lived_cache_aware.cost_usd,
r.phase_isolated.cost_usd,
);
assert!(r.net_savings_usd >= -1e-9, "{}: net negative", r.model_key);
}
}
#[test]
fn cache_priced_models_show_a_strict_win() {
let sc = scorecard();
let priced: Vec<&DualArmResult> = sc.results.iter().filter(|r| r.cache_priced).collect();
assert!(priced.len() >= 3, "expected several cache-priced models");
for r in priced {
assert!(
r.long_lived_cache_aware.cost_usd < r.phase_isolated.cost_usd,
"{}: expected strict win",
r.model_key
);
assert!(
r.savings_pct > 50.0,
"{}: cache-priced win should be large, got {:.1}%",
r.model_key,
r.savings_pct
);
}
}
#[test]
fn non_caching_models_still_win_on_compression() {
let sc = scorecard();
let gemini = sc
.results
.iter()
.find(|r| r.model_key == "gemini-2.5-pro")
.expect("gemini in matrix");
assert!(!gemini.cache_priced, "gemini-2.5-pro has no cache discount");
assert!(
gemini.long_lived_cache_aware.cost_usd < gemini.phase_isolated.cost_usd,
"compression alone must still beat raw re-sends"
);
}
#[test]
fn scorecard_is_deterministic() {
let a = scorecard();
let b = scorecard();
assert_eq!(a.determinism_digest, b.determinism_digest);
assert_eq!(a.turns, b.turns);
assert_eq!(a.total_raw_input_tokens, b.total_raw_input_tokens);
assert_eq!(a.to_json(), b.to_json());
}
#[test]
fn output_is_held_equal_so_comparison_is_input_side() {
let sc = scorecard();
for r in &sc.results {
assert_eq!(r.phase_isolated.cache_read_tokens, 0);
assert_eq!(r.phase_isolated.cache_write_tokens, 0);
assert_eq!(r.long_lived_cache_aware.input_tokens, 0);
assert!(r.long_lived_cache_aware.cache_read_tokens > 0);
}
}
#[test]
fn human_and_json_render() {
let sc = scorecard();
let human = sc.to_human();
assert!(human.contains("dual-arm self-verify"));
assert!(human.contains("claude-opus-4.5"));
let json: serde_json::Value = serde_json::from_str(&sc.to_json()).unwrap();
assert!(json["results"].as_array().unwrap().len() >= 5);
assert_eq!(json["schema_version"], 1);
}
#[test]
fn cache_preservation_ratio_is_cache_read_share() {
let turns = vec![
Turn { raw: 100, lean: 40 },
Turn { raw: 200, lean: 60 },
Turn { raw: 50, lean: 13 },
];
let t = accumulate(&turns);
assert_eq!(cache_preservation_ratio(&t), round4(140.0 / 253.0));
assert_eq!(
cache_preservation_ratio(&ArmTokens {
arm_a_input: 0,
arm_b_cache_read: 0,
arm_b_cache_write: 0,
total_lean_prefix: 0,
}),
0.0
);
}
#[test]
fn cache_preservation_ratio_surfaces_on_real_scorecard() {
let sc = scorecard();
assert!(
(0.0..=1.0).contains(&sc.cache_preservation_ratio),
"ratio out of range: {}",
sc.cache_preservation_ratio
);
assert!(sc.cache_preservation_ratio > 0.0);
assert!(sc.to_human().contains("preservation ratio"));
let json: serde_json::Value = serde_json::from_str(&sc.to_json()).unwrap();
assert!(json["cache_preservation_ratio"].is_number());
}
#[test]
fn accumulate_matches_closed_form() {
let turns = vec![
Turn { raw: 100, lean: 40 },
Turn { raw: 200, lean: 60 },
Turn { raw: 50, lean: 13 },
];
let t = accumulate(&turns);
assert_eq!(t.arm_a_input, 750);
assert_eq!(t.total_lean_prefix, 253);
assert_eq!(
t.arm_b_cache_read + t.arm_b_cache_write,
t.total_lean_prefix
);
assert_eq!(t.arm_b_cache_read, 140);
assert_eq!(t.arm_b_cache_write, 113);
}
}