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}