Skip to main content

nmbrs_metrics/
polydat_nodes.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Polydat-side metric-reading node registration.
5//!
6//! Formerly lived at `polydat::library::metrics`. Moved here so polydat
7//! can publish to crates.io without a reverse dep on `nmbrs-metrics`.
8//! `inventory` is the registration channel — polydat's
9//! `register_nodes!` macro emits an `inventory::submit!` block from
10//! this crate; polydat picks it up at link time without knowing
11//! who registered it.
12//!
13//! Polydat node functions for reading live metrics from the unified
14//! [`MetricsQuery`] (SRD-42 §"MetricsQuery").
15//!
16//! - `metric(label_pattern, stat)` — reads
17//!   [`MetricsQuery::session_lifetime`]: session running **totals**
18//!   (`cycles`/`errors`) and the session-average `rate` (total ÷ session age).
19//! - `metric_window(label_pattern, stat)` — at the smallest cadence: the
20//!   latest window's per-window **increase** via
21//!   [`MetricsQuery::increase_over`] (`cycles`/`errors`, and `rate` =
22//!   increase ÷ interval), and its latency **distribution** via
23//!   [`MetricsQuery::distribution_over`] (`p50`/`p99`/`mean`).
24//!
25//! Both are non-deterministic context nodes. In strict mode they
26//! require explicit acknowledgment. The query reference is captured
27//! at node construction from a global static set by the runner.
28//!
29//! ## Stat accessors (PromQL-aligned word stems)
30//!
31//! - `"cycles"` — cycles_total: session **total** (`metric`) / per-window
32//!   **increase** (`metric_window`)
33//! - `"errors"` — errors_total: session total / per-window increase
34//! - `"rate"` — cycles/second (session-average / per-window)
35//! - `"p50"`, `"p99"`, `"mean"` — latency quantiles from cycles_servicetime (nanos)
36
37use std::sync::{Arc, LazyLock, Mutex};
38
39// Node metadata + registration are emitted by `#[polydat::polydat_node]`
40// (fully-qualified `polydat::…` paths, including the `Const<…>` marker), so
41// no `polydat::ast` / `polydat::dsl::registry` imports are needed here.
42use crate::metrics_query::{MetricsQuery, Selection};
43use crate::snapshot::{MetricSet, MetricValue};
44
45/// Global metrics query reference. Set by the runner once the
46/// cadence reporter is built. Polydat metric nodes capture this at
47/// construction time.
48static METRICS_QUERY: LazyLock<Mutex<Option<Arc<MetricsQuery>>>> =
49    LazyLock::new(|| Mutex::new(None));
50
51/// Set the global metrics query for Polydat node access.
52pub fn set_global_query(query: Arc<MetricsQuery>) {
53    *METRICS_QUERY.lock().unwrap_or_else(|e| e.into_inner()) = Some(query);
54}
55
56/// Get the global metrics query reference.
57fn get_query() -> Option<Arc<MetricsQuery>> {
58    METRICS_QUERY
59        .lock()
60        .unwrap_or_else(|e| e.into_inner())
61        .clone()
62}
63
64/// Build a [`Selection`] from a `"family, key=value, key~substring"`
65/// pattern, returning the family name (when one was given) alongside.
66/// A bare token — no `=` / `~` — names the metric FAMILY, i.e. the
67/// registered instrument name (`result_failure`, `attempt_failure`,
68/// `cycles_servicetime`, …): the same names metrics.db carries, so
69/// the reader vocabulary and the metric namespace are one. Labeled
70/// parts narrow to matching series within the selection.
71fn selection_from_pattern(pattern: &str) -> (Selection, Option<String>) {
72    let mut sel = Selection::all();
73    let mut family: Option<String> = None;
74    for part in pattern.split(',').map(str::trim) {
75        if part.is_empty() {
76            continue;
77        }
78        if let Some((key, value)) = part.split_once('=') {
79            sel = sel.with_label(key.trim(), value.trim());
80        } else if let Some((key, substring)) = part.split_once('~') {
81            sel = sel.with_label_containing(key.trim(), substring.trim());
82        } else {
83            sel = sel.with_family(part);
84            family = Some(part.to_string());
85        }
86    }
87    (sel, family)
88}
89
90/// Read a stat from the canonical session-lifetime view.
91///
92/// Signature: `metric(label_pattern: const str, stat: const str) -> f64`.
93/// Reads [`MetricsQuery::session_lifetime`] — session running totals
94/// (`cycles`/`errors`) and the session-average `rate`. Authored via
95/// `#[polydat::polydat_node]` (SRD-80b).
96///
97/// Intrinsically `Nondeterministic`: reads the live session-lifetime view
98/// the cadence pipeline frames, so the node is never const-folded and is
99/// re-evaluated on every pull — a metrics reader can never cache a stale
100/// (e.g. compile-time-empty) value.
101#[polydat::polydat_node(
102    category = Context,
103    purity = Nondeterministic("reads live session-lifetime metrics; value changes over the run"),
104)]
105fn metric(label_pattern: Const<&str>, stat: Const<&str>) -> f64 {
106    let (sel, family) = selection_from_pattern(label_pattern.0);
107    let Some(fam) = family else {
108        warn_family_required("metric", label_pattern.0);
109        return 0.0;
110    };
111    get_query()
112        .map(|q| q.session_lifetime(&sel))
113        .and_then(|snap| extract_family_stat(&snap, &fam, stat.0))
114        .unwrap_or(0.0)
115}
116
117/// Read a stat from the latest closed smallest-cadence window.
118///
119/// Signature: `metric_window(label_pattern: const str, stat: const str) ->
120/// f64`. Counter stats read the per-window INCREASE (`cycles`/`errors`, and
121/// `rate` = increase ÷ interval); latency quantiles (`p50`/`p99`/`mean`)
122/// read the merged window DISTRIBUTION. Contrast [`metric`], which reads
123/// session-lifetime running totals.
124///
125/// Intrinsically `Nondeterministic` — see [`metric`].
126#[polydat::polydat_node(
127    category = Context,
128    purity = Nondeterministic("reads the latest framed cadence window; value changes over the run"),
129)]
130fn metric_window(label_pattern: Const<&str>, stat: Const<&str>) -> f64 {
131    let (sel, family) = selection_from_pattern(label_pattern.0);
132    let Some(family) = family else {
133        warn_family_required("metric_window", label_pattern.0);
134        return 0.0;
135    };
136    get_query()
137        .and_then(|q| {
138            let smallest = q.reporter().declared_cadences().smallest();
139            if smallest.is_zero() {
140                return None;
141            }
142            let snap = match stat.0 {
143                "p50" | "p99" | "mean" => q.distribution_over(smallest, &sel),
144                _ => q.increase_over(smallest, &sel),
145            };
146            extract_family_stat(&snap, &family, stat.0)
147        })
148        .unwrap_or(0.0)
149}
150
151/// Extract a named stat from a [`MetricSet`].
152/// A family-less pattern has nothing to read since the canned stat
153/// vocabulary was retired (2026-07-10): warn ONCE per distinct
154/// pattern — visible, never silent, and not per-eval spam on the
155/// objective/guard evaluation paths — and let the reader yield 0.0.
156fn warn_family_required(node: &str, pattern: &str) {
157    static WARNED: LazyLock<Mutex<std::collections::HashSet<String>>> =
158        LazyLock::new(|| Mutex::new(std::collections::HashSet::new()));
159    let key = format!("{node}:{pattern}");
160    let mut warned = WARNED.lock().unwrap_or_else(|e| e.into_inner());
161    if warned.insert(key) {
162        crate::diag::warn(&format!(
163            "{node}('{pattern}', …): no metric family named — the first \
164             bare token in the pattern must be an instrument name (e.g. \
165             {node}('errors_total', 'rate')); reading 0.0"
166        ));
167    }
168}
169
170/// Read `stat` from the NAMED family's first series — the ONE
171/// vocabulary: any registered counter / gauge / histogram is
172/// readable by its own name. Stats: `count` (counter cumulative or
173/// histogram count), `value` (gauge, falling back to counter
174/// cumulative), `rate` (counter cumulative over the snapshot's
175/// interval — the session average for `metric`, the window rate for
176/// `metric_window`), `mean` / `p50` / `p99` (histograms). Returns
177/// `None` — surfaced as `0.0` by the reader nodes — when the family
178/// is absent from the snapshot or the stat doesn't apply to its
179/// type.
180/// SRD-75 (C5) — does a `metric()`-style selector currently resolve to
181/// at least one REGISTERED instrument? True when the pattern names a
182/// family and the session-lifetime view contains ≥1 instance matching
183/// the full selection. The phase-poll `require:` strict gate polls
184/// this within its grace window; the `metric()` readers themselves
185/// stay lenient (0.0 + one-shot warn) because a stop predicate over a
186/// not-yet-registered family is a legitimate "not yet" state — only a
187/// coordination GATE treats absence as a bug.
188pub fn metric_selector_resolves(pattern: &str) -> bool {
189    let (sel, family) = selection_from_pattern(pattern);
190    let Some(fam) = family else { return false };
191    get_query()
192        .map(|q| q.session_lifetime(&sel))
193        .and_then(|snap| {
194            snap.family(&fam)
195                // Mirror `extract_family_stat` exactly: an instance with a
196                // readable POINT — presence without a point still reads
197                // 0.0 at the `metric()` reader, so it must not count as
198                // resolved.
199                .map(|f| f.metrics().next().and_then(|m| m.point()).is_some())
200        })
201        .unwrap_or(false)
202}
203
204fn extract_family_stat(snapshot: &MetricSet, family: &str, stat: &str) -> Option<f64> {
205    let f = snapshot.family(family)?;
206    let m = f.metrics().next()?;
207    match (m.point()?.value(), stat) {
208        (MetricValue::Counter(c), "count" | "value") => Some(c.cumulative as f64),
209        (MetricValue::Counter(c), "rate") => {
210            let secs = snapshot.interval().as_secs_f64().max(0.001);
211            Some(c.cumulative as f64 / secs)
212        }
213        (MetricValue::Gauge(g), "value") => Some(g.value),
214        (MetricValue::Histogram(h), "count") => Some(h.count as f64),
215        (MetricValue::Histogram(h), "mean") if h.count > 0 => Some(h.reservoir.mean()),
216        (MetricValue::Histogram(h), "p50") if h.count > 0 => {
217            Some(h.reservoir.value_at_quantile(0.50) as f64)
218        }
219        (MetricValue::Histogram(h), "p99") if h.count > 0 => {
220            Some(h.reservoir.value_at_quantile(0.99) as f64)
221        }
222        _ => None,
223    }
224}
225
226// `metric` / `metric_window` are authored via `#[polydat::polydat_node]`
227// above — their FuncSig + builder are macro-generated and registered through
228// the macro's own `inventory::submit!`, so no hand-written `signatures()` /
229// `build_node()` / `register_nodes!` is needed here.