1mod aggregate;
10pub mod badge;
11pub mod baseline_resolve;
12pub mod bisect;
13pub mod blame;
14pub mod cargo_bench;
15mod check;
16pub mod comparison_logic;
17mod diff;
18pub mod discover;
19mod explain;
20pub mod init;
21mod paired;
22mod promote;
23mod report;
24mod sensor_report;
25mod trend;
26pub mod watch;
27
28pub use aggregate::{AggregateOutcome, AggregateRequest, AggregateUseCase};
29pub use badge::{
30 Badge, BadgeInput, BadgeOutcome, BadgeRequest, BadgeStyle, BadgeType, BadgeUseCase,
31};
32pub use bisect::{BisectRequest, BisectUseCase};
33pub use blame::{BlameOutcome, BlameRequest, BlameUseCase};
34pub use check::{CheckOutcome, CheckRequest, CheckUseCase};
35pub use diff::{
36 BenchDiffOutcome, DiffOutcome, DiffRequest, DiffUseCase, discover_config, render_json_diff,
37 render_terminal_diff,
38};
39pub use explain::{ExplainOutcome, ExplainRequest, ExplainUseCase};
40pub use paired::{PairedRunOutcome, PairedRunRequest, PairedRunUseCase};
41pub use promote::{PromoteRequest, PromoteResult, PromoteUseCase};
42pub use report::{ReportRequest, ReportResult, ReportUseCase};
43pub use sensor_report::{
44 BenchOutcome, SensorCheckOptions, SensorReportBuilder, classify_error,
45 default_engine_capability, run_sensor_check, sensor_fingerprint,
46};
47pub use trend::{
48 TrendOutcome, TrendRequest, TrendUseCase, format_trend_chart, format_trend_output,
49};
50
51pub use perfgate_render::{
53 direction_str, format_metric, format_metric_with_statistic, format_pct, format_value,
54 github_annotations, markdown_template_context, metric_status_icon, metric_status_str,
55 parse_reason_token, render_markdown, render_markdown_template, render_reason_line,
56};
57
58pub use perfgate_export::{CompareExportRow, ExportFormat, ExportUseCase, RunExportRow};
60
61use perfgate_adapters::{CommandSpec, HostProbe, HostProbeOptions, ProcessRunner, RunResult};
62use perfgate_domain::{
63 Comparison, SignificancePolicy, compare_runs, compute_stats, detect_host_mismatch,
64};
65use perfgate_types::{
66 BenchMeta, Budget, CompareReceipt, CompareRef, HostMismatchInfo, HostMismatchPolicy, Metric,
67 MetricStatistic, RunMeta, RunReceipt, Sample, ToolInfo,
68};
69use std::collections::BTreeMap;
70use std::path::PathBuf;
71use std::time::Duration;
72
73pub trait Clock: Send + Sync {
74 fn now_rfc3339(&self) -> String;
75}
76
77#[derive(Debug, Default, Clone)]
78pub struct SystemClock;
79
80impl Clock for SystemClock {
81 fn now_rfc3339(&self) -> String {
82 use time::format_description::well_known::Rfc3339;
83 time::OffsetDateTime::now_utc()
84 .format(&Rfc3339)
85 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
86 }
87}
88
89#[derive(Debug, Clone, Default)]
90pub struct RunBenchRequest {
91 pub name: String,
92 pub cwd: Option<PathBuf>,
93 pub command: Vec<String>,
94 pub repeat: u32,
95 pub warmup: u32,
96 pub work_units: Option<u64>,
97 pub timeout: Option<Duration>,
98 pub env: Vec<(String, String)>,
99 pub output_cap_bytes: usize,
100
101 pub allow_nonzero: bool,
104
105 pub include_hostname_hash: bool,
108}
109
110#[derive(Debug, Clone)]
111pub struct RunBenchOutcome {
112 pub receipt: RunReceipt,
113
114 pub failed: bool,
116
117 pub reasons: Vec<String>,
119}
120
121pub struct RunBenchUseCase<R: ProcessRunner, H: HostProbe, C: Clock> {
122 runner: R,
123 host_probe: H,
124 clock: C,
125 tool: ToolInfo,
126}
127
128impl<R: ProcessRunner, H: HostProbe, C: Clock> RunBenchUseCase<R, H, C> {
129 pub fn new(runner: R, host_probe: H, clock: C, tool: ToolInfo) -> Self {
130 Self {
131 runner,
132 host_probe,
133 clock,
134 tool,
135 }
136 }
137
138 pub fn execute(&self, req: RunBenchRequest) -> anyhow::Result<RunBenchOutcome> {
139 let run_id = uuid::Uuid::new_v4().to_string();
140 let started_at = self.clock.now_rfc3339();
141
142 let host_options = HostProbeOptions {
143 include_hostname_hash: req.include_hostname_hash,
144 };
145 let host = self.host_probe.probe(&host_options);
146
147 let bench = BenchMeta {
148 name: req.name.clone(),
149 cwd: req.cwd.as_ref().map(|p| p.to_string_lossy().to_string()),
150 command: req.command.clone(),
151 repeat: req.repeat,
152 warmup: req.warmup,
153 work_units: req.work_units,
154 timeout_ms: req.timeout.map(|d| d.as_millis() as u64),
155 };
156
157 let mut samples: Vec<Sample> = Vec::new();
158 let mut reasons: Vec<String> = Vec::new();
159
160 let total = req.warmup + req.repeat;
161
162 for i in 0..total {
163 let is_warmup = i < req.warmup;
164
165 let spec = CommandSpec {
166 name: req.name.clone(),
167 argv: req.command.clone(),
168 cwd: req.cwd.clone(),
169 env: req.env.clone(),
170 timeout: req.timeout,
171 output_cap_bytes: req.output_cap_bytes,
172 };
173
174 let run = self.runner.run(&spec).map_err(|e| match e {
175 perfgate_adapters::AdapterError::RunCommand { command, reason } => {
176 anyhow::anyhow!("failed to run iteration {}: {}: {}", i + 1, command, reason)
177 }
178 _ => anyhow::anyhow!("failed to run iteration {}: {}", i + 1, e),
179 })?;
180
181 let s = sample_from_run(run, is_warmup);
182 if !is_warmup {
183 if s.timed_out {
184 reasons.push(format!("iteration {} timed out", i + 1));
185 }
186 if s.exit_code != 0 {
187 reasons.push(format!("iteration {} exit code {}", i + 1, s.exit_code));
188 }
189 }
190
191 samples.push(s);
192 }
193
194 let stats = compute_stats(&samples, req.work_units)?;
195
196 let ended_at = self.clock.now_rfc3339();
197
198 let receipt = RunReceipt {
199 schema: perfgate_types::RUN_SCHEMA_V1.to_string(),
200 tool: self.tool.clone(),
201 run: RunMeta {
202 id: run_id,
203 started_at,
204 ended_at,
205 host,
206 },
207 bench,
208 samples,
209 stats,
210 };
211
212 let failed = !reasons.is_empty();
213
214 if failed && !req.allow_nonzero {
215 }
218
219 Ok(RunBenchOutcome {
220 receipt,
221 failed,
222 reasons,
223 })
224 }
225}
226
227fn sample_from_run(run: RunResult, warmup: bool) -> Sample {
228 Sample {
229 wall_ms: run.wall_ms,
230 exit_code: run.exit_code,
231 warmup,
232 timed_out: run.timed_out,
233 cpu_ms: run.cpu_ms,
234 page_faults: run.page_faults,
235 ctx_switches: run.ctx_switches,
236 max_rss_kb: run.max_rss_kb,
237 io_read_bytes: run.io_read_bytes,
238 io_write_bytes: run.io_write_bytes,
239 network_packets: run.network_packets,
240 energy_uj: run.energy_uj,
241 binary_bytes: run.binary_bytes,
242 stdout: if run.stdout.is_empty() {
243 None
244 } else {
245 Some(String::from_utf8_lossy(&run.stdout).to_string())
246 },
247 stderr: if run.stderr.is_empty() {
248 None
249 } else {
250 Some(String::from_utf8_lossy(&run.stderr).to_string())
251 },
252 }
253}
254
255#[derive(Debug, Clone)]
256pub struct CompareRequest {
257 pub baseline: RunReceipt,
258 pub current: RunReceipt,
259 pub budgets: BTreeMap<Metric, Budget>,
260 pub metric_statistics: BTreeMap<Metric, MetricStatistic>,
261 pub significance: Option<SignificancePolicy>,
262 pub baseline_ref: CompareRef,
263 pub current_ref: CompareRef,
264 pub tool: ToolInfo,
265 #[allow(dead_code)]
267 pub host_mismatch_policy: HostMismatchPolicy,
268}
269
270#[derive(Debug, Clone)]
272pub struct CompareResult {
273 pub receipt: CompareReceipt,
274 pub host_mismatch: Option<HostMismatchInfo>,
276}
277
278pub struct CompareUseCase;
279
280impl CompareUseCase {
281 pub fn execute(req: CompareRequest) -> anyhow::Result<CompareResult> {
282 let host_mismatch = if req.host_mismatch_policy != HostMismatchPolicy::Ignore {
284 detect_host_mismatch(&req.baseline.run.host, &req.current.run.host)
285 } else {
286 None
287 };
288
289 if req.host_mismatch_policy == HostMismatchPolicy::Error
291 && let Some(mismatch) = &host_mismatch
292 {
293 anyhow::bail!(
294 "host mismatch detected (--host-mismatch=error): {}",
295 mismatch.reasons.join("; ")
296 );
297 }
298
299 let Comparison { deltas, verdict } = compare_runs(
300 &req.baseline,
301 &req.current,
302 &req.budgets,
303 &req.metric_statistics,
304 req.significance,
305 )?;
306
307 let receipt = CompareReceipt {
308 schema: perfgate_types::COMPARE_SCHEMA_V1.to_string(),
309 tool: req.tool,
310 bench: req.current.bench,
311 baseline_ref: req.baseline_ref,
312 current_ref: req.current_ref,
313 budgets: req.budgets,
314 deltas,
315 verdict,
316 };
317
318 Ok(CompareResult {
319 receipt,
320 host_mismatch,
321 })
322 }
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328 use perfgate_types::{
329 Delta, Direction, HostInfo, MetricStatistic, MetricStatus, RUN_SCHEMA_V1, RunMeta,
330 RunReceipt, Stats, U64Summary, Verdict, VerdictCounts, VerdictStatus,
331 };
332 use std::collections::BTreeMap;
333
334 fn make_compare_receipt(status: MetricStatus) -> CompareReceipt {
335 let mut budgets = BTreeMap::new();
336 budgets.insert(
337 Metric::WallMs,
338 Budget {
339 threshold: 0.2,
340 warn_threshold: 0.1,
341 noise_threshold: None,
342 noise_policy: perfgate_types::NoisePolicy::Ignore,
343 direction: Direction::Lower,
344 },
345 );
346
347 let mut deltas = BTreeMap::new();
348 deltas.insert(
349 Metric::WallMs,
350 Delta {
351 baseline: 100.0,
352 current: 115.0,
353 ratio: 1.15,
354 pct: 0.15,
355 regression: 0.15,
356 cv: None,
357 noise_threshold: None,
358 statistic: MetricStatistic::Median,
359 significance: None,
360 status,
361 },
362 );
363
364 CompareReceipt {
365 schema: perfgate_types::COMPARE_SCHEMA_V1.to_string(),
366 tool: ToolInfo {
367 name: "perfgate".into(),
368 version: "0.1.0".into(),
369 },
370 bench: BenchMeta {
371 name: "bench".into(),
372 cwd: None,
373 command: vec!["true".into()],
374 repeat: 1,
375 warmup: 0,
376 work_units: None,
377 timeout_ms: None,
378 },
379 baseline_ref: CompareRef {
380 path: None,
381 run_id: None,
382 },
383 current_ref: CompareRef {
384 path: None,
385 run_id: None,
386 },
387 budgets,
388 deltas,
389 verdict: Verdict {
390 status: VerdictStatus::Warn,
391 counts: VerdictCounts {
392 pass: if status == MetricStatus::Pass { 1 } else { 0 },
393 warn: if status == MetricStatus::Warn { 1 } else { 0 },
394 fail: if status == MetricStatus::Fail { 1 } else { 0 },
395 skip: if status == MetricStatus::Skip { 1 } else { 0 },
396 },
397 reasons: vec!["wall_ms_warn".to_string()],
398 },
399 }
400 }
401
402 fn make_run_receipt_with_host(host: HostInfo, wall_ms: u64) -> RunReceipt {
403 RunReceipt {
404 schema: RUN_SCHEMA_V1.to_string(),
405 tool: ToolInfo {
406 name: "perfgate".to_string(),
407 version: "0.1.0".to_string(),
408 },
409 run: RunMeta {
410 id: "run-id".to_string(),
411 started_at: "2024-01-01T00:00:00Z".to_string(),
412 ended_at: "2024-01-01T00:00:01Z".to_string(),
413 host,
414 },
415 bench: BenchMeta {
416 name: "bench".to_string(),
417 cwd: None,
418 command: vec!["true".to_string()],
419 repeat: 1,
420 warmup: 0,
421 work_units: None,
422 timeout_ms: None,
423 },
424 samples: Vec::new(),
425 stats: Stats {
426 wall_ms: U64Summary::new(wall_ms, wall_ms, wall_ms),
427 cpu_ms: None,
428 page_faults: None,
429 ctx_switches: None,
430 max_rss_kb: None,
431 io_read_bytes: None,
432 io_write_bytes: None,
433 network_packets: None,
434 energy_uj: None,
435 binary_bytes: None,
436 throughput_per_s: None,
437 },
438 }
439 }
440
441 #[test]
442 fn markdown_renders_table() {
443 let mut budgets = BTreeMap::new();
444 budgets.insert(
445 Metric::WallMs,
446 Budget {
447 threshold: 0.2,
448 warn_threshold: 0.18,
449 noise_threshold: None,
450 noise_policy: perfgate_types::NoisePolicy::Ignore,
451 direction: Direction::Lower,
452 },
453 );
454
455 let mut deltas = BTreeMap::new();
456 deltas.insert(
457 Metric::WallMs,
458 Delta {
459 baseline: 1000.0,
460 current: 1100.0,
461 ratio: 1.1,
462 pct: 0.1,
463 regression: 0.1,
464 cv: None,
465 noise_threshold: None,
466 statistic: MetricStatistic::Median,
467 significance: None,
468 status: MetricStatus::Pass,
469 },
470 );
471
472 let compare = CompareReceipt {
473 schema: perfgate_types::COMPARE_SCHEMA_V1.to_string(),
474 tool: ToolInfo {
475 name: "perfgate".into(),
476 version: "0.1.0".into(),
477 },
478 bench: BenchMeta {
479 name: "demo".into(),
480 cwd: None,
481 command: vec!["true".into()],
482 repeat: 1,
483 warmup: 0,
484 work_units: None,
485 timeout_ms: None,
486 },
487 baseline_ref: CompareRef {
488 path: None,
489 run_id: None,
490 },
491 current_ref: CompareRef {
492 path: None,
493 run_id: None,
494 },
495 budgets,
496 deltas,
497 verdict: Verdict {
498 status: VerdictStatus::Pass,
499 counts: VerdictCounts {
500 pass: 1,
501 warn: 0,
502 fail: 0,
503 skip: 0,
504 },
505 reasons: vec![],
506 },
507 };
508
509 let md = render_markdown(&compare);
510 assert!(md.contains("| metric | baseline"));
511 assert!(md.contains("wall_ms"));
512 }
513
514 #[test]
515 fn markdown_template_renders_context_rows() {
516 let compare = make_compare_receipt(MetricStatus::Warn);
517 let template = "{{header}}\nbench={{bench.name}}\n{{#each rows}}metric={{metric}} status={{status}}\n{{/each}}";
518
519 let rendered = render_markdown_template(&compare, template).expect("render template");
520 assert!(rendered.contains("bench=bench"));
521 assert!(rendered.contains("metric=wall_ms"));
522 assert!(rendered.contains("status=warn"));
523 }
524
525 #[test]
526 fn markdown_template_strict_mode_rejects_unknown_fields() {
527 let compare = make_compare_receipt(MetricStatus::Warn);
528 let err = render_markdown_template(&compare, "{{does_not_exist}}").unwrap_err();
529 assert!(
530 err.to_string().contains("render markdown template"),
531 "unexpected error: {}",
532 err
533 );
534 }
535
536 #[test]
537 fn parse_reason_token_handles_valid_and_invalid() {
538 let parsed = parse_reason_token("wall_ms_warn");
539 assert!(parsed.is_some());
540 let (metric, status) = parsed.unwrap();
541 assert_eq!(metric, Metric::WallMs);
542 assert_eq!(status, MetricStatus::Warn);
543
544 assert!(parse_reason_token("wall_ms_pass").is_none());
545 assert!(parse_reason_token("unknown_warn").is_none());
546 }
547
548 #[test]
549 fn render_reason_line_formats_thresholds() {
550 let compare = make_compare_receipt(MetricStatus::Warn);
551 let line = render_reason_line(&compare, "wall_ms_warn");
552 assert!(line.contains("warn >="));
553 assert!(line.contains("fail >"));
554 assert!(line.contains("+15.00%"));
555 }
556
557 #[test]
558 fn render_reason_line_falls_back_when_missing_budget() {
559 let mut compare = make_compare_receipt(MetricStatus::Warn);
560 compare.budgets.clear();
561 let line = render_reason_line(&compare, "wall_ms_warn");
562 assert_eq!(line, "- wall_ms_warn\n");
563 }
564
565 #[test]
566 fn format_value_and_pct_render_expected_strings() {
567 assert_eq!(format_value(Metric::ThroughputPerS, 1.23456), "1.235");
568 assert_eq!(format_value(Metric::WallMs, 123.0), "123");
569 assert_eq!(format_pct(0.1), "+10.00%");
570 assert_eq!(format_pct(-0.1), "-10.00%");
571 assert_eq!(format_pct(0.0), "0.00%");
572 }
573
574 #[test]
575 fn github_annotations_only_warn_and_fail() {
576 let mut compare = make_compare_receipt(MetricStatus::Warn);
577 compare.deltas.insert(
578 Metric::MaxRssKb,
579 Delta {
580 baseline: 100.0,
581 current: 150.0,
582 ratio: 1.5,
583 pct: 0.5,
584 regression: 0.5,
585 cv: None,
586 noise_threshold: None,
587 statistic: MetricStatistic::Median,
588 significance: None,
589 status: MetricStatus::Fail,
590 },
591 );
592 compare.deltas.insert(
593 Metric::ThroughputPerS,
594 Delta {
595 baseline: 100.0,
596 current: 90.0,
597 ratio: 0.9,
598 pct: -0.1,
599 regression: 0.0,
600 cv: None,
601 noise_threshold: None,
602 statistic: MetricStatistic::Median,
603 significance: None,
604 status: MetricStatus::Pass,
605 },
606 );
607
608 let lines = github_annotations(&compare);
609 assert_eq!(lines.len(), 2);
610 assert!(lines.iter().any(|l| l.starts_with("::warning::")));
611 assert!(lines.iter().any(|l| l.starts_with("::error::")));
612 assert!(lines.iter().all(|l| !l.contains("throughput_per_s")));
613 }
614
615 #[test]
616 fn sample_from_run_sets_optional_stdout_stderr() {
617 let run = RunResult {
618 wall_ms: 100,
619 exit_code: 0,
620 timed_out: false,
621 cpu_ms: None,
622 page_faults: None,
623 ctx_switches: None,
624 max_rss_kb: None,
625 io_read_bytes: None,
626 io_write_bytes: None,
627 network_packets: None,
628 energy_uj: None,
629 binary_bytes: None,
630 stdout: b"ok".to_vec(),
631 stderr: vec![],
632 };
633
634 let sample = sample_from_run(run, false);
635 assert_eq!(sample.stdout.as_deref(), Some("ok"));
636 assert!(sample.stderr.is_none());
637 }
638
639 #[test]
640 fn compare_use_case_host_mismatch_policies() {
641 let baseline = make_run_receipt_with_host(
642 HostInfo {
643 os: "linux".to_string(),
644 arch: "x86_64".to_string(),
645 cpu_count: None,
646 memory_bytes: None,
647 hostname_hash: None,
648 },
649 100,
650 );
651 let current = make_run_receipt_with_host(
652 HostInfo {
653 os: "windows".to_string(),
654 arch: "x86_64".to_string(),
655 cpu_count: None,
656 memory_bytes: None,
657 hostname_hash: None,
658 },
659 100,
660 );
661
662 let mut budgets = BTreeMap::new();
663 budgets.insert(
664 Metric::WallMs,
665 Budget {
666 threshold: 0.2,
667 warn_threshold: 0.1,
668 noise_threshold: None,
669 noise_policy: perfgate_types::NoisePolicy::Ignore,
670 direction: Direction::Lower,
671 },
672 );
673
674 let err = CompareUseCase::execute(CompareRequest {
675 baseline: baseline.clone(),
676 current: current.clone(),
677 budgets: budgets.clone(),
678 metric_statistics: BTreeMap::new(),
679 significance: None,
680 baseline_ref: CompareRef {
681 path: None,
682 run_id: None,
683 },
684 current_ref: CompareRef {
685 path: None,
686 run_id: None,
687 },
688 tool: ToolInfo {
689 name: "perfgate".to_string(),
690 version: "0.1.0".to_string(),
691 },
692 host_mismatch_policy: HostMismatchPolicy::Error,
693 })
694 .unwrap_err();
695 assert!(err.to_string().contains("host mismatch"));
696
697 let matching = CompareUseCase::execute(CompareRequest {
698 baseline: baseline.clone(),
699 current: baseline.clone(),
700 budgets: budgets.clone(),
701 metric_statistics: BTreeMap::new(),
702 significance: None,
703 baseline_ref: CompareRef {
704 path: None,
705 run_id: None,
706 },
707 current_ref: CompareRef {
708 path: None,
709 run_id: None,
710 },
711 tool: ToolInfo {
712 name: "perfgate".to_string(),
713 version: "0.1.0".to_string(),
714 },
715 host_mismatch_policy: HostMismatchPolicy::Error,
716 })
717 .expect("matching hosts should not error");
718 assert!(matching.host_mismatch.is_none());
719
720 let ignore = CompareUseCase::execute(CompareRequest {
721 baseline,
722 current,
723 budgets,
724 metric_statistics: BTreeMap::new(),
725 significance: None,
726 baseline_ref: CompareRef {
727 path: None,
728 run_id: None,
729 },
730 current_ref: CompareRef {
731 path: None,
732 run_id: None,
733 },
734 tool: ToolInfo {
735 name: "perfgate".to_string(),
736 version: "0.1.0".to_string(),
737 },
738 host_mismatch_policy: HostMismatchPolicy::Ignore,
739 })
740 .expect("ignore mismatch should succeed");
741
742 assert!(ignore.host_mismatch.is_none());
743 }
744}
745
746#[cfg(test)]
747mod property_tests {
748 use super::*;
749 use perfgate_types::{
750 Delta, Direction, MetricStatistic, MetricStatus, Verdict, VerdictCounts, VerdictStatus,
751 };
752 use proptest::prelude::*;
753
754 fn non_empty_string() -> impl Strategy<Value = String> {
758 "[a-zA-Z0-9_-]{1,20}".prop_map(|s| s)
759 }
760
761 fn tool_info_strategy() -> impl Strategy<Value = ToolInfo> {
763 (non_empty_string(), non_empty_string())
764 .prop_map(|(name, version)| ToolInfo { name, version })
765 }
766
767 fn bench_meta_strategy() -> impl Strategy<Value = BenchMeta> {
769 (
770 non_empty_string(),
771 proptest::option::of(non_empty_string()),
772 proptest::collection::vec(non_empty_string(), 1..5),
773 1u32..100,
774 0u32..10,
775 proptest::option::of(1u64..10000),
776 proptest::option::of(100u64..60000),
777 )
778 .prop_map(
779 |(name, cwd, command, repeat, warmup, work_units, timeout_ms)| BenchMeta {
780 name,
781 cwd,
782 command,
783 repeat,
784 warmup,
785 work_units,
786 timeout_ms,
787 },
788 )
789 }
790
791 fn compare_ref_strategy() -> impl Strategy<Value = CompareRef> {
793 (
794 proptest::option::of(non_empty_string()),
795 proptest::option::of(non_empty_string()),
796 )
797 .prop_map(|(path, run_id)| CompareRef { path, run_id })
798 }
799
800 fn direction_strategy() -> impl Strategy<Value = Direction> {
802 prop_oneof![Just(Direction::Lower), Just(Direction::Higher),]
803 }
804
805 fn budget_strategy() -> impl Strategy<Value = Budget> {
807 (0.01f64..1.0, 0.01f64..1.0, direction_strategy()).prop_map(
808 |(threshold, warn_factor, direction)| {
809 let warn_threshold = threshold * warn_factor;
811 Budget {
812 noise_threshold: None,
813 noise_policy: perfgate_types::NoisePolicy::Ignore,
814 threshold,
815 warn_threshold,
816 direction,
817 }
818 },
819 )
820 }
821
822 fn metric_status_strategy() -> impl Strategy<Value = MetricStatus> {
824 prop_oneof![
825 Just(MetricStatus::Pass),
826 Just(MetricStatus::Warn),
827 Just(MetricStatus::Fail),
828 Just(MetricStatus::Skip),
829 ]
830 }
831
832 fn delta_strategy() -> impl Strategy<Value = Delta> {
834 (
835 0.1f64..10000.0, 0.1f64..10000.0, metric_status_strategy(),
838 )
839 .prop_map(|(baseline, current, status)| {
840 let ratio = current / baseline;
841 let pct = (current - baseline) / baseline;
842 let regression = if pct > 0.0 { pct } else { 0.0 };
843 Delta {
844 baseline,
845 current,
846 ratio,
847 pct,
848 regression,
849 cv: None,
850 noise_threshold: None,
851 statistic: MetricStatistic::Median,
852 significance: None,
853 status,
854 }
855 })
856 }
857
858 fn verdict_status_strategy() -> impl Strategy<Value = VerdictStatus> {
860 prop_oneof![
861 Just(VerdictStatus::Pass),
862 Just(VerdictStatus::Warn),
863 Just(VerdictStatus::Fail),
864 Just(VerdictStatus::Skip),
865 ]
866 }
867
868 fn verdict_counts_strategy() -> impl Strategy<Value = VerdictCounts> {
870 (0u32..10, 0u32..10, 0u32..10, 0u32..10).prop_map(|(pass, warn, fail, skip)| {
871 VerdictCounts {
872 pass,
873 warn,
874 fail,
875 skip,
876 }
877 })
878 }
879
880 fn verdict_strategy() -> impl Strategy<Value = Verdict> {
882 (
883 verdict_status_strategy(),
884 verdict_counts_strategy(),
885 proptest::collection::vec("[a-zA-Z0-9 ]{1,50}", 0..5),
886 )
887 .prop_map(|(status, counts, reasons)| Verdict {
888 status,
889 counts,
890 reasons,
891 })
892 }
893
894 fn metric_strategy() -> impl Strategy<Value = Metric> {
896 prop_oneof![
897 Just(Metric::BinaryBytes),
898 Just(Metric::CpuMs),
899 Just(Metric::CtxSwitches),
900 Just(Metric::IoReadBytes),
901 Just(Metric::IoWriteBytes),
902 Just(Metric::WallMs),
903 Just(Metric::MaxRssKb),
904 Just(Metric::NetworkPackets),
905 Just(Metric::PageFaults),
906 Just(Metric::ThroughputPerS),
907 ]
908 }
909
910 fn budgets_map_strategy() -> impl Strategy<Value = BTreeMap<Metric, Budget>> {
912 proptest::collection::btree_map(metric_strategy(), budget_strategy(), 0..8)
913 }
914
915 fn deltas_map_strategy() -> impl Strategy<Value = BTreeMap<Metric, Delta>> {
917 proptest::collection::btree_map(metric_strategy(), delta_strategy(), 0..8)
918 }
919
920 fn compare_receipt_strategy() -> impl Strategy<Value = CompareReceipt> {
922 (
923 tool_info_strategy(),
924 bench_meta_strategy(),
925 compare_ref_strategy(),
926 compare_ref_strategy(),
927 budgets_map_strategy(),
928 deltas_map_strategy(),
929 verdict_strategy(),
930 )
931 .prop_map(
932 |(tool, bench, baseline_ref, current_ref, budgets, deltas, verdict)| {
933 CompareReceipt {
934 schema: perfgate_types::COMPARE_SCHEMA_V1.to_string(),
935 tool,
936 bench,
937 baseline_ref,
938 current_ref,
939 budgets,
940 deltas,
941 verdict,
942 }
943 },
944 )
945 }
946
947 proptest! {
957 #![proptest_config(ProptestConfig::with_cases(100))]
958
959 #[test]
960 fn markdown_rendering_completeness(receipt in compare_receipt_strategy()) {
961 let md = render_markdown(&receipt);
962
963 let expected_emoji = match receipt.verdict.status {
965 VerdictStatus::Pass => "✅",
966 VerdictStatus::Warn => "⚠️",
967 VerdictStatus::Fail => "❌",
968 VerdictStatus::Skip => "⏭️",
969 };
970 prop_assert!(
971 md.contains(expected_emoji),
972 "Markdown should contain verdict emoji '{}' for status {:?}. Got:\n{}",
973 expected_emoji,
974 receipt.verdict.status,
975 md
976 );
977
978 let expected_status_word = match receipt.verdict.status {
980 VerdictStatus::Pass => "pass",
981 VerdictStatus::Warn => "warn",
982 VerdictStatus::Fail => "fail",
983 VerdictStatus::Skip => "skip",
984 };
985 prop_assert!(
986 md.contains(expected_status_word),
987 "Markdown should contain status word '{}'. Got:\n{}",
988 expected_status_word,
989 md
990 );
991
992 prop_assert!(
994 md.contains(&receipt.bench.name),
995 "Markdown should contain benchmark name '{}'. Got:\n{}",
996 receipt.bench.name,
997 md
998 );
999
1000 prop_assert!(
1002 md.contains("| metric |"),
1003 "Markdown should contain table header. Got:\n{}",
1004 md
1005 );
1006
1007 for metric in receipt.deltas.keys() {
1009 let metric_name = metric.as_str();
1010 prop_assert!(
1011 md.contains(metric_name),
1012 "Markdown should contain metric '{}'. Got:\n{}",
1013 metric_name,
1014 md
1015 );
1016 }
1017
1018 for reason in &receipt.verdict.reasons {
1020 prop_assert!(
1021 md.contains(reason),
1022 "Markdown should contain verdict reason '{}'. Got:\n{}",
1023 reason,
1024 md
1025 );
1026 }
1027
1028 if !receipt.verdict.reasons.is_empty() {
1030 prop_assert!(
1031 md.contains("**Notes:**"),
1032 "Markdown should contain Notes section when there are reasons. Got:\n{}",
1033 md
1034 );
1035 }
1036 }
1037 }
1038
1039 proptest! {
1049 #![proptest_config(ProptestConfig::with_cases(100))]
1050
1051 #[test]
1052 fn github_annotation_generation(receipt in compare_receipt_strategy()) {
1053 let annotations = github_annotations(&receipt);
1054
1055 let expected_fail_count = receipt.deltas.values()
1057 .filter(|d| d.status == MetricStatus::Fail)
1058 .count();
1059 let expected_warn_count = receipt.deltas.values()
1060 .filter(|d| d.status == MetricStatus::Warn)
1061 .count();
1062 let expected_pass_count = receipt.deltas.values()
1063 .filter(|d| d.status == MetricStatus::Pass)
1064 .count();
1065
1066 let actual_error_count = annotations.iter()
1068 .filter(|a| a.starts_with("::error::"))
1069 .count();
1070 let actual_warning_count = annotations.iter()
1071 .filter(|a| a.starts_with("::warning::"))
1072 .count();
1073
1074 prop_assert_eq!(
1076 actual_error_count,
1077 expected_fail_count,
1078 "Expected {} ::error:: annotations for {} Fail metrics, got {}. Annotations: {:?}",
1079 expected_fail_count,
1080 expected_fail_count,
1081 actual_error_count,
1082 annotations
1083 );
1084
1085 prop_assert_eq!(
1087 actual_warning_count,
1088 expected_warn_count,
1089 "Expected {} ::warning:: annotations for {} Warn metrics, got {}. Annotations: {:?}",
1090 expected_warn_count,
1091 expected_warn_count,
1092 actual_warning_count,
1093 annotations
1094 );
1095
1096 let total_annotations = annotations.len();
1099 let expected_total = expected_fail_count + expected_warn_count;
1100 prop_assert_eq!(
1101 total_annotations,
1102 expected_total,
1103 "Expected {} total annotations (fail: {}, warn: {}, pass: {} should produce none), got {}. Annotations: {:?}",
1104 expected_total,
1105 expected_fail_count,
1106 expected_warn_count,
1107 expected_pass_count,
1108 total_annotations,
1109 annotations
1110 );
1111
1112 for (metric, delta) in &receipt.deltas {
1114 if delta.status == MetricStatus::Pass || delta.status == MetricStatus::Skip {
1115 continue; }
1117
1118 let metric_name = metric.as_str();
1119
1120 let matching_annotation = annotations.iter().find(|a| a.contains(metric_name));
1122
1123 prop_assert!(
1124 matching_annotation.is_some(),
1125 "Expected annotation for metric '{}' with status {:?}. Annotations: {:?}",
1126 metric_name,
1127 delta.status,
1128 annotations
1129 );
1130
1131 let annotation = matching_annotation.unwrap();
1132
1133 prop_assert!(
1135 annotation.contains(&receipt.bench.name),
1136 "Annotation should contain bench name '{}'. Got: {}",
1137 receipt.bench.name,
1138 annotation
1139 );
1140
1141 prop_assert!(
1143 annotation.contains(metric_name),
1144 "Annotation should contain metric name '{}'. Got: {}",
1145 metric_name,
1146 annotation
1147 );
1148
1149 let pct_str = format_pct(delta.pct);
1152 prop_assert!(
1153 annotation.contains(&pct_str),
1154 "Annotation should contain delta percentage '{}'. Got: {}",
1155 pct_str,
1156 annotation
1157 );
1158
1159 match delta.status {
1161 MetricStatus::Fail => {
1162 prop_assert!(
1163 annotation.starts_with("::error::"),
1164 "Fail metric should produce ::error:: annotation. Got: {}",
1165 annotation
1166 );
1167 }
1168 MetricStatus::Warn => {
1169 prop_assert!(
1170 annotation.starts_with("::warning::"),
1171 "Warn metric should produce ::warning:: annotation. Got: {}",
1172 annotation
1173 );
1174 }
1175 MetricStatus::Pass | MetricStatus::Skip => unreachable!(),
1176 }
1177 }
1178 }
1179 }
1180}