1use {
8 crate::{
9 budget::{BudgetVerdict, Rejection, Status, metric_value},
10 contract::{Contract, Metric, Mode},
11 record::{PerformanceRecord, Side, TrialOutcome},
12 },
13 std::fmt::Write as _,
14};
15
16fn value(metric: Metric, value: f64) -> String {
17 match metric {
18 Metric::RecordsPerSecond => format!("{value:.0}"),
19 Metric::PeakRssMib => format!("{value:.1}"),
20 Metric::LatencyMsP50
21 | Metric::LatencyMsP95
22 | Metric::LatencyMsP99
23 | Metric::CpuMicrosPerRecord => format!("{value:.3}"),
24 }
25}
26
27fn percent(ratio: f64) -> String { format!("{:+.1}%", ratio * 100.0) }
28
29fn status(status: Status, gate_performance: bool) -> &'static str {
30 match status {
31 Status::WithinBudget => "within budget",
32 Status::Improved => "improved",
33 Status::NoisyNotMaterial => "noisy, not material",
34 Status::MaterialRegression if gate_performance => "**material regression**",
35 Status::MaterialRegression => "material difference (not gating)",
36 Status::ExcessVariance => "**excess variance**",
37 Status::NotBudgeted => "recorded",
38 }
39}
40
41fn rejection(rejection: &Rejection) -> String {
42 match rejection {
43 Rejection::CorrectnessFailure {
44 cell,
45 round,
46 side,
47 reason,
48 } => format!(
49 "correctness failure in `{cell}` round {round} ({}): {reason}",
50 side.name()
51 ),
52 Rejection::MissingTrial { cell, round, side } => {
53 format!("missing trial: `{cell}` round {round} ({})", side.name())
54 }
55 Rejection::NotInterleaved { cell, round } => format!(
56 "`{cell}` ran the same side first in rounds {} and {round}",
57 round - 1
58 ),
59 Rejection::MaterialRegression {
60 cell,
61 metric,
62 worse_change,
63 allowed_change,
64 } => format!(
65 "`{cell}` {} is {} worse; the budget allows {}",
66 metric.label(),
67 percent(*worse_change),
68 percent(*allowed_change)
69 ),
70 Rejection::ExcessVariance {
71 cell,
72 metric,
73 spread,
74 max_spread,
75 } => format!(
76 "`{cell}` {} spread is {}; the variance budget is {}",
77 metric.label(),
78 percent(*spread),
79 percent(*max_spread)
80 ),
81 }
82}
83
84#[must_use]
86pub fn render_markdown(
87 contract: &Contract,
88 record: &PerformanceRecord,
89 verdict: &BudgetVerdict,
90) -> String {
91 let mut out = String::new();
92 let reference = &record.reference;
93 let candidate = &record.candidate;
94 let against = match record.mode {
95 Mode::BeforeAfter => "an earlier krabka revision",
96 Mode::PinnedKafka => "the pinned Apache Kafka image",
97 };
98 let _ = writeln!(out, "# Performance budget: `{}`\n", contract.name);
99 let _ = writeln!(
100 out,
101 "These results compare one candidate build with {against} under the \
102 `{}` contract on the host below. They are measured limits of that \
103 contract, not a ranking of brokers and not a supported limit.\n",
104 contract.name
105 );
106 let _ = writeln!(
107 out,
108 "**Verdict: {}.**\n",
109 if verdict.passed { "pass" } else { "reject" }
110 );
111 if !verdict.rejections.is_empty() {
112 for item in &verdict.rejections {
113 let _ = writeln!(out, "- {}", rejection(item));
114 }
115 out.push('\n');
116 }
117
118 out.push_str("## Provenance\n\n| Side | Label | Revision | Image | Digest |\n| :--- | :--- | :--- | :--- | :--- |\n");
119 for (side, subject) in [(Side::Reference, reference), (Side::Candidate, candidate)] {
120 let _ = writeln!(
121 out,
122 "| {} | {} | `{}` | `{}` | `{}` |",
123 side.name(),
124 subject.label,
125 subject.revision,
126 subject.image,
127 subject.image_digest
128 );
129 }
130 let host = &record.host;
131 let _ = writeln!(
132 out,
133 "\nClient: `{}` (`{}`). Contract digest: `{}`. Started {}.\n",
134 record.client_image, record.client_image_digest, record.contract_sha256, record.started_at
135 );
136 let _ = writeln!(
137 out,
138 "Host: {}, {} logical CPUs, {} bytes of memory, {} bytes of free disk, \
139 kernel {}.\n",
140 host.cpu_model,
141 host.logical_cpus,
142 host.memory_bytes,
143 host.disk_available_bytes,
144 host.kernel
145 );
146 let cluster = &contract.cluster;
147 let _ = writeln!(
148 out,
149 "Cluster: {} combined broker/controllers, replication factor {}, \
150 `min.insync.replicas={}`, `acks=all`, idempotence, 65,536-byte batches, \
151 5 ms linger. {} interleaved rounds; each round runs every cell once per side.\n",
152 cluster.brokers, cluster.replication_factor, cluster.min_insync_replicas, contract.rounds
153 );
154 if !record.settings.is_empty() {
155 out.push_str("Settings:");
156 for (key, setting) in &record.settings {
157 let _ = write!(out, " `{key}={setting}`");
158 }
159 out.push_str(".\n\n");
160 }
161
162 out.push_str("## Budgets\n\nDeclared before the run. A regression is material when the median gets worse by more than the materiality floor and by more than the multiplier times the larger side's relative median absolute deviation. A side whose spread exceeds the variance budget makes the cell inconclusive.\n\n| Metric | Materiality | Spread multiplier | Variance budget |\n| :--- | ---: | ---: | ---: |\n");
163 for (metric, budget) in &contract.budgets {
164 let _ = writeln!(
165 out,
166 "| {} | {:.1}% | {:.1} | {:.1}% |",
167 metric.label(),
168 budget.materiality_percent,
169 budget.spread_multiplier,
170 budget.max_spread_percent
171 );
172 }
173
174 out.push_str("\n## Results\n\nMedians over the interleaved rounds. A change is relative to the reference median; positive is worse.\n\n| Cell | Metric | Reference | Candidate | Change | Spread (ref / cand) | Allowed | Status |\n| :--- | :--- | ---: | ---: | ---: | :--- | ---: | :--- |\n");
175 for cell in &contract.cells {
176 let Some(metrics) = verdict.cells.get(&cell.id) else {
177 let _ = writeln!(
178 out,
179 "| `{}` | all | — | — | — | — | — | not evaluated |",
180 cell.id
181 );
182 continue;
183 };
184 for (metric, judged) in metrics {
185 let allowed = if judged.status == Status::NotBudgeted {
186 "—".to_owned()
187 } else {
188 percent(judged.allowed_change)
189 };
190 let _ = writeln!(
191 out,
192 "| `{}` | {} | {} | {} | {} | {:.1}% / {:.1}% | {} | {} |",
193 cell.id,
194 metric.label(),
195 value(*metric, judged.reference_median),
196 value(*metric, judged.candidate_median),
197 percent(judged.worse_change),
198 judged.reference_spread * 100.0,
199 judged.candidate_spread * 100.0,
200 allowed,
201 status(judged.status, verdict.gate_performance)
202 );
203 }
204 }
205
206 out.push_str("\n## Raw trials\n\n| Order | Cell | Round | Side | records/s | p50 ms | p95 ms | p99 ms | CPU µs/record | peak RSS MiB | Outcome |\n| ---: | :--- | ---: | :--- | ---: | ---: | ---: | ---: | ---: | ---: | :--- |\n");
207 for trial in &record.trials {
208 let _ = write!(
209 out,
210 "| {} | `{}` | {} | {} |",
211 trial.order,
212 trial.cell,
213 trial.round,
214 trial.side.name()
215 );
216 match &trial.outcome {
217 TrialOutcome::Completed(measurement) => {
218 for metric in Metric::ALL {
219 let _ = write!(
220 out,
221 " {} |",
222 value(metric, metric_value(metric, measurement))
223 );
224 }
225 let _ = writeln!(
226 out,
227 " sent {} / consumed {} / duplicates {} / errors {} |",
228 measurement.sent,
229 measurement.consumed,
230 measurement.duplicates,
231 measurement.errors
232 );
233 }
234 TrialOutcome::Failed { reason } => {
235 let _ = writeln!(out, " — | — | — | — | — | — | failed: {reason} |");
236 }
237 }
238 }
239 out
240}