Skip to main content

nmbrs_runtime/readouts/builtins/
metric.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! `metric` — the parameterised readout that surfaces
5//! workload-emphasised metrics (recall, latency, etc.).
6//!
7//! Push 3 surface only: the readout exists in the registry
8//! and renders the context's pre-formatted chip string
9//! (`status_metric_chips()`). The glob filtering itself
10//! still lives in `ActivityMetrics::collect_status_values`
11//! and is driven by the workload's `status_metrics:` field.
12//! Push 4 will:
13//!
14//! - Add a typed `LiveMetricSource` to the context.
15//! - Have `metric` accept its glob via `pattern=`
16//!   options (or the colon shorthand `metric:recall*`).
17//! - Drop the `status_metric_chips()` accessor in favour
18//!   of per-metric resolution inside the readout.
19//!
20//! For now this is a thin shim that exists so the body
21//! grammar can reference `metric` without errors.
22
23use crate::lifecycle::SubjectKind;
24use crate::readouts::buf::ReadoutBuf;
25use crate::readouts::context::ReadoutContext;
26use crate::readouts::readout::{ContentMode, Lod, Readout, ReadoutOptions};
27
28pub struct Metric;
29
30impl Readout for Metric {
31    fn name(&self) -> &'static str {
32        "metric"
33    }
34    fn accepts(&self) -> &'static [SubjectKind] {
35        &[SubjectKind::Phase]
36    }
37
38    fn render(
39        &self,
40        ctx: &dyn ReadoutContext,
41        _lod: Lod,
42        mode: ContentMode,
43        opts: &ReadoutOptions,
44        out: &mut dyn ReadoutBuf,
45    ) -> usize {
46        match mode {
47            ContentMode::Value => {
48                let chips = ctx.status_metric_chips();
49                if chips.is_empty() {
50                    return 0;
51                }
52                // Push 9b: glob-filter the chips by the
53                // `pattern=` option (set via the `name:arg`
54                // colon-shorthand or `pattern="recall*"`
55                // long form). Bare `metric` with no
56                // pattern emits the whole chip string —
57                // backwards-compatible with Push 3.
58                let filtered = match opts.get_str("pattern") {
59                    Some(pat) => filter_chips(&chips, pat),
60                    None => chips,
61                };
62                let bytes = filtered.len();
63                let _ = out.write_str(&filtered);
64                bytes
65            }
66            ContentMode::Explanation => {
67                // Push 7: descriptor — short token
68                // explaining what the metric chips show.
69                // Only emits when the value path would have
70                // something to show, so the overlay matches
71                // visibility.
72                if ctx.status_metric_chips().is_empty() {
73                    return 0;
74                }
75                let s = match opts.get_str("pattern") {
76                    Some(pat) => format!(" (live aggregates matching '{pat}')"),
77                    None => " (live aggregates: name:value pairs)".to_string(),
78                };
79                let _ = out.write_str(&s);
80                s.len()
81            }
82        }
83    }
84}
85
86/// Filter the pre-rendered chip string (` name:value name:value …`)
87/// down to entries whose names match the glob pattern.
88/// Each chip is split on `:` to extract the name; the
89/// pattern is matched via the same minimal `*`/`?` glob
90/// the rest of the engine uses.
91///
92/// Output format mirrors the input — leading-space-
93/// separated chips — so callers concatenate without
94/// extra whitespace handling. Empty result when nothing
95/// matched.
96fn filter_chips(chips: &str, pattern: &str) -> String {
97    // Split the chip string on whitespace boundaries.
98    // Each chip starts with a leading space (per the
99    // ActivityMetrics::collect_status_values format),
100    // so we walk word-by-word.
101    let mut out = String::with_capacity(chips.len());
102    for chip in chips.split_whitespace() {
103        let name = chip.split_once(':').map(|(n, _)| n).unwrap_or(chip);
104        if glob_match(pattern, name) {
105            out.push(' ');
106            out.push_str(chip);
107        }
108    }
109    out
110}
111
112/// Minimal glob match copy from `nmbrs-runtime::activity`.
113/// `*` matches zero-or-more, `?` matches one. Recursive,
114/// fine at the chip-name lengths we deal with.
115fn glob_match(pattern: &str, candidate: &str) -> bool {
116    glob_match_bytes(pattern.as_bytes(), candidate.as_bytes())
117}
118
119fn glob_match_bytes(pat: &[u8], s: &[u8]) -> bool {
120    match (pat.first(), s.first()) {
121        (None, None) => true,
122        (Some(b'*'), _) => {
123            glob_match_bytes(&pat[1..], s) || (!s.is_empty() && glob_match_bytes(pat, &s[1..]))
124        }
125        (Some(b'?'), Some(_)) => glob_match_bytes(&pat[1..], &s[1..]),
126        (Some(p), Some(c)) if p == c => glob_match_bytes(&pat[1..], &s[1..]),
127        _ => false,
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::readouts::buf::StringBuf;
135
136    struct Ctx {
137        chips: String,
138    }
139    impl ReadoutContext for Ctx {
140        fn subject_name(&self) -> &str {
141            "x"
142        }
143        fn subject_seq(&self) -> Option<(usize, usize)> {
144            None
145        }
146        fn subject_labels(&self) -> &str {
147            ""
148        }
149        fn cycles_completed(&self) -> u64 {
150            0
151        }
152        fn cycles_total(&self) -> u64 {
153            0
154        }
155        fn ops_ok(&self) -> u64 {
156            0
157        }
158        fn errors(&self) -> u64 {
159            0
160        }
161        fn retries(&self) -> u64 {
162            0
163        }
164        fn concurrency(&self) -> usize {
165            0
166        }
167        fn elapsed_secs(&self) -> f64 {
168            0.0
169        }
170        fn consumed(&self) -> u64 {
171            0
172        }
173        fn status_metric_chips(&self) -> String {
174            self.chips.clone()
175        }
176        fn depth_indent(&self) -> &str {
177            ""
178        }
179        fn use_color(&self) -> bool {
180            false
181        }
182        fn event(&self) -> crate::lifecycle::EventType {
183            crate::lifecycle::EventType::Update
184        }
185    }
186
187    #[test]
188    fn renders_chips_string() {
189        let ctx = Ctx {
190            chips: " recall_at_10:79.62% latency_p99:1.23ms".into(),
191        };
192        let mut s = String::new();
193        let mut buf = StringBuf::new(&mut s);
194        let n = Metric.render(
195            &ctx,
196            Lod::Labeled,
197            ContentMode::Value,
198            &ReadoutOptions::new(),
199            &mut buf,
200        );
201        assert!(n > 0);
202        assert_eq!(s, " recall_at_10:79.62% latency_p99:1.23ms");
203    }
204
205    #[test]
206    fn empty_chips_renders_zero() {
207        let ctx = Ctx {
208            chips: String::new(),
209        };
210        let mut s = String::new();
211        let mut buf = StringBuf::new(&mut s);
212        let n = Metric.render(
213            &ctx,
214            Lod::Labeled,
215            ContentMode::Value,
216            &ReadoutOptions::new(),
217            &mut buf,
218        );
219        assert_eq!(n, 0);
220        assert!(s.is_empty());
221    }
222
223    #[test]
224    fn explanation_mode_emits_descriptor_when_chips_present() {
225        let ctx = Ctx {
226            chips: " latency_p99:1ms".into(),
227        };
228        let mut s = String::new();
229        let mut buf = StringBuf::new(&mut s);
230        let n = Metric.render(
231            &ctx,
232            Lod::Labeled,
233            ContentMode::Explanation,
234            &ReadoutOptions::new(),
235            &mut buf,
236        );
237        assert!(n > 0);
238        assert!(
239            s.contains("live aggregates"),
240            "expected descriptor, got {s}"
241        );
242    }
243
244    #[test]
245    fn explanation_mode_zero_when_no_chips() {
246        let ctx = Ctx {
247            chips: String::new(),
248        };
249        let mut s = String::new();
250        let mut buf = StringBuf::new(&mut s);
251        let n = Metric.render(
252            &ctx,
253            Lod::Labeled,
254            ContentMode::Explanation,
255            &ReadoutOptions::new(),
256            &mut buf,
257        );
258        assert_eq!(n, 0);
259    }
260
261    #[test]
262    fn pattern_filter_keeps_only_matching_chips() {
263        let ctx = Ctx {
264            chips: " recall_at_10:79.62% latency_p99:1.23ms recall_at_1:42.00% latency_max:5ms"
265                .into(),
266        };
267        let mut opts = ReadoutOptions::new();
268        opts.set(
269            "pattern",
270            super::super::super::OptionValue::Str("recall*".into()),
271        );
272        let mut s = String::new();
273        let mut buf = StringBuf::new(&mut s);
274        Metric.render(&ctx, Lod::Labeled, ContentMode::Value, &opts, &mut buf);
275        assert_eq!(s, " recall_at_10:79.62% recall_at_1:42.00%");
276    }
277
278    #[test]
279    fn pattern_filter_latency_family() {
280        let ctx = Ctx {
281            chips: " recall_at_10:79.62% latency_p50:1ms latency_p99:2ms".into(),
282        };
283        let mut opts = ReadoutOptions::new();
284        opts.set(
285            "pattern",
286            super::super::super::OptionValue::Str("latency*".into()),
287        );
288        let mut s = String::new();
289        let mut buf = StringBuf::new(&mut s);
290        Metric.render(&ctx, Lod::Labeled, ContentMode::Value, &opts, &mut buf);
291        assert_eq!(s, " latency_p50:1ms latency_p99:2ms");
292    }
293
294    #[test]
295    fn pattern_filter_exact_name_only_one_chip() {
296        let ctx = Ctx {
297            chips: " recall_at_10:79.62% recall_at_1:42.00%".into(),
298        };
299        let mut opts = ReadoutOptions::new();
300        opts.set(
301            "pattern",
302            super::super::super::OptionValue::Str("recall_at_1".into()),
303        );
304        let mut s = String::new();
305        let mut buf = StringBuf::new(&mut s);
306        Metric.render(&ctx, Lod::Labeled, ContentMode::Value, &opts, &mut buf);
307        // Only the exact-name chip; recall_at_10 is NOT
308        // a match because its name is different.
309        assert_eq!(s, " recall_at_1:42.00%");
310    }
311
312    #[test]
313    fn pattern_with_no_match_returns_zero() {
314        let ctx = Ctx {
315            chips: " recall_at_10:79.62%".into(),
316        };
317        let mut opts = ReadoutOptions::new();
318        opts.set(
319            "pattern",
320            super::super::super::OptionValue::Str("nonexistent*".into()),
321        );
322        let mut s = String::new();
323        let mut buf = StringBuf::new(&mut s);
324        let n = Metric.render(&ctx, Lod::Labeled, ContentMode::Value, &opts, &mut buf);
325        assert_eq!(n, 0);
326        assert_eq!(s, "");
327    }
328
329    #[test]
330    fn no_pattern_emits_all_chips() {
331        let ctx = Ctx {
332            chips: " recall_at_10:79.62% latency_p99:1ms".into(),
333        };
334        let mut s = String::new();
335        let mut buf = StringBuf::new(&mut s);
336        Metric.render(
337            &ctx,
338            Lod::Labeled,
339            ContentMode::Value,
340            &ReadoutOptions::new(),
341            &mut buf,
342        );
343        // Bare `metric` (no pattern) preserves the
344        // backwards-compatible Push 3 behaviour.
345        assert_eq!(s, " recall_at_10:79.62% latency_p99:1ms");
346    }
347}