1use crate::stats::TagType;
2use crate::{Metric, MetricSet, metric_names};
3use radiate_utils::SmallStr;
4use std::borrow::Cow;
5use std::time::Duration;
6use std::{fmt::Write as _, io};
7
8const NAME_WIDTH: usize = 26;
9
10fn truncate_name(name: &str) -> Cow<'_, str> {
11 let len = name.chars().count();
12 if len <= NAME_WIDTH {
13 return Cow::Borrowed(name);
14 }
15
16 let available = NAME_WIDTH - 3;
17 let head_len = available.div_ceil(2);
18 let tail_len = available - head_len;
19 let head = name.chars().take(head_len).collect::<String>();
20 let tail = name.chars().skip(len - tail_len).collect::<String>();
21
22 Cow::Owned(format!("{head}...{tail}"))
23}
24
25pub fn sparkline(values: &[f32], width: usize) -> String {
26 if values.is_empty() || width == 0 {
27 return String::new();
28 }
29
30 let blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
31 let (min, max) = values
32 .iter()
33 .fold((f32::INFINITY, f32::NEG_INFINITY), |(mn, mx), &v| {
34 (mn.min(v), mx.max(v))
35 });
36
37 let span = (max - min).max(1e-12);
38 let step = (values.len() as f32 / width as f32).max(1.0);
39 let mut out = String::with_capacity(width);
40
41 let mut idx = 0.0;
42 for _ in 0..width {
43 let i = f32::floor(idx) as usize;
44 let v = *values.get(i.min(values.len() - 1)).unwrap_or(&min);
45 let level = (((v - min) / span) * ((blocks.len() - 1) as f32)).round() as usize;
46 out.push(blocks[level.min(blocks.len() - 1)]);
47 idx += step;
48 }
49 out
50}
51
52pub fn render_dashboard(metrics: &MetricSet) -> io::Result<String> {
53 let mut out = String::new();
54
55 let mut push_val = |name: &SmallStr, label: &str| {
56 if let Some(m) = metrics.get(name) {
57 let mu = m.mean();
58 write!(out, " {label}: {:.3}", mu).unwrap();
59 }
60 };
61
62 push_val(&metric_names::PCT_CARRYOVER, "carryover");
63 push_val(&metric_names::PCT_DIVERSITY, "diversity");
64
65 let mut push_int = |name: &SmallStr, label: &str| {
66 if let Some(m) = metrics.get(name) {
67 write!(out, " {label}: {}", m.last_value() as i64).unwrap();
68 }
69 };
70
71 push_int(&metric_names::UNIQUE_MEMBERS, "unique_members");
72 push_int(&metric_names::UNIQUE_SCORES, "unique_scores");
73
74 if let Some(m) = metrics.get(metric_names::BEST_SCORE_IMPROVEMENT) {
75 write!(out, " improvements: {}", m.count() as i64).unwrap();
76 }
77
78 if let Some(m) = metrics.get(metric_names::TIME)
79 && let Some(mu) = m.times().map(|t| t.mean())
80 {
81 write!(out, " iter_time(mean): {}", fmt_duration(mu)).unwrap();
82 }
83
84 Ok(if out.is_empty() {
85 "—".into()
86 } else {
87 out.replace('\n', "")
88 })
89}
90
91fn render_table_header(mut out: String) -> io::Result<String> {
92 writeln!(
93 out,
94 "{:<26} | {:<6} | {:<10} | {:<10} | {:<10} | {:<6} | {:<12} | {:<10} | {:<10} | {:<10}",
95 "Name", "Type", "Mean", "Min", "Max", "N", "Total", "StdDev", "Skew", "Kurt"
96 )
97 .unwrap();
98 writeln!(out, "{}", "-".repeat(135)).unwrap();
99 Ok(out)
100}
101
102pub fn render_metric_rows_full(
103 out: &mut String,
104 name: &str,
105 m: &Metric,
106 tag: TagType,
107) -> io::Result<()> {
108 if let Some(dist) = m.distributions()
111 && tag == TagType::Distribution
112 {
113 writeln!(
114 out,
115 "{:<26} | {:<6} | {:<10.3} | {:<10.3} | {:<10.3} | {:<6} | {:<12.3} | {:<10.3} | {:<10.3} | {:<10.3}",
116 truncate_name(name),
117 "dist",
118 dist.mean(),
119 dist.min(),
120 dist.max(),
121 dist.count(),
122 dist.sum() / 1_000.0, dist.stddev(),
124 dist.skewness(),
125 dist.kurtosis()
126 ,
127 ).unwrap();
128 }
129
130 if let Some(stat) = m.stats()
131 && tag == TagType::Statistic
132 {
133 writeln!(
134 out,
135 "{:<26} | {:<6} | {:<10.3} | {:<10.3} | {:<10.3} | {:<6} | {:<12.3} | {:<10.3} | {:<10.3} | {:<10.3}",
136 truncate_name(name),
137 "value",
138 stat.mean(),
139 stat.min(),
140 stat.max(),
141 stat.count(),
142 stat.sum() / 1_000.0, stat.stddev(),
144 stat.skewness(),
145 stat.kurtosis(),
146 ).unwrap();
147 }
148
149 if let Some(t) = m.times()
151 && tag == TagType::Time
152 {
153 writeln!(
154 out,
155 "{:<26} | {:<6} | {:<10} | {:<10} | {:<10} | {:<6} | {:<12} | {:<10} | {:<10} | {:<10}",
156 truncate_name(name),
157 "time",
158 fmt_duration(t.mean()),
159 fmt_duration(t.min()),
160 fmt_duration(t.max()),
161 t.count(),
162 fmt_duration(t.sum()),
163 fmt_duration(t.stddev()),
164 "-",
165 "-",
166 )
167 .unwrap();
168 }
169
170 Ok(())
171}
172
173fn render_tagged(ms: &MetricSet, tag: TagType, title: &str) -> io::Result<String> {
174 let mut out = String::new();
175 writeln!(out, "== {} ==", title).unwrap();
176 out = render_table_header(out)?;
177
178 let mut items: Vec<_> = ms.iter_tagged(tag).collect();
179 items.sort_by(|a, b| a.name().cmp(b.name()));
180
181 for m in items {
182 render_metric_rows_full(&mut out, m.name().as_str(), m, tag)?;
183 }
184
185 Ok(out)
186}
187
188pub fn render_full(metrics: &MetricSet) -> io::Result<String> {
189 let mut out = String::new();
190
191 let summary = metrics.summary();
192 let dash = render_dashboard(metrics)?;
193 writeln!(
194 out,
195 "----- Metrics ----- ({} :: {}) \n{}",
196 summary.metrics, summary.updates, dash
197 )
198 .unwrap();
199
200 let dist = render_tagged(metrics, TagType::Distribution, "Distributions")?;
201 writeln!(out, "\n{}", dist).unwrap();
202
203 let generation = render_tagged(metrics, TagType::Statistic, "Statistics")?;
204 writeln!(out, "\n{}", generation).unwrap();
205
206 let life = render_tagged(metrics, TagType::Time, "Times")?;
207 writeln!(out, "\n{}", life).unwrap();
208
209 Ok(out)
210}
211
212pub fn fmt_duration(d: Duration) -> String {
213 let ns = d.as_nanos();
214 if ns == 0 {
215 "0ns".into()
216 } else if ns < 1_000 {
217 format!("{ns}ns")
218 } else if ns < 1_000_000 {
219 format!("{:.2}µs", ns as f64 / 1e3)
220 } else if ns < 1_000_000_000 {
221 format!("{:.2}ms", ns as f64 / 1e6)
222 } else {
223 format!("{:.2}s", ns as f64 / 1e9)
224 }
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230
231 #[test]
232 fn short_name_unchanged() {
233 assert_eq!(truncate_name("count.species"), "count.species");
234 }
235
236 #[test]
237 fn name_at_limit_unchanged() {
238 let n: String = std::iter::repeat_n('a', NAME_WIDTH).collect();
239 assert_eq!(truncate_name(&n), n.as_str());
240 }
241
242 #[test]
243 fn long_name_gets_middle_ellipsis() {
244 let out = truncate_name("mutate.graph.invalid.rejected");
245 assert_eq!(out.chars().count(), NAME_WIDTH);
246 assert!(out.contains("..."));
247 assert!(out.starts_with("mutate"));
248 assert!(out.ends_with("rejected"));
249 }
250}