1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(tag = "kind", rename_all = "snake_case")]
33pub enum TagMatcher {
34 Exact {
37 value: String,
39 },
40 Prefix {
44 value: String,
46 },
47 Axis {
50 axis: TaxonomyAxis,
52 },
53 AxisKey {
58 axis: TaxonomyAxis,
60 key: String,
63 },
64 Regex {
80 pattern: String,
82 },
83 VersionRange {
92 axis_key: String,
95 min: Option<String>,
97 max: Option<String>,
99 },
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
111pub enum TagMatcherError {
112 RegexNotBuiltIn {
116 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 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 pub fn matches_any(&self, tags: &[String]) -> bool {
179 self.compile().matches_any(tags)
180 }
181
182 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 #[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
232enum 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
323#[serde(tag = "kind", rename_all = "snake_case")]
324pub enum GroupBy {
325 #[default]
327 Class,
328 State,
331 Region,
333 Publisher,
335 TagStem {
343 prefix: String,
346 },
347 TagValue {
351 axis: TaxonomyAxis,
353 key: String,
356 },
357}
358
359impl GroupBy {
360 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
399#[serde(tag = "kind", rename_all = "snake_case")]
400pub enum Aggregation {
401 Count,
403 DistinctPublishers,
408 DistinctValues {
413 axis: TaxonomyAxis,
415 key: String,
417 },
418 SumNumericTag {
426 axis_key: String,
428 },
429 MinNumericTag {
436 axis_key: String,
438 },
439 MaxNumericTag {
443 axis_key: String,
445 },
446}
447
448#[derive(Clone, Copy)]
453enum CompiledAgg<'a> {
454 Count,
455 DistinctPublishers,
456 DistinctValues {
457 axis: TaxonomyAxis,
458 key: &'a str,
459 },
460 Numeric {
464 axis: TaxonomyAxis,
465 key: &'a str,
466 },
467 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 pub fn aggregate(
504 &self,
505 matcher: Option<TagMatcher>,
506 group_by: GroupBy,
507 agg: Aggregation,
508 ) -> Vec<(String, u64)> {
509 let mut buckets: HashMap<String, BucketAccum> = HashMap::new();
514 let compiled = matcher.as_ref().map(TagMatcher::compile);
515 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 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 pub fn capacity_ranking<R>(&self, query: CapacityQuery, rtt_lookup: R) -> Vec<CapacityRow>
603 where
604 R: Fn(NodeId) -> Option<u32>,
605 {
606 let mut buckets: HashMap<String, CapacityAccum> = HashMap::new();
610 let compiled_matcher = query.matcher.as_ref().map(TagMatcher::compile);
611 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 if membership.state == NodeState::Faulty {
625 continue;
626 }
627
628 if let Some(m) = &compiled_matcher {
630 if !m.matches_any(&membership.tags) {
631 continue;
632 }
633 }
634
635 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
714pub struct CapacityQuery {
715 pub matcher: Option<TagMatcher>,
718 pub group_by: GroupBy,
720 pub max_rtt_ms: Option<u32>,
723 pub sum_axis_key: Option<String>,
728 pub limit: usize,
731}
732
733#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
735pub struct CapacityRow {
736 pub bucket: String,
738 pub idle: u64,
740 pub busy: u64,
742 pub reserved: u64,
744 pub available: u64,
748 pub summed_capacity: Option<u64>,
752}
753
754#[derive(Default)]
755struct BucketAccum {
756 count: u64,
761 publishers: HashSet<NodeId>,
765 distinct_values: HashSet<String>,
768 numeric_sum: u64,
771 numeric_min: Option<u64>,
776 numeric_max: Option<u64>,
779}
780
781#[derive(Default)]
782struct CapacityAccum {
783 idle: u64,
784 busy: u64,
785 reserved: u64,
786 summed_capacity: Option<u64>,
790}
791
792fn 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
803fn tag_stem_after(tag: &str, prefix: &str) -> Option<String> {
811 let rest = tag.strip_prefix(prefix)?;
812 if rest.is_empty() {
813 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
826fn 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
839fn numeric_value_for_split(raw: &str, axis: TaxonomyAxis, key: &str) -> Option<u64> {
848 axis_value_for(raw, axis, key)?.parse::<u64>().ok()
849}
850
851fn 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#[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 let fold = new_fold();
913 let kp = EntityKeypair::generate();
914 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 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 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 #[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 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 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 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 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 #[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 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 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 #[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 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 let rows = fold.aggregate(
1181 None,
1182 GroupBy::Region,
1183 Aggregation::DistinctValues {
1184 axis: TaxonomyAxis::Software,
1185 key: "python".into(),
1186 },
1187 );
1188 assert_eq!(
1191 rows,
1192 vec![("us-east".to_string(), 2), ("us-west".to_string(), 1)]
1193 );
1194 }
1195
1196 #[test]
1199 fn matcher_narrows_before_grouping() {
1200 let fold = populated_fold();
1201 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 #[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 #[test]
1267 fn aggregation_sum_numeric_tag_sums_parseable_values() {
1268 let fold = populated_fold();
1269 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 fold.apply(sign(
1290 &kp,
1291 0xA,
1292 0x100,
1293 &["hardware.gpu.count=8"],
1294 NodeState::Idle,
1295 Some("r1"),
1296 ))
1297 .unwrap();
1298 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 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 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 let rows = fold.capacity_ranking(
1343 CapacityQuery {
1344 group_by: GroupBy::Region,
1345 ..CapacityQuery::default()
1346 },
1347 |_| None,
1348 );
1349 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 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 let lookup = rtt_map(&[(0xA, 10), (0xB, 50), (0xC, 200)]);
1392 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 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 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 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 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 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 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 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 #[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 Some(TagMatcher::Regex {
1603 pattern: r"^hardware\.gpu\.(h100|a100)$".into(),
1604 }),
1605 GroupBy::Publisher,
1606 Aggregation::Count,
1607 );
1608 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 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 let _ = fold.aggregate(
1653 Some(TagMatcher::Regex {
1654 pattern: r"^hardware\.gpu".into(),
1655 }),
1656 GroupBy::Publisher,
1657 Aggregation::Count,
1658 );
1659 }
1660
1661 #[test]
1664 fn matcher_version_range_picks_entries_within_inclusive_bounds() {
1665 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 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 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 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 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 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 #[test]
1823 fn aggregation_min_max_numeric_tag_per_bucket() {
1824 let fold = populated_fold();
1825 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 #[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 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 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}