Skip to main content

krabka_bench_driver/
report.rs

1//! Aggregate per-run JSON outputs into a single Markdown summary.
2
3use {
4    crate::{
5        aggregate::{CellKey, stat},
6        graph::{
7            TaggedRun, push_fmt, render_html as render_graph_html,
8            render_web_fragment as render_graph_web_fragment,
9        },
10        numeric::{mebibytes_f64, millis_f64, to_f64},
11        qualification::{
12            RateRecovery, consumer_sample_rate, mean_failover_recovery, mean_rate_recovery,
13            producer_sample_rate,
14        },
15        scenario::{Disturbance, LatencyPercentiles, RunOutput, Stack},
16    },
17    anyhow::{Context, Result},
18    krabka_units::{fmt::Human as _, prelude::*},
19    std::{
20        collections::BTreeMap,
21        fs,
22        path::{Path, PathBuf},
23    },
24};
25
26/// How a metric's cross-run mean is written into a table cell.
27///
28/// A mean and a standard deviation are dimension-agnostic, so the statistics
29/// below run over plain numbers in one fixed unit per metric. This enum says
30/// which quantity to rebuild the mean into, so the cell carries its unit.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32enum Render {
33    /// A dimensionless number, such as a count or a rate of dimensionless
34    /// things.
35    Number,
36    /// A time extent, from a sample of seconds.
37    Extent,
38    /// A byte count, from a sample of bytes.
39    Size,
40    /// A byte throughput, from a sample of bytes per second.
41    Throughput,
42    /// An event rate, from a sample of events per second.
43    Rate,
44}
45
46impl Render {
47    /// The mean of `values` in this metric's unit.
48    fn mean(self, values: &[f64]) -> String {
49        let m = stat(values).mean;
50        match self {
51            Self::Number => format!("{m:.3}"),
52            Self::Extent => Time::from_secs_f64(m).human().to_string(),
53            Self::Size => ByteSize::from_bytes_f64(m).human().to_string(),
54            Self::Throughput => ByteRate::from_bytes_per_sec_f64(m).human().to_string(),
55            Self::Rate => Frequency::from_per_sec(m).human().to_string(),
56        }
57    }
58
59    /// Renders per-run samples as `mean` for a single run, or as `mean (±cv%)`
60    /// for more than one run. `cv` is the coefficient of variation.
61    fn cell(self, values: &[f64]) -> String {
62        let rendered = self.mean(values);
63        if values.len() > 1 {
64            let cv = stat(values).cv_percent();
65            format!("{rendered} (±{cv:.0}%)")
66        } else {
67            rendered
68        }
69    }
70}
71
72/// Walks `input_dir` for `*.json` files, deserializes each one into a
73/// `RunOutput`, groups them by `(scenario name, broker_count)`, averages each
74/// metric across all runs in a group, and writes a Markdown summary.
75/// # Errors
76/// Returns an error when input data is invalid, required I/O fails, or the destination rejects the generated report or audit event.
77pub fn render_markdown(input_dir: &Path, strict: bool) -> Result<String> {
78    // Group by `CellKey`: the scenario name with the whole topology. That keeps
79    // the same scenario run at two topologies in separate cells, and collects
80    // every repeated run of one cell together to average. The key carries the
81    // partition count and the replication factor as well as the broker count,
82    // so a rerun after a topology change cannot be averaged into the old
83    // numbers.
84    let mut by_group: BTreeMap<CellKey, Vec<RunOutput>> = BTreeMap::new();
85    for RunFile { run, .. } in collect_runs(input_dir, strict)? {
86        by_group.entry(CellKey::of(&run)).or_default().push(run);
87    }
88
89    let mut out = String::new();
90    out.push_str("# Krabka vs Strimzi benchmark — results\n\n");
91    if by_group.is_empty() {
92        out.push_str("_no `RunOutput` JSON files found in input dir_\n");
93        return Ok(out);
94    }
95    out.push_str("Each cell is the **mean across all runs** of a (scenario, topology); `(±N%)` is the coefficient of variation (sample stddev ÷ mean), shown when a cell has more than one run. The `ratio` column is `krabka / kafka` for throughput / efficiency (higher is better for Krabka) and `kafka / krabka` for latency / resource (lower-is-better Krabka still > 1).\n\n");
96
97    for (key, runs) in &by_group {
98        render_group(&mut out, key, runs);
99    }
100
101    Ok(out)
102}
103
104fn render_group(out: &mut String, key: &CellKey, runs: &[RunOutput]) {
105    let krabka: Vec<&RunOutput> = runs
106        .iter()
107        .filter(|r| matches!(r.stack, Stack::Krabka))
108        .collect();
109    let kafka: Vec<&RunOutput> = runs
110        .iter()
111        .filter(|r| matches!(r.stack, Stack::Kafka))
112        .collect();
113
114    let stacks = StackRuns {
115        krabka: &krabka,
116        kafka: &kafka,
117    };
118
119    push_fmt(out, format_args!("## {}\n\n", key.label()));
120
121    if let Some(r) = runs.first() {
122        push_fmt(
123            out,
124            format_args!(
125                "Topology: partitions={}, RF={}, broker_count={} (per stack). Duration={}, warmup={}. Runs averaged: krabka={}, kafka={}.\n\n",
126                r.topology.partitions,
127                r.topology.replication_factor,
128                r.topology.broker_count,
129                r.scenario.duration.human(),
130                r.scenario.warmup.human(),
131                krabka.len(),
132                kafka.len(),
133            ),
134        );
135    }
136
137    // A run whose Prometheus capture failed carries `Resource::default()`,
138    // which is all zeros. Zero CPU and zero memory read as an exceptionally
139    // good lower-is-better result, so say when a cell holds one instead of
140    // letting the reader take the resource rows at face value.
141    let unmeasured = runs
142        .iter()
143        .filter(|r| {
144            r.notes.iter().any(|n| {
145                n == "prometheus-url-not-set"
146                    || n.starts_with("prometheus-client-failed:")
147                    || n.starts_with("prometheus-capture-failed:")
148            })
149        })
150        .count();
151    if unmeasured > 0 {
152        push_fmt(
153            out,
154            format_args!(
155                "> **Resource rows are incomplete.** {unmeasured} of {} run(s) captured no \
156                 broker CPU or memory. Their resource and efficiency figures are zeros and \
157                 not measurements.\n\n",
158                runs.len(),
159            ),
160        );
161    }
162
163    render_topline_table(out, stacks);
164
165    // ── Latency percentiles (mean across runs) ──────────────────────────
166    render_latency_table(out, "**Producer ack latency:**", stacks, |r| {
167        &r.producer_latency
168    });
169    render_latency_table(out, "**Consumer end-to-end latency:**", stacks, |r| {
170        &r.consumer_e2e_latency
171    });
172
173    // ── Kafka-only memory split (mean across kafka runs that report it) ──
174    let heap: Vec<f64> = kafka
175        .iter()
176        .filter_map(|r| r.resource.jvm_heap_used)
177        .map(ByteSizeExt::bytes_f64)
178        .collect();
179    let nonheap: Vec<f64> = kafka
180        .iter()
181        .filter_map(|r| r.resource.jvm_nonheap_used)
182        .map(ByteSizeExt::bytes_f64)
183        .collect();
184    let pc: Vec<f64> = kafka
185        .iter()
186        .filter_map(|r| r.resource.kafka_page_cache_approx)
187        .map(ByteSizeExt::bytes_f64)
188        .collect();
189    if !heap.is_empty() && !nonheap.is_empty() && !pc.is_empty() {
190        let ws: Vec<f64> = kafka
191            .iter()
192            .map(|r| r.resource.mem_cgroup_working_set.bytes_f64())
193            .collect();
194        out.push_str("**Kafka memory split:**\n\n");
195        push_fmt(
196            out,
197            format_args!(
198                "- JVM heap used: {}\n- JVM non-heap used: {}\n- Page-cache (approx, working-set − heap − non-heap): {}\n- cgroup working-set (limit-relevant): {}\n\n",
199                Render::Size.mean(&heap),
200                Render::Size.mean(&nonheap),
201                Render::Size.mean(&pc),
202                Render::Size.mean(&ws),
203            ),
204        );
205    }
206
207    // ── Failover disturbance (mean across runs that injected a kill) ────
208    for (label, stack_runs) in [("krabka", &krabka), ("kafka", &kafka)] {
209        let dists: Vec<&Disturbance> = stack_runs
210            .iter()
211            .filter_map(|r| r.disturbance.as_ref())
212            .collect();
213        if dists.is_empty() {
214            continue;
215        }
216        let recovery: Vec<f64> = dists
217            .iter()
218            .map(|d| d.recovery_at_ms.since(d.kill_at_ms).secs_f64())
219            .collect();
220        let dropped: Vec<f64> = dists.iter().map(|d| to_f64(d.dropped.0)).collect();
221        let spike: Vec<f64> = dists
222            .iter()
223            .map(|d| d.latency_spike_max.secs_f64())
224            .collect();
225        push_fmt(
226            out,
227            format_args!(
228                "**Failover ({label}, n={}):** recovery {}, {:.0} drops, max latency spike {}.\n\n",
229                dists.len(),
230                Render::Extent.mean(&recovery),
231                stat(&dropped).mean,
232                Render::Extent.mean(&spike),
233            ),
234        );
235    }
236    render_failover_comparison(out, &krabka, &kafka);
237
238    render_notes_and_errors(out, &krabka, &kafka);
239    out.push('\n');
240}
241
242/// The topline table: one row per headline metric, krabka against kafka.
243fn render_topline_table(out: &mut String, stacks: StackRuns<'_>) {
244    // ── Topline table ───────────────────────────────────────────────────
245    out.push_str("| metric | krabka | kafka | ratio |\n");
246    out.push_str("|---|---|---|---|\n");
247    row_metric(
248        out,
249        "producer msgs/s (higher better)",
250        stacks,
251        RunMetric {
252            value: |t| t.throughput.producer_rate.per_sec_f64(),
253            render: Render::Rate,
254        },
255        true,
256    );
257    row_metric(
258        out,
259        "consumer msgs/s (higher better)",
260        stacks,
261        RunMetric {
262            value: |t| t.throughput.consumer_rate.per_sec_f64(),
263            render: Render::Rate,
264        },
265        true,
266    );
267    row_metric(
268        out,
269        "producer byte rate (higher better)",
270        stacks,
271        RunMetric {
272            value: |t| producer_byte_rate(t).bytes_per_sec_f64(),
273            render: Render::Throughput,
274        },
275        true,
276    );
277    row_metric(
278        out,
279        "p99 producer ack (lower better)",
280        stacks,
281        RunMetric {
282            value: |t| t.producer_latency.p99.secs_f64(),
283            render: Render::Extent,
284        },
285        false,
286    );
287    row_metric(
288        out,
289        "p99 consumer e2e (lower better)",
290        stacks,
291        RunMetric {
292            value: |t| t.consumer_e2e_latency.p99.secs_f64(),
293            render: Render::Extent,
294        },
295        false,
296    );
297    row_metric(
298        out,
299        "msgs/s per CPU-core (higher better)",
300        stacks,
301        RunMetric {
302            value: |t| t.resource.msgs_per_cpu_second.per_sec_f64(),
303            render: Render::Rate,
304        },
305        true,
306    );
307    row_metric(
308        out,
309        "cgroup working set (lower better)",
310        stacks,
311        RunMetric {
312            value: |t| t.resource.mem_cgroup_working_set.bytes_f64(),
313            render: Render::Size,
314        },
315        false,
316    );
317    row_metric(
318        out,
319        "startup (CR-apply → Ready) (lower better)",
320        stacks,
321        RunMetric {
322            value: |t| t.startup.unwrap_or(Time::ZERO).secs_f64(),
323            render: Render::Extent,
324        },
325        false,
326    );
327    row_metric(
328        out,
329        "first ack (Ready → first ack) (lower better)",
330        stacks,
331        RunMetric {
332            value: |t| t.first_ack.secs_f64(),
333            render: Render::Extent,
334        },
335        false,
336    );
337    out.push('\n');
338}
339
340fn render_notes_and_errors(out: &mut String, krabka: &[&RunOutput], kafka: &[&RunOutput]) {
341    for (label, stack_runs) in [("krabka", krabka), ("kafka", kafka)] {
342        let mut notes: Vec<String> = Vec::new();
343        let mut errors: Vec<String> = Vec::new();
344        for r in stack_runs {
345            for n in &r.notes {
346                if !notes.contains(n) {
347                    notes.push(n.clone());
348                }
349            }
350            for e in &r.errors {
351                if !errors.contains(e) {
352                    errors.push(e.clone());
353                }
354            }
355        }
356        if !notes.is_empty() {
357            push_fmt(
358                out,
359                format_args!("_{label} notes:_ {}\n\n", notes.join(", ")),
360            );
361        }
362        if !errors.is_empty() {
363            push_fmt(
364                out,
365                format_args!("_{label} errors:_ {}\n\n", truncate_list(&errors, 3)),
366            );
367        }
368    }
369}
370
371/// Load benchmark runs from JSON files in `input_dir`.
372/// # Errors
373/// Returns an error if the directory cannot be read, or a run is invalid in strict mode.
374pub fn load_runs(input_dir: &Path, strict: bool) -> Result<Vec<RunOutput>> {
375    Ok(collect_runs(input_dir, strict)?
376        .into_iter()
377        .map(|file| file.run)
378        .collect())
379}
380
381fn truncate_list(items: &[String], n: usize) -> String {
382    let head: Vec<_> = items.iter().take(n).cloned().collect();
383    let extra = items.len().saturating_sub(n);
384    if extra > 0 {
385        format!("{} (+{extra} more)", head.join("; "))
386    } else {
387        head.join("; ")
388    }
389}
390
391type LatencySelector = fn(&LatencyPercentiles) -> f64;
392
393/// One percentile row of a latency table.
394struct LatencyRow {
395    label: &'static str,
396    value: LatencySelector,
397    render: Render,
398}
399
400/// The runs of the two stacks in one cell.
401#[derive(Clone, Copy)]
402struct StackRuns<'a> {
403    krabka: &'a [&'a RunOutput],
404    kafka: &'a [&'a RunOutput],
405}
406
407/// How to read one metric out of a run and how to write its mean.
408#[derive(Clone, Copy)]
409struct RunMetric {
410    value: fn(&RunOutput) -> f64,
411    render: Render,
412}
413
414/// A result file that parsed into a run.
415struct RunFile {
416    path: PathBuf,
417    run: RunOutput,
418}
419
420/// The percentile rows of a latency table. Each row holds a label, a way to read
421/// the value out in the unit the statistics run over, and a way to write the
422/// mean back.
423fn latency_rows() -> [LatencyRow; 7] {
424    let extent = |label, value: LatencySelector| LatencyRow {
425        label,
426        value,
427        render: Render::Extent,
428    };
429    [
430        extent("p50", |p| p.p50.secs_f64()),
431        extent("p95", |p| p.p95.secs_f64()),
432        extent("p99", |p| p.p99.secs_f64()),
433        extent("p99.9", |p| p.p999.secs_f64()),
434        extent("max", |p| p.max.secs_f64()),
435        extent("mean", |p| p.mean.secs_f64()),
436        LatencyRow {
437            label: "count",
438            value: |p| to_f64(p.count),
439            render: Render::Number,
440        },
441    ]
442}
443
444/// One stack-vs-stack latency percentile table.
445fn render_latency_table(
446    out: &mut String,
447    heading: &str,
448    StackRuns { krabka, kafka }: StackRuns<'_>,
449    select: fn(&RunOutput) -> &LatencyPercentiles,
450) {
451    push_fmt(out, format_args!("{heading}\n\n"));
452    out.push_str("| percentile | krabka | kafka |\n|---|---|---|\n");
453    for LatencyRow {
454        label,
455        value,
456        render,
457    } in latency_rows()
458    {
459        let c: Vec<f64> = krabka.iter().map(|r| value(select(r))).collect();
460        let k: Vec<f64> = kafka.iter().map(|r| value(select(r))).collect();
461        push_fmt(
462            out,
463            format_args!("| {label} | {} | {} |\n", render.cell(&c), render.cell(&k)),
464        );
465    }
466    out.push('\n');
467}
468
469/// The rate at which a run pushed record bytes over its measurement window.
470fn producer_byte_rate(r: &RunOutput) -> ByteRate {
471    let window = if r.scenario.duration > Time::ZERO {
472        r.scenario.duration
473    } else {
474        secs(1)
475    };
476    byte_rate(r.throughput.bytes_in, window)
477}
478
479fn render_failover_comparison(out: &mut String, krabka: &[&RunOutput], kafka: &[&RunOutput]) {
480    let c_recovery = mean_failover_recovery(krabka);
481    let k_recovery = mean_failover_recovery(kafka);
482    let (Some(c_recovery), Some(k_recovery)) = (c_recovery, k_recovery) else {
483        return;
484    };
485
486    let verdict = if c_recovery <= k_recovery {
487        "PASS"
488    } else {
489        "FAIL"
490    };
491    let delta = if c_recovery <= k_recovery {
492        k_recovery - c_recovery
493    } else {
494        c_recovery - k_recovery
495    };
496    let faster = if c_recovery <= k_recovery {
497        "faster than"
498    } else {
499        "slower than"
500    };
501    push_fmt(
502        out,
503        format_args!(
504            "**Failover comparison:** {verdict} — Krabka recovered {} {faster} kafka (krabka {}, kafka {}).\n\n",
505            delta.human(),
506            c_recovery.human(),
507            k_recovery.human(),
508        ),
509    );
510
511    let c_producer_rate = mean_rate_recovery(krabka, producer_sample_rate);
512    let k_producer_rate = mean_rate_recovery(kafka, producer_sample_rate);
513    let c_consumer_rate = mean_rate_recovery(krabka, consumer_sample_rate);
514    let k_consumer_rate = mean_rate_recovery(kafka, consumer_sample_rate);
515    if c_producer_rate.is_none()
516        && k_producer_rate.is_none()
517        && c_consumer_rate.is_none()
518        && k_consumer_rate.is_none()
519    {
520        return;
521    }
522
523    out.push_str("| stack | recovery ms | producer baseline msgs/s | producer min after kill msgs/s | producer rate recovery ms | consumer baseline msgs/s | consumer min after kill msgs/s | consumer rate recovery ms |\n");
524    out.push_str("|---|---:|---:|---:|---:|---:|---:|---:|\n");
525    render_rate_recovery_row(out, "krabka", c_recovery, c_producer_rate, c_consumer_rate);
526    render_rate_recovery_row(out, "kafka", k_recovery, k_producer_rate, k_consumer_rate);
527    out.push('\n');
528}
529
530fn render_rate_recovery_row(
531    out: &mut String,
532    stack: &str,
533    failover_recovery: Time,
534    producer_rate: Option<RateRecovery>,
535    consumer_rate: Option<RateRecovery>,
536) {
537    let recovery = |rate: &RateRecovery| {
538        rate.recovery
539            .map_or_else(|| "unrecovered".into(), |t| t.human().to_string())
540    };
541    match (producer_rate, consumer_rate) {
542        (Some(producer_rate), Some(consumer_rate)) => push_fmt(
543            out,
544            format_args!(
545                "| {stack} | {} | {} | {} | {} | {} | {} | {} |\n",
546                failover_recovery.human(),
547                producer_rate.baseline.human(),
548                producer_rate.min_after_kill.human(),
549                recovery(&producer_rate),
550                consumer_rate.baseline.human(),
551                consumer_rate.min_after_kill.human(),
552                recovery(&consumer_rate),
553            ),
554        ),
555        _ => push_fmt(
556            out,
557            format_args!(
558                "| {stack} | {} | n/a | n/a | n/a | n/a | n/a | n/a |\n",
559                failover_recovery.human()
560            ),
561        ),
562    }
563}
564
565/// Renders one comparison row. The row holds the mean (± CV) of `value` over
566/// each stack's runs, and the krabka-vs-kafka ratio of the two means.
567///
568/// The ratio divides two means in the same unit, so it is dimensionless
569/// whatever `render` writes the cells as.
570fn row_metric(
571    out: &mut String,
572    label: &str,
573    StackRuns { krabka, kafka }: StackRuns<'_>,
574    RunMetric { value, render }: RunMetric,
575    higher_is_better: bool,
576) {
577    let cvals: Vec<f64> = krabka.iter().map(|&r| value(r)).collect();
578    let kvals: Vec<f64> = kafka.iter().map(|&r| value(r)).collect();
579    let c = stat(&cvals).mean;
580    let k = stat(&kvals).mean;
581    let ratio = if higher_is_better {
582        if k > 0.0 {
583            format!("{:.2}×", c / k)
584        } else {
585            "—".into()
586        }
587    } else if c > 0.0 {
588        format!("{:.2}×", k / c)
589    } else {
590        "—".into()
591    };
592    push_fmt(
593        out,
594        format_args!(
595            "| {label} | {} | {} | {ratio} |\n",
596            render.cell(&cvals),
597            render.cell(&kvals)
598        ),
599    );
600}
601
602// ── CSV exports (graph-ready) ────────────────────────────────────────────────
603
604/// Reads and parses every `*.json` in `input_dir` into `RunOutput`s, and keeps
605/// the source path. The per-run `-runNN` tag lives in the filename.
606fn collect_runs(input_dir: &Path, strict: bool) -> Result<Vec<RunFile>> {
607    let mut runs: Vec<RunFile> = Vec::new();
608    let entries =
609        fs::read_dir(input_dir).with_context(|| format!("read_dir {}", input_dir.display()))?;
610    for e in entries {
611        let e = e.context("dir entry")?;
612        let path = e.path();
613        if path.extension().and_then(|s| s.to_str()) != Some("json") {
614            continue;
615        }
616        let body = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
617        match serde_json::from_str::<RunOutput>(&body) {
618            Ok(run) => runs.push(RunFile { path, run }),
619            Err(e) => {
620                if strict {
621                    return Err(anyhow::anyhow!("failed to parse {}: {e}", path.display()));
622                }
623                eprintln!("warn: skipping unparseable {}: {e}", path.display());
624            }
625        }
626    }
627    Ok(runs)
628}
629
630/// The per-run tag from a result filename (`...-run07.json` → `run07`), or
631/// `single` for an untagged one-off run.
632fn run_tag_from_path(path: &Path) -> String {
633    let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
634    if let Some((_, digits)) = stem.rsplit_once("-run")
635        && !digits.is_empty()
636        && digits.chars().all(|c| c.is_ascii_digit())
637    {
638        return format!("run{digits}");
639    }
640    "single".to_owned()
641}
642
643/// Minimal CSV quoting. This wraps the field in double quotes, and doubles the
644/// internal quotes, only when the field contains a comma, quote, or newline.
645fn csv_field(s: &str) -> String {
646    if s.contains(',') || s.contains('"') || s.contains('\n') {
647        format!("\"{}\"", s.replace('"', "\"\""))
648    } else {
649        s.to_owned()
650    }
651}
652
653/// Both CSV exports use the same stable ordering of runs.
654fn collect_csv_runs(input_dir: &Path, strict: bool) -> Result<Vec<RunFile>> {
655    let mut runs = collect_runs(input_dir, strict)?;
656    runs.sort_by(|a, b| {
657        (
658            a.run.scenario.name.as_str(),
659            a.run.topology.broker_count,
660            a.run.stack.as_str(),
661            run_tag_from_path(&a.path),
662        )
663            .cmp(&(
664                b.run.scenario.name.as_str(),
665                b.run.topology.broker_count,
666                b.run.stack.as_str(),
667                run_tag_from_path(&b.path),
668            ))
669    });
670
671    Ok(runs)
672}
673
674/// One wide row per run, where every aggregate metric is a column. Group by
675/// `(scenario, stack, broker_count)` in any tool to draw krabka-vs-kafka bars
676/// with run-to-run error bars.
677/// # Errors
678/// Returns an error when input data is invalid, required I/O fails, or the destination rejects the generated report or audit event.
679pub fn render_csv(input_dir: &Path, strict: bool) -> Result<String> {
680    let runs = collect_csv_runs(input_dir, strict)?;
681    // Every column names the unit it is written in, because a CSV cell is a
682    // bare number: sizes in bytes or MiB, extents in ms.
683    let mut out = String::new();
684    out.push_str(
685        "scenario,stack,run_tag,broker_count,partitions,replication_factor,producers,consumers,\
686msg_size_bytes,acks,mode_tag,duration_s,wallclock_start_unix_ms,\
687msgs_produced,msgs_consumed,mb_in,mb_out,producer_msgs_per_sec,consumer_msgs_per_sec,\
688producer_p50_ms,producer_p95_ms,producer_p99_ms,producer_p999_ms,producer_max_ms,\
689consumer_p50_ms,consumer_p95_ms,consumer_p99_ms,consumer_p999_ms,consumer_max_ms,\
690broker_cpu_seconds,mem_working_set_bytes,msgs_per_cpu_core,\
691jvm_heap_used_bytes,jvm_nonheap_used_bytes,kafka_page_cache_approx_bytes,\
692startup_ms,first_ack_ms,failover_recovery_ms,failover_dropped,failover_latency_spike_max_ms,\
693notes,errors_count\n",
694    );
695    for RunFile { path, run: r } in &runs {
696        let acks = format!("{:?}", r.scenario.acks).to_lowercase();
697        let mode_tag = format!("{:?}", r.scenario.mode_tag).to_lowercase();
698        let (rec, drop, spike) = r.disturbance.as_ref().map_or_else(
699            || (String::new(), String::new(), String::new()),
700            |d| {
701                (
702                    d.recovery_at_ms
703                        .since(d.kill_at_ms)
704                        .millis_i64()
705                        .to_string(),
706                    d.dropped.to_string(),
707                    format!("{:.3}", millis_f64(d.latency_spike_max)),
708                )
709            },
710        );
711        let opt_bytes = |o: Option<ByteSize>| o.map_or_default(|v| v.bytes_i64().to_string());
712        let opt_millis = |o: Option<Time>| o.map_or_default(|v| v.millis_i64().to_string());
713        let p = &r.producer_latency;
714        let c = &r.consumer_e2e_latency;
715        let cols = [
716            csv_field(&r.scenario.name),
717            r.stack.as_str().to_owned(),
718            run_tag_from_path(path),
719            r.topology.broker_count.to_string(),
720            r.topology.partitions.to_string(),
721            r.topology.replication_factor.to_string(),
722            r.scenario.producers.to_string(),
723            r.scenario.consumers.to_string(),
724            r.scenario.msg_size.bytes_u64().to_string(),
725            acks,
726            mode_tag,
727            r.scenario.duration.secs_i64().to_string(),
728            r.wallclock_start_unix_ms.to_string(),
729            r.throughput.msgs_produced.to_string(),
730            r.throughput.msgs_consumed.to_string(),
731            format!("{:.6}", mebibytes_f64(r.throughput.bytes_in)),
732            format!("{:.6}", mebibytes_f64(r.throughput.bytes_out)),
733            format!("{:.3}", r.throughput.producer_rate.per_sec_f64()),
734            format!("{:.3}", r.throughput.consumer_rate.per_sec_f64()),
735            format!("{:.3}", millis_f64(p.p50)),
736            format!("{:.3}", millis_f64(p.p95)),
737            format!("{:.3}", millis_f64(p.p99)),
738            format!("{:.3}", millis_f64(p.p999)),
739            format!("{:.3}", millis_f64(p.max)),
740            format!("{:.3}", millis_f64(c.p50)),
741            format!("{:.3}", millis_f64(c.p95)),
742            format!("{:.3}", millis_f64(c.p99)),
743            format!("{:.3}", millis_f64(c.p999)),
744            format!("{:.3}", millis_f64(c.max)),
745            format!("{:.3}", r.resource.broker_cpu.secs_f64()),
746            r.resource.mem_cgroup_working_set.bytes_u64().to_string(),
747            format!("{:.3}", r.resource.msgs_per_cpu_second.per_sec_f64()),
748            opt_bytes(r.resource.jvm_heap_used),
749            opt_bytes(r.resource.jvm_nonheap_used),
750            opt_bytes(r.resource.kafka_page_cache_approx),
751            opt_millis(r.startup),
752            r.first_ack.millis_i64().to_string(),
753            rec,
754            drop,
755            spike,
756            csv_field(&r.notes.join("; ")),
757            r.errors.len().to_string(),
758        ];
759        out.push_str(&cols.join(","));
760        out.push('\n');
761    }
762    Ok(out)
763}
764
765/// Long, tidy time-series CSV with one row per (run × time-offset × metric).
766///
767/// This is the graph-ready export for values plotted *over the test*. Filter by
768/// `metric` and group by `(scenario, stack, run_tag)` to draw lines.
769/// # Errors
770/// Returns an error when input data is invalid, required I/O fails, or the destination rejects the generated report or audit event.
771pub fn render_timeseries_csv(input_dir: &Path, strict: bool) -> Result<String> {
772    let runs = collect_csv_runs(input_dir, strict)?;
773    let mut out = String::new();
774    out.push_str("scenario,stack,broker_count,partitions,replication_factor,run_tag,t_offset_ms,metric,value\n");
775    for RunFile { path, run: r } in &runs {
776        let prefix = format!(
777            "{},{},{},{},{},{}",
778            csv_field(&r.scenario.name),
779            r.stack.as_str(),
780            r.topology.broker_count,
781            r.topology.partitions,
782            r.topology.replication_factor,
783            run_tag_from_path(path),
784        );
785        for s in &r.samples {
786            for (metric, value) in [
787                ("producer_msgs_per_sec", s.producer_rate.per_sec_f64()),
788                ("consumer_msgs_per_sec", s.consumer_rate.per_sec_f64()),
789                ("producer_p50_ms", millis_f64(s.producer_p50)),
790                ("producer_p99_ms", millis_f64(s.producer_p99)),
791                ("consumer_e2e_p99_ms", millis_f64(s.consumer_e2e_p99)),
792            ] {
793                push_fmt(
794                    &mut out,
795                    format_args!("{prefix},{},{metric},{value:.3}\n", s.t_offset_ms),
796                );
797            }
798        }
799        for b in &r.broker_samples {
800            push_fmt(
801                &mut out,
802                format_args!(
803                    "{prefix},{},broker_cpu_cores,{:.4}\n",
804                    b.t_offset_ms, b.cpu_cores
805                ),
806            );
807            push_fmt(
808                &mut out,
809                format_args!(
810                    "{prefix},{},broker_mem_working_set_bytes,{}\n",
811                    b.t_offset_ms,
812                    b.mem_working_set.bytes_u64()
813                ),
814            );
815        }
816    }
817    Ok(out)
818}
819
820/// Renders a self-contained Plotly HTML report from every run in `input_dir`.
821///
822/// The report holds bar charts and averaged time-series. This function delegates
823/// the aggregation to [`crate::aggregate`] and the figure building to
824/// [`crate::graph`].
825/// # Errors
826/// Returns an error when input data is invalid, required I/O fails, or the destination rejects the generated report or audit event.
827pub fn render_html(input_dir: &Path, strict: bool, title: &str) -> Result<String> {
828    let outputs: Vec<RunOutput> = collect_runs(input_dir, strict)?
829        .into_iter()
830        .map(|file| file.run)
831        .collect();
832    Ok(render_graph_html(&outputs, title))
833}
834
835/// Renders the website HTML fragment from every run in `input_dir`.
836///
837/// The fragment holds per-run and averaged throughput, CPU, and memory charts.
838/// This function pairs each run with the `runNN` tag it parses from the result
839/// filename, so every per-run trace carries a label.
840/// # Errors
841/// Returns an error when input data is invalid, required I/O fails, or the destination rejects the generated report or audit event.
842pub fn render_web_fragment(input_dir: &Path, strict: bool) -> Result<String> {
843    let tagged: Vec<TaggedRun> = collect_runs(input_dir, strict)?
844        .into_iter()
845        .map(|file| TaggedRun {
846            tag: run_tag_from_path(&file.path),
847            run: file.run,
848        })
849        .collect();
850    Ok(render_graph_web_fragment(&tagged))
851}
852
853#[cfg(test)]
854mod tests {
855    use {
856        super::*,
857        crate::{
858            ids::{MessageCount, TimeOffsetMs, WallclockMs},
859            numeric::{event_rate, nonnegative_i64_to_u64},
860            qualification::failover_gate_violations,
861            scenario::{
862                Disturbance, FailoverSpec, LatencyPercentiles, ModeTag, Resource, Sample,
863                Throughput, Topology,
864                fixture::{scenario, short_scenario},
865            },
866        },
867        assert2::check,
868        tempfile::tempdir,
869    };
870
871    fn fake_run(stack: Stack, msgs: u64) -> RunOutput {
872        RunOutput {
873            scenario: scenario("small-msg-saturate"),
874            stack,
875            topology: Topology {
876                partitions: 6,
877                replication_factor: 1,
878                broker_count: 1,
879            },
880            wallclock_start_unix_ms: WallclockMs(0),
881            wallclock_end_unix_ms: WallclockMs(60_000),
882            throughput: Throughput {
883                msgs_produced: MessageCount(msgs),
884                msgs_consumed: MessageCount(msgs),
885                bytes_in: mebibytes(5),
886                bytes_out: mebibytes(5),
887                producer_rate: event_rate(msgs, secs(60)),
888                consumer_rate: event_rate(msgs, secs(60)),
889            },
890            ..RunOutput::default_placeholder()
891        }
892    }
893
894    fn fake_failover_run(stack: Stack, recovery: Time, post_kill_min: Frequency) -> RunOutput {
895        let mut r = fake_run(stack, 600_000);
896        r.scenario.name = "failover".into();
897        r.scenario.mode_tag = ModeTag::Cluster;
898        r.scenario.partitions = 12;
899        r.scenario.replication_factor = 3;
900        r.scenario.duration = secs(12);
901        r.scenario.warmup = Time::ZERO;
902        r.scenario.failover = Some(FailoverSpec {
903            kill_after: secs(4),
904            target: "partition0_leader".into(),
905        });
906        r.topology.partitions = 12;
907        r.topology.replication_factor = 3;
908        r.topology.broker_count = 3;
909        r.disturbance = Some(Disturbance {
910            kill_at_ms: TimeOffsetMs(4_000),
911            recovery_at_ms: TimeOffsetMs(4_000 + nonnegative_i64_to_u64(recovery.millis_i64())),
912            dropped: MessageCount(0),
913            latency_spike_max: millis(42),
914        });
915        r.samples = vec![
916            Sample {
917                t_offset_ms: TimeOffsetMs(0),
918                producer_rate: per_sec(10_000),
919                consumer_rate: per_sec(9_800),
920                ..Sample::default()
921            },
922            Sample {
923                t_offset_ms: TimeOffsetMs(2_000),
924                producer_rate: per_sec(10_200),
925                consumer_rate: per_sec(9_900),
926                ..Sample::default()
927            },
928            Sample {
929                t_offset_ms: TimeOffsetMs(4_000),
930                producer_rate: post_kill_min,
931                consumer_rate: post_kill_min * 0.9,
932                ..Sample::default()
933            },
934            Sample {
935                t_offset_ms: TimeOffsetMs(6_000),
936                producer_rate: per_sec(9_500),
937                consumer_rate: per_sec(9_200),
938                ..Sample::default()
939            },
940        ];
941        r
942    }
943
944    // Spare default impl scoped to tests.
945    impl RunOutput {
946        fn default_placeholder() -> Self {
947            Self {
948                scenario: short_scenario(1),
949                stack: Stack::Krabka,
950                topology: Topology {
951                    partitions: 1,
952                    replication_factor: 1,
953                    broker_count: 1,
954                },
955                wallclock_start_unix_ms: WallclockMs(0),
956                wallclock_end_unix_ms: WallclockMs(0),
957                throughput: Throughput::default(),
958                resource: Resource::default(),
959                disturbance: None,
960                startup: None,
961                first_ack: Time::ZERO,
962                producer_latency: LatencyPercentiles::default(),
963                consumer_e2e_latency: LatencyPercentiles::default(),
964                errors: Vec::new(),
965                notes: Vec::new(),
966                samples: Vec::new(),
967                broker_samples: Vec::new(),
968            }
969        }
970    }
971
972    #[test]
973    fn renders_a_simple_pair() {
974        let dir = tempdir().unwrap();
975        let krabka = fake_run(Stack::Krabka, 600_000);
976        let kafka = fake_run(Stack::Kafka, 400_000);
977        write_run(dir.path().join("krabka.json"), &krabka);
978        write_run(dir.path().join("kafka.json"), &kafka);
979        let md = render_markdown(dir.path(), true).unwrap();
980        // "1.50×" is the ratio 600k / 400k.
981        for needle in ["small-msg-saturate", "producer msgs/s", "1.50×"] {
982            assert2::assert!(md.contains(needle));
983        }
984    }
985
986    #[test]
987    fn time_series_failure_is_reported_without_marking_scalar_resource_rows_missing() {
988        let dir = tempdir().unwrap();
989        let mut krabka = fake_run(Stack::Krabka, 600_000);
990        krabka
991            .notes
992            .push("prometheus-resource-series-failed: timestamps are not aligned".into());
993        write_run(dir.path().join("krabka.json"), &krabka);
994        write_run(
995            dir.path().join("kafka.json"),
996            &fake_run(Stack::Kafka, 400_000),
997        );
998
999        let md = render_markdown(dir.path(), true).unwrap();
1000
1001        assert2::assert!(md.contains("prometheus-resource-series-failed"));
1002        assert2::assert!(!md.contains("Resource rows are incomplete"));
1003    }
1004
1005    #[test]
1006    fn averages_multiple_runs_per_cell() {
1007        let dir = tempdir().unwrap();
1008        // Three krabka runs (600k/700k/800k → mean 700k) and three identical
1009        // kafka runs (400k). The report should average each stack and ratio
1010        // the means: 700k/400k = 1.75×.
1011        for (i, msgs) in [600_000u64, 700_000, 800_000].iter().enumerate() {
1012            write_run(
1013                dir.path().join(format!("krabka-run{i}.json")),
1014                &fake_run(Stack::Krabka, *msgs),
1015            );
1016        }
1017        for i in 0..3 {
1018            write_run(
1019                dir.path().join(format!("kafka-run{i}.json")),
1020                &fake_run(Stack::Kafka, 400_000),
1021            );
1022        }
1023        let md = render_markdown(dir.path(), true).unwrap();
1024        // Multi-run cells carry a coefficient-of-variation marker ("±").
1025        for needle in ["1.75×", "Runs averaged: krabka=3, kafka=3", "±"] {
1026            assert2::assert!(md.contains(needle));
1027        }
1028    }
1029
1030    #[test]
1031    fn handles_empty_dir() {
1032        let dir = tempdir().unwrap();
1033        let md = render_markdown(dir.path(), false).unwrap();
1034        assert2::assert!(md.contains("no `RunOutput` JSON files found"));
1035    }
1036
1037    #[test]
1038    fn markdown_cells_use_shared_sample_statistics() {
1039        for (values, expected) in [
1040            (&[][..], "0.000"),
1041            (&[2.0][..], "2.000"),
1042            (&[1.0, 3.0][..], "2.000 (±71%)"),
1043            (&[0.0, 0.0][..], "0.000 (±0%)"),
1044        ] {
1045            assert2::assert!(Render::Number.cell(values) == expected);
1046        }
1047    }
1048
1049    #[test]
1050    fn failover_summary_compares_recovery_and_rate_over_time() {
1051        let dir = tempdir().unwrap();
1052        write_default_failover_runs(dir.path());
1053
1054        let md = render_markdown(dir.path(), true).unwrap();
1055
1056        for needle in [
1057            "**Failover comparison:** PASS",
1058            "Krabka recovered 1s faster than kafka",
1059            "| stack | recovery ms | producer baseline msgs/s | producer min after kill msgs/s | producer rate recovery ms | consumer baseline msgs/s | consumer min after kill msgs/s | consumer rate recovery ms |",
1060            "| krabka | 2s | 10100/s | 8000/s | 2s | 9850/s | 7200/s | 2s |",
1061            "| kafka | 3s | 10100/s | 6000/s | 2s | 9850/s | 5400/s | 2s |",
1062        ] {
1063            assert2::assert!(md.contains(needle));
1064        }
1065    }
1066
1067    #[test]
1068    fn failover_gate_passes_when_krabka_recovers_no_slower_with_rate_samples() {
1069        let runs = [
1070            fake_failover_run(Stack::Krabka, secs(2), per_sec(8_000)),
1071            fake_failover_run(Stack::Kafka, secs(3), per_sec(6_000)),
1072        ];
1073        let violations = failover_gate_violations(&runs);
1074
1075        assert2::assert!(violations.is_empty());
1076    }
1077
1078    fn write_default_failover_runs(dir: &Path) {
1079        write_failover_runs(
1080            dir,
1081            &fake_failover_run(Stack::Krabka, secs(2), per_sec(8_000)),
1082            &fake_failover_run(Stack::Kafka, secs(3), per_sec(6_000)),
1083        );
1084    }
1085
1086    fn check_failover_violation(dir: &Path, krabka: &RunOutput, expected: &str) {
1087        check_failover_runs(
1088            dir,
1089            krabka,
1090            &fake_failover_run(Stack::Kafka, secs(3), per_sec(6_000)),
1091            &[expected],
1092        );
1093    }
1094
1095    fn check_failover_runs(dir: &Path, krabka: &RunOutput, kafka: &RunOutput, expected: &[&str]) {
1096        write_failover_runs(dir, krabka, kafka);
1097        let violations = failover_gate_violations(&load_runs(dir, true).unwrap());
1098        for expected in expected {
1099            assert_violation_contains(&violations, expected);
1100        }
1101    }
1102
1103    fn assert_violation_contains(violations: &[String], expected: &str) {
1104        assert2::assert!(
1105            violations
1106                .iter()
1107                .any(|violation| violation.contains(expected)),
1108            "{expected}: {violations:?}"
1109        );
1110    }
1111
1112    fn write_run(path: impl AsRef<Path>, run: &RunOutput) {
1113        fs::write(path, serde_json::to_string(run).unwrap()).unwrap();
1114    }
1115
1116    fn write_failover_runs(dir: &Path, krabka: &RunOutput, kafka: &RunOutput) {
1117        for (name, run) in [("krabka", krabka), ("kafka", kafka)] {
1118            write_run(
1119                dir.join(format!("{name}-failover-3broker-rf3-run01.json")),
1120                run,
1121            );
1122        }
1123    }
1124
1125    /// Write one krabka run and one kafka run into `dir`, so a gate test only
1126    /// has to say how it damaged the krabka half.
1127    fn write_failover_pair(dir: &Path, krabka: &RunOutput) {
1128        write_failover_runs(
1129            dir,
1130            krabka,
1131            &fake_failover_run(Stack::Kafka, secs(3), per_sec(6_000)),
1132        );
1133    }
1134
1135    fn check_failover_pair(dir: &Path, krabka: &RunOutput, expected: impl Fn(&String) -> bool) {
1136        write_failover_pair(dir, krabka);
1137        let violations = failover_gate_violations(&load_runs(dir, true).unwrap());
1138        assert2::assert!(violations.iter().any(expected), "{violations:?}");
1139    }
1140
1141    /// A run whose producer tasks failed still writes a `RunOutput`. Its
1142    /// recovery stamp is meaningless, so the gate must refuse it rather than
1143    /// compare it.
1144    #[test]
1145    fn failover_gate_rejects_a_run_that_reported_errors() {
1146        let dir = tempdir().unwrap();
1147        let mut krabka = fake_failover_run(Stack::Krabka, secs(2), per_sec(8_000));
1148        krabka.errors = vec!["producer-0-build: connection refused".into()];
1149        check_failover_pair(dir.path(), &krabka, |v| {
1150            v.contains("krabka failover run is not a measurement") && v.contains("reported errors")
1151        });
1152    }
1153
1154    /// A transient error before the kill used to stamp recovery earlier than
1155    /// the kill. `TimeOffsetMs::since` saturates that to 0 ms, which reads as
1156    /// an instant recovery and wins the comparison.
1157    #[test]
1158    fn failover_gate_rejects_a_recovery_before_the_kill() {
1159        let dir = tempdir().unwrap();
1160        let mut krabka = fake_failover_run(Stack::Krabka, secs(2), per_sec(8_000));
1161        if let Some(disturbance) = krabka.disturbance.as_mut() {
1162            disturbance.recovery_at_ms = TimeOffsetMs(3_000);
1163        }
1164        check_failover_pair(dir.path(), &krabka, |v| {
1165            v.contains("is before the kill stamp")
1166        });
1167    }
1168
1169    /// A workload that moved nothing has a zero baseline, and a zero baseline
1170    /// never shows a drop after the kill.
1171    #[test]
1172    fn failover_gate_rejects_a_run_with_no_traffic() {
1173        let dir = tempdir().unwrap();
1174        let mut krabka = fake_failover_run(Stack::Krabka, secs(2), per_sec(8_000));
1175        krabka.throughput.msgs_produced = MessageCount(0);
1176        krabka.throughput.producer_rate = Frequency::ZERO;
1177        check_failover_pair(dir.path(), &krabka, |v| v.contains("produced no messages"));
1178    }
1179
1180    /// A kill that never landed leaves `kill_at_ms` at zero.
1181    #[test]
1182    fn failover_gate_rejects_a_run_with_no_recorded_kill() {
1183        let dir = tempdir().unwrap();
1184        let mut krabka = fake_failover_run(Stack::Krabka, secs(2), per_sec(8_000));
1185        krabka.disturbance = None;
1186        check_failover_pair(dir.path(), &krabka, |v| {
1187            v.contains("recorded no disturbance")
1188        });
1189    }
1190
1191    #[test]
1192    fn failover_gate_fails_without_failover_results() {
1193        let dir = tempdir().unwrap();
1194        write_run(
1195            dir.path()
1196                .join("krabka-small-msg-saturate-3broker-rf3-run01.json"),
1197            &fake_run(Stack::Krabka, 600_000),
1198        );
1199        write_run(
1200            dir.path()
1201                .join("kafka-small-msg-saturate-3broker-rf3-run01.json"),
1202            &fake_run(Stack::Kafka, 400_000),
1203        );
1204
1205        let violations = failover_gate_violations(&load_runs(dir.path(), true).unwrap());
1206
1207        assert_violation_contains(&violations, "missing failover results");
1208    }
1209
1210    #[test]
1211    fn failover_gate_does_not_compare_different_topologies() {
1212        let dir = tempdir().unwrap();
1213        let mut krabka = fake_failover_run(Stack::Krabka, secs(2), per_sec(8_000));
1214        krabka.topology.partitions = 12;
1215        krabka.topology.replication_factor = 3;
1216        let mut kafka = fake_failover_run(Stack::Kafka, secs(3), per_sec(6_000));
1217        kafka.topology.partitions = 24;
1218        kafka.topology.replication_factor = 3;
1219        write_run(
1220            dir.path().join("krabka-failover-3broker-rf3-run01.json"),
1221            &krabka,
1222        );
1223        write_run(
1224            dir.path().join("kafka-failover-3broker-rf3-24p-run01.json"),
1225            &kafka,
1226        );
1227
1228        let violations = failover_gate_violations(&load_runs(dir.path(), true).unwrap());
1229
1230        assert_violation_contains(
1231            &violations,
1232            "failover @ 3 broker(s), 12 partitions, RF=3: missing kafka failover disturbance result",
1233        );
1234        assert_violation_contains(
1235            &violations,
1236            "failover @ 3 broker(s), 24 partitions, RF=3: missing Krabka failover disturbance result",
1237        );
1238    }
1239
1240    #[test]
1241    fn failover_gate_fails_when_krabka_recovers_slower_or_rate_samples_missing() {
1242        let dir = tempdir().unwrap();
1243        let mut kafka = fake_failover_run(Stack::Kafka, secs(2), per_sec(6_000));
1244        kafka.samples.clear();
1245        check_failover_runs(
1246            dir.path(),
1247            &fake_failover_run(Stack::Krabka, secs(4), per_sec(8_000)),
1248            &kafka,
1249            &[
1250                "Krabka recovery 4s is slower than kafka 2s",
1251                "kafka failover run is missing message-rate samples",
1252            ],
1253        );
1254    }
1255
1256    fn rate_recovery_tail() -> Sample {
1257        Sample {
1258            t_offset_ms: TimeOffsetMs(8_000),
1259            producer_rate: per_sec(9_500),
1260            consumer_rate: per_sec(9_200),
1261            ..Sample::default()
1262        }
1263    }
1264
1265    fn check_slow_rate_recovery(mut krabka: RunOutput, expected: &str) {
1266        let dir = tempdir().unwrap();
1267        krabka.samples.push(rate_recovery_tail());
1268        check_failover_violation(dir.path(), &krabka, expected);
1269    }
1270
1271    #[test]
1272    fn failover_gate_fails_when_krabka_message_rate_recovers_slower_than_kafka() {
1273        let mut krabka = fake_failover_run(Stack::Krabka, secs(2), per_sec(8_000));
1274        krabka.samples[3].producer_rate = per_sec(8500);
1275        check_slow_rate_recovery(
1276            krabka,
1277            "Krabka producer rate recovery 4s is slower than kafka 2s",
1278        );
1279    }
1280
1281    #[test]
1282    fn failover_gate_fails_when_krabka_message_rate_never_recovers() {
1283        let dir = tempdir().unwrap();
1284        let mut krabka = fake_failover_run(Stack::Krabka, secs(2), per_sec(8_000));
1285        let mut kafka = fake_failover_run(Stack::Kafka, secs(3), per_sec(6_000));
1286        let set_slow_rate = |run: &mut RunOutput| {
1287            for sample in run
1288                .samples
1289                .iter_mut()
1290                .filter(|sample| sample.t_offset_ms >= 4_000)
1291            {
1292                sample.producer_rate = per_sec(8_500);
1293            }
1294        };
1295        set_slow_rate(&mut krabka);
1296        set_slow_rate(&mut kafka);
1297        write_failover_runs(dir.path(), &krabka, &kafka);
1298
1299        let violations = failover_gate_violations(&load_runs(dir.path(), true).unwrap());
1300
1301        assert_violation_contains(&violations, "Krabka producer rate did not recover");
1302    }
1303
1304    #[test]
1305    fn failover_gate_fails_when_krabka_consumer_rate_recovers_slower_than_kafka() {
1306        let mut krabka = fake_failover_run(Stack::Krabka, secs(2), per_sec(8_000));
1307        krabka.samples[3].consumer_rate = per_sec(8000);
1308        check_slow_rate_recovery(
1309            krabka,
1310            "Krabka consumer rate recovery 4s is slower than kafka 2s",
1311        );
1312    }
1313
1314    #[test]
1315    fn failover_gate_rejects_worse_drops_and_latency_than_kafka() {
1316        let rows = [
1317            (
1318                MessageCount(5),
1319                millis(42),
1320                "Krabka dropped 5 messages vs kafka 0",
1321            ),
1322            (
1323                MessageCount(0),
1324                millis(90),
1325                "Krabka latency spike 90ms is higher than kafka 42ms",
1326            ),
1327        ];
1328        for (dropped, latency, expected) in rows {
1329            let dir = tempdir().unwrap();
1330            let mut krabka = fake_failover_run(Stack::Krabka, secs(2), per_sec(8_000));
1331            let disturbance = krabka.disturbance.as_mut().unwrap();
1332            disturbance.dropped = dropped;
1333            disturbance.latency_spike_max = latency;
1334            check_failover_violation(dir.path(), &krabka, expected);
1335        }
1336    }
1337
1338    fn write_comparison_runs(dir: &Path) {
1339        write_run(
1340            dir.join("krabka-small-msg-saturate-6broker-rf3-run01.json"),
1341            &fake_run(Stack::Krabka, 600_000),
1342        );
1343        write_run(
1344            dir.join("kafka-small-msg-saturate-6broker-rf3-run01.json"),
1345            &fake_run(Stack::Kafka, 400_000),
1346        );
1347    }
1348
1349    #[test]
1350    fn html_report_loads_runs_and_embeds_plotly() {
1351        let dir = tempdir().unwrap();
1352        write_comparison_runs(dir.path());
1353        let html = render_html(dir.path(), true, "Bench").unwrap();
1354        for needle in [
1355            "<html",
1356            "Bench",
1357            "small-msg-saturate",
1358            "Producer throughput",
1359        ] {
1360            assert2::assert!(html.contains(needle));
1361        }
1362        // The standalone report is an artifact people open offline, so it
1363        // carries plotly.js rather than a link to it. `Plotly.newPlot` is the
1364        // call the inline figures make, and it is in the bundle, not in a
1365        // `<script src>`.
1366        // The bundle's own text mentions the CDN, so the check is on the
1367        // `<script src>` tag and not on the host name.
1368        assert2::assert!(!html.contains("<script src=\"https://cdn.plot.ly"));
1369        assert2::assert!(html.contains("Plotly.newPlot"));
1370        // The bundle is megabytes. A page that lost it would still contain
1371        // every string above.
1372        assert2::assert!(html.len() > 1_000_000, "page is {} bytes", html.len());
1373    }
1374
1375    #[test]
1376    fn web_fragment_loads_runs_with_tags() {
1377        let dir = tempdir().unwrap();
1378        write_comparison_runs(dir.path());
1379        let frag = render_web_fragment(dir.path(), true).unwrap();
1380        // A fragment, not a full page (no `<html>` wrapper). It is inlined into
1381        // a page the site serves online, so it keeps the CDN reference that the
1382        // standalone report drops.
1383        for (needle, want) in [
1384            ("<html", false),
1385            ("cdn.plot.ly/plotly-3.0.1", true),
1386            ("Per run", true),
1387            ("small-msg-saturate", true),
1388        ] {
1389            assert2::assert!(frag.contains(needle) == want);
1390        }
1391    }
1392
1393    #[test]
1394    fn summary_csv_has_header_and_one_row_per_run() {
1395        let dir = tempdir().unwrap();
1396        write_run(
1397            dir.path()
1398                .join("krabka-small-msg-saturate-6broker-rf3-run01.json"),
1399            &fake_run(Stack::Krabka, 600_000),
1400        );
1401        let csv = render_csv(dir.path(), true).unwrap();
1402        let lines: Vec<&str> = csv.lines().collect();
1403        assert2::assert!(lines.len() == 2); // header + 1 run (index guard)
1404        check!(lines[0].starts_with("scenario,stack,run_tag,"));
1405        // run_tag parsed from the filename
1406        check!(lines[1].contains(",run01,"));
1407        check!(lines[1].starts_with("small-msg-saturate,krabka,"));
1408    }
1409
1410    #[test]
1411    fn timeseries_csv_emits_long_rows_per_sample() {
1412        use crate::scenario::{BrokerSample, Sample};
1413        let dir = tempdir().unwrap();
1414        let mut r = fake_run(Stack::Krabka, 600_000);
1415        r.samples = vec![
1416            Sample {
1417                t_offset_ms: TimeOffsetMs(0),
1418                producer_rate: per_sec(1000),
1419                consumer_rate: per_sec(900),
1420                producer_p50: micros(1500),
1421                producer_p99: micros(4200),
1422                consumer_e2e_p99: millis(7),
1423            },
1424            Sample {
1425                t_offset_ms: TimeOffsetMs(2000),
1426                producer_rate: per_sec(1100),
1427                consumer_rate: per_sec(950),
1428                producer_p50: micros(1600),
1429                producer_p99: micros(4500),
1430                consumer_e2e_p99: micros(7500),
1431            },
1432        ];
1433        r.broker_samples = vec![BrokerSample {
1434            t_offset_ms: TimeOffsetMs(0),
1435            cpu_cores: 2.5,
1436            mem_working_set: mebibytes(1),
1437        }];
1438        write_run(dir.path().join("krabka-x-6broker-rf3-run03.json"), &r);
1439        let csv = render_timeseries_csv(dir.path(), true).unwrap();
1440        assert2::assert!(
1441            csv.lines()
1442                .next()
1443                .unwrap()
1444                .ends_with("t_offset_ms,metric,value")
1445        );
1446        // 2 samples × 5 client metrics + 1 broker sample × 2 metrics = 12 rows.
1447        assert2::assert!(csv.lines().count() == 1 + 12);
1448        for needle in [
1449            ",run03,0,producer_msgs_per_sec,1000.000",
1450            ",run03,0,broker_cpu_cores,2.5000",
1451            ",run03,2000,producer_p99_ms,4.500",
1452        ] {
1453            assert2::assert!(csv.contains(needle));
1454        }
1455    }
1456}