Skip to main content

nmbrs_metrics/
cadence.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Cadence planning: user-declared cadences + auto-intermediate tree
5//! synthesis.
6//!
7//! This module owns the *planning* side of SRD-42 §"Canonical Cadences"
8//! and §"Auto-Intermediate Buckets" — the [`Cadences`] type for the
9//! user's declared list and the [`CadenceTree`] planner that
10//! synthesizes hidden layers and emits the realized tree at INFO.
11//!
12//! It does NOT own the runtime store of windowed snapshots — that
13//! belongs to [`crate::cadence_reporter::CadenceReporter`].
14
15use std::time::Duration;
16
17// =========================================================================
18// Cadences — user-declared list
19// =========================================================================
20
21/// User-declared set of canonical latency cadences. The windows the
22/// user sees in every consumer (TUI panels, summary reports, etc.)
23/// are exactly these, in the order they were declared.
24#[derive(Clone, Debug)]
25pub struct Cadences {
26    /// Cadences in user-declared order. Duplicates are folded out on
27    /// construction; ordering is preserved so consumers that display
28    /// columns-per-cadence line up with the user's mental model.
29    ordered: Vec<Duration>,
30}
31
32/// Error type for [`Cadences::parse`]. Cadence values that are
33/// structurally illegal (empty input, un-parseable tokens) surface
34/// here; *semantic* validity against a base interval is deferred to
35/// [`CadenceTree::plan`] so the checks can see the actual scheduler
36/// base (which may not be 1 s in tests or alternate configurations).
37#[derive(Debug, PartialEq, Eq)]
38pub enum CadenceParseError {
39    /// The input string was empty.
40    Empty,
41    /// A token couldn't be parsed as a duration.
42    BadToken(String),
43}
44
45impl std::fmt::Display for CadenceParseError {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        match self {
48            Self::Empty => write!(f, "empty cadence list"),
49            Self::BadToken(s) => write!(f, "not a duration: '{s}' (try '10s', '1m', '1h')"),
50        }
51    }
52}
53
54impl std::error::Error for CadenceParseError {}
55
56/// Error returned by [`CadenceTree::plan_validated`] when a declared
57/// cadence violates a base-interval invariant.
58#[derive(Debug, PartialEq, Eq)]
59pub enum CadenceTreeError {
60    /// A cadence is smaller than the scheduler's base interval.
61    BelowBase { cadence: Duration, base: Duration },
62    /// A cadence is not an integer multiple of the base interval.
63    NotMultiple { cadence: Duration, base: Duration },
64}
65
66impl std::fmt::Display for CadenceTreeError {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        match self {
69            Self::BelowBase { cadence, base } => write!(
70                f,
71                "cadence {cadence:?} is smaller than base interval {base:?}"
72            ),
73            Self::NotMultiple { cadence, base } => write!(
74                f,
75                "cadence {cadence:?} is not an integer multiple of base {base:?}"
76            ),
77        }
78    }
79}
80
81impl std::error::Error for CadenceTreeError {}
82
83impl Cadences {
84    /// Construct from an ordered list. Filters duplicates (first
85    /// occurrence wins) and preserves declaration order.
86    ///
87    /// Semantic validity against a scheduler base interval is
88    /// deferred to [`CadenceTree::plan_validated`] — this constructor
89    /// accepts any positive `Duration` so tests and alternate
90    /// scheduler configurations (sub-second base intervals) work
91    /// without a bypass.
92    pub fn new(cadences: &[Duration]) -> Result<Self, CadenceParseError> {
93        let mut seen = std::collections::HashSet::new();
94        let mut ordered = Vec::with_capacity(cadences.len());
95        for c in cadences {
96            if seen.insert(*c) {
97                ordered.push(*c);
98            }
99        }
100        if ordered.is_empty() {
101            return Err(CadenceParseError::Empty);
102        }
103        Ok(Self { ordered })
104    }
105
106    /// Default cadences used when the user didn't specify any:
107    /// `1s, 10s, 30s, 1m, 5m`.
108    ///
109    /// The 1s layer gives consumers (TUI, programmatic pulls)
110    /// a tight short-term window — `cadence_window(1s)` always
111    /// returns data. Cadence-layer overhead is negligible per the
112    /// `cadence_layout` bench, so including 1s in defaults is low
113    /// cost. `10s` is the default SQLite persistence cadence;
114    /// `30s, 1m, 5m` give coarser rollups for summary reports and
115    /// long-run trends.
116    pub fn defaults() -> Self {
117        Self::new(&[
118            Duration::from_secs(1),
119            Duration::from_secs(10),
120            Duration::from_secs(30),
121            Duration::from_secs(60),
122            Duration::from_secs(300),
123        ])
124        .expect("static default cadences are valid")
125    }
126
127    /// Parse `"10s,1m,10m,10h"` into a cadence list. Whitespace is
128    /// ignored; units: `s`, `m`, `h`. See [`CadenceParseError`] for
129    /// failure modes.
130    pub fn parse(s: &str) -> Result<Self, CadenceParseError> {
131        let mut cadences = Vec::new();
132        for token in s.split(',') {
133            let t = token.trim();
134            if t.is_empty() {
135                continue;
136            }
137            cadences.push(parse_duration(t).map_err(|_| CadenceParseError::BadToken(t.into()))?);
138        }
139        Self::new(&cadences)
140    }
141
142    /// Cadences in user-declared order. Hidden-intermediate buckets
143    /// introduced by the scheduler (phase 2) are NOT included here —
144    /// this iterator only yields what the user asked for.
145    pub fn iter(&self) -> impl Iterator<Item = Duration> + '_ {
146        self.ordered.iter().copied()
147    }
148
149    /// Number of declared cadences.
150    pub fn len(&self) -> usize {
151        self.ordered.len()
152    }
153
154    /// True when no cadences are declared. `Cadences::new` rejects
155    /// empty input, so this is always `false` for constructed values.
156    pub fn is_empty(&self) -> bool {
157        self.ordered.is_empty()
158    }
159
160    /// Smallest cadence — the finest granularity the user asked for.
161    /// Acts as the effective "now" bucket for consumers that don't
162    /// have a non-draining live read.
163    pub fn smallest(&self) -> Duration {
164        self.ordered.iter().copied().min().unwrap_or_default()
165    }
166
167    /// Largest cadence — the coarsest horizon the user asked for.
168    pub fn largest(&self) -> Duration {
169        self.ordered.iter().copied().max().unwrap_or_default()
170    }
171}
172
173/// Parse a human-duration string like `10s`, `500ms`, `1m`, `2h`.
174/// Plain integers without a unit are interpreted as seconds.
175pub fn parse_duration(s: &str) -> Result<Duration, ()> {
176    let s = s.trim();
177    if let Some(n) = s.strip_suffix("ms") {
178        return n
179            .trim()
180            .parse::<u64>()
181            .map(Duration::from_millis)
182            .map_err(|_| ());
183    }
184    if let Some(n) = s.strip_suffix('s') {
185        return n
186            .trim()
187            .parse::<u64>()
188            .map(Duration::from_secs)
189            .map_err(|_| ());
190    }
191    if let Some(n) = s.strip_suffix('m') {
192        return n
193            .trim()
194            .parse::<u64>()
195            .map(|v| Duration::from_secs(v * 60))
196            .map_err(|_| ());
197    }
198    if let Some(n) = s.strip_suffix('h') {
199        return n
200            .trim()
201            .parse::<u64>()
202            .map(|v| Duration::from_secs(v * 3600))
203            .map_err(|_| ());
204    }
205    s.parse::<u64>().map(Duration::from_secs).map_err(|_| ())
206}
207
208// =========================================================================
209// CadenceTree — auto-intermediate planner (SRD-42 phase 2)
210// =========================================================================
211
212/// Default maximum fan-in between adjacent layers in the realized
213/// cadence tree.
214///
215/// ## What max-fan-in means
216///
217/// Each cadence layer (e.g. 10 s) aggregates samples from the layer
218/// below it (e.g. 1 s). The **fan-in** is the integer ratio
219/// `next.interval / prev.interval` — how many of the lower-layer
220/// windows roll up into one upper-layer window.
221///
222/// - Declared `[1 s, 10 s]` → fan-in `10:1` (each 10 s window
223///   summarizes 10 of the 1 s windows below it).
224/// - Declared `[1 s, 5 m]` → fan-in `300:1` — too aggressive. The
225///   planner refuses to leave that as the realized layout because
226///   a single 5-minute window summarizing 300 of the 1 s windows
227///   loses too much accuracy in the upper-layer aggregates.
228///
229/// ## Why a cap exists
230///
231/// The accuracy of the upper-layer aggregates (mean / p99 /
232/// histogram fan-out) degrades non-linearly as the fan-in grows.
233/// At 20:1 the loss is bounded; at 300:1 the rolled-up percentile
234/// is effectively a coin flip. Limiting adjacent fan-in to a known
235/// ratio keeps the cost-vs-accuracy trade-off predictable.
236///
237/// ## How the planner enforces it
238///
239/// When two declared layers have a ratio above `max_fan_in`, the
240/// planner synthesizes **hidden intermediate layers** — geometric
241/// midpoints that keep every adjacent step within the limit. With
242/// `max_fan_in = 20`:
243///
244/// - Declared `[1 s, 5 m]` (300:1) → realized `[1 s, (20 s), 5 m]`
245///   — `20 s` is hidden, the operator never sees it as a column
246///   but the upper-layer aggregates use it. Adjacent ratios:
247///   `20:1` and `15:1`, both under the cap.
248///
249/// The synthesized line in the run log surfaces hidden layers in
250/// parentheses, with the trailing `/ N` carrying this cap:
251///
252/// ```text
253/// metrics: cadences: [1s, (20s), 5m] / 20
254/// ```
255///
256/// ## Tuning
257///
258/// 20 is the workload default. Lower values (more intermediate
259/// layers) trade storage for accuracy at very wide cadence
260/// declarations. Higher values trust the operator that the
261/// rolled-up percentile is "good enough." Override via the
262/// `latency-fan-in=` CLI / param when SRD-42 needs the knob —
263/// currently not exposed (the constant is the single source of
264/// truth project-wide).
265pub const DEFAULT_MAX_FAN_IN: u32 = 20;
266
267/// One layer in the realized cadence tree.
268#[derive(Clone, Copy, Debug, PartialEq, Eq)]
269pub struct CadenceLayer {
270    pub interval: Duration,
271    /// True when the layer was synthesized by the planner (not
272    /// declared by the user). Hidden layers feed accumulation only —
273    /// they are never surfaced via [`Cadences::iter`] or counted as
274    /// user-visible columns.
275    pub hidden: bool,
276}
277
278/// The realized cadence tree: user-declared layers plus any hidden
279/// intermediates synthesized to keep adjacent fan-in ≤ `max_fan_in`.
280///
281/// This is the consumer-facing planning result that the scheduler
282/// reads to build its chained tree (SRD-42 §Tree Construction).
283/// The user-facing `cadences()` view returns only declared layers.
284#[derive(Clone, Debug)]
285pub struct CadenceTree {
286    declared: Cadences,
287    layers: Vec<CadenceLayer>,
288    max_fan_in: u32,
289}
290
291impl CadenceTree {
292    /// Plan a tree from user-declared cadences, validating every
293    /// cadence against the scheduler's `base_interval`.
294    ///
295    /// Returns an error when any declared cadence is below the base
296    /// or is not an integer multiple of it. Otherwise produces the
297    /// realized tree (declared + auto-inserted hidden layers).
298    ///
299    /// This is a plan-time hard error per SRD-42 §"Constraints" —
300    /// validation happens once on tree construction, not on every
301    /// tick.
302    pub fn plan_validated(
303        declared: Cadences,
304        max_fan_in: u32,
305        base_interval: Duration,
306    ) -> Result<Self, CadenceTreeError> {
307        for c in declared.iter() {
308            if c < base_interval {
309                return Err(CadenceTreeError::BelowBase {
310                    cadence: c,
311                    base: base_interval,
312                });
313            }
314            if base_interval.as_nanos() == 0 || c.as_nanos() % base_interval.as_nanos() != 0 {
315                return Err(CadenceTreeError::NotMultiple {
316                    cadence: c,
317                    base: base_interval,
318                });
319            }
320        }
321        Ok(Self::plan(declared, max_fan_in))
322    }
323
324    /// Plan a tree from user-declared cadences without a base-interval
325    /// check. Adjacent layers are kept within `max_fan_in`:1 by
326    /// inserting geometrically-spaced hidden intermediates. Synthesis
327    /// is logged at INFO so operators can see the realized layout
328    /// from the run log.
329    ///
330    /// Callers that have a known scheduler base interval should
331    /// prefer [`Self::plan_validated`].
332    pub fn plan(declared: Cadences, max_fan_in: u32) -> Self {
333        let mut sorted: Vec<Duration> = declared.iter().collect();
334        sorted.sort_unstable();
335        sorted.dedup();
336
337        let declared_set: std::collections::HashSet<Duration> = sorted.iter().copied().collect();
338
339        let mut layers: Vec<Duration> = sorted.clone();
340        synthesize_intermediates(&mut layers, max_fan_in);
341
342        let realized: Vec<CadenceLayer> = layers
343            .iter()
344            .map(|d| CadenceLayer {
345                interval: *d,
346                hidden: !declared_set.contains(d),
347            })
348            .collect();
349
350        log_realized_tree(&realized, max_fan_in);
351
352        Self {
353            declared,
354            layers: realized,
355            max_fan_in,
356        }
357    }
358
359    /// Plan with the default `max_fan_in` ([`DEFAULT_MAX_FAN_IN`]).
360    pub fn plan_default(declared: Cadences) -> Self {
361        Self::plan(declared, DEFAULT_MAX_FAN_IN)
362    }
363
364    /// User-declared cadences in their original declaration order.
365    pub fn declared(&self) -> &Cadences {
366        &self.declared
367    }
368
369    /// All layers (declared + hidden), sorted ascending by interval.
370    pub fn layers(&self) -> &[CadenceLayer] {
371        &self.layers
372    }
373
374    /// Maximum fan-in used during planning.
375    pub fn max_fan_in(&self) -> u32 {
376        self.max_fan_in
377    }
378
379    /// Just the hidden (auto-inserted) layers, ascending.
380    pub fn hidden(&self) -> impl Iterator<Item = Duration> + '_ {
381        self.layers.iter().filter(|l| l.hidden).map(|l| l.interval)
382    }
383
384    /// Route a reporter's preferred interval to the nearest
385    /// declared cadence ≥ `preferred`. If no declared cadence meets
386    /// or exceeds `preferred`, returns the *largest* declared
387    /// cadence as a best effort. Returns `None` only when the tree
388    /// has no declared cadences at all (which
389    /// [`CadenceTree::plan`] disallows).
390    ///
391    /// Per SRD-42 §"SQLite — near-time persistence": "If `10s` is
392    /// in the declared cadence list, use it. Otherwise, use the
393    /// next-higher declared cadence above `10s`." This helper
394    /// implements that general rule for any preferred interval.
395    pub fn align_to_declared(&self, preferred: Duration) -> Option<Duration> {
396        let mut declared_sorted: Vec<Duration> = self.declared.iter().collect();
397        declared_sorted.sort_unstable();
398        if declared_sorted.is_empty() {
399            return None;
400        }
401        declared_sorted
402            .iter()
403            .copied()
404            .find(|&d| d >= preferred)
405            .or_else(|| declared_sorted.last().copied())
406    }
407}
408
409/// Walk adjacent pairs in `layers` (sorted ascending). For each gap
410/// where `b/a > k`, insert geometrically-spaced intermediates rounded
411/// to a human-friendly duration. Returns the inserted intermediates,
412/// in insertion order, paired with the (a, b) bracket they filled.
413fn synthesize_intermediates(
414    layers: &mut Vec<Duration>,
415    k: u32,
416) -> Vec<(Duration, Duration, Duration)> {
417    let mut inserted: Vec<(Duration, Duration, Duration)> = Vec::new();
418    if k < 2 || layers.len() < 2 {
419        return inserted;
420    }
421    let k_f = k as f64;
422
423    let max_rounds = 8;
424    for _ in 0..max_rounds {
425        let mut changed = false;
426        let mut i = 0;
427        while i + 1 < layers.len() {
428            let a = layers[i];
429            let b = layers[i + 1];
430            let ratio = b.as_secs_f64() / a.as_secs_f64().max(f64::EPSILON);
431            if ratio <= k_f {
432                i += 1;
433                continue;
434            }
435            let n_steps = (ratio.ln() / k_f.ln()).ceil().max(1.0) as u32;
436            let n_inserts = n_steps.saturating_sub(1).max(1);
437            let step_ratio = ratio.powf(1.0 / (n_inserts as f64 + 1.0));
438
439            let mut new_intervals: Vec<Duration> = Vec::with_capacity(n_inserts as usize);
440            for j in 1..=n_inserts {
441                let raw_secs = a.as_secs_f64() * step_ratio.powi(j as i32);
442                let nice = nicest_duration(raw_secs);
443                if nice > a
444                    && nice < b
445                    && !new_intervals.contains(&nice)
446                    && !layers[..=i].contains(&nice)
447                    && !layers[i + 1..].contains(&nice)
448                {
449                    new_intervals.push(nice);
450                    inserted.push((a, b, nice));
451                }
452            }
453
454            if new_intervals.is_empty() {
455                let mid_secs = (a.as_secs_f64() * step_ratio).round().max(1.0) as u64;
456                let mid = Duration::from_secs(mid_secs);
457                if mid > a && mid < b {
458                    new_intervals.push(mid);
459                    inserted.push((a, b, mid));
460                }
461            }
462
463            if !new_intervals.is_empty() {
464                let insert_at = i + 1;
465                for (off, d) in new_intervals.iter().enumerate() {
466                    layers.insert(insert_at + off, *d);
467                }
468                changed = true;
469                continue;
470            }
471            i += 1;
472        }
473        if !changed {
474            break;
475        }
476    }
477
478    layers.sort_unstable();
479    layers.dedup();
480    inserted
481}
482
483/// "Nice" durations the planner prefers when rounding intermediate
484/// layers, ascending. Picked to be readable at a glance and consistent
485/// with operator instinct (5s, 10s, 30s, 1m, …).
486const NICE_SECONDS: &[u64] = &[
487    1,
488    2,
489    5,
490    10,
491    15,
492    20,
493    30,
494    45,
495    60,
496    2 * 60,
497    5 * 60,
498    10 * 60,
499    15 * 60,
500    20 * 60,
501    30 * 60,
502    45 * 60,
503    3600,
504    2 * 3600,
505    3 * 3600,
506    4 * 3600,
507    6 * 3600,
508    8 * 3600,
509    12 * 3600,
510    24 * 3600,
511    2 * 86_400,
512    7 * 86_400,
513];
514
515/// Round a duration (given in seconds, possibly fractional) to the
516/// nearest entry in [`NICE_SECONDS`] by log-ratio — preserves the
517/// geometric center of the bracket better than linear rounding.
518fn nicest_duration(secs: f64) -> Duration {
519    if !secs.is_finite() || secs <= 0.0 {
520        return Duration::from_secs(1);
521    }
522    let target = secs.ln();
523    let best = NICE_SECONDS
524        .iter()
525        .min_by(|a, b| {
526            let da = ((**a as f64).ln() - target).abs();
527            let db = ((**b as f64).ln() - target).abs();
528            da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
529        })
530        .copied()
531        .unwrap_or_else(|| secs.round() as u64);
532    Duration::from_secs(best)
533}
534
535/// Render a duration as a human-friendly string (`10s`, `1m`, `1h`,
536/// `1h30m`). Operators reading the realized-tree log line should be
537/// able to mentally compare these to the cadences they declared.
538pub fn format_duration_short(d: Duration) -> String {
539    let total = d.as_secs();
540    if total == 0 {
541        // Sub-second cadences (e.g. a 100ms optimizer finest layer)
542        // render in milliseconds rather than collapsing to "0s".
543        let ms = d.subsec_millis();
544        return if ms == 0 {
545            "0s".into()
546        } else {
547            format!("{ms}ms")
548        };
549    }
550    let h = total / 3600;
551    let m = (total % 3600) / 60;
552    let s = total % 60;
553    match (h, m, s) {
554        (h, 0, 0) if h > 0 => format!("{h}h"),
555        (h, m, 0) if h > 0 => format!("{h}h{m}m"),
556        (0, m, 0) if m > 0 => format!("{m}m"),
557        (0, 0, s) => format!("{s}s"),
558        (0, m, s) if m > 0 => format!("{m}m{s}s"),
559        (h, m, s) => format!("{h}h{m}m{s}s"),
560    }
561}
562
563fn log_realized_tree(realized: &[CadenceLayer], max_fan_in: u32) {
564    // One-line summary of the realized cadence layout:
565    //   `metrics: cadences: [1s, (10s), 30s, 1m, 5m] / 20`
566    // Parens mark auto-inserted layers — synthesized by the
567    // planner to keep adjacent fan-in within `max_fan_in:1`
568    // when the declared layers are too far apart. The
569    // trailing `/ <n>` reads as "max fan-in 20:1".
570    let cadences_str = realized
571        .iter()
572        .map(|l| {
573            let s = format_duration_short(l.interval);
574            if l.hidden { format!("({s})") } else { s }
575        })
576        .collect::<Vec<_>>()
577        .join(", ");
578    crate::diag::info(&format!(
579        "metrics: cadences: [{cadences_str}] / {max_fan_in}"
580    ));
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586
587    #[test]
588    fn cadence_parse_normal() {
589        let c = Cadences::parse("10s,1m,10m,1h").unwrap();
590        let got: Vec<_> = c.iter().collect();
591        assert_eq!(
592            got,
593            vec![
594                Duration::from_secs(10),
595                Duration::from_secs(60),
596                Duration::from_secs(600),
597                Duration::from_secs(3600),
598            ]
599        );
600    }
601
602    #[test]
603    fn cadence_parse_whitespace_and_units() {
604        let c = Cadences::parse(" 30s , 5m,2h ").unwrap();
605        let got: Vec<_> = c.iter().collect();
606        assert_eq!(got[0], Duration::from_secs(30));
607        assert_eq!(got[1], Duration::from_secs(300));
608        assert_eq!(got[2], Duration::from_secs(7200));
609    }
610
611    #[test]
612    fn cadence_parse_accepts_sub_second() {
613        // Sub-second values are now accepted at parse time — base-
614        // interval validity is checked at plan time by
615        // [`CadenceTree::plan_validated`].
616        let c = Cadences::parse("500ms,1s").unwrap();
617        let got: Vec<_> = c.iter().collect();
618        assert_eq!(got[0], Duration::from_millis(500));
619        assert_eq!(got[1], Duration::from_secs(1));
620    }
621
622    #[test]
623    fn cadence_tree_plan_validated_rejects_below_base() {
624        let c = Cadences::new(&[Duration::from_millis(500), Duration::from_secs(1)]).unwrap();
625        let err =
626            CadenceTree::plan_validated(c, DEFAULT_MAX_FAN_IN, Duration::from_secs(1)).unwrap_err();
627        assert!(matches!(err, CadenceTreeError::BelowBase { .. }));
628    }
629
630    #[test]
631    fn cadence_tree_plan_validated_rejects_non_multiple() {
632        let c = Cadences::new(&[Duration::from_millis(1500)]).unwrap();
633        let err =
634            CadenceTree::plan_validated(c, DEFAULT_MAX_FAN_IN, Duration::from_secs(1)).unwrap_err();
635        assert!(matches!(err, CadenceTreeError::NotMultiple { .. }));
636    }
637
638    #[test]
639    fn align_to_declared_picks_smallest_above_preferred() {
640        let c = Cadences::parse("5s,10s,1m,5m").unwrap();
641        let tree = CadenceTree::plan(c, DEFAULT_MAX_FAN_IN);
642        assert_eq!(
643            tree.align_to_declared(Duration::from_secs(1)),
644            Some(Duration::from_secs(5))
645        );
646        assert_eq!(
647            tree.align_to_declared(Duration::from_secs(10)),
648            Some(Duration::from_secs(10))
649        );
650        assert_eq!(
651            tree.align_to_declared(Duration::from_secs(30)),
652            Some(Duration::from_secs(60))
653        );
654        // Preferred > largest → falls back to largest.
655        assert_eq!(
656            tree.align_to_declared(Duration::from_secs(3600)),
657            Some(Duration::from_secs(300))
658        );
659    }
660
661    #[test]
662    fn cadence_tree_plan_validated_accepts_exact_multiple() {
663        let c = Cadences::new(&[Duration::from_secs(1), Duration::from_secs(10)]).unwrap();
664        let tree =
665            CadenceTree::plan_validated(c, DEFAULT_MAX_FAN_IN, Duration::from_secs(1)).unwrap();
666        assert_eq!(tree.layers().len(), 2);
667    }
668
669    #[test]
670    fn cadence_tree_inserts_hidden_for_large_ratio() {
671        let tree = CadenceTree::plan(
672            Cadences::parse("10s,1m,10m,10h").unwrap(),
673            DEFAULT_MAX_FAN_IN,
674        );
675        let layers: Vec<Duration> = tree.layers().iter().map(|l| l.interval).collect();
676        for d in [10, 60, 600, 36000].iter().map(|s| Duration::from_secs(*s)) {
677            assert!(layers.contains(&d));
678        }
679        assert!(tree.hidden().count() >= 1);
680    }
681}