1use std::io::{IsTerminal, Write};
7
8use moonpool_assertions::AssertKind;
9
10use super::report::{
11 AssertionDetail, AssertionStatus, BucketSiteSummary, ExplorationReport, SaturationSignal,
12 SimulationReport, format_recipe,
13};
14
15mod ansi {
20 pub const RESET: &str = "\x1b[0m";
21 pub const BOLD: &str = "\x1b[1m";
22 pub const DIM: &str = "\x1b[2m";
23 pub const RED: &str = "\x1b[31m";
24 pub const GREEN: &str = "\x1b[32m";
25 pub const YELLOW: &str = "\x1b[33m";
26 pub const BOLD_RED: &str = "\x1b[1;31m";
27 pub const BOLD_GREEN: &str = "\x1b[1;32m";
28 pub const BOLD_YELLOW: &str = "\x1b[1;33m";
29 pub const BOLD_CYAN: &str = "\x1b[1;36m";
30}
31
32fn use_color() -> bool {
34 std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none()
35}
36
37fn fmt_num(n: u64) -> String {
43 let s = n.to_string();
44 let mut result = String::with_capacity(s.len() + s.len() / 3);
45 for (i, c) in s.chars().rev().enumerate() {
46 if i > 0 && i % 3 == 0 {
47 result.push(',');
48 }
49 result.push(c);
50 }
51 result.chars().rev().collect()
52}
53
54fn fmt_i64(n: i64) -> String {
56 if n < 0 {
57 format!("-{}", fmt_num(n.unsigned_abs()))
58 } else {
59 fmt_num(n.unsigned_abs())
60 }
61}
62
63fn f64_to_u64_saturating(v: f64) -> u64 {
65 const TWO_POW_64: f64 = 18_446_744_073_709_551_616.0;
66 if !v.is_finite() || v <= 0.0 {
67 0
68 } else if v >= TWO_POW_64 {
69 u64::MAX
70 } else {
71 unsafe { v.round().to_int_unchecked::<u64>() }
73 }
74}
75
76fn fmt_duration(d: std::time::Duration) -> String {
78 let total_ms = d.as_millis();
79 if total_ms < 1000 {
80 format!("{total_ms}ms")
81 } else if total_ms < 60_000 {
82 format!("{:.2}s", d.as_secs_f64())
83 } else {
84 let mins = d.as_secs() / 60;
85 let secs = d.as_secs() % 60;
86 format!("{mins}m {secs:02}s")
87 }
88}
89
90fn kind_label(kind: AssertKind) -> &'static str {
92 match kind {
93 AssertKind::Always => "always",
94 AssertKind::AlwaysOrUnreachable => "always?",
95 AssertKind::Sometimes => "sometimes",
96 AssertKind::Reachable => "reachable",
97 AssertKind::Unreachable => "unreachable",
98 AssertKind::NumericAlways => "num-always",
99 AssertKind::NumericSometimes => "numeric",
100 AssertKind::BooleanSometimesAll => "frontier",
101 }
102}
103
104fn kind_sort_order(kind: AssertKind) -> u8 {
106 match kind {
107 AssertKind::Always => 0,
108 AssertKind::AlwaysOrUnreachable => 1,
109 AssertKind::Unreachable => 2,
110 AssertKind::NumericAlways => 3,
111 AssertKind::Sometimes => 4,
112 AssertKind::Reachable => 5,
113 AssertKind::NumericSometimes => 6,
114 AssertKind::BooleanSometimesAll => 7,
115 }
116}
117
118const BAR_WIDTH: usize = 20;
123
124fn progress_bar(fraction: f64, color: bool) -> String {
126 let bar_width_u32 = u32::try_from(BAR_WIDTH).expect("bar width fits in u32");
127 let filled_f = (fraction * f64::from(bar_width_u32))
128 .round()
129 .clamp(0.0, f64::from(bar_width_u32));
130 let filled = unsafe { filled_f.to_int_unchecked::<usize>() }.min(BAR_WIDTH);
132 let empty = BAR_WIDTH - filled;
133
134 let bar_color = if !color {
135 ""
136 } else if fraction >= 0.5 {
137 ansi::GREEN
138 } else if fraction >= 0.2 {
139 ansi::YELLOW
140 } else {
141 ansi::RED
142 };
143 let reset = if color { ansi::RESET } else { "" };
144
145 format!(
146 "{}{}{}{} {:.1}%",
147 bar_color,
148 "█".repeat(filled),
149 "░".repeat(empty),
150 reset,
151 fraction * 100.0
152 )
153}
154
155const RULE_WIDTH: usize = 56;
160
161fn section_header(w: &mut impl Write, title: &str, color: bool, style: &str) {
163 let prefix = "━━━ ";
164 let suffix_char = '━';
165 let content_len = prefix.len() + title.len() + 1; let trail = if RULE_WIDTH > content_len {
168 RULE_WIDTH - content_len
169 } else {
170 3
171 };
172
173 if color {
174 let _ = write!(w, "\n{style}{prefix}{title} ");
175 for _ in 0..trail {
176 let _ = write!(w, "{suffix_char}");
177 }
178 let _ = writeln!(w, "{}", ansi::RESET);
179 } else {
180 let _ = write!(w, "\n{prefix}{title} ");
181 for _ in 0..trail {
182 let _ = write!(w, "{suffix_char}");
183 }
184 let _ = writeln!(w);
185 }
186}
187
188fn status_icon(status: AssertionStatus, color: bool) -> &'static str {
193 match (status, color) {
194 (AssertionStatus::Pass, true) => "\x1b[32m✓\x1b[0m",
195 (AssertionStatus::Fail, true) => "\x1b[1;31m✗\x1b[0m",
196 (AssertionStatus::Miss, true) => "\x1b[33m○\x1b[0m",
197 (AssertionStatus::Pass, false) => "✓",
198 (AssertionStatus::Fail, false) => "✗",
199 (AssertionStatus::Miss, false) => "○",
200 }
201}
202
203pub fn eprint_report(report: &SimulationReport) {
212 let color = use_color();
213 let mut w = std::io::stderr().lock();
214 write_report(&mut w, report, color);
215}
216
217fn write_report_summary(w: &mut impl Write, report: &SimulationReport, color: bool) {
218 let rate = report.success_rate();
219 let (rate_icon, rate_color) = if report.failed_runs == 0 {
220 ("✓", ansi::BOLD_GREEN)
221 } else {
222 ("✗", ansi::BOLD_RED)
223 };
224
225 if color {
226 let _ = writeln!(
227 w,
228 " {} iterations {} passed {} failed {rate_color}{rate_icon} {rate:.1}%{reset}",
229 report.iterations,
230 report.successful_runs,
231 report.failed_runs,
232 rate_color = rate_color,
233 rate_icon = rate_icon,
234 rate = rate,
235 reset = ansi::RESET,
236 );
237 } else {
238 let _ = writeln!(
239 w,
240 " {} iterations {} passed {} failed {rate_icon} {rate:.1}%",
241 report.iterations,
242 report.successful_runs,
243 report.failed_runs,
244 rate_icon = rate_icon,
245 rate = rate,
246 );
247 }
248}
249
250fn write_report_timing(w: &mut impl Write, report: &SimulationReport) {
251 let _ = writeln!(w);
252 let _ = writeln!(
253 w,
254 " Wall Time {:<14} {} total",
255 fmt_duration(report.average_wall_time()) + " avg",
256 fmt_duration(report.metrics.wall_time),
257 );
258 let _ = writeln!(
259 w,
260 " Sim Time {} avg",
261 fmt_duration(report.average_simulated_time()),
262 );
263 let _ = writeln!(
264 w,
265 " Events {} avg",
266 fmt_num(f64_to_u64_saturating(report.average_events_processed())),
267 );
268}
269
270fn write_report_faulty_seeds(w: &mut impl Write, report: &SimulationReport, color: bool) {
271 if report.seeds_failing.is_empty() {
272 return;
273 }
274 let _ = writeln!(w);
275 if color {
276 let _ = write!(w, " {}Faulty seeds:{} ", ansi::BOLD_RED, ansi::RESET);
277 } else {
278 let _ = write!(w, " Faulty seeds: ");
279 }
280 let _ = writeln!(w, "{:?}", report.seeds_failing);
281}
282
283fn write_report(w: &mut impl Write, report: &SimulationReport, color: bool) {
284 section_header(w, "Simulation Report", color, ansi::BOLD_CYAN);
286
287 write_report_summary(w, report, color);
288 write_report_timing(w, report);
289 write_report_faulty_seeds(w, report, color);
290
291 if let Some(ref exp) = report.exploration {
293 write_exploration(w, exp, color);
294 }
295
296 if !report.assertion_details.is_empty() {
298 write_assertions(w, &report.assertion_details, color);
299 }
300
301 if !report.assertion_violations.is_empty() {
303 section_header(w, "Violations", color, ansi::BOLD_RED);
304 for v in &report.assertion_violations {
305 if color {
306 let _ = writeln!(w, " {}✗{} {}", ansi::BOLD_RED, ansi::RESET, v);
307 } else {
308 let _ = writeln!(w, " ✗ {v}");
309 }
310 }
311 }
312
313 if !report.coverage_violations.is_empty() {
315 section_header(w, "Coverage Gaps", color, ansi::BOLD_YELLOW);
316 for v in &report.coverage_violations {
317 if color {
318 let _ = writeln!(w, " {}○{} {}", ansi::YELLOW, ansi::RESET, v);
319 } else {
320 let _ = writeln!(w, " ○ {v}");
321 }
322 }
323 }
324
325 if !report.bucket_summaries.is_empty() {
327 write_buckets(w, &report.bucket_summaries, color);
328 }
329
330 if let Some(sat) = &report.saturation {
332 let (title, style) = if report.convergence_timeout {
333 ("Saturation FAILED", ansi::BOLD_RED)
334 } else {
335 ("Saturated", ansi::BOLD)
336 };
337 section_header(w, title, color, style);
338 match sat.signal {
339 SaturationSignal::CodeCoverage => {
340 let _ = writeln!(
341 w,
342 " {} / {} sometimes hit · code coverage stable at {} / {} edges for {} seeds",
343 sat.sometimes_hit,
344 sat.sometimes_total,
345 fmt_num(u64::try_from(sat.edges_covered).unwrap_or(u64::MAX)),
346 fmt_num(u64::try_from(sat.edges_total).unwrap_or(u64::MAX)),
347 sat.plateau_seeds,
348 );
349 }
350 SaturationSignal::AssertionCoverage => {
351 let _ = writeln!(
352 w,
353 " {} / {} sometimes hit · assertion coverage stable for {} seeds (no sancov — run via 'cargo xtask sim run' for code-coverage-driven stop)",
354 sat.sometimes_hit, sat.sometimes_total, sat.plateau_seeds,
355 );
356 }
357 }
358 if report.convergence_timeout {
359 let _ = writeln!(
360 w,
361 " UntilCoverageStable hit the iteration cap without saturating.",
362 );
363 }
364 } else if report.convergence_timeout {
365 section_header(w, "Saturation FAILED", color, ansi::BOLD_RED);
366 let _ = writeln!(
367 w,
368 " UntilCoverageStable hit the iteration cap without saturating.",
369 );
370 }
371
372 if report.seeds_used.len() > 1 {
374 write_seeds(w, report, color);
375 }
376
377 let _ = writeln!(w);
378}
379
380fn write_exploration(w: &mut impl Write, exp: &ExplorationReport, color: bool) {
385 section_header(w, "Exploration", color, ansi::BOLD_CYAN);
386
387 if exp.converged {
388 if color {
389 let _ = writeln!(
390 w,
391 " Status {}CONVERGED{}",
392 ansi::BOLD_GREEN,
393 ansi::RESET
394 );
395 } else {
396 let _ = writeln!(w, " Status CONVERGED");
397 }
398 }
399
400 let _ = writeln!(
401 w,
402 " Timelines {:<16} Bugs {}",
403 fmt_num(exp.total_timelines),
404 fmt_num(exp.bugs_found),
405 );
406 let _ = writeln!(
407 w,
408 " Fork Points {:<16} Energy {} remaining",
409 fmt_num(exp.fork_points),
410 fmt_i64(exp.energy_remaining),
411 );
412
413 if exp.realloc_pool_remaining != 0 {
414 let _ = writeln!(w, " Realloc Pool {}", fmt_i64(exp.realloc_pool_remaining));
415 }
416
417 let _ = writeln!(w);
419 if exp.coverage_total > 0 {
420 let frac = f64::from(exp.coverage_bits) / f64::from(exp.coverage_total);
421 let _ = writeln!(
422 w,
423 " Exploration {} {} / {} bits",
424 progress_bar(frac, color),
425 fmt_num(u64::from(exp.coverage_bits)),
426 fmt_num(u64::from(exp.coverage_total)),
427 );
428 }
429 if exp.sancov_edges_total > 0 {
430 let covered_u32 = u32::try_from(exp.sancov_edges_covered).unwrap_or(u32::MAX);
431 let total_u32 = u32::try_from(exp.sancov_edges_total).unwrap_or(u32::MAX);
432 let frac = f64::from(covered_u32) / f64::from(total_u32);
433 let _ = writeln!(
434 w,
435 " Code Cov {} {} / {} edges",
436 progress_bar(frac, color),
437 fmt_num(u64::try_from(exp.sancov_edges_covered).unwrap_or(u64::MAX)),
438 fmt_num(u64::try_from(exp.sancov_edges_total).unwrap_or(u64::MAX)),
439 );
440 }
441
442 if !exp.bug_recipes.is_empty() {
444 let _ = writeln!(w);
445 if color {
446 let _ = writeln!(w, " {}Bug Recipes{}", ansi::BOLD, ansi::RESET);
447 } else {
448 let _ = writeln!(w, " Bug Recipes");
449 }
450 for br in &exp.bug_recipes {
451 let _ = writeln!(w, " seed={}: {}", br.seed, format_recipe(&br.recipe));
452 }
453 }
454}
455
456fn write_assertions(w: &mut impl Write, details: &[AssertionDetail], color: bool) {
457 section_header(
458 w,
459 &format!("Assertions ({})", details.len()),
460 color,
461 ansi::BOLD_CYAN,
462 );
463
464 let mut sorted: Vec<&AssertionDetail> = details.iter().collect();
465 sorted.sort_by(|a, b| {
466 kind_sort_order(a.kind)
467 .cmp(&kind_sort_order(b.kind))
468 .then(a.status.cmp(&b.status))
469 .then(a.msg.cmp(&b.msg))
470 });
471
472 let max_msg = sorted
474 .iter()
475 .map(|d| d.msg.len())
476 .max()
477 .unwrap_or(0)
478 .min(40);
479
480 for detail in &sorted {
481 let icon = status_icon(detail.status, color);
482 let kind = kind_label(detail.kind);
483 let msg = &detail.msg;
484 let display_msg = if msg.len() > 40 {
486 format!("\"{}...\"", &msg[..37])
487 } else {
488 format!("\"{msg}\"")
489 };
490
491 let stats = match detail.kind {
492 AssertKind::Sometimes | AssertKind::Reachable => {
493 let total = detail.pass_count + detail.fail_count;
494 let rate = if total > 0 {
495 let pass_u32 = u32::try_from(detail.pass_count).unwrap_or(u32::MAX);
496 let total_u32 = u32::try_from(total).unwrap_or(u32::MAX);
497 (f64::from(pass_u32) / f64::from(total_u32)) * 100.0
498 } else {
499 0.0
500 };
501 format!(
502 "{} / {} ({:.1}%)",
503 fmt_num(detail.pass_count),
504 fmt_num(total),
505 rate
506 )
507 }
508 AssertKind::NumericSometimes | AssertKind::NumericAlways => {
509 format!(
510 "{} pass {} fail best: {}",
511 fmt_num(detail.pass_count),
512 fmt_num(detail.fail_count),
513 detail.watermark,
514 )
515 }
516 AssertKind::BooleanSometimesAll => {
517 format!(
518 "{} calls frontier: {}",
519 fmt_num(detail.pass_count),
520 detail.frontier,
521 )
522 }
523 _ => {
524 format!(
526 "{} pass {} fail",
527 fmt_num(detail.pass_count),
528 fmt_num(detail.fail_count),
529 )
530 }
531 };
532
533 let pad = if max_msg + 2 > display_msg.len() {
535 max_msg + 2 - display_msg.len()
536 } else {
537 1
538 };
539
540 let _ = writeln!(
541 w,
542 " {icon} {kind:<12} {display_msg}{padding}{stats}",
543 icon = icon,
544 kind = kind,
545 display_msg = display_msg,
546 padding = " ".repeat(pad),
547 stats = stats,
548 );
549 }
550}
551
552fn write_buckets(w: &mut impl Write, summaries: &[BucketSiteSummary], color: bool) {
553 let total_buckets: usize = summaries.iter().map(|s| s.buckets_discovered).sum();
554 section_header(
555 w,
556 &format!(
557 "Buckets ({} across {} sites)",
558 total_buckets,
559 summaries.len()
560 ),
561 color,
562 ansi::BOLD_CYAN,
563 );
564 for bs in summaries {
565 let _ = writeln!(
566 w,
567 " {:<34} {:>3} buckets {:>8} hits",
568 format!("\"{}\"", bs.msg),
569 bs.buckets_discovered,
570 fmt_num(bs.total_hits),
571 );
572 }
573}
574
575fn write_seeds(w: &mut impl Write, report: &SimulationReport, color: bool) {
576 section_header(w, "Seeds", color, ansi::BOLD_CYAN);
577
578 let dim = if color { ansi::DIM } else { "" };
579 let reset = if color { ansi::RESET } else { "" };
580
581 let per_seed_tl = report.exploration.as_ref().map(|e| &e.per_seed_timelines);
582
583 for (i, seed) in report.seeds_used.iter().enumerate() {
584 if let Some(Ok(m)) = report.individual_metrics.get(i) {
585 let tl_suffix = per_seed_tl
586 .and_then(|v| v.get(i))
587 .map(|t| format!(" {} timelines", fmt_num(*t)))
588 .unwrap_or_default();
589 let is_failed = report.seeds_failing.contains(seed);
590 if is_failed && color {
591 let _ = writeln!(
592 w,
593 " {red}#{:<3} seed={:<14} {} {} sim {} events{tl}{reset}",
594 i + 1,
595 seed,
596 fmt_duration(m.wall_time),
597 fmt_duration(m.simulated_time),
598 fmt_num(m.events_processed),
599 tl = tl_suffix,
600 red = ansi::RED,
601 reset = ansi::RESET,
602 );
603 } else {
604 let _ = writeln!(
605 w,
606 " {dim}#{:<3} seed={:<14} {} {} sim {} events{tl}{reset}",
607 i + 1,
608 seed,
609 fmt_duration(m.wall_time),
610 fmt_duration(m.simulated_time),
611 fmt_num(m.events_processed),
612 tl = tl_suffix,
613 dim = dim,
614 reset = reset,
615 );
616 }
617 } else if let Some(Err(_)) = report.individual_metrics.get(i) {
618 if color {
619 let _ = writeln!(
620 w,
621 " {red}#{:<3} seed={:<14} FAILED{reset}",
622 i + 1,
623 seed,
624 red = ansi::BOLD_RED,
625 reset = ansi::RESET,
626 );
627 } else {
628 let _ = writeln!(w, " #{:<3} seed={:<14} FAILED", i + 1, seed);
629 }
630 }
631 }
632}