Skip to main content

net/adapter/net/behavior/fold/
capability_aggregation.rs

1//! Capacity-aggregation surface on top of [`Fold<CapabilityFold>`].
2//!
3//! Composes three orthogonal axes — `TagMatcher × GroupBy ×
4//! Aggregation` — into a single materialized-view method,
5//! [`Fold::aggregate`](super::Fold::aggregate). Operators ask
6//! "what's available, bucketed how, counted how" and the fold answers
7//! by walking its live `(class, node) → CapabilityMembership` store
8//! once.
9//!
10//! Sub-step 6c-A scope: ships the matcher / group_by / aggregation
11//! variants that don't need regex / semver / numeric-tag parsing.
12//! `Regex`, `VersionRange`, `SumNumericTag`, and `Min/MaxNumericTag`
13//! land in 6c-B (capacity ranking) and 6c-C (advanced matchers).
14//!
15//! See `docs/plans/MULTIFOLD_PHASE_6C_CAPACITY_AGGREGATION.md`.
16
17use std::collections::{HashMap, HashSet};
18
19use serde::{Deserialize, Serialize};
20
21use super::capability::{CapabilityFold, CapabilityMembership, NodeState};
22use super::state::NodeId;
23use super::Fold;
24use crate::adapter::net::behavior::tag::{Tag, TaxonomyAxis};
25
26/// Pre-grouping filter — picks which entries the aggregation walks.
27///
28/// Applied against each entry's `tags` array; an entry is included if
29/// ANY of its tags matches the matcher. The 6c-A scope covers the
30/// four variants that don't pull in `regex` or `semver` dependencies.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(tag = "kind", rename_all = "snake_case")]
33pub enum TagMatcher {
34    /// Exact tag string match — e.g. `"software.python=3.11"` matches
35    /// only entries carrying that exact canonical tag.
36    Exact {
37        /// The literal tag string to match against.
38        value: String,
39    },
40    /// Tag-string prefix — e.g. `"hardware.gpu"` matches
41    /// `"hardware.gpu"` and `"hardware.gpu.vram_gb=80"` and any other
42    /// tag starting with the prefix.
43    Prefix {
44        /// The tag prefix to match against.
45        value: String,
46    },
47    /// Tag is anywhere in the given taxonomy axis. Matches every
48    /// axis-prefixed tag (presence + value) in that axis.
49    Axis {
50        /// Taxonomy axis the tag must live in.
51        axis: TaxonomyAxis,
52    },
53    /// Tag has a specific (axis, key) regardless of value.
54    /// `AxisKey { axis: Hardware, key: "gpu.count" }` matches
55    /// `"hardware.gpu.count=8"` and `"hardware.gpu.count=16"` but not
56    /// `"hardware.gpu.vram_gb=80"`.
57    AxisKey {
58        /// Taxonomy axis the tag must live in.
59        axis: TaxonomyAxis,
60        /// Key portion after the `<axis>.` prefix, regardless of any
61        /// value the tag may carry.
62        key: String,
63    },
64    /// Regex match against the canonical tag string form. Invalid
65    /// patterns reject everything (the matcher fails closed —
66    /// safer than silently treating bad patterns as wildcards).
67    /// Compiled per `matches_one` call; callers expecting heavy
68    /// reuse should pre-filter via a coarser matcher first.
69    ///
70    /// **Feature-gated.** Requires the `regex` Cargo feature on the
71    /// receiving binary. The variant is part of the wire format
72    /// unconditionally (so peers can exchange it), but a binary built
73    /// without `regex` cannot evaluate it. Callers that accept
74    /// user-supplied matchers should call [`TagMatcher::validate`]
75    /// first to surface [`TagMatcherError::RegexNotBuiltIn`]
76    /// explicitly; passing an unvalidated `Regex` matcher into
77    /// [`Fold::aggregate`] / [`Fold::capacity_ranking`] on a
78    /// regex-less binary panics with a build-time-config message.
79    Regex {
80        /// Regular-expression pattern to match against the tag.
81        pattern: String,
82    },
83    /// Semver range against a specific axis-key value. Picks
84    /// `AxisValue` tags whose `(axis, key)` matches `axis_key`
85    /// (canonical dotted form, e.g. `"software.python"`) and whose
86    /// `value` parses as a semver `Version` within
87    /// `[min, max]` (inclusive). `min`/`max` are
88    /// `Option<String>` semver expressions — `None` means
89    /// unbounded on that side. Unparseable values are skipped
90    /// silently.
91    VersionRange {
92        /// Canonical `<axis>.<key>` string of the value-bearing
93        /// tag (e.g. `"software.python"`).
94        axis_key: String,
95        /// Inclusive lower bound. `None` = no lower bound.
96        min: Option<String>,
97        /// Inclusive upper bound. `None` = no upper bound.
98        max: Option<String>,
99    },
100}
101
102/// Error surfaced when a [`TagMatcher`] variant can't be evaluated
103/// by the current binary because the gating Cargo feature wasn't
104/// compiled in.
105///
106/// Today the only variant is [`TagMatcherError::RegexNotBuiltIn`].
107/// More variants may land if other matchers gain feature gates
108/// (e.g. a future ML-tag-classifier matcher behind `--features
109/// classify`).
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub enum TagMatcherError {
112    /// A [`TagMatcher::Regex`] was used, but the receiving binary
113    /// was built without `--features regex`. Carries the offending
114    /// pattern so the caller can surface it in the error message.
115    RegexNotBuiltIn {
116        /// The regex pattern the caller attempted to evaluate.
117        pattern: String,
118    },
119}
120
121impl std::fmt::Display for TagMatcherError {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        match self {
124            Self::RegexNotBuiltIn { pattern } => write!(
125                f,
126                "TagMatcher::Regex {{ pattern: {pattern:?} }} requires the \
127                 `regex` Cargo feature; this binary was built without it. \
128                 Rebuild with `--features regex` or use a different matcher \
129                 (Exact / Prefix / Axis / AxisKey / VersionRange).",
130            ),
131        }
132    }
133}
134
135impl std::error::Error for TagMatcherError {}
136
137impl TagMatcher {
138    /// Verify this matcher can be evaluated by the current binary's
139    /// feature set. Callers that accept user-supplied matchers
140    /// (RPC handlers, language-binding constructors, CLI parsers)
141    /// should call this BEFORE handing the matcher to
142    /// [`Fold::aggregate`] / [`Fold::capacity_ranking`] so an
143    /// unsupported variant surfaces as a structured error instead
144    /// of a panic on first compile.
145    ///
146    /// Returns `Ok(())` for every variant the build supports.
147    /// Returns `Err(TagMatcherError::RegexNotBuiltIn { .. })` only
148    /// when the matcher is [`Self::Regex`] and the binary was built
149    /// without `--features regex`.
150    pub fn validate(&self) -> Result<(), TagMatcherError> {
151        match self {
152            #[cfg(not(feature = "regex"))]
153            Self::Regex { pattern } => Err(TagMatcherError::RegexNotBuiltIn {
154                pattern: pattern.clone(),
155            }),
156            _ => Ok(()),
157        }
158    }
159
160    /// True if at least one element of `tags` matches this matcher.
161    /// Same semantic the aggregation entry points use — exposed as a
162    /// public method so off-fold callers (e.g.
163    /// [`super::super::super::MeshNode::list_tools`](super::super::super::MeshNode))
164    /// can apply the same pre-grouping filter without re-implementing
165    /// the variant dispatch.
166    ///
167    /// Compiles on every call. Callers that need to evaluate a
168    /// large entry set against the same matcher should keep the
169    /// `[`Fold::aggregate`]` path, which compiles once and reuses
170    /// across the walk. `list_tools` accepts the per-call compile
171    /// cost because the fold size is small relative to the
172    /// aggregation paths' hot loops.
173    ///
174    /// **Panics** if `self` is [`Self::Regex`] and the binary was
175    /// built without the `regex` Cargo feature; same contract as
176    /// [`Fold::aggregate`]. Call [`Self::validate`] up front if the
177    /// matcher came from an untrusted source.
178    pub fn matches_any(&self, tags: &[String]) -> bool {
179        self.compile().matches_any(tags)
180    }
181
182    /// Build a [`CompiledMatcher`] that pre-resolves expensive
183    /// per-call work (regex compile, semver bound parse, axis-key
184    /// split). Used at the top of [`Fold::aggregate`] and
185    /// [`Fold::capacity_ranking`] so the per-entry walk amortizes
186    /// the parse cost over every tag instead of paying it on each
187    /// `matches_one` invocation.
188    ///
189    /// **Panics** if `self` is [`Self::Regex`] and the binary was
190    /// built without the `regex` Cargo feature. Callers that accept
191    /// user-supplied matchers should [`Self::validate`] first so
192    /// the build-time-config mismatch surfaces as a structured
193    /// `TagMatcherError` instead of a panic.
194    fn compile(&self) -> CompiledMatcher<'_> {
195        match self {
196            Self::Exact { value } => CompiledMatcher::Exact { value },
197            Self::Prefix { value } => CompiledMatcher::Prefix { value },
198            Self::Axis { axis } => CompiledMatcher::Axis { axis: *axis },
199            Self::AxisKey { axis, key } => CompiledMatcher::AxisKey { axis: *axis, key },
200            #[cfg(feature = "regex")]
201            Self::Regex { pattern } => CompiledMatcher::Regex {
202                re: regex::Regex::new(pattern).ok(),
203            },
204            // Feature-disabled receivers panic loudly so the
205            // build-time misconfiguration surfaces at first use.
206            // Operators get a clear "rebuild with --features regex"
207            // message instead of silent empty results that look
208            // indistinguishable from "no entries match." Callers
209            // accepting user-supplied matchers should
210            // `TagMatcher::validate(&matcher)?` ahead of this site
211            // to get a structured `TagMatcherError` instead.
212            #[cfg(not(feature = "regex"))]
213            Self::Regex { pattern } => panic!(
214                "{}",
215                TagMatcherError::RegexNotBuiltIn {
216                    pattern: pattern.clone(),
217                }
218            ),
219            Self::VersionRange { axis_key, min, max } => match split_axis_key(axis_key) {
220                Some((axis, key)) => CompiledMatcher::VersionRange {
221                    axis,
222                    key,
223                    min: min.as_deref().and_then(|s| semver::Version::parse(s).ok()),
224                    max: max.as_deref().and_then(|s| semver::Version::parse(s).ok()),
225                },
226                None => CompiledMatcher::MatchesNothing,
227            },
228        }
229    }
230}
231
232/// Precompiled view of a [`TagMatcher`]. Constructed once per
233/// aggregation call and reused across every entry; consolidates
234/// the regex compile + semver parse + axis-key split that the
235/// wire-shape matcher otherwise repeats on each tag.
236///
237/// Invalid inputs (bad regex pattern, malformed axis-key) collapse
238/// to [`CompiledMatcher::MatchesNothing`] / `Regex { re: None }`
239/// so the matcher fails closed — preserving the prior
240/// "invalid → matches nothing" contract pinned by
241/// `matcher_regex_with_invalid_pattern_matches_nothing`.
242enum CompiledMatcher<'a> {
243    Exact {
244        value: &'a str,
245    },
246    Prefix {
247        value: &'a str,
248    },
249    Axis {
250        axis: TaxonomyAxis,
251    },
252    AxisKey {
253        axis: TaxonomyAxis,
254        key: &'a str,
255    },
256    #[cfg(feature = "regex")]
257    Regex {
258        re: Option<regex::Regex>,
259    },
260    VersionRange {
261        axis: TaxonomyAxis,
262        key: &'a str,
263        min: Option<semver::Version>,
264        max: Option<semver::Version>,
265    },
266    /// Fallthrough for matchers whose construction parameters are
267    /// malformed (e.g. `VersionRange` with an unrecognized axis
268    /// prefix). Matches no tag.
269    MatchesNothing,
270}
271
272impl CompiledMatcher<'_> {
273    fn matches_any(&self, tags: &[String]) -> bool {
274        tags.iter().any(|t| self.matches_one(t))
275    }
276
277    fn matches_one(&self, raw: &str) -> bool {
278        match self {
279            Self::Exact { value } => raw == *value,
280            Self::Prefix { value } => raw.starts_with(value),
281            Self::Axis { axis } => Tag::parse(raw)
282                .ok()
283                .is_some_and(|t| t.axis_key_ref().map(|(a, _)| a) == Some(*axis)),
284            Self::AxisKey { axis, key } => Tag::parse(raw).ok().is_some_and(
285                |t| matches!(t.axis_key_ref(), Some((a, k)) if a == *axis && k == *key),
286            ),
287            #[cfg(feature = "regex")]
288            Self::Regex { re } => re.as_ref().is_some_and(|r| r.is_match(raw)),
289            Self::VersionRange {
290                axis,
291                key,
292                min,
293                max,
294            } => {
295                let Some(value) = axis_value_for(raw, *axis, key) else {
296                    return false;
297                };
298                let Ok(parsed) = semver::Version::parse(&value) else {
299                    return false;
300                };
301                if let Some(lo) = min.as_ref() {
302                    if parsed < *lo {
303                        return false;
304                    }
305                }
306                if let Some(hi) = max.as_ref() {
307                    if parsed > *hi {
308                        return false;
309                    }
310                }
311                true
312            }
313            Self::MatchesNothing => false,
314        }
315    }
316}
317
318/// Bucket-key derivation — for each matching entry, decides which
319/// bucket(s) it contributes to. Most variants produce one bucket per
320/// entry; `TagStem` and `TagValue` can produce zero, one, or many
321/// (one per matching tag on the entry).
322#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
323#[serde(tag = "kind", rename_all = "snake_case")]
324pub enum GroupBy {
325    /// Each entry's `class_hash`, rendered as `"0x{:x}"`.
326    #[default]
327    Class,
328    /// Each entry's `state` (`"idle"` / `"busy"` / `"reserved"` /
329    /// `"faulty"`).
330    State,
331    /// Each entry's `region` (or `"(none)"` for unset).
332    Region,
333    /// Each entry's publisher `node_id`, rendered as `"0x{:x}"`.
334    Publisher,
335    /// Bucket by tag stem. For each tag matching `<prefix>` or
336    /// `<prefix>.<rest>`, the bucket key is the next dotted segment
337    /// after the prefix. `TagStem { prefix: "hardware.gpu" }` over a
338    /// tag set containing `"hardware.gpu.h100"` and
339    /// `"hardware.gpu.a100"` produces buckets `"h100"` and `"a100"`.
340    /// Bare `"hardware.gpu"` itself produces the bucket `"(present)"`
341    /// so presence-only tags don't disappear.
342    TagStem {
343        /// Prefix that an entry's tag must start with for the stem
344        /// extraction to apply.
345        prefix: String,
346    },
347    /// Bucket by the value of a specific axis-key. For each
348    /// `AxisValue { axis, key, value }` tag on the entry matching the
349    /// requested `(axis, key)`, the bucket key is the captured value.
350    TagValue {
351        /// Taxonomy axis the tag must live in.
352        axis: TaxonomyAxis,
353        /// Key portion the tag must carry; bucket key is the value
354        /// portion after the separator.
355        key: String,
356    },
357}
358
359impl GroupBy {
360    /// Compute the bucket keys this entry contributes to. Returns a
361    /// `Vec` since `TagStem` / `TagValue` may produce multiple
362    /// buckets per entry.
363    fn bucket_keys(&self, membership: &CapabilityMembership, publisher: NodeId) -> Vec<String> {
364        match self {
365            Self::Class => vec![format!("0x{:x}", membership.class_hash)],
366            Self::State => vec![state_label(membership.state).to_string()],
367            Self::Region => vec![membership
368                .region
369                .clone()
370                .unwrap_or_else(|| "(none)".to_string())],
371            Self::Publisher => vec![format!("0x{:x}", publisher)],
372            Self::TagStem { prefix } => {
373                let mut buckets: Vec<String> = membership
374                    .tags
375                    .iter()
376                    .filter_map(|t| tag_stem_after(t, prefix))
377                    .collect();
378                buckets.sort();
379                buckets.dedup();
380                buckets
381            }
382            Self::TagValue { axis, key } => {
383                let mut values: Vec<String> = membership
384                    .tags
385                    .iter()
386                    .filter_map(|raw| axis_value_for(raw, *axis, key))
387                    .collect();
388                values.sort();
389                values.dedup();
390                values
391            }
392        }
393    }
394}
395
396/// Per-bucket reduction — once entries are bucketed, this decides
397/// what numeric value lands in the row.
398#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
399#[serde(tag = "kind", rename_all = "snake_case")]
400pub enum Aggregation {
401    /// Number of entries in each bucket. The natural "how many".
402    Count,
403    /// Distinct publisher `node_id`s contributing to the bucket. An
404    /// entry contributing under multiple buckets counts once per
405    /// bucket but doesn't double-count its publisher in any given
406    /// bucket.
407    DistinctPublishers,
408    /// Distinct values for the given `(axis, key)` observed across
409    /// entries in the bucket. `DistinctValues { axis: Hardware, key:
410    /// "gpu.vram_gb" }` answers "how many distinct GPU memory sizes
411    /// are running in this region?"
412    DistinctValues {
413        /// Taxonomy axis the value-bearing tag lives in.
414        axis: TaxonomyAxis,
415        /// Key portion of the value-bearing tag.
416        key: String,
417    },
418    /// Sum the numeric value of `<axis_key>=<n>` tags across the
419    /// bucket. The `axis_key` field is the canonical dotted
420    /// axis-key (e.g. `"hardware.gpu.count"`); only `AxisValue`
421    /// tags whose `(axis, key)` matches are considered. Values
422    /// that don't parse as `u64` are skipped silently. Saturating
423    /// addition — overflow caps at `u64::MAX` rather than
424    /// panicking.
425    SumNumericTag {
426        /// Canonical `<axis>.<key>` of the numeric-value tag to sum.
427        axis_key: String,
428    },
429    /// Minimum observed numeric value of an `<axis_key>=<n>` tag
430    /// across the bucket. Returns `0` when no parseable values are
431    /// observed in the bucket (an operator who needs to distinguish
432    /// "no values observed" from "min is 0" should use
433    /// `capacity_ranking` with `sum_axis_key`, which surfaces
434    /// `Option<u64>`).
435    MinNumericTag {
436        /// Canonical `<axis>.<key>` of the numeric-value tag to min.
437        axis_key: String,
438    },
439    /// Maximum observed numeric value of an `<axis_key>=<n>` tag
440    /// across the bucket. Returns `0` when no parseable values are
441    /// observed (same caveat as `MinNumericTag`).
442    MaxNumericTag {
443        /// Canonical `<axis>.<key>` of the numeric-value tag to max.
444        axis_key: String,
445    },
446}
447
448/// Precompiled view of an [`Aggregation`]. Like [`CompiledMatcher`],
449/// hoists the `axis_key` split + `TaxonomyAxis` lookup out of the
450/// per-entry walk so the parse cost amortizes to one call per
451/// aggregation instead of one per (entry, tag) pair.
452#[derive(Clone, Copy)]
453enum CompiledAgg<'a> {
454    Count,
455    DistinctPublishers,
456    DistinctValues {
457        axis: TaxonomyAxis,
458        key: &'a str,
459    },
460    /// Shared accumulator for `Sum/Min/Max NumericTag` — all three
461    /// project from the same per-bucket numeric_sum / numeric_min /
462    /// numeric_max fields, so the per-entry inner loop is identical.
463    Numeric {
464        axis: TaxonomyAxis,
465        key: &'a str,
466    },
467    /// Fallthrough for malformed `axis_key` inputs (unknown axis
468    /// prefix, missing dot). The per-entry loop does no per-tag
469    /// work; the projection later surfaces `0` for empty buckets.
470    Inert,
471}
472
473impl<'a> CompiledAgg<'a> {
474    fn compile(agg: &'a Aggregation) -> CompiledAgg<'a> {
475        match agg {
476            Aggregation::Count => CompiledAgg::Count,
477            Aggregation::DistinctPublishers => CompiledAgg::DistinctPublishers,
478            Aggregation::DistinctValues { axis, key } => {
479                CompiledAgg::DistinctValues { axis: *axis, key }
480            }
481            Aggregation::SumNumericTag { axis_key }
482            | Aggregation::MinNumericTag { axis_key }
483            | Aggregation::MaxNumericTag { axis_key } => match split_axis_key(axis_key) {
484                Some((axis, key)) => CompiledAgg::Numeric { axis, key },
485                None => CompiledAgg::Inert,
486            },
487        }
488    }
489}
490
491impl Fold<CapabilityFold> {
492    /// Walk the fold once and produce a `Vec<(bucket, value)>` sorted
493    /// lexicographically by bucket key.
494    ///
495    /// `matcher = None` includes every entry; otherwise an entry is
496    /// included only if at least one of its tags matches the matcher.
497    /// `group_by` decides how matching entries are bucketed (one
498    /// entry can land in multiple buckets via `TagStem` / `TagValue`).
499    /// `agg` decides what numeric value each bucket carries.
500    ///
501    /// Returns an empty `Vec` when no entries match. Bucket order is
502    /// stable across calls so operator tooling can diff snapshots.
503    pub fn aggregate(
504        &self,
505        matcher: Option<TagMatcher>,
506        group_by: GroupBy,
507        agg: Aggregation,
508    ) -> Vec<(String, u64)> {
509        // Phase 1: walk state once, materialize a per-bucket
510        // accumulator. We need to track publishers and observed values
511        // separately because the aggregation type decides which to
512        // count.
513        let mut buckets: HashMap<String, BucketAccum> = HashMap::new();
514        let compiled = matcher.as_ref().map(TagMatcher::compile);
515        // Pre-resolve the aggregation's `(axis, key)` once so the
516        // per-(entry, tag) loop doesn't re-split `axis_key` on
517        // every call. Malformed axis_key collapses to
518        // `CompiledAgg::Inert` so the loop becomes a no-op.
519        let compiled_agg = CompiledAgg::compile(&agg);
520
521        self.with_state(|state| {
522            for ((_class, publisher), entry) in state.entries.iter() {
523                let membership = &entry.payload;
524                if let Some(m) = &compiled {
525                    if !m.matches_any(&membership.tags) {
526                        continue;
527                    }
528                }
529                let keys = group_by.bucket_keys(membership, *publisher);
530                if keys.is_empty() {
531                    continue;
532                }
533                for key in keys {
534                    let slot = buckets.entry(key).or_default();
535                    slot.count = slot.count.saturating_add(1);
536                    slot.publishers.insert(*publisher);
537                    match compiled_agg {
538                        CompiledAgg::DistinctValues { axis, key: k } => {
539                            for raw in &membership.tags {
540                                if let Some(v) = axis_value_for(raw, axis, k) {
541                                    slot.distinct_values.insert(v);
542                                }
543                            }
544                        }
545                        CompiledAgg::Numeric { axis, key: k } => {
546                            for raw in &membership.tags {
547                                if let Some(n) = numeric_value_for_split(raw, axis, k) {
548                                    slot.numeric_sum = slot.numeric_sum.saturating_add(n);
549                                    slot.numeric_min =
550                                        Some(slot.numeric_min.map_or(n, |cur| cur.min(n)));
551                                    slot.numeric_max =
552                                        Some(slot.numeric_max.map_or(n, |cur| cur.max(n)));
553                                }
554                            }
555                        }
556                        CompiledAgg::Count
557                        | CompiledAgg::DistinctPublishers
558                        | CompiledAgg::Inert => {}
559                    }
560                }
561            }
562        });
563
564        // Phase 2: project to the requested aggregation, sort by
565        // bucket key.
566        let mut rows: Vec<(String, u64)> = buckets
567            .into_iter()
568            .map(|(bucket, slot)| {
569                let v: u64 = match &agg {
570                    Aggregation::Count => slot.count,
571                    Aggregation::DistinctPublishers => slot.publishers.len() as u64,
572                    Aggregation::DistinctValues { .. } => slot.distinct_values.len() as u64,
573                    Aggregation::SumNumericTag { .. } => slot.numeric_sum,
574                    Aggregation::MinNumericTag { .. } => slot.numeric_min.unwrap_or(0),
575                    Aggregation::MaxNumericTag { .. } => slot.numeric_max.unwrap_or(0),
576                };
577                (bucket, v)
578            })
579            .collect();
580        rows.sort_by(|a, b| a.0.cmp(&b.0));
581        rows
582    }
583
584    /// Capacity-ranked materialized view: bucket the fold's entries
585    /// per `query.group_by`, break each bucket down by state, and
586    /// (optionally) sum a numeric tag across the bucket. Returns
587    /// rows sorted by `available` descending, ties broken by
588    /// bucket key ascending; truncated to `query.limit` (0 = no
589    /// truncation).
590    ///
591    /// `rtt_lookup` maps a publisher's `node_id` to current RTT in
592    /// milliseconds. The closure may return `None`; entries whose
593    /// publisher returns `None` are dropped when
594    /// `query.max_rtt_ms` is set (fail-closed — never-pinged nodes
595    /// don't get to ride a "fastest available" filter as zero).
596    /// When `query.max_rtt_ms` is `None`, the closure is never
597    /// called and all reachable entries pass.
598    ///
599    /// Faulty entries are always excluded from the row counts —
600    /// they don't contribute to `idle` / `busy` / `reserved` /
601    /// `available` regardless of RTT.
602    pub fn capacity_ranking<R>(&self, query: CapacityQuery, rtt_lookup: R) -> Vec<CapacityRow>
603    where
604        R: Fn(NodeId) -> Option<u32>,
605    {
606        // Per-bucket accumulator. Distinct from `BucketAccum` above
607        // because we need state-broken-down counts, which the base
608        // `aggregate` path collapses.
609        let mut buckets: HashMap<String, CapacityAccum> = HashMap::new();
610        let compiled_matcher = query.matcher.as_ref().map(TagMatcher::compile);
611        // Pre-split `sum_axis_key` so the per-tag loop doesn't
612        // re-parse it. `None` either way disables the
613        // summed_capacity column; a malformed axis_key also
614        // disables it (fail-closed — matches `numeric_value_for`'s
615        // None-on-unknown-axis-prefix contract).
616        let sum_axis_split: Option<(TaxonomyAxis, &str)> =
617            query.sum_axis_key.as_deref().and_then(split_axis_key);
618
619        self.with_state(|state| {
620            for ((_class, publisher), entry) in state.entries.iter() {
621                let membership = &entry.payload;
622
623                // Faulty never makes it into the row counts.
624                if membership.state == NodeState::Faulty {
625                    continue;
626                }
627
628                // Matcher gate.
629                if let Some(m) = &compiled_matcher {
630                    if !m.matches_any(&membership.tags) {
631                        continue;
632                    }
633                }
634
635                // RTT gate. `None` returned for an unknown publisher
636                // when `max_rtt_ms` is set drops the entry (fail-
637                // closed). When `max_rtt_ms` is `None` we skip the
638                // lookup entirely.
639                if let Some(max) = query.max_rtt_ms {
640                    let Some(rtt) = rtt_lookup(*publisher) else {
641                        continue;
642                    };
643                    if rtt > max {
644                        continue;
645                    }
646                }
647
648                let keys = query.group_by.bucket_keys(membership, *publisher);
649                if keys.is_empty() {
650                    continue;
651                }
652
653                // Sum the per-entry numeric capacity once and add it
654                // to every bucket the entry contributes to. An entry
655                // landing in two `TagStem` buckets counts once toward
656                // each bucket's summed_capacity — same shape the
657                // state counts use.
658                let entry_capacity: Option<u64> = sum_axis_split.map(|(axis, key)| {
659                    membership
660                        .tags
661                        .iter()
662                        .filter_map(|t| numeric_value_for_split(t, axis, key))
663                        .fold(0u64, |acc, n| acc.saturating_add(n))
664                });
665
666                for key in keys {
667                    let slot = buckets.entry(key).or_default();
668                    match membership.state {
669                        NodeState::Idle => slot.idle = slot.idle.saturating_add(1),
670                        NodeState::Busy => slot.busy = slot.busy.saturating_add(1),
671                        NodeState::Reserved => slot.reserved = slot.reserved.saturating_add(1),
672                        NodeState::Faulty => unreachable!("filtered above"),
673                    }
674                    if let Some(c) = entry_capacity {
675                        slot.summed_capacity =
676                            Some(slot.summed_capacity.unwrap_or(0).saturating_add(c));
677                    }
678                }
679            }
680        });
681
682        // Project to rows.
683        let mut rows: Vec<CapacityRow> = buckets
684            .into_iter()
685            .map(|(bucket, slot)| {
686                let available = slot
687                    .idle
688                    .saturating_add(slot.busy)
689                    .saturating_add(slot.reserved);
690                CapacityRow {
691                    bucket,
692                    idle: slot.idle,
693                    busy: slot.busy,
694                    reserved: slot.reserved,
695                    available,
696                    summed_capacity: slot.summed_capacity,
697                }
698            })
699            .collect();
700
701        // Sort by available descending; tie-break on bucket key
702        // ascending so the output is deterministic.
703        rows.sort_by(|a, b| b.available.cmp(&a.available).then(a.bucket.cmp(&b.bucket)));
704
705        if query.limit > 0 && rows.len() > query.limit {
706            rows.truncate(query.limit);
707        }
708        rows
709    }
710}
711
712/// Operator-facing query shape for [`Fold::capacity_ranking`].
713#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
714pub struct CapacityQuery {
715    /// Pre-filter on entries before grouping. `None` includes every
716    /// non-faulty entry.
717    pub matcher: Option<TagMatcher>,
718    /// How to bucket matching entries.
719    pub group_by: GroupBy,
720    /// Drop entries whose publisher's RTT exceeds this. `None` =
721    /// no RTT filter (consider every reachable non-faulty entry).
722    pub max_rtt_ms: Option<u32>,
723    /// Optional canonical axis-key string to sum across each
724    /// bucket's entries (e.g. `"hardware.gpu.count"` for total
725    /// GPU capacity per bucket). `None` leaves
726    /// `CapacityRow::summed_capacity` as `None`.
727    pub sum_axis_key: Option<String>,
728    /// Top-N buckets by `available` descending. `0` = no
729    /// truncation.
730    pub limit: usize,
731}
732
733/// One row of the capacity-ranked materialized view.
734#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
735pub struct CapacityRow {
736    /// Bucket key (the stem / value / state-name / region / etc.).
737    pub bucket: String,
738    /// Entries in `Idle` that pass the matcher + RTT filters.
739    pub idle: u64,
740    /// Entries in `Busy` that pass.
741    pub busy: u64,
742    /// Entries in `Reserved` that pass.
743    pub reserved: u64,
744    /// Total reachable across non-faulty states (idle + busy +
745    /// reserved). Faulty entries are excluded from the upstream
746    /// walk and never contribute.
747    pub available: u64,
748    /// Sum of the `sum_axis_key` numeric tag across the bucket's
749    /// matching entries. `None` when no `sum_axis_key` was
750    /// requested.
751    pub summed_capacity: Option<u64>,
752}
753
754#[derive(Default)]
755struct BucketAccum {
756    /// Raw entry count contributing to this bucket. One entry that
757    /// contributes via two `TagStem` buckets counts once in each
758    /// bucket's `count` (which matches what an operator means by
759    /// "how many entries in this bucket").
760    count: u64,
761    /// Publisher `node_id`s contributing to this bucket. Set
762    /// semantics — two entries from the same publisher count as one
763    /// in `DistinctPublishers`.
764    publishers: HashSet<NodeId>,
765    /// Observed `(axis, key) → value` strings for
766    /// `Aggregation::DistinctValues`.
767    distinct_values: HashSet<String>,
768    /// Running saturating sum for `Aggregation::SumNumericTag`.
769    /// Stays 0 when no numeric values are observed.
770    numeric_sum: u64,
771    /// Running minimum for `Aggregation::MinNumericTag`. `None`
772    /// until the first parseable value lands; the projection
773    /// surfaces `0` for empty buckets per the
774    /// `MinNumericTag` doc-comment.
775    numeric_min: Option<u64>,
776    /// Running maximum for `Aggregation::MaxNumericTag`. Same
777    /// shape as `numeric_min`.
778    numeric_max: Option<u64>,
779}
780
781#[derive(Default)]
782struct CapacityAccum {
783    idle: u64,
784    busy: u64,
785    reserved: u64,
786    /// `None` when no `sum_axis_key` was configured on the query;
787    /// `Some(0)` when the axis-key was requested but no entry in
788    /// the bucket carried a parseable value.
789    summed_capacity: Option<u64>,
790}
791
792/// Canonical lowercase state name. Same shape as the wire form
793/// `serde(rename_all = "snake_case")` produces.
794fn state_label(state: NodeState) -> &'static str {
795    match state {
796        NodeState::Idle => "idle",
797        NodeState::Busy => "busy",
798        NodeState::Reserved => "reserved",
799        NodeState::Faulty => "faulty",
800    }
801}
802
803/// Strip `<prefix>` off `tag` and return the next dotted segment
804/// (everything up to the next `.`, `=`, or `:`).
805/// - `"hardware.gpu.h100"` with prefix `"hardware.gpu"` → `"h100"`.
806/// - `"hardware.gpu"` with prefix `"hardware.gpu"` → `"(present)"`.
807/// - `"hardware.gpu.vram_gb=80"` with prefix `"hardware.gpu"` →
808///   `"vram_gb"`.
809/// - non-matching tag → `None`.
810fn tag_stem_after(tag: &str, prefix: &str) -> Option<String> {
811    let rest = tag.strip_prefix(prefix)?;
812    if rest.is_empty() {
813        // Exact match on prefix; presence form gets its own bucket so
814        // it doesn't silently merge with a missing-stem case.
815        return Some("(present)".to_string());
816    }
817    let rest = rest.strip_prefix('.')?;
818    let stem_end = rest.find(['.', '=', ':']).unwrap_or(rest.len());
819    if stem_end == 0 {
820        None
821    } else {
822        Some(rest[..stem_end].to_string())
823    }
824}
825
826/// Extract the value of an `AxisValue` tag matching `(axis, key)`.
827/// Returns `None` for `AxisPresent`, `Reserved`, or `Legacy` tags, or
828/// when the axis-key pair doesn't match.
829fn axis_value_for(raw: &str, want_axis: TaxonomyAxis, want_key: &str) -> Option<String> {
830    let tag = Tag::parse(raw).ok()?;
831    match tag {
832        Tag::AxisValue {
833            axis, key, value, ..
834        } if axis == want_axis && key == want_key => Some(value),
835        _ => None,
836    }
837}
838
839/// Parse the numeric `u64` value of an `AxisValue` tag whose
840/// `(axis, key)` matches the caller-supplied pre-split pair.
841/// Returns `None` for non-matching tags, non-`AxisValue` variants,
842/// or values that don't parse as `u64`.
843///
844/// Callers that have the `<axis>.<key>` form in dotted-string
845/// shape should first resolve through [`split_axis_key`] so the
846/// parse hoists outside any per-tag loop.
847fn numeric_value_for_split(raw: &str, axis: TaxonomyAxis, key: &str) -> Option<u64> {
848    axis_value_for(raw, axis, key)?.parse::<u64>().ok()
849}
850
851/// Split a canonical `"<axis>.<key>"` string into its
852/// `(TaxonomyAxis, key)` pair. Returns `None` for malformed inputs
853/// (missing dot or unknown axis prefix).
854fn split_axis_key(want_axis_key: &str) -> Option<(TaxonomyAxis, &str)> {
855    let (want_axis_str, want_key) = want_axis_key.split_once('.')?;
856    let want_axis = TaxonomyAxis::from_prefix(want_axis_str)?;
857    Some((want_axis, want_key))
858}
859
860// ============================================================================
861// Tests
862// ============================================================================
863
864#[cfg(test)]
865mod tests {
866    use super::*;
867    use crate::adapter::net::behavior::fold::wire::SignedAnnouncement;
868    use crate::adapter::net::behavior::fold::EnvelopeMeta;
869    use crate::adapter::net::behavior::fold::FoldKind;
870    use crate::adapter::net::identity::EntityKeypair;
871    use std::collections::BTreeMap;
872    use std::time::Duration;
873
874    fn new_fold() -> Fold<CapabilityFold> {
875        Fold::<CapabilityFold>::with_sweep_interval(Duration::ZERO)
876    }
877
878    fn sign(
879        kp: &EntityKeypair,
880        publisher: NodeId,
881        class: u64,
882        tags: &[&str],
883        state: NodeState,
884        region: Option<&str>,
885    ) -> SignedAnnouncement<CapabilityMembership> {
886        SignedAnnouncement::sign(
887            kp,
888            CapabilityFold::KIND_ID,
889            class,
890            publisher,
891            1,
892            EnvelopeMeta::default(),
893            CapabilityMembership {
894                class_hash: class,
895                tags: tags.iter().map(|s| (*s).to_string()).collect(),
896                hardware: None,
897                state,
898                region: region.map(|s| s.to_string()),
899                price_quote: None,
900                reflex_addr: None,
901                allowed_nodes: Vec::new(),
902                allowed_subnets: Vec::new(),
903                allowed_groups: Vec::new(),
904                metadata: BTreeMap::new(),
905            },
906        )
907        .expect("sign")
908    }
909
910    fn populated_fold() -> Fold<CapabilityFold> {
911        // Three publishers, mix of GPU types + regions + states.
912        let fold = new_fold();
913        let kp = EntityKeypair::generate();
914        // 0xA — h100 / us-east / idle
915        fold.apply(sign(
916            &kp,
917            0xA,
918            0x100,
919            &[
920                "hardware.gpu",
921                "hardware.gpu.h100",
922                "hardware.gpu.count=8",
923                "software.python=3.11",
924            ],
925            NodeState::Idle,
926            Some("us-east"),
927        ))
928        .unwrap();
929        // 0xB — h100 / us-east / busy
930        fold.apply(sign(
931            &kp,
932            0xB,
933            0x100,
934            &[
935                "hardware.gpu",
936                "hardware.gpu.h100",
937                "hardware.gpu.count=4",
938                "software.python=3.12",
939            ],
940            NodeState::Busy,
941            Some("us-east"),
942        ))
943        .unwrap();
944        // 0xC — a100 / us-west / idle
945        fold.apply(sign(
946            &kp,
947            0xC,
948            0x200,
949            &[
950                "hardware.gpu",
951                "hardware.gpu.a100",
952                "hardware.gpu.count=2",
953                "software.python=3.11",
954            ],
955            NodeState::Idle,
956            Some("us-west"),
957        ))
958        .unwrap();
959        fold
960    }
961
962    // ── TagMatcher variants ────────────────────────────────────
963
964    #[test]
965    fn matcher_exact_picks_only_exact_tag() {
966        let fold = populated_fold();
967        let rows = fold.aggregate(
968            Some(TagMatcher::Exact {
969                value: "software.python=3.11".into(),
970            }),
971            GroupBy::Publisher,
972            Aggregation::Count,
973        );
974        let publishers: Vec<&str> = rows.iter().map(|(b, _)| b.as_str()).collect();
975        assert_eq!(publishers, vec!["0xa", "0xc"]);
976    }
977
978    #[test]
979    fn matcher_prefix_picks_everything_under_the_prefix() {
980        let fold = populated_fold();
981        // Every entry has at least one `hardware.gpu*` tag → all three
982        // publishers match.
983        let rows = fold.aggregate(
984            Some(TagMatcher::Prefix {
985                value: "hardware.gpu".into(),
986            }),
987            GroupBy::Publisher,
988            Aggregation::Count,
989        );
990        assert_eq!(rows.len(), 3);
991    }
992
993    #[test]
994    fn matcher_axis_picks_every_entry_in_that_axis() {
995        let fold = populated_fold();
996        let rows = fold.aggregate(
997            Some(TagMatcher::Axis {
998                axis: TaxonomyAxis::Hardware,
999            }),
1000            GroupBy::Publisher,
1001            Aggregation::Count,
1002        );
1003        assert_eq!(rows.len(), 3, "every entry has a hardware.* tag");
1004    }
1005
1006    #[test]
1007    fn matcher_axis_key_picks_only_entries_with_that_key() {
1008        let fold = populated_fold();
1009        // (Hardware, "gpu.count") matches every entry — all three have
1010        // `hardware.gpu.count=N`.
1011        let rows = fold.aggregate(
1012            Some(TagMatcher::AxisKey {
1013                axis: TaxonomyAxis::Hardware,
1014                key: "gpu.count".into(),
1015            }),
1016            GroupBy::Publisher,
1017            Aggregation::Count,
1018        );
1019        assert_eq!(rows.len(), 3);
1020
1021        // (Software, "python") matches every entry too — `python=3.11`
1022        // / `python=3.12` are `AxisValue { key: "python", ... }`.
1023        let rows = fold.aggregate(
1024            Some(TagMatcher::AxisKey {
1025                axis: TaxonomyAxis::Software,
1026                key: "python".into(),
1027            }),
1028            GroupBy::Publisher,
1029            Aggregation::Count,
1030        );
1031        assert_eq!(rows.len(), 3);
1032
1033        // (Hardware, "nonexistent") matches none.
1034        let rows = fold.aggregate(
1035            Some(TagMatcher::AxisKey {
1036                axis: TaxonomyAxis::Hardware,
1037                key: "nonexistent".into(),
1038            }),
1039            GroupBy::Publisher,
1040            Aggregation::Count,
1041        );
1042        assert!(rows.is_empty());
1043    }
1044
1045    #[test]
1046    fn no_matcher_includes_every_entry() {
1047        let fold = populated_fold();
1048        let rows = fold.aggregate(None, GroupBy::Publisher, Aggregation::Count);
1049        assert_eq!(rows.len(), 3);
1050    }
1051
1052    // ── GroupBy variants ────────────────────────────────────────
1053
1054    #[test]
1055    fn group_by_class_buckets_by_class_hash() {
1056        let fold = populated_fold();
1057        let rows = fold.aggregate(None, GroupBy::Class, Aggregation::Count);
1058        assert_eq!(
1059            rows,
1060            vec![("0x100".to_string(), 2), ("0x200".to_string(), 1)]
1061        );
1062    }
1063
1064    #[test]
1065    fn group_by_state_buckets_idle_busy_reserved_faulty() {
1066        let fold = populated_fold();
1067        let rows = fold.aggregate(None, GroupBy::State, Aggregation::Count);
1068        assert_eq!(rows, vec![("busy".to_string(), 1), ("idle".to_string(), 2)]);
1069    }
1070
1071    #[test]
1072    fn group_by_region_renders_none_as_explicit_string() {
1073        let fold = populated_fold();
1074        let rows = fold.aggregate(None, GroupBy::Region, Aggregation::Count);
1075        assert_eq!(
1076            rows,
1077            vec![("us-east".to_string(), 2), ("us-west".to_string(), 1)]
1078        );
1079
1080        // Now add a region-less publisher.
1081        let kp = EntityKeypair::generate();
1082        fold.apply(sign(&kp, 0xD, 0x300, &[], NodeState::Idle, None))
1083            .unwrap();
1084        let rows = fold.aggregate(None, GroupBy::Region, Aggregation::Count);
1085        assert_eq!(
1086            rows,
1087            vec![
1088                ("(none)".to_string(), 1),
1089                ("us-east".to_string(), 2),
1090                ("us-west".to_string(), 1),
1091            ]
1092        );
1093    }
1094
1095    #[test]
1096    fn group_by_publisher_buckets_by_node_id_hex() {
1097        let fold = populated_fold();
1098        let rows = fold.aggregate(None, GroupBy::Publisher, Aggregation::Count);
1099        assert_eq!(
1100            rows,
1101            vec![
1102                ("0xa".to_string(), 1),
1103                ("0xb".to_string(), 1),
1104                ("0xc".to_string(), 1),
1105            ]
1106        );
1107    }
1108
1109    #[test]
1110    fn group_by_tag_stem_buckets_per_dotted_stem_after_prefix() {
1111        let fold = populated_fold();
1112        // `hardware.gpu` stems: h100 (2 publishers), a100 (1),
1113        // count (3 — every entry has a `hardware.gpu.count=N`),
1114        // plus the bare `hardware.gpu` becomes "(present)" for each.
1115        let rows = fold.aggregate(
1116            None,
1117            GroupBy::TagStem {
1118                prefix: "hardware.gpu".into(),
1119            },
1120            Aggregation::Count,
1121        );
1122        let map: HashMap<String, u64> = rows.into_iter().collect();
1123        assert_eq!(map.get("h100").copied(), Some(2));
1124        assert_eq!(map.get("a100").copied(), Some(1));
1125        assert_eq!(map.get("count").copied(), Some(3));
1126        assert_eq!(map.get("(present)").copied(), Some(3));
1127    }
1128
1129    #[test]
1130    fn group_by_tag_value_extracts_value_after_separator() {
1131        let fold = populated_fold();
1132        let rows = fold.aggregate(
1133            None,
1134            GroupBy::TagValue {
1135                axis: TaxonomyAxis::Software,
1136                key: "python".into(),
1137            },
1138            Aggregation::Count,
1139        );
1140        assert_eq!(rows, vec![("3.11".to_string(), 2), ("3.12".to_string(), 1)]);
1141    }
1142
1143    // ── Aggregation variants ───────────────────────────────────
1144
1145    #[test]
1146    fn aggregation_count_returns_entry_count_per_bucket() {
1147        let fold = populated_fold();
1148        let rows = fold.aggregate(None, GroupBy::Region, Aggregation::Count);
1149        assert_eq!(
1150            rows,
1151            vec![("us-east".to_string(), 2), ("us-west".to_string(), 1)]
1152        );
1153    }
1154
1155    #[test]
1156    fn aggregation_distinct_publishers_dedupes_per_bucket() {
1157        // Two entries from the SAME publisher in two classes; bucket
1158        // by region. `DistinctPublishers` should report 1 publisher
1159        // in that region, not 2.
1160        let fold = new_fold();
1161        let kp = EntityKeypair::generate();
1162        fold.apply(sign(&kp, 0xA, 0x100, &[], NodeState::Idle, Some("us-east")))
1163            .unwrap();
1164        fold.apply(sign(&kp, 0xA, 0x200, &[], NodeState::Idle, Some("us-east")))
1165            .unwrap();
1166        fold.apply(sign(&kp, 0xB, 0x100, &[], NodeState::Idle, Some("us-east")))
1167            .unwrap();
1168
1169        let by_count = fold.aggregate(None, GroupBy::Region, Aggregation::Count);
1170        assert_eq!(by_count, vec![("us-east".to_string(), 3)]);
1171
1172        let by_publishers = fold.aggregate(None, GroupBy::Region, Aggregation::DistinctPublishers);
1173        assert_eq!(by_publishers, vec![("us-east".to_string(), 2)]);
1174    }
1175
1176    #[test]
1177    fn aggregation_distinct_values_counts_unique_values_per_bucket() {
1178        let fold = populated_fold();
1179        // For each region, count distinct python versions.
1180        let rows = fold.aggregate(
1181            None,
1182            GroupBy::Region,
1183            Aggregation::DistinctValues {
1184                axis: TaxonomyAxis::Software,
1185                key: "python".into(),
1186            },
1187        );
1188        // us-east has 3.11 (0xA) + 3.12 (0xB) → 2 distinct.
1189        // us-west has 3.11 (0xC) → 1 distinct.
1190        assert_eq!(
1191            rows,
1192            vec![("us-east".to_string(), 2), ("us-west".to_string(), 1)]
1193        );
1194    }
1195
1196    // ── Composition ─────────────────────────────────────────────
1197
1198    #[test]
1199    fn matcher_narrows_before_grouping() {
1200        let fold = populated_fold();
1201        // Only h100 publishers, bucketed by region. 0xA + 0xB are both
1202        // h100 / us-east; 0xC is a100 / us-west and is filtered out.
1203        let rows = fold.aggregate(
1204            Some(TagMatcher::Exact {
1205                value: "hardware.gpu.h100".into(),
1206            }),
1207            GroupBy::Region,
1208            Aggregation::Count,
1209        );
1210        assert_eq!(rows, vec![("us-east".to_string(), 2)]);
1211    }
1212
1213    #[test]
1214    fn empty_fold_aggregates_to_empty_vec() {
1215        let fold = new_fold();
1216        let rows = fold.aggregate(None, GroupBy::Region, Aggregation::Count);
1217        assert!(rows.is_empty());
1218    }
1219
1220    #[test]
1221    fn matcher_that_excludes_everything_returns_empty() {
1222        let fold = populated_fold();
1223        let rows = fold.aggregate(
1224            Some(TagMatcher::Exact {
1225                value: "nope".into(),
1226            }),
1227            GroupBy::Region,
1228            Aggregation::Count,
1229        );
1230        assert!(rows.is_empty());
1231    }
1232
1233    // ── Helpers ─────────────────────────────────────────────────
1234
1235    #[test]
1236    fn tag_stem_after_handles_bare_presence_form() {
1237        assert_eq!(
1238            tag_stem_after("hardware.gpu", "hardware.gpu"),
1239            Some("(present)".to_string())
1240        );
1241    }
1242
1243    #[test]
1244    fn tag_stem_after_extracts_segment_up_to_next_separator() {
1245        assert_eq!(
1246            tag_stem_after("hardware.gpu.h100", "hardware.gpu"),
1247            Some("h100".to_string())
1248        );
1249        assert_eq!(
1250            tag_stem_after("hardware.gpu.vram_gb=80", "hardware.gpu"),
1251            Some("vram_gb".to_string())
1252        );
1253        assert_eq!(
1254            tag_stem_after("hardware.gpu.count:8", "hardware.gpu"),
1255            Some("count".to_string())
1256        );
1257    }
1258
1259    #[test]
1260    fn tag_stem_after_returns_none_for_non_matching_tag() {
1261        assert_eq!(tag_stem_after("software.python=3.11", "hardware.gpu"), None);
1262    }
1263
1264    // ── 6c-B: SumNumericTag aggregation ────────────────────────
1265
1266    #[test]
1267    fn aggregation_sum_numeric_tag_sums_parseable_values() {
1268        let fold = populated_fold();
1269        // For each region, sum the `hardware.gpu.count` value across
1270        // entries. us-east has 8 + 4 = 12; us-west has 2.
1271        let rows = fold.aggregate(
1272            None,
1273            GroupBy::Region,
1274            Aggregation::SumNumericTag {
1275                axis_key: "hardware.gpu.count".into(),
1276            },
1277        );
1278        assert_eq!(
1279            rows,
1280            vec![("us-east".to_string(), 12), ("us-west".to_string(), 2)]
1281        );
1282    }
1283
1284    #[test]
1285    fn aggregation_sum_numeric_tag_skips_unparseable_and_missing() {
1286        let fold = new_fold();
1287        let kp = EntityKeypair::generate();
1288        // 0xA: parseable count.
1289        fold.apply(sign(
1290            &kp,
1291            0xA,
1292            0x100,
1293            &["hardware.gpu.count=8"],
1294            NodeState::Idle,
1295            Some("r1"),
1296        ))
1297        .unwrap();
1298        // 0xB: unparseable value (matches the (axis, key) but not numeric).
1299        fold.apply(sign(
1300            &kp,
1301            0xB,
1302            0x100,
1303            &["hardware.gpu.count=not-a-number"],
1304            NodeState::Idle,
1305            Some("r1"),
1306        ))
1307        .unwrap();
1308        // 0xC: doesn't carry the tag at all.
1309        fold.apply(sign(
1310            &kp,
1311            0xC,
1312            0x100,
1313            &["hardware.gpu"],
1314            NodeState::Idle,
1315            Some("r1"),
1316        ))
1317        .unwrap();
1318
1319        let rows = fold.aggregate(
1320            None,
1321            GroupBy::Region,
1322            Aggregation::SumNumericTag {
1323                axis_key: "hardware.gpu.count".into(),
1324            },
1325        );
1326        assert_eq!(rows, vec![("r1".to_string(), 8)]);
1327    }
1328
1329    // ── 6c-B: capacity_ranking ─────────────────────────────────
1330
1331    /// `rtt_lookup` stub: returns the same RTT for every node, or
1332    /// `None` for nodes not in the map.
1333    fn rtt_map(entries: &[(NodeId, u32)]) -> impl Fn(NodeId) -> Option<u32> + '_ {
1334        move |id| entries.iter().find(|(n, _)| *n == id).map(|(_, r)| *r)
1335    }
1336
1337    #[test]
1338    fn capacity_ranking_breaks_down_state_per_bucket() {
1339        let fold = populated_fold();
1340        // No matcher, group by region, no RTT filter, no sum_axis_key.
1341        // us-east: 0xA idle + 0xB busy. us-west: 0xC idle.
1342        let rows = fold.capacity_ranking(
1343            CapacityQuery {
1344                group_by: GroupBy::Region,
1345                ..CapacityQuery::default()
1346            },
1347            |_| None,
1348        );
1349        // Sort by available descending: us-east(2) before us-west(1).
1350        assert_eq!(rows.len(), 2);
1351        assert_eq!(rows[0].bucket, "us-east");
1352        assert_eq!(rows[0].idle, 1);
1353        assert_eq!(rows[0].busy, 1);
1354        assert_eq!(rows[0].reserved, 0);
1355        assert_eq!(rows[0].available, 2);
1356        assert_eq!(rows[0].summed_capacity, None);
1357        assert_eq!(rows[1].bucket, "us-west");
1358        assert_eq!(rows[1].idle, 1);
1359        assert_eq!(rows[1].available, 1);
1360    }
1361
1362    #[test]
1363    fn capacity_ranking_excludes_faulty_entries() {
1364        let fold = populated_fold();
1365        let kp = EntityKeypair::generate();
1366        fold.apply(sign(
1367            &kp,
1368            0xD,
1369            0x100,
1370            &["hardware.gpu"],
1371            NodeState::Faulty,
1372            Some("us-east"),
1373        ))
1374        .unwrap();
1375        // us-east still 2 available (the faulty entry doesn't bump it).
1376        let rows = fold.capacity_ranking(
1377            CapacityQuery {
1378                group_by: GroupBy::Region,
1379                ..CapacityQuery::default()
1380            },
1381            |_| None,
1382        );
1383        let east = rows.iter().find(|r| r.bucket == "us-east").unwrap();
1384        assert_eq!(east.available, 2);
1385    }
1386
1387    #[test]
1388    fn capacity_ranking_honors_max_rtt_ms() {
1389        let fold = populated_fold();
1390        // 0xA = 10ms, 0xB = 50ms, 0xC = 200ms.
1391        let lookup = rtt_map(&[(0xA, 10), (0xB, 50), (0xC, 200)]);
1392        // max=100ms admits 0xA + 0xB but not 0xC.
1393        let rows = fold.capacity_ranking(
1394            CapacityQuery {
1395                group_by: GroupBy::Region,
1396                max_rtt_ms: Some(100),
1397                ..CapacityQuery::default()
1398            },
1399            &lookup,
1400        );
1401        // Only us-east contributes (0xA + 0xB), and us-west is dropped.
1402        assert_eq!(rows.len(), 1);
1403        assert_eq!(rows[0].bucket, "us-east");
1404        assert_eq!(rows[0].available, 2);
1405    }
1406
1407    #[test]
1408    fn capacity_ranking_drops_publishers_with_unknown_rtt_when_filter_set() {
1409        let fold = populated_fold();
1410        // Only 0xA has a known RTT; 0xB and 0xC are unknown → dropped.
1411        let lookup = rtt_map(&[(0xA, 10)]);
1412        let rows = fold.capacity_ranking(
1413            CapacityQuery {
1414                group_by: GroupBy::Region,
1415                max_rtt_ms: Some(100),
1416                ..CapacityQuery::default()
1417            },
1418            &lookup,
1419        );
1420        assert_eq!(rows.len(), 1);
1421        assert_eq!(rows[0].bucket, "us-east");
1422        assert_eq!(rows[0].available, 1, "only 0xA survived; 0xB unknown");
1423    }
1424
1425    #[test]
1426    fn capacity_ranking_no_rtt_filter_skips_lookup() {
1427        let fold = populated_fold();
1428        // Lookup should not be invoked at all when max_rtt_ms is None.
1429        let calls = std::cell::Cell::new(0u32);
1430        let rows = fold.capacity_ranking(
1431            CapacityQuery {
1432                group_by: GroupBy::Region,
1433                ..CapacityQuery::default()
1434            },
1435            |_| {
1436                calls.set(calls.get() + 1);
1437                Some(0)
1438            },
1439        );
1440        assert_eq!(calls.get(), 0);
1441        assert_eq!(rows.len(), 2);
1442    }
1443
1444    #[test]
1445    fn capacity_ranking_sum_axis_key_aggregates_per_bucket() {
1446        let fold = populated_fold();
1447        let rows = fold.capacity_ranking(
1448            CapacityQuery {
1449                group_by: GroupBy::Region,
1450                sum_axis_key: Some("hardware.gpu.count".into()),
1451                ..CapacityQuery::default()
1452            },
1453            |_| None,
1454        );
1455        let east = rows.iter().find(|r| r.bucket == "us-east").unwrap();
1456        let west = rows.iter().find(|r| r.bucket == "us-west").unwrap();
1457        assert_eq!(east.summed_capacity, Some(12), "0xA=8 + 0xB=4");
1458        assert_eq!(west.summed_capacity, Some(2), "0xC=2");
1459    }
1460
1461    #[test]
1462    fn capacity_ranking_sum_axis_key_unset_keeps_field_none() {
1463        let fold = populated_fold();
1464        let rows = fold.capacity_ranking(
1465            CapacityQuery {
1466                group_by: GroupBy::Region,
1467                ..CapacityQuery::default()
1468            },
1469            |_| None,
1470        );
1471        for row in &rows {
1472            assert_eq!(row.summed_capacity, None);
1473        }
1474    }
1475
1476    #[test]
1477    fn capacity_ranking_sorts_by_available_descending_then_bucket_ascending() {
1478        let fold = new_fold();
1479        let kp = EntityKeypair::generate();
1480        // Three regions, populating different counts: us-east=3,
1481        // us-west=1, eu-west=3 (same available as us-east; tie-break
1482        // on bucket ascending puts eu-west first).
1483        for nid in [1u64, 2, 3] {
1484            fold.apply(sign(&kp, nid, 0x100, &[], NodeState::Idle, Some("us-east")))
1485                .unwrap();
1486        }
1487        fold.apply(sign(&kp, 10, 0x100, &[], NodeState::Idle, Some("us-west")))
1488            .unwrap();
1489        for nid in [100u64, 101, 102] {
1490            fold.apply(sign(&kp, nid, 0x100, &[], NodeState::Idle, Some("eu-west")))
1491                .unwrap();
1492        }
1493        let rows = fold.capacity_ranking(
1494            CapacityQuery {
1495                group_by: GroupBy::Region,
1496                ..CapacityQuery::default()
1497            },
1498            |_| None,
1499        );
1500        let buckets: Vec<&str> = rows.iter().map(|r| r.bucket.as_str()).collect();
1501        assert_eq!(buckets, vec!["eu-west", "us-east", "us-west"]);
1502    }
1503
1504    #[test]
1505    fn capacity_ranking_truncates_to_limit() {
1506        let fold = new_fold();
1507        let kp = EntityKeypair::generate();
1508        for nid in 1u64..=10 {
1509            fold.apply(sign(
1510                &kp,
1511                nid,
1512                0x100,
1513                &[],
1514                NodeState::Idle,
1515                Some(&format!("region-{}", nid % 5)),
1516            ))
1517            .unwrap();
1518        }
1519        let rows = fold.capacity_ranking(
1520            CapacityQuery {
1521                group_by: GroupBy::Region,
1522                limit: 3,
1523                ..CapacityQuery::default()
1524            },
1525            |_| None,
1526        );
1527        assert_eq!(rows.len(), 3);
1528    }
1529
1530    #[test]
1531    fn capacity_ranking_matcher_narrows_before_state_breakdown() {
1532        let fold = populated_fold();
1533        // Only h100 publishers (0xA idle + 0xB busy).
1534        let rows = fold.capacity_ranking(
1535            CapacityQuery {
1536                matcher: Some(TagMatcher::Exact {
1537                    value: "hardware.gpu.h100".into(),
1538                }),
1539                group_by: GroupBy::Region,
1540                ..CapacityQuery::default()
1541            },
1542            |_| None,
1543        );
1544        assert_eq!(rows.len(), 1);
1545        assert_eq!(rows[0].bucket, "us-east");
1546        assert_eq!(rows[0].idle, 1);
1547        assert_eq!(rows[0].busy, 1);
1548        assert_eq!(rows[0].available, 2);
1549    }
1550
1551    /// Test-only helper: dotted-axis-key wrapper around
1552    /// `numeric_value_for_split`. Exists so test cases can keep
1553    /// using the same wire-shape strings the FFI callers pass in,
1554    /// even though the hot-loop production code now hoists the
1555    /// `split_axis_key` step outside the per-tag loop.
1556    fn numeric_value_for(raw: &str, want_axis_key: &str) -> Option<u64> {
1557        let (axis, key) = split_axis_key(want_axis_key)?;
1558        numeric_value_for_split(raw, axis, key)
1559    }
1560
1561    #[test]
1562    fn numeric_value_for_parses_axis_value_tag() {
1563        assert_eq!(
1564            numeric_value_for("hardware.gpu.count=8", "hardware.gpu.count"),
1565            Some(8)
1566        );
1567        assert_eq!(
1568            numeric_value_for("hardware.gpu.count=garbage", "hardware.gpu.count"),
1569            None
1570        );
1571        assert_eq!(
1572            numeric_value_for("hardware.gpu", "hardware.gpu.count"),
1573            None
1574        );
1575        assert_eq!(
1576            numeric_value_for("software.python=3.11", "hardware.gpu.count"),
1577            None
1578        );
1579    }
1580
1581    #[test]
1582    fn numeric_value_for_rejects_malformed_axis_key() {
1583        // Pre-split helper would now return None at `split_axis_key`;
1584        // pin both failure modes here so future moves don't silently
1585        // drop one branch.
1586        assert_eq!(numeric_value_for("hardware.gpu.count=8", "no-dot"), None);
1587        assert_eq!(
1588            numeric_value_for("hardware.gpu.count=8", "unknown.count"),
1589            None
1590        );
1591    }
1592
1593    // ── 6c-C: TagMatcher::Regex ────────────────────────────────
1594
1595    #[cfg(feature = "regex")]
1596    #[test]
1597    fn matcher_regex_matches_pattern_against_canonical_form() {
1598        let fold = populated_fold();
1599        let rows = fold.aggregate(
1600            // h100 OR a100 (literal dots — these are tag stems, not
1601            // regex metachars in the user's mental model).
1602            Some(TagMatcher::Regex {
1603                pattern: r"^hardware\.gpu\.(h100|a100)$".into(),
1604            }),
1605            GroupBy::Publisher,
1606            Aggregation::Count,
1607        );
1608        // All three publishers carry either `hardware.gpu.h100` or
1609        // `hardware.gpu.a100`.
1610        assert_eq!(rows.len(), 3);
1611    }
1612
1613    #[cfg(feature = "regex")]
1614    #[test]
1615    fn matcher_regex_with_invalid_pattern_matches_nothing() {
1616        let fold = populated_fold();
1617        // Unclosed character class — invalid pattern.
1618        let rows = fold.aggregate(
1619            Some(TagMatcher::Regex {
1620                pattern: r"[unclosed".into(),
1621            }),
1622            GroupBy::Publisher,
1623            Aggregation::Count,
1624        );
1625        assert!(rows.is_empty(), "invalid regex must reject everything");
1626    }
1627
1628    #[cfg(not(feature = "regex"))]
1629    #[test]
1630    fn matcher_regex_without_feature_validate_returns_explicit_error() {
1631        let matcher = TagMatcher::Regex {
1632            pattern: r"^hardware\.gpu".into(),
1633        };
1634        let err = matcher
1635            .validate()
1636            .expect_err("validate must surface RegexNotBuiltIn without the regex feature");
1637        match err {
1638            TagMatcherError::RegexNotBuiltIn { pattern } => {
1639                assert_eq!(pattern, r"^hardware\.gpu");
1640            }
1641        }
1642    }
1643
1644    #[cfg(not(feature = "regex"))]
1645    #[test]
1646    #[should_panic(expected = "requires the `regex` Cargo feature")]
1647    fn matcher_regex_without_feature_aggregate_panics_with_actionable_message() {
1648        let fold = populated_fold();
1649        // Caller skipped `validate()` — `aggregate` must surface the
1650        // build-time misconfiguration as a panic rather than a silent
1651        // empty result.
1652        let _ = fold.aggregate(
1653            Some(TagMatcher::Regex {
1654                pattern: r"^hardware\.gpu".into(),
1655            }),
1656            GroupBy::Publisher,
1657            Aggregation::Count,
1658        );
1659    }
1660
1661    // ── 6c-C: TagMatcher::VersionRange ─────────────────────────
1662
1663    #[test]
1664    fn matcher_version_range_picks_entries_within_inclusive_bounds() {
1665        // Canonical 3-component versions only — `semver::Version::parse`
1666        // rejects 2-component "3.11" so the matcher would silently
1667        // skip those; the sibling `..._skips_unparseable_values` test
1668        // pins that branch.
1669        let fold = new_fold();
1670        let kp = EntityKeypair::generate();
1671        for (node_id, value) in [(0xA, "3.11.0"), (0xB, "3.12.0"), (0xC, "3.11.0")] {
1672            fold.apply(sign(
1673                &kp,
1674                node_id,
1675                0x100,
1676                &[&format!("software.python={value}")],
1677                NodeState::Idle,
1678                None,
1679            ))
1680            .unwrap();
1681        }
1682        let rows = fold.aggregate(
1683            Some(TagMatcher::VersionRange {
1684                axis_key: "software.python".into(),
1685                min: Some("3.11.0".into()),
1686                max: Some("3.11.0".into()),
1687            }),
1688            GroupBy::Publisher,
1689            Aggregation::Count,
1690        );
1691        let mut publishers: Vec<&str> = rows.iter().map(|(b, _)| b.as_str()).collect();
1692        publishers.sort_unstable();
1693        assert_eq!(publishers, vec!["0xa", "0xc"]);
1694    }
1695
1696    #[test]
1697    fn matcher_version_range_handles_unbounded_min_or_max() {
1698        let fold = new_fold();
1699        let kp = EntityKeypair::generate();
1700        fold.apply(sign(
1701            &kp,
1702            0xA,
1703            0x100,
1704            &["software.runtime=1.0.0"],
1705            NodeState::Idle,
1706            None,
1707        ))
1708        .unwrap();
1709        fold.apply(sign(
1710            &kp,
1711            0xB,
1712            0x100,
1713            &["software.runtime=2.5.0"],
1714            NodeState::Idle,
1715            None,
1716        ))
1717        .unwrap();
1718        fold.apply(sign(
1719            &kp,
1720            0xC,
1721            0x100,
1722            &["software.runtime=3.10.0"],
1723            NodeState::Idle,
1724            None,
1725        ))
1726        .unwrap();
1727
1728        // No min, max=2.5.0 → admits 0xA + 0xB.
1729        let rows = fold.aggregate(
1730            Some(TagMatcher::VersionRange {
1731                axis_key: "software.runtime".into(),
1732                min: None,
1733                max: Some("2.5.0".into()),
1734            }),
1735            GroupBy::Publisher,
1736            Aggregation::Count,
1737        );
1738        assert_eq!(rows.len(), 2);
1739
1740        // min=2.5.0, no max → admits 0xB + 0xC.
1741        let rows = fold.aggregate(
1742            Some(TagMatcher::VersionRange {
1743                axis_key: "software.runtime".into(),
1744                min: Some("2.5.0".into()),
1745                max: None,
1746            }),
1747            GroupBy::Publisher,
1748            Aggregation::Count,
1749        );
1750        assert_eq!(rows.len(), 2);
1751
1752        // No bounds at all → admits everything matching the axis-key.
1753        let rows = fold.aggregate(
1754            Some(TagMatcher::VersionRange {
1755                axis_key: "software.runtime".into(),
1756                min: None,
1757                max: None,
1758            }),
1759            GroupBy::Publisher,
1760            Aggregation::Count,
1761        );
1762        assert_eq!(rows.len(), 3);
1763    }
1764
1765    #[test]
1766    fn matcher_version_range_skips_unparseable_values() {
1767        let fold = new_fold();
1768        let kp = EntityKeypair::generate();
1769        fold.apply(sign(
1770            &kp,
1771            0xA,
1772            0x100,
1773            &["software.runtime=not-a-version"],
1774            NodeState::Idle,
1775            None,
1776        ))
1777        .unwrap();
1778        let rows = fold.aggregate(
1779            Some(TagMatcher::VersionRange {
1780                axis_key: "software.runtime".into(),
1781                min: None,
1782                max: None,
1783            }),
1784            GroupBy::Publisher,
1785            Aggregation::Count,
1786        );
1787        assert!(rows.is_empty(), "unparseable values must be skipped");
1788    }
1789
1790    #[test]
1791    fn matcher_version_range_with_unknown_axis_prefix_matches_nothing() {
1792        // Compiled matcher's `MatchesNothing` arm: an axis_key whose
1793        // axis prefix doesn't resolve through `TaxonomyAxis::from_prefix`
1794        // must reject every entry, not panic.
1795        let fold = populated_fold();
1796        let rows = fold.aggregate(
1797            Some(TagMatcher::VersionRange {
1798                axis_key: "garbage.runtime".into(),
1799                min: None,
1800                max: None,
1801            }),
1802            GroupBy::Publisher,
1803            Aggregation::Count,
1804        );
1805        assert!(rows.is_empty());
1806
1807        // Also covers the "no dot in axis_key" path.
1808        let rows = fold.aggregate(
1809            Some(TagMatcher::VersionRange {
1810                axis_key: "no-dot-anywhere".into(),
1811                min: None,
1812                max: None,
1813            }),
1814            GroupBy::Publisher,
1815            Aggregation::Count,
1816        );
1817        assert!(rows.is_empty());
1818    }
1819
1820    // ── 6c-C: Min/MaxNumericTag ────────────────────────────────
1821
1822    #[test]
1823    fn aggregation_min_max_numeric_tag_per_bucket() {
1824        let fold = populated_fold();
1825        // us-east: counts 8 (0xA) + 4 (0xB) → min=4, max=8.
1826        // us-west: count 2 (0xC) → min=2, max=2.
1827        let mins = fold.aggregate(
1828            None,
1829            GroupBy::Region,
1830            Aggregation::MinNumericTag {
1831                axis_key: "hardware.gpu.count".into(),
1832            },
1833        );
1834        assert_eq!(
1835            mins,
1836            vec![("us-east".to_string(), 4), ("us-west".to_string(), 2)]
1837        );
1838        let maxes = fold.aggregate(
1839            None,
1840            GroupBy::Region,
1841            Aggregation::MaxNumericTag {
1842                axis_key: "hardware.gpu.count".into(),
1843            },
1844        );
1845        assert_eq!(
1846            maxes,
1847            vec![("us-east".to_string(), 8), ("us-west".to_string(), 2)]
1848        );
1849    }
1850
1851    /// Pin the wire-format JSON shape for cross-binding parity.
1852    /// Bindings (TS, Python, Go, C) encode + decode this exact
1853    /// shape, so an update to either the field names or the
1854    /// `kind` discriminants needs to land in lockstep across
1855    /// every binding. The test serializes one example of every
1856    /// variant and asserts the byte form is what the bindings
1857    /// expect.
1858    #[test]
1859    fn serde_shapes_match_cross_binding_wire_format() {
1860        assert_eq!(
1861            serde_json::to_string(&TagMatcher::Exact {
1862                value: "software.python=3.11".into()
1863            })
1864            .unwrap(),
1865            r#"{"kind":"exact","value":"software.python=3.11"}"#,
1866        );
1867        assert_eq!(
1868            serde_json::to_string(&TagMatcher::Prefix {
1869                value: "hardware.gpu".into()
1870            })
1871            .unwrap(),
1872            r#"{"kind":"prefix","value":"hardware.gpu"}"#,
1873        );
1874        assert_eq!(
1875            serde_json::to_string(&TagMatcher::Axis {
1876                axis: TaxonomyAxis::Hardware
1877            })
1878            .unwrap(),
1879            r#"{"kind":"axis","axis":"hardware"}"#,
1880        );
1881        assert_eq!(
1882            serde_json::to_string(&TagMatcher::AxisKey {
1883                axis: TaxonomyAxis::Hardware,
1884                key: "gpu.count".into()
1885            })
1886            .unwrap(),
1887            r#"{"kind":"axis_key","axis":"hardware","key":"gpu.count"}"#,
1888        );
1889        assert_eq!(
1890            serde_json::to_string(&TagMatcher::Regex {
1891                pattern: "^a$".into()
1892            })
1893            .unwrap(),
1894            r#"{"kind":"regex","pattern":"^a$"}"#,
1895        );
1896        assert_eq!(
1897            serde_json::to_string(&TagMatcher::VersionRange {
1898                axis_key: "software.python".into(),
1899                min: Some("3.10.0".into()),
1900                max: None
1901            })
1902            .unwrap(),
1903            r#"{"kind":"version_range","axis_key":"software.python","min":"3.10.0","max":null}"#,
1904        );
1905
1906        assert_eq!(
1907            serde_json::to_string(&GroupBy::Class).unwrap(),
1908            r#"{"kind":"class"}"#,
1909        );
1910        assert_eq!(
1911            serde_json::to_string(&GroupBy::TagStem {
1912                prefix: "hardware.gpu".into()
1913            })
1914            .unwrap(),
1915            r#"{"kind":"tag_stem","prefix":"hardware.gpu"}"#,
1916        );
1917        assert_eq!(
1918            serde_json::to_string(&GroupBy::TagValue {
1919                axis: TaxonomyAxis::Software,
1920                key: "python".into()
1921            })
1922            .unwrap(),
1923            r#"{"kind":"tag_value","axis":"software","key":"python"}"#,
1924        );
1925
1926        assert_eq!(
1927            serde_json::to_string(&Aggregation::Count).unwrap(),
1928            r#"{"kind":"count"}"#,
1929        );
1930        assert_eq!(
1931            serde_json::to_string(&Aggregation::SumNumericTag {
1932                axis_key: "hardware.gpu.count".into()
1933            })
1934            .unwrap(),
1935            r#"{"kind":"sum_numeric_tag","axis_key":"hardware.gpu.count"}"#,
1936        );
1937
1938        // Round-trip the full query.
1939        let q = CapacityQuery {
1940            matcher: Some(TagMatcher::Prefix {
1941                value: "hardware.gpu".into(),
1942            }),
1943            group_by: GroupBy::TagStem {
1944                prefix: "hardware.gpu".into(),
1945            },
1946            max_rtt_ms: Some(50),
1947            sum_axis_key: Some("hardware.gpu.count".into()),
1948            limit: 5,
1949        };
1950        let s = serde_json::to_string(&q).unwrap();
1951        let back: CapacityQuery = serde_json::from_str(&s).unwrap();
1952        assert_eq!(q, back);
1953    }
1954
1955    #[test]
1956    fn aggregation_min_max_numeric_tag_returns_zero_for_buckets_with_no_values() {
1957        let fold = new_fold();
1958        let kp = EntityKeypair::generate();
1959        // No `hardware.gpu.count` tag on this entry.
1960        fold.apply(sign(
1961            &kp,
1962            0xA,
1963            0x100,
1964            &["hardware.gpu"],
1965            NodeState::Idle,
1966            Some("r1"),
1967        ))
1968        .unwrap();
1969        let rows = fold.aggregate(
1970            None,
1971            GroupBy::Region,
1972            Aggregation::MinNumericTag {
1973                axis_key: "hardware.gpu.count".into(),
1974            },
1975        );
1976        assert_eq!(
1977            rows,
1978            vec![("r1".to_string(), 0)],
1979            "no parseable values in bucket → 0 (per Min/MaxNumericTag doc)",
1980        );
1981    }
1982}