Skip to main content

radiate_core/stats/
fmt.rs

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    // Value row
109
110    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, // scale sum to avoid overflow
123            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, // scale sum to avoid overflow
143            stat.stddev(),
144            stat.skewness(),
145            stat.kurtosis(),
146        ).unwrap();
147    }
148
149    // Time row
150    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}