Skip to main content

cli_denoiser/bench/
mod.rs

1mod corpus;
2
3use std::time::Instant;
4
5use crate::filters::ansi::AnsiFilter;
6use crate::filters::cargo::CargoFilter;
7use crate::filters::dedup::DedupFilter;
8use crate::filters::docker::DockerFilter;
9use crate::filters::generic::GenericFilter;
10use crate::filters::git::GitFilter;
11use crate::filters::kubectl::KubectlFilter;
12use crate::filters::npm::NpmFilter;
13use crate::filters::progress::ProgressFilter;
14use crate::filters::{CommandKind, Filter};
15use crate::pipeline::Pipeline;
16
17/// Full benchmark results.
18#[derive(Debug, serde::Serialize)]
19pub struct BenchResults {
20    pub version: String,
21    pub timestamp: String,
22    pub scenarios: Vec<ScenarioResult>,
23    pub totals: Totals,
24}
25
26/// Result for a single benchmark scenario.
27#[derive(Debug, serde::Serialize)]
28pub struct ScenarioResult {
29    pub name: String,
30    pub command_kind: String,
31    pub original_tokens: usize,
32    pub filtered_tokens: usize,
33    pub savings_tokens: usize,
34    #[serde(serialize_with = "serialize_f64")]
35    pub savings_percent: f64,
36    #[serde(serialize_with = "serialize_f64")]
37    pub latency_us: f64,
38    pub original_lines: usize,
39    pub filtered_lines: usize,
40    pub signal_preserved: bool,
41}
42
43/// Aggregate totals.
44#[derive(Debug, serde::Serialize)]
45pub struct Totals {
46    pub total_original_tokens: usize,
47    pub total_filtered_tokens: usize,
48    pub total_savings: usize,
49    #[serde(serialize_with = "serialize_f64")]
50    pub overall_savings_percent: f64,
51    #[serde(serialize_with = "serialize_f64")]
52    pub avg_latency_us: f64,
53    pub zero_false_positives: bool,
54}
55
56#[allow(clippy::trivially_copy_pass_by_ref)]
57fn serialize_f64<S>(value: &f64, serializer: S) -> Result<S::Ok, S::Error>
58where
59    S: serde::Serializer,
60{
61    serializer.serialize_f64((value * 10.0).round() / 10.0)
62}
63
64/// Run all benchmark scenarios.
65#[must_use]
66pub fn run_all() -> BenchResults {
67    let scenarios_def = corpus::all_scenarios();
68    let mut results = Vec::with_capacity(scenarios_def.len());
69
70    for scenario in &scenarios_def {
71        let pipeline = build_bench_pipeline(&scenario.kind);
72
73        let start = Instant::now();
74        let pipe_result = pipeline.process(&scenario.input);
75        let elapsed = start.elapsed();
76
77        let original_lines = scenario.input.lines().count();
78        let filtered_lines = pipe_result.output.lines().count();
79
80        // Verify zero false positives: all required signal strings must be present
81        let signal_preserved = scenario
82            .required_signals
83            .iter()
84            .all(|sig| pipe_result.output.contains(sig));
85
86        results.push(ScenarioResult {
87            name: scenario.name.clone(),
88            command_kind: format!("{:?}", scenario.kind),
89            original_tokens: pipe_result.original_tokens,
90            filtered_tokens: pipe_result.filtered_tokens,
91            savings_tokens: pipe_result.savings,
92            savings_percent: pipe_result.savings_percent(),
93            #[allow(clippy::cast_precision_loss)]
94            latency_us: elapsed.as_micros() as f64,
95            original_lines,
96            filtered_lines,
97            signal_preserved,
98        });
99    }
100
101    let total_original: usize = results.iter().map(|r| r.original_tokens).sum();
102    let total_filtered: usize = results.iter().map(|r| r.filtered_tokens).sum();
103    let total_savings = total_original.saturating_sub(total_filtered);
104    let avg_latency = if results.is_empty() {
105        0.0
106    } else {
107        #[allow(clippy::cast_precision_loss)]
108        {
109            results.iter().map(|r| r.latency_us).sum::<f64>() / results.len() as f64
110        }
111    };
112
113    #[allow(clippy::cast_precision_loss)]
114    let overall_pct = if total_original == 0 {
115        0.0
116    } else {
117        (total_savings as f64 / total_original as f64) * 100.0
118    };
119
120    BenchResults {
121        version: env!("CARGO_PKG_VERSION").to_string(),
122        timestamp: chrono::Utc::now().to_rfc3339(),
123        scenarios: results,
124        totals: Totals {
125            total_original_tokens: total_original,
126            total_filtered_tokens: total_filtered,
127            total_savings,
128            overall_savings_percent: overall_pct,
129            avg_latency_us: avg_latency,
130            zero_false_positives: scenarios_def
131                .iter()
132                .zip(
133                    // We need to re-check since results moved
134                    scenarios_def.iter().map(|s| {
135                        let p = build_bench_pipeline(&s.kind);
136                        let r = p.process(&s.input);
137                        s.required_signals.iter().all(|sig| r.output.contains(sig))
138                    }),
139                )
140                .all(|(_, preserved)| preserved),
141        },
142    }
143}
144
145/// Print human-readable summary.
146pub fn print_summary(results: &BenchResults) {
147    println!("cli-denoiser benchmark v{}\n", results.version);
148    println!(
149        "  {:<30} {:>8} {:>8} {:>7} {:>8} {:>6}",
150        "Scenario", "Original", "Filtered", "Saved", "Savings", "Signal"
151    );
152    println!("  {}", "-".repeat(75));
153
154    for s in &results.scenarios {
155        let signal = if s.signal_preserved { "OK" } else { "FAIL" };
156        println!(
157            "  {:<30} {:>8} {:>8} {:>7} {:>7.1}% {:>6}",
158            s.name,
159            s.original_tokens,
160            s.filtered_tokens,
161            s.savings_tokens,
162            s.savings_percent,
163            signal
164        );
165    }
166
167    println!("  {}", "-".repeat(75));
168    println!(
169        "  {:<30} {:>8} {:>8} {:>7} {:>7.1}% {:>6}",
170        "TOTAL",
171        results.totals.total_original_tokens,
172        results.totals.total_filtered_tokens,
173        results.totals.total_savings,
174        results.totals.overall_savings_percent,
175        if results.totals.zero_false_positives {
176            "OK"
177        } else {
178            "FAIL"
179        }
180    );
181    println!(
182        "\n  Avg latency: {:.0}us | Zero false positives: {}",
183        results.totals.avg_latency_us, results.totals.zero_false_positives
184    );
185}
186
187fn build_bench_pipeline(kind: &CommandKind) -> Pipeline {
188    let mut pipeline = Pipeline::new();
189    pipeline.add_filter(Box::new(AnsiFilter));
190    pipeline.add_filter(Box::new(ProgressFilter));
191    pipeline.add_filter(Box::new(DedupFilter::new()));
192
193    let specific: Box<dyn Filter> = match kind {
194        CommandKind::Git => Box::new(GitFilter),
195        CommandKind::Npm => Box::new(NpmFilter),
196        CommandKind::Cargo => Box::new(CargoFilter),
197        CommandKind::Docker => Box::new(DockerFilter),
198        CommandKind::Kubectl => Box::new(KubectlFilter),
199        CommandKind::Unknown => Box::new(GenericFilter),
200    };
201    pipeline.add_filter(specific);
202    pipeline
203}