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.