Skip to main content

lean_ctx/core/shadow/
comparison.rs

1//! Aggregate baseline-versus-treatment comparison.
2
3use serde::{Deserialize, Serialize};
4
5use super::{baseline::BaselineMeasurement, recommendation::TreatmentMeasurement};
6
7/// Reports whose baseline uses each task's own host model (3.11.0 and later).
8pub const SAME_HOST_MODEL_SEMANTICS: &str = "same-host-model/v2";
9/// Reports written before 3.11.0 carry no `semantics` field. Their baseline was priced with a
10/// fixed reference model, so cost differences may be model price differences and their
11/// recommendations may name a model switch. They are kept as records, never shown as current.
12pub const LEGACY_SEMANTICS: &str = "legacy-reference-model";
13
14fn legacy_semantics() -> String {
15    LEGACY_SEMANTICS.into()
16}
17
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19pub struct ShadowReport {
20    #[serde(default = "legacy_semantics")]
21    pub semantics: String,
22    pub report_id: String,
23    pub timestamp: String,
24    pub tasks_count: u32,
25    pub tasks_accepted: u32,
26    pub baseline: AggregateMetrics,
27    pub treatment: AggregateMetrics,
28    /// Treatment minus baseline; negative values mean the treatment is lower.
29    pub delta: DeltaMetrics,
30    pub savings: SavingsMetrics,
31    pub recommendations: Vec<Recommendation>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
35pub struct AggregateMetrics {
36    pub total_cost_micros: u64,
37    pub total_tokens: u64,
38    pub avg_cpao_micros: Option<u64>,
39    pub avg_latency_ms: u64,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
43pub struct DeltaMetrics {
44    pub cost_micros: i64,
45    pub tokens: i64,
46    pub cpao_micros: Option<i64>,
47}
48
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50pub struct SavingsMetrics {
51    pub absolute_micros: u64,
52    /// Negative values indicate a cost increase instead of savings.
53    pub relative_percent: f64,
54    pub tokens_saved: u64,
55    pub quality_maintained: bool,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct Recommendation {
60    pub category: String,
61    pub description: String,
62    pub estimated_savings_micros: u64,
63}
64
65pub fn compare(
66    baselines: &[BaselineMeasurement],
67    treatments: &[TreatmentMeasurement],
68) -> ShadowReport {
69    let baseline = aggregate(baselines.iter().map(Metric::baseline));
70    let treatment = aggregate(treatments.iter().map(Metric::treatment));
71    let cost_delta = signed_delta(treatment.total_cost_micros, baseline.total_cost_micros);
72    let token_delta = signed_delta(treatment.total_tokens, baseline.total_tokens);
73    let baseline_accepted = baselines
74        .iter()
75        .filter(|item| item.outcome_accepted)
76        .count();
77    let treatment_accepted = treatments
78        .iter()
79        .filter(|item| item.outcome_accepted)
80        .count();
81    let quality_maintained = acceptance_rate(treatment_accepted, treatments.len())
82        >= acceptance_rate(baseline_accepted, baselines.len());
83    let absolute_micros = baseline
84        .total_cost_micros
85        .saturating_sub(treatment.total_cost_micros);
86    let tokens_saved = baseline.total_tokens.saturating_sub(treatment.total_tokens);
87    let savings = SavingsMetrics {
88        absolute_micros,
89        relative_percent: percentage_savings(
90            baseline.total_cost_micros,
91            treatment.total_cost_micros,
92        ),
93        tokens_saved,
94        quality_maintained,
95    };
96    let delta = DeltaMetrics {
97        cost_micros: cost_delta,
98        tokens: token_delta,
99        cpao_micros: match (treatment.avg_cpao_micros, baseline.avg_cpao_micros) {
100            (Some(treatment), Some(baseline)) => Some(signed_delta(treatment, baseline)),
101            _ => None,
102        },
103    };
104    let recommendations = recommendations(baselines, treatments, &savings);
105
106    ShadowReport {
107        semantics: SAME_HOST_MODEL_SEMANTICS.into(),
108        report_id: format!(
109            "shadow-{}-{}-{}",
110            baseline.total_cost_micros, treatment.total_cost_micros, baseline.total_tokens
111        ),
112        timestamp: "deterministic".into(),
113        tasks_count: u32::try_from(baselines.len()).unwrap_or(u32::MAX),
114        tasks_accepted: u32::try_from(treatment_accepted).unwrap_or(u32::MAX),
115        baseline,
116        treatment,
117        delta,
118        savings,
119        recommendations,
120    }
121}
122
123/// #1905: the baseline is simulated from the same task and outcome signals as
124/// the treatment, so the acceptance comparison cannot show a quality change.
125pub const BASELINE_NOTE: &str = "Note: the baseline is simulated (uncompressed token counts, same outcome signals), not a second measured run. Cost and token deltas are estimates; the acceptance line is not a measured quality comparison.";
126
127impl ShadowReport {
128    /// A stored report from before the same-host-model comparison (see [`LEGACY_SEMANTICS`]).
129    pub fn is_legacy(&self) -> bool {
130        self.semantics != SAME_HOST_MODEL_SEMANTICS
131    }
132}
133
134pub const LEGACY_NOTE: &str = "Legacy record: written before reports compared each task with its own host model. Its baseline may be priced with a different reference model, so its cost difference is not a context saving and its recommendations are not current guidance. Kept for history only.";
135
136pub fn format_report(report: &ShadowReport) -> String {
137    if report.is_legacy() {
138        // Token counts do not depend on the model, so they stay readable; costs and
139        // recommendations from the reference-model baseline are withheld.
140        return format!(
141            "# Shadow Comparison Report (legacy record)\n\n{LEGACY_NOTE}\n\nRecorded tasks: {}. Tokens: baseline {}, treatment {}.\n\n{BASELINE_NOTE}\n\n## Recommendations\n- Withheld: recommendations of legacy records are not current guidance.",
142            report.tasks_count, report.baseline.total_tokens, report.treatment.total_tokens,
143        );
144    }
145    let recommendations = if report.recommendations.is_empty() {
146        "- No recommendation: treatment did not reduce cost without lowering outcome acceptance."
147            .into()
148    } else {
149        report
150            .recommendations
151            .iter()
152            .map(|item| {
153                format!(
154                    "- {}: {} (estimated savings: {} micros)",
155                    item.category, item.description, item.estimated_savings_micros
156                )
157            })
158            .collect::<Vec<_>>()
159            .join("\n")
160    };
161
162    format!(
163        "# Shadow Comparison Report\n\n| Metric | Baseline | Treatment | Delta |\n|---|---:|---:|---:|\n| Cost (micros) | {} | {} | {} |\n| Tokens | {} | {} | {} |\n| Avg CPAO (micros) | {} | {} | {} |\n| Avg latency (ms) | {} | {} | {} |\n\nCost savings: {} micros ({:.2}%). Token savings: {}. Outcome acceptance not below baseline: {}.\n\n{BASELINE_NOTE}\n\n## Recommendations\n{}",
164        report.baseline.total_cost_micros,
165        report.treatment.total_cost_micros,
166        format_delta(report.delta.cost_micros, "saved", "more"),
167        report.baseline.total_tokens,
168        report.treatment.total_tokens,
169        format_delta(report.delta.tokens, "saved", "more"),
170        display_cpao(report.baseline.avg_cpao_micros),
171        display_cpao(report.treatment.avg_cpao_micros),
172        display_cpao_delta(report.delta.cpao_micros),
173        report.baseline.avg_latency_ms,
174        report.treatment.avg_latency_ms,
175        format_delta(
176            signed_delta(
177                report.treatment.avg_latency_ms,
178                report.baseline.avg_latency_ms
179            ),
180            "faster",
181            "slower",
182        ),
183        report.savings.absolute_micros,
184        report.savings.relative_percent,
185        report.savings.tokens_saved,
186        if report.savings.quality_maintained {
187            "yes"
188        } else {
189            "no"
190        },
191        recommendations,
192    )
193}
194
195fn aggregate(items: impl Iterator<Item = Metric>) -> AggregateMetrics {
196    let values: Vec<_> = items.collect();
197    let accepted: Vec<_> = values.iter().filter(|item| item.accepted).collect();
198    AggregateMetrics {
199        total_cost_micros: values.iter().map(|item| item.cost).sum(),
200        total_tokens: values.iter().map(|item| item.tokens).sum(),
201        avg_cpao_micros: (!accepted.is_empty())
202            .then(|| accepted.iter().map(|item| item.cost).sum::<u64>() / accepted.len() as u64),
203        avg_latency_ms: if values.is_empty() {
204            0
205        } else {
206            values.iter().map(|item| item.duration).sum::<u64>() / values.len() as u64
207        },
208    }
209}
210
211/// LeanCTX never recommends a model: the host chooses it. A cost difference is attributed to
212/// context compression only when every task was priced with the same model on both sides, so a
213/// price difference between models can never appear as a context saving.
214fn recommendations(
215    baselines: &[BaselineMeasurement],
216    treatments: &[TreatmentMeasurement],
217    savings: &SavingsMetrics,
218) -> Vec<Recommendation> {
219    let same_models = baselines.len() == treatments.len()
220        && baselines
221            .iter()
222            .zip(treatments)
223            .all(|(baseline, treatment)| baseline.model == treatment.model);
224    if !same_models
225        || savings.absolute_micros == 0
226        || savings.tokens_saved == 0
227        || !savings.quality_maintained
228    {
229        return Vec::new();
230    }
231    vec![Recommendation {
232        category: "Context compression".into(),
233        description: format!(
234            "Keep context compression: it removed an estimated {} tokens against the simulated baseline.",
235            savings.tokens_saved
236        ),
237        estimated_savings_micros: savings.absolute_micros,
238    }]
239}
240
241#[derive(Clone, Copy)]
242struct Metric {
243    tokens: u64,
244    cost: u64,
245    duration: u64,
246    accepted: bool,
247}
248
249impl Metric {
250    fn baseline(item: &BaselineMeasurement) -> Self {
251        Self::new(
252            item.input_tokens,
253            item.output_tokens,
254            item.total_cost_micros,
255            item.duration_ms,
256            item.outcome_accepted,
257        )
258    }
259
260    fn treatment(item: &TreatmentMeasurement) -> Self {
261        Self::new(
262            item.input_tokens,
263            item.output_tokens,
264            item.total_cost_micros,
265            item.duration_ms,
266            item.outcome_accepted,
267        )
268    }
269
270    fn new(input: u64, output: u64, cost: u64, duration: u64, accepted: bool) -> Self {
271        Self {
272            tokens: input.saturating_add(output),
273            cost,
274            duration,
275            accepted,
276        }
277    }
278}
279
280fn acceptance_rate(accepted: usize, total: usize) -> f64 {
281    if total == 0 {
282        1.0
283    } else {
284        accepted as f64 / total as f64
285    }
286}
287
288fn percentage_savings(baseline: u64, treatment: u64) -> f64 {
289    if baseline == 0 {
290        0.0
291    } else {
292        (baseline as f64 - treatment as f64) * 100.0 / baseline as f64
293    }
294}
295
296fn signed_delta(treatment: u64, baseline: u64) -> i64 {
297    let difference = i128::from(treatment) - i128::from(baseline);
298    i64::try_from(difference).unwrap_or_else(|_| {
299        if difference.is_negative() {
300            i64::MIN
301        } else {
302            i64::MAX
303        }
304    })
305}
306
307fn format_delta(delta: i64, lower_label: &str, higher_label: &str) -> String {
308    match delta.cmp(&0) {
309        std::cmp::Ordering::Less => format!("{} {lower_label}", delta.unsigned_abs()),
310        std::cmp::Ordering::Greater => format!("{} {higher_label}", delta.unsigned_abs()),
311        std::cmp::Ordering::Equal => "unchanged".into(),
312    }
313}
314
315fn display_cpao(value: Option<u64>) -> String {
316    value.map_or_else(|| "n/a".into(), |item| item.to_string())
317}
318
319fn display_cpao_delta(value: Option<i64>) -> String {
320    value.map_or_else(
321        || "n/a".into(),
322        |delta| format_delta(delta, "lower", "higher"),
323    )
324}