Skip to main content

zenkey_fleet/model/
skeleton.rs

1//! The declared keyspace, built without subscribing to anything (issue #84).
2//!
3//! A Zenoh subscription cannot be "metadata only" — once declared, payloads
4//! flow. So a lazy explorer's tree starts from what costs (almost) nothing:
5//!
6//! - **registry slices** → the *declared* subject families per producer,
7//!   with `{var}` positions kept symbolic;
8//! - **the liveliness roster** → which origins run which producers
9//!   (zero-payload by construction, RFC 04 §5);
10//! - **admin-space declared entities** ([`crate::declared_entities`]) →
11//!   keyexprs sessions actually declared, when an admin space answers at all
12//!   (`adminspace.enabled` defaults to false — absence is "not available",
13//!   never "nothing declared").
14//!
15//! [`merge`] then folds the skeleton with the *observed* tree
16//! ([`crate::KeyTreeSnapshot`]) and the active watch set into one tree with a
17//! typed per-node [`NodeStatus`] — the acceptance criterion of RFC 09 §5.1
18//! O4/O5 applied to a tree: "declared, never seen" and "watched, quiet" are
19//! different facts and must be different *types*, not rendering conventions.
20
21use std::collections::BTreeMap;
22
23use zenoh::key_expr::keyexpr;
24
25use crate::model::registry::SliceSet;
26use crate::model::tree::{KeyTreeSnapshot, TreeNode};
27
28/// One skeleton chunk: concrete, or a declared variable kept symbolic.
29///
30/// The display form of a variable is `{name}` — `{` sorts after the
31/// alphanumerics, so variables naturally list after their literal siblings in
32/// a `BTreeMap<String, _>`.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum SkeletonChunk {
35    Literal(String),
36    /// `{var}` — exactly one chunk, member set unknown.
37    Var(String),
38    /// `{var...}` — the whole remaining tail (RFC 03 §1.4's rest-variable).
39    Rest(String),
40}
41
42impl SkeletonChunk {
43    fn parse(chunk: &str) -> SkeletonChunk {
44        if let Some(inner) = chunk.strip_prefix('{').and_then(|c| c.strip_suffix("...}")) {
45            SkeletonChunk::Rest(inner.to_string())
46        } else if let Some(inner) = chunk.strip_prefix('{').and_then(|c| c.strip_suffix('}')) {
47            SkeletonChunk::Var(inner.to_string())
48        } else {
49            SkeletonChunk::Literal(chunk.to_string())
50        }
51    }
52
53    /// The display key (and `BTreeMap` key) for this chunk.
54    pub fn display(&self) -> String {
55        match self {
56            SkeletonChunk::Literal(s) => s.clone(),
57            SkeletonChunk::Var(v) => format!("{{{v}}}"),
58            SkeletonChunk::Rest(v) => format!("{{{v}...}}"),
59        }
60    }
61
62    /// The selector chunk this position contributes when testing watch
63    /// coverage: a variable is any-one-chunk, a rest is any-tail.
64    fn selector_chunk(&self) -> &str {
65        match self {
66            SkeletonChunk::Literal(s) => s,
67            SkeletonChunk::Var(_) => "*",
68            SkeletonChunk::Rest(_) => "**",
69        }
70    }
71}
72
73/// Why we believe a node exists. At least one flag is set on every skeleton
74/// node (an all-false node would be a node nobody claimed).
75#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
76pub struct Evidence {
77    /// A registry-slice subject template covers it.
78    pub declared: bool,
79    /// The liveliness roster places this origin/producer here.
80    pub alive: bool,
81    /// An admin-space declared entity's keyexpr names it.
82    pub admin: bool,
83}
84
85impl Evidence {
86    fn merge(self, other: Evidence) -> Evidence {
87        Evidence {
88            declared: self.declared || other.declared,
89            alive: self.alive || other.alive,
90            admin: self.admin || other.admin,
91        }
92    }
93}
94
95/// The declaring registry entry behind a skeleton leaf.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct DeclRef {
98    pub producer: String,
99    /// The subject pattern as declared (`disk/{mount}/used`).
100    pub path: String,
101    pub type_name: String,
102}
103
104#[derive(Debug, Clone, Default)]
105pub struct SkeletonNode {
106    pub chunk: Option<SkeletonChunk>,
107    pub children: BTreeMap<String, SkeletonNode>,
108    pub evidence: Evidence,
109    /// Set on template leaves.
110    pub decl: Option<DeclRef>,
111}
112
113impl SkeletonNode {
114    fn insert(&mut self, chunks: &[SkeletonChunk], evidence: Evidence, decl: Option<DeclRef>) {
115        self.evidence = self.evidence.merge(evidence);
116        let Some((first, rest)) = chunks.split_first() else {
117            if decl.is_some() {
118                self.decl = decl;
119            }
120            return;
121        };
122        let child = self
123            .children
124            .entry(first.display())
125            .or_insert_with(|| SkeletonNode {
126                chunk: Some(first.clone()),
127                ..SkeletonNode::default()
128            });
129        child.insert(rest, evidence, decl);
130    }
131}
132
133/// What fed the skeleton — the O5 coverage statement, typed.
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub struct SkeletonCoverage {
136    pub slices: usize,
137    pub roster_origins: usize,
138    /// `None` = the admin space was not asked or did not answer — which is
139    /// *not* an observed zero (RFC 09 §5.1 O4).
140    pub admin_entities: Option<usize>,
141}
142
143#[derive(Debug, Clone)]
144pub struct Skeleton {
145    pub root: SkeletonNode,
146    pub coverage: SkeletonCoverage,
147}
148
149impl Skeleton {
150    /// Build the declared keyspace. Pure — every input was gathered by the
151    /// caller, this only assembles.
152    ///
153    /// Expansion rules:
154    /// - a **host** slice × every roster origin running that producer →
155    ///   `<base>/v1/<origin>/<class>/<producer>/<pattern…>` (evidence:
156    ///   declared+alive);
157    /// - a host slice with **no** live origin → the same under a symbolic
158    ///   `{origin}` variable (declared, not placed — still visible, O4);
159    /// - a **service** slice → `<base>/v1/<origin>/<class>/<pattern…>` with
160    ///   no producer chunk (RFC 03 §1.5);
161    /// - admin entities with concrete (wildcard-free) keyexprs insert as
162    ///   literal paths; wildcard-bearing ones are not expanded into fake
163    ///   concrete nodes — they only contribute to the coverage count.
164    pub fn build(
165        base: &str,
166        slices: &SliceSet,
167        roster: &BTreeMap<String, Vec<String>>,
168        admin: Option<&crate::DeclaredEntities>,
169    ) -> Skeleton {
170        let mut root = SkeletonNode::default();
171        let base_chunks: Vec<SkeletonChunk> = if base.is_empty() {
172            Vec::new()
173        } else {
174            base.split('/')
175                .map(|c| SkeletonChunk::Literal(c.to_string()))
176                .collect()
177        };
178
179        for slice in slices.slices() {
180            for subject in &slice.subjects {
181                let decl = DeclRef {
182                    producer: slice.name.clone(),
183                    path: subject.path.clone(),
184                    type_name: subject.type_name.clone(),
185                };
186                let tail: Vec<SkeletonChunk> =
187                    subject.path.split('/').map(SkeletonChunk::parse).collect();
188
189                if let Some(origin) = &slice.service_origin {
190                    // Service origin: no producer chunk (RFC 03 §1.5).
191                    let mut path = base_chunks.clone();
192                    path.push(SkeletonChunk::Literal("v1".into()));
193                    path.push(SkeletonChunk::Literal(origin.token().to_string()));
194                    path.push(SkeletonChunk::Literal(subject.class.token().to_string()));
195                    path.extend(tail.clone());
196                    root.insert(
197                        &path,
198                        Evidence {
199                            declared: true,
200                            ..Evidence::default()
201                        },
202                        Some(decl.clone()),
203                    );
204                    continue;
205                }
206
207                // Host producer: place under every live origin running it…
208                let live: Vec<&String> = roster
209                    .iter()
210                    .filter(|(_, producers)| {
211                        producers
212                            .iter()
213                            .any(|p| p == &slice.name || instance_base(p) == slice.name)
214                    })
215                    .map(|(origin, _)| origin)
216                    .collect();
217                if live.is_empty() {
218                    // …or, unplaced, under a symbolic {origin}: declared but
219                    // not currently served anywhere we can see.
220                    let mut path = base_chunks.clone();
221                    path.push(SkeletonChunk::Literal("v1".into()));
222                    path.push(SkeletonChunk::Var("origin".into()));
223                    path.push(SkeletonChunk::Literal(subject.class.token().to_string()));
224                    path.push(SkeletonChunk::Literal(slice.name.clone()));
225                    path.extend(tail.clone());
226                    root.insert(
227                        &path,
228                        Evidence {
229                            declared: true,
230                            ..Evidence::default()
231                        },
232                        Some(decl.clone()),
233                    );
234                } else {
235                    for origin in live {
236                        let mut path = base_chunks.clone();
237                        path.push(SkeletonChunk::Literal("v1".into()));
238                        path.push(SkeletonChunk::Literal(origin.clone()));
239                        path.push(SkeletonChunk::Literal(subject.class.token().to_string()));
240                        path.push(SkeletonChunk::Literal(slice.name.clone()));
241                        path.extend(tail.clone());
242                        root.insert(
243                            &path,
244                            Evidence {
245                                declared: true,
246                                alive: true,
247                                ..Evidence::default()
248                            },
249                            Some(decl.clone()),
250                        );
251                    }
252                }
253            }
254        }
255
256        let mut admin_count = None;
257        if let Some(entities) = admin {
258            admin_count = Some(entities.entities.len());
259            for e in &entities.entities {
260                if e.keyexpr.contains('*') {
261                    continue; // never invent concrete nodes from wildcards
262                }
263                let path: Vec<SkeletonChunk> = e
264                    .keyexpr
265                    .split('/')
266                    .map(|c| SkeletonChunk::Literal(c.to_string()))
267                    .collect();
268                root.insert(
269                    &path,
270                    Evidence {
271                        admin: true,
272                        ..Evidence::default()
273                    },
274                    None,
275                );
276            }
277        }
278
279        Skeleton {
280            root,
281            coverage: SkeletonCoverage {
282                slices: slices.slices().len(),
283                roster_origins: roster.len(),
284                admin_entities: admin_count,
285            },
286        }
287    }
288}
289
290/// `snmp-2` → `snmp` (the instance suffix, RFC 03 §1.5).
291fn instance_base(producer: &str) -> &str {
292    match producer.rsplit_once('-') {
293        Some((name, suffix))
294            if !suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit()) =>
295        {
296            name
297        }
298        _ => producer,
299    }
300}
301
302/// The typed declared/observed state of one merged node.
303///
304/// Derived purely from (has traffic stats) × (a watch covers it):
305///
306/// |                | covered by a watch | not covered            |
307/// |----------------|--------------------|------------------------|
308/// | **has stats**  | `Observed`         | `Unwatched` (leftover) |
309/// | **no stats**   | `WatchedQuiet`     | `DeclaredOnly`         |
310///
311/// `Unwatched` is transient by construction —
312/// [`Monitor::unwatch`](crate::Monitor::unwatch) retires uncovered stats
313/// immediately — but the
314/// state exists so the interval between release and retirement never renders
315/// as live observation.
316#[derive(Debug, Clone, Copy, PartialEq, Eq)]
317pub enum NodeStatus {
318    /// Skeleton evidence only — never seen a payload here.
319    DeclaredOnly(Evidence),
320    /// A watch covers it; no traffic has arrived. "Quiet" is an observation.
321    WatchedQuiet(Evidence),
322    /// Traffic observed under an active watch.
323    Observed(Evidence),
324    /// Stats linger but no watch covers them any more.
325    Unwatched(Evidence),
326}
327
328impl NodeStatus {
329    pub fn evidence(self) -> Evidence {
330        match self {
331            NodeStatus::DeclaredOnly(e)
332            | NodeStatus::WatchedQuiet(e)
333            | NodeStatus::Observed(e)
334            | NodeStatus::Unwatched(e) => e,
335        }
336    }
337}
338
339/// Owned copy of a [`TreeNode`]'s numbers (the merged tree must not borrow
340/// the snapshot).
341#[derive(Debug, Clone, Copy, PartialEq)]
342pub struct NodeStats {
343    pub count: u64,
344    pub bytes: u64,
345    pub rate_hz: f64,
346    pub subtree_count: u64,
347    pub subtree_bytes: u64,
348    pub subtree_rate_hz: f64,
349    pub subtree_keys: usize,
350    /// Newest sample anywhere in the subtree — the per-node freshness signal
351    /// (issue #65; computed by the snapshot since the beginning, now carried
352    /// through the merge instead of dropped).
353    pub subtree_last_seen: Option<std::time::Instant>,
354}
355
356impl NodeStats {
357    /// Project one snapshot node's numbers.
358    ///
359    /// `pub` because a frontend that caches the tree's *shape* has to join the
360    /// numbers back on at draw time, and reimplementing this field-by-field in
361    /// the frontend is how the two would drift (zengui #177). The standing rule
362    /// applies — a missing type is a `zenkey-fleet` issue, not a frontend
363    /// workaround (`docs/redesign-2026-07.md` §15).
364    pub fn from_tree(node: &TreeNode) -> NodeStats {
365        NodeStats {
366            count: node.count,
367            bytes: node.bytes,
368            rate_hz: node.rate_hz,
369            subtree_count: node.subtree_count,
370            subtree_bytes: node.subtree_bytes,
371            subtree_rate_hz: node.subtree_rate_hz,
372            subtree_keys: node.subtree_keys,
373            subtree_last_seen: node.subtree_last_seen,
374        }
375    }
376}
377
378/// One node of the merged declared∪observed tree.
379#[derive(Debug, Clone)]
380pub struct MergedNode {
381    pub children: BTreeMap<String, MergedNode>,
382    pub status: NodeStatus,
383    pub stats: Option<NodeStats>,
384    pub decl: Option<DeclRef>,
385}
386
387/// Fold the skeleton, the observed snapshot, and the active watch set into
388/// one tree. Runs at tick cadence — the same order of work as a flatten.
389pub fn merge(skeleton: &Skeleton, observed: &KeyTreeSnapshot, watched: &[String]) -> MergedNode {
390    // Borrowed, not owned: `keyexpr::new(&str)` validates without allocating,
391    // where `KeyExpr::new(String)` builds an `OwnedKeyExpr` (an `Arc<str>`
392    // copy) per selector per tick (`docs/zero-copy.md`).
393    let watched: Vec<&keyexpr> = watched
394        .iter()
395        .filter_map(|w| keyexpr::new(w.as_str()).ok())
396        .collect();
397    // One reusable buffer for the descent's prefixes, instead of a fresh
398    // `String` per node per tick.
399    let mut path = String::new();
400    merge_nodes(
401        Some(&skeleton.root),
402        Some(&observed.root),
403        &watched,
404        &mut path,
405    )
406}
407
408fn merge_nodes(
409    skel: Option<&SkeletonNode>,
410    obs: Option<&TreeNode>,
411    watched: &[&keyexpr],
412    path: &mut String,
413) -> MergedNode {
414    let evidence = skel.map(|s| s.evidence).unwrap_or_default();
415    let stats = obs.map(NodeStats::from_tree);
416    let covered = is_covered(path, watched);
417    let status = match (stats.is_some(), covered) {
418        (true, true) => NodeStatus::Observed(evidence),
419        (true, false) => NodeStatus::Unwatched(evidence),
420        (false, true) => NodeStatus::WatchedQuiet(evidence),
421        (false, false) => NodeStatus::DeclaredOnly(evidence),
422    };
423
424    let mut names: Vec<&String> = Vec::new();
425    if let Some(s) = skel {
426        names.extend(s.children.keys());
427    }
428    if let Some(o) = obs {
429        names.extend(o.children.keys());
430    }
431    names.sort();
432    names.dedup();
433
434    let mut children = BTreeMap::new();
435    for name in names {
436        let skel_child = skel.and_then(|s| s.children.get(name));
437        let obs_child = obs.and_then(|o| o.children.get(name));
438        // Coverage tests run on *selector* form: symbolic chunks widen.
439        // `selector_chunk()` already returns `&str`, so nothing is owned here.
440        let sel_chunk = skel_child
441            .and_then(|c| c.chunk.as_ref())
442            .map(|c| c.selector_chunk())
443            .unwrap_or(name.as_str());
444        let mark = path.len();
445        if !path.is_empty() {
446            path.push('/');
447        }
448        path.push_str(sel_chunk);
449        let child = merge_nodes(skel_child, obs_child, watched, path);
450        path.truncate(mark);
451        children.insert(name.clone(), child);
452    }
453
454    MergedNode {
455        children,
456        status,
457        stats,
458        decl: skel.and_then(|s| s.decl.clone()),
459    }
460}
461
462/// Does any active watch reach into this subtree?
463///
464/// Deliberately generous: a node counts as covered when a watch *intersects*
465/// its subtree (`prefix/**`), so an ancestor of a watched subtree reads
466/// "watched" rather than "declared only" — the honest reading of "some watch
467/// reaches below here". The root is covered iff anything is watched.
468fn is_covered(prefix: &str, watched: &[&keyexpr]) -> bool {
469    if watched.is_empty() {
470        return false;
471    }
472    if prefix.is_empty() {
473        return true;
474    }
475    // A thread-local scratch buffer: this runs once per node per tick, and a
476    // fresh `String` plus an `OwnedKeyExpr` each time was the heaviest thing
477    // on the render path (`docs/zero-copy.md`).
478    thread_local! {
479        static SUBTREE: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
480    }
481    SUBTREE.with(|buf| {
482        let mut buf = buf.borrow_mut();
483        buf.clear();
484        buf.push_str(prefix);
485        buf.push_str("/**");
486        let Ok(node) = keyexpr::new(buf.as_str()) else {
487            return false;
488        };
489        watched.iter().any(|w| w.intersects(node))
490    })
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496    use crate::model::stats::StatsTable;
497    use std::time::Instant;
498    use zenkey::slice::{RegistrySlice, SubjectDecl};
499
500    fn subject(path: &str, class: &str) -> SubjectDecl {
501        let mut d = SubjectDecl::new(path, zenkey::Declared::parse(class));
502        d.type_name = "T".into();
503        d
504    }
505
506    fn host_slice(name: &str, subjects: Vec<SubjectDecl>) -> RegistrySlice {
507        let mut slice = RegistrySlice::new("1.0", "test", name);
508        slice.subjects = subjects;
509        slice
510    }
511
512    #[test]
513    fn skeleton_places_declared_subjects_under_live_origins() {
514        let slices = SliceSet::from_slices(vec![host_slice(
515            "sysinfo",
516            vec![subject("disk/{mount}/used", "telemetry")],
517        )]);
518        let mut roster = BTreeMap::new();
519        roster.insert("h-3fa9c2d41b7e".to_string(), vec!["sysinfo".to_string()]);
520        let skel = Skeleton::build("", &slices, &roster, None);
521
522        let node = &skel.root.children["v1"].children["h-3fa9c2d41b7e"].children["telemetry"]
523            .children["sysinfo"]
524            .children["disk"]
525            .children["{mount}"]
526            .children["used"];
527        assert!(node.evidence.declared && node.evidence.alive);
528        assert_eq!(node.decl.as_ref().unwrap().type_name, "T");
529        assert_eq!(
530            skel.coverage.admin_entities, None,
531            "admin not asked is None, not zero (O4)"
532        );
533    }
534
535    /// A declared producer with no live origin is still visible — under a
536    /// symbolic {origin}, never invented as a concrete host.
537    #[test]
538    fn skeleton_keeps_unplaced_producers_symbolic() {
539        let slices = SliceSet::from_slices(vec![host_slice(
540            "sysinfo",
541            vec![subject("health", "state")],
542        )]);
543        let skel = Skeleton::build("", &slices, &BTreeMap::new(), None);
544        let origin = &skel.root.children["v1"].children["{origin}"];
545        assert!(origin.evidence.declared && !origin.evidence.alive);
546        assert!(
547            origin.children["state"].children["sysinfo"].children["health"]
548                .evidence
549                .declared
550        );
551    }
552
553    /// A service slice omits the producer chunk (RFC 03 §1.5).
554    #[test]
555    fn skeleton_places_service_origins_without_a_producer_chunk() {
556        let mut slice = host_slice("catalog", vec![subject("entity/{id}", "state")]);
557        slice.service_origin = Some(zenkey::Declared::parse("@catalog"));
558        let slices = SliceSet::from_slices(vec![slice]);
559        let skel = Skeleton::build("", &slices, &BTreeMap::new(), None);
560        let state = &skel.root.children["v1"].children["@catalog"].children["state"];
561        assert!(state.children["entity"].children["{id}"].evidence.declared);
562    }
563
564    /// Admin evidence inserts concrete keyexprs and never expands wildcards
565    /// into fake concrete nodes.
566    #[test]
567    fn skeleton_admin_evidence_is_concrete_only() {
568        let entities = crate::DeclaredEntities {
569            entities: vec![
570                crate::DeclaredEntity {
571                    kind: crate::EntityKind::Publisher,
572                    keyexpr: "v1/h-aabbccddeeff/telemetry/x/m".into(),
573                    node_zid: "z".into(),
574                    sources: serde_json::Value::Null,
575                },
576                crate::DeclaredEntity {
577                    kind: crate::EntityKind::Subscriber,
578                    keyexpr: "v1/*/state/**".into(),
579                    node_zid: "z".into(),
580                    sources: serde_json::Value::Null,
581                },
582            ],
583        };
584        let skel = Skeleton::build("", &SliceSet::default(), &BTreeMap::new(), Some(&entities));
585        assert_eq!(skel.coverage.admin_entities, Some(2));
586        let concrete = &skel.root.children["v1"].children["h-aabbccddeeff"];
587        assert!(concrete.evidence.admin);
588        // The wildcard subscriber created no children under v1 beyond the
589        // concrete one.
590        assert_eq!(skel.root.children["v1"].children.len(), 1);
591    }
592
593    /// The four NodeStatus values are types, produced by the documented
594    /// (stats × coverage) table.
595    #[test]
596    fn merge_produces_all_four_statuses() {
597        // Skeleton declares two leaves under one live origin.
598        let slices = SliceSet::from_slices(vec![host_slice(
599            "sysinfo",
600            vec![subject("cpu", "telemetry"), subject("mem", "telemetry")],
601        )]);
602        let mut roster = BTreeMap::new();
603        roster.insert("h-3fa9c2d41b7e".to_string(), vec!["sysinfo".to_string()]);
604        let skel = Skeleton::build("", &slices, &roster, None);
605
606        // Observed traffic: cpu (watched) and a foreign key (not watched).
607        let mut stats = StatsTable::new();
608        let now = Instant::now();
609        stats.record(
610            "v1/h-3fa9c2d41b7e/telemetry/sysinfo/cpu",
611            4,
612            None,
613            now,
614            None,
615            None,
616        );
617        stats.record("demo/foreign", 4, None, now, None, None);
618        let observed = KeyTreeSnapshot::build(&stats);
619
620        let watched = vec!["v1/h-3fa9c2d41b7e/telemetry/**".to_string()];
621        let merged = merge(&skel, &observed, &watched);
622
623        let sysinfo = &merged.children["v1"].children["h-3fa9c2d41b7e"].children["telemetry"]
624            .children["sysinfo"];
625        assert!(matches!(
626            sysinfo.children["cpu"].status,
627            NodeStatus::Observed(_)
628        ));
629        assert!(
630            matches!(sysinfo.children["mem"].status, NodeStatus::WatchedQuiet(_)),
631            "declared, covered, no traffic — quiet is an observation"
632        );
633        assert!(matches!(
634            merged.children["demo"].children["foreign"].status,
635            NodeStatus::Unwatched(_)
636        ));
637
638        // No watches at all: everything declared reads DeclaredOnly.
639        let merged = merge(&skel, &KeyTreeSnapshot::default(), &[]);
640        assert!(matches!(
641            merged.children["v1"].status,
642            NodeStatus::DeclaredOnly(_)
643        ));
644    }
645
646    #[test]
647    fn instance_suffixes_place_under_their_base_producer() {
648        let slices = SliceSet::from_slices(vec![host_slice(
649            "snmp",
650            vec![subject("if/{iface}/in", "telemetry")],
651        )]);
652        let mut roster = BTreeMap::new();
653        roster.insert("h-aabbccddeeff".to_string(), vec!["snmp-2".to_string()]);
654        let skel = Skeleton::build("", &slices, &roster, None);
655        assert!(
656            skel.root.children["v1"].children["h-aabbccddeeff"].children["telemetry"].children
657                ["snmp"]
658                .evidence
659                .alive
660        );
661    }
662}