Skip to main content

rtime_core/
servo.rs

1//! PI servo clock discipline for NTP/PTP.
2//!
3//! Implements a proportional-integral controller that adjusts system clock
4//! frequency to converge the measured offset to zero. The servo operates in
5//! three phases:
6//!
7//!  1. **Init** -- discard the first few samples while filters warm up.
8//!  2. **FLL (Frequency-Lock Loop)** -- large gains for fast initial convergence.
9//!  3. **PLL (Phase-Lock Loop)** -- small gains for fine-grained tracking.
10
11use tracing::warn;
12
13/// Servo operating state.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum ServoState {
16    /// Initial state, waiting for first sample.
17    Init,
18    /// Frequency-Lock Loop: fast initial convergence using large gains.
19    FrequencyLock,
20    /// Phase-Lock Loop: fine-grained tracking with smaller gains.
21    PhaseLock,
22}
23
24/// Action to take on the system clock after processing a sample.
25#[derive(Debug, Clone)]
26pub enum ServoAction {
27    /// Adjust frequency by this many PPM.
28    AdjustFrequency { ppm: f64 },
29    /// Step the clock by this offset (too large for slew).
30    Step { offset_ns: i64 },
31    /// Measurement was implausibly large; ignored without touching state.
32    Reject { offset_ns: i64 },
33    /// No action needed (waiting for more samples).
34    None,
35}
36
37/// Configuration for the PI servo.
38#[derive(Debug, Clone)]
39pub struct ServoConfig {
40    /// Step threshold in nanoseconds. Offsets larger than this trigger a step.
41    /// Default: 128_000_000 (128ms).
42    pub step_threshold_ns: f64,
43    /// Panic threshold in nanoseconds. Offsets whose absolute value exceeds
44    /// this are rejected outright -- neither slewed nor stepped -- on the
45    /// assumption that any jump this large is a bug or a spoofed/corrupt
46    /// NTP reply rather than real drift. Must be greater than
47    /// `step_threshold_ns`. Default: 1_000_000_000 (1s).
48    pub panic_threshold_ns: f64,
49    /// Allow a single unrestricted step the first time the servo sees a
50    /// measurement, bypassing `panic_threshold_ns` on that first sample only.
51    /// Matches `ntpd -g` semantics: after a cold boot or long VM suspend the
52    /// real-world offset can legitimately exceed any sane steady-state panic
53    /// guard, and refusing to step would strand the clock forever. Once the
54    /// first step is performed, the panic clamp engages for all subsequent
55    /// samples. Default: true.
56    pub allow_initial_step: bool,
57    /// Maximum frequency adjustment in PPM. Default: 500.0.
58    pub max_frequency: f64,
59    /// Number of initial samples to skip (for filter warmup). Default: 4.
60    pub init_samples: u32,
61    /// Number of FLL samples before switching to PLL. Default: 8.
62    pub fll_samples: u32,
63}
64
65impl Default for ServoConfig {
66    fn default() -> Self {
67        Self {
68            step_threshold_ns: 128_000_000.0,
69            panic_threshold_ns: 1_000_000_000.0,
70            allow_initial_step: true,
71            max_frequency: 500.0,
72            init_samples: 4,
73            fll_samples: 8,
74        }
75    }
76}
77
78/// PI (Proportional-Integral) servo for clock discipline.
79///
80/// Processes offset samples and produces clock adjustment actions. The servo
81/// transitions through [`ServoState::Init`] -> [`ServoState::FrequencyLock`] ->
82/// [`ServoState::PhaseLock`] as it gathers enough samples to converge.
83pub struct PiServo {
84    config: ServoConfig,
85    state: ServoState,
86    /// Accumulated integral term.
87    integral: f64,
88    /// Current frequency offset estimate (PPM).
89    frequency: f64,
90    /// Previous offset for derivative calculation (ns).
91    last_offset: Option<f64>,
92    /// Number of samples processed.
93    sample_count: u32,
94    /// Whether a Step has ever been performed by this servo instance. Used
95    /// together with `config.allow_initial_step` to permit one unrestricted
96    /// step at startup; subsequent samples are clamped by the panic threshold.
97    /// Not cleared by `reset()` — once we've stepped, we trust the clamp.
98    has_stepped: bool,
99}
100
101impl PiServo {
102    /// Create a new PI servo with the given configuration.
103    pub fn new(config: ServoConfig) -> Self {
104        Self {
105            config,
106            state: ServoState::Init,
107            integral: 0.0,
108            frequency: 0.0,
109            last_offset: None,
110            sample_count: 0,
111            has_stepped: false,
112        }
113    }
114
115    /// Create a new PI servo with default configuration.
116    pub fn with_defaults() -> Self {
117        Self::new(ServoConfig::default())
118    }
119
120    /// Current servo state.
121    pub fn state(&self) -> ServoState {
122        self.state
123    }
124
125    /// Current estimated frequency offset in PPM.
126    pub fn frequency(&self) -> f64 {
127        self.frequency
128    }
129
130    /// Number of samples processed so far.
131    pub fn sample_count(&self) -> u32 {
132        self.sample_count
133    }
134
135    /// Process a new offset measurement and return the action to take.
136    ///
137    /// # Arguments
138    /// - `offset_ns`: measured clock offset in nanoseconds (positive means local
139    ///   clock is ahead).
140    /// - `poll_interval_secs`: current polling interval in seconds (tau). This
141    ///   controls the PI gain scaling.
142    pub fn sample(&mut self, offset_ns: f64, poll_interval_secs: f64) -> ServoAction {
143        // Panic clamp: reject implausibly large offsets before they touch any
144        // state. A real drift this large doesn't occur on a running kernel;
145        // a value this big means the measurement is corrupt, spoofed, or the
146        // result of an NTP-era wrap. Stepping would wreck the clock (see the
147        // year-9920 incident that motivated this guard).
148        //
149        // Exception: if `allow_initial_step` is set and we have never stepped,
150        // permit one unrestricted step. After a cold boot or long VM suspend
151        // the real-world offset can legitimately exceed any sane steady-state
152        // panic guard; without this bypass the clock is stranded forever.
153        // Equivalent to `ntpd -g`.
154        if offset_ns.abs() > self.config.panic_threshold_ns {
155            if self.config.allow_initial_step && !self.has_stepped {
156                warn!(
157                    "Initial offset {:.0} ns exceeds panic threshold {:.0} ns; \
158                     performing one-shot startup step (allow_initial_step=true).",
159                    offset_ns, self.config.panic_threshold_ns
160                );
161                self.has_stepped = true;
162                self.reset();
163                return ServoAction::Step {
164                    offset_ns: offset_ns as i64,
165                };
166            }
167            warn!(
168                "Rejecting implausible offset: {:.0} ns exceeds panic threshold {:.0} ns",
169                offset_ns, self.config.panic_threshold_ns
170            );
171            return ServoAction::Reject {
172                offset_ns: offset_ns as i64,
173            };
174        }
175
176        self.sample_count += 1;
177
178        // Step detection: if offset is larger than the threshold, step the clock
179        // and reset state so the servo re-converges from scratch.
180        if offset_ns.abs() > self.config.step_threshold_ns {
181            self.has_stepped = true;
182            self.reset();
183            return ServoAction::Step {
184                offset_ns: offset_ns as i64,
185            };
186        }
187
188        // Init phase: skip the first N samples so upstream filters can warm up.
189        if self.sample_count <= self.config.init_samples {
190            self.last_offset = Some(offset_ns);
191            return ServoAction::None;
192        }
193
194        // Transition out of Init on the first real sample.
195        if self.state == ServoState::Init {
196            self.state = ServoState::FrequencyLock;
197        }
198
199        let tau = poll_interval_secs;
200
201        // Determine number of samples in the active (non-init) phase.
202        let active_samples = self.sample_count - self.config.init_samples;
203
204        // Transition from FLL to PLL once we have enough FLL samples.
205        if self.state == ServoState::FrequencyLock && active_samples > self.config.fll_samples {
206            self.state = ServoState::PhaseLock;
207        }
208
209        // Compute PI gains based on the current operating mode.
210        //
211        // The controller works in the offset domain (nanoseconds) and produces a
212        // frequency correction in PPM.  A conversion factor of 1e-3/tau translates
213        // from "nanoseconds per tau-seconds" into PPM:
214        //   1 ns offset / tau s  =  1e-9 / tau  fractional freq
215        //   * 1e6  =  1e-3 / tau  PPM
216        let (kp, ki) = match self.state {
217            ServoState::FrequencyLock => {
218                // FLL: large gains for fast convergence.
219                //   raw Kp = 2*tau,  raw Ki = tau^2
220                //   scaled by 1e-3/tau to convert ns -> PPM.
221                let scale = 1.0e-3 / tau;
222                (2.0 * tau * scale, tau * tau * scale)
223            }
224            ServoState::PhaseLock => {
225                // PLL: smaller gains for fine-grained tracking.
226                //   raw Kp = 0.7/tau,  raw Ki = Kp^2 / 4
227                //   scaled by 1e-3/tau to convert ns -> PPM.
228                let scale = 1.0e-3 / tau;
229                let kp_raw = 0.7 / tau;
230                let ki_raw = kp_raw * kp_raw / 4.0;
231                (kp_raw * scale, ki_raw * scale)
232            }
233            ServoState::Init => unreachable!(),
234        };
235
236        // Update integral term.
237        self.integral += offset_ns * ki;
238
239        // Proportional term.
240        let proportional = offset_ns * kp;
241
242        // Total correction.
243        self.frequency = proportional + self.integral;
244
245        // Clamp to maximum allowed frequency.
246        self.frequency = self
247            .frequency
248            .clamp(-self.config.max_frequency, self.config.max_frequency);
249
250        self.last_offset = Some(offset_ns);
251
252        ServoAction::AdjustFrequency {
253            ppm: self.frequency,
254        }
255    }
256
257    /// Reset the servo to its initial state.
258    pub fn reset(&mut self) {
259        self.state = ServoState::Init;
260        self.integral = 0.0;
261        self.frequency = 0.0;
262        self.last_offset = None;
263        self.sample_count = 0;
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    const POLL_INTERVAL: f64 = 16.0; // typical NTP poll interval in seconds
272
273    fn default_servo() -> PiServo {
274        PiServo::with_defaults()
275    }
276
277    fn servo_with_config(init: u32, fll: u32) -> PiServo {
278        PiServo::new(ServoConfig {
279            init_samples: init,
280            fll_samples: fll,
281            ..ServoConfig::default()
282        })
283    }
284
285    // ---------------------------------------------------------------
286    // Init state returns None for the first N samples
287    // ---------------------------------------------------------------
288    #[test]
289    fn init_state_returns_none() {
290        let mut servo = default_servo();
291        // Default init_samples = 4; samples 1..=4 should return None.
292        for i in 1..=4 {
293            let action = servo.sample(1000.0, POLL_INTERVAL);
294            assert!(
295                matches!(action, ServoAction::None),
296                "sample {i} should be None during init"
297            );
298            assert_eq!(servo.state(), ServoState::Init);
299        }
300    }
301
302    // ---------------------------------------------------------------
303    // Large offset triggers Step
304    // ---------------------------------------------------------------
305    #[test]
306    fn large_offset_triggers_step() {
307        let mut servo = default_servo();
308        // An offset > 128ms should step.
309        let action = servo.sample(200_000_000.0, POLL_INTERVAL);
310        match action {
311            ServoAction::Step { offset_ns } => {
312                assert_eq!(offset_ns, 200_000_000);
313            }
314            other => panic!("expected Step, got {:?}", other),
315        }
316        // After a step the servo should be back in Init.
317        assert_eq!(servo.state(), ServoState::Init);
318        assert_eq!(servo.sample_count(), 0);
319    }
320
321    #[test]
322    fn negative_large_offset_triggers_step() {
323        let mut servo = default_servo();
324        let action = servo.sample(-200_000_000.0, POLL_INTERVAL);
325        match action {
326            ServoAction::Step { offset_ns } => {
327                assert_eq!(offset_ns, -200_000_000);
328            }
329            other => panic!("expected Step, got {:?}", other),
330        }
331    }
332
333    // ---------------------------------------------------------------
334    // FLL mode adjusts frequency
335    // ---------------------------------------------------------------
336    #[test]
337    fn fll_mode_adjusts_frequency() {
338        let mut servo = servo_with_config(2, 8);
339        // Burn through init.
340        for _ in 0..2 {
341            servo.sample(5000.0, POLL_INTERVAL);
342        }
343        assert_eq!(servo.state(), ServoState::Init);
344
345        // Next sample should transition to FLL and produce a frequency adjustment.
346        let action = servo.sample(5000.0, POLL_INTERVAL);
347        assert_eq!(servo.state(), ServoState::FrequencyLock);
348        match action {
349            ServoAction::AdjustFrequency { ppm } => {
350                assert!(ppm != 0.0, "FLL should produce non-zero correction");
351            }
352            other => panic!("expected AdjustFrequency, got {:?}", other),
353        }
354    }
355
356    // ---------------------------------------------------------------
357    // PLL mode adjusts frequency with smaller correction
358    // ---------------------------------------------------------------
359    #[test]
360    fn pll_mode_smaller_correction() {
361        let mut servo = servo_with_config(1, 2);
362        // Init phase.
363        servo.sample(1000.0, POLL_INTERVAL);
364
365        // FLL phase: gather 2 samples.
366        servo.sample(1000.0, POLL_INTERVAL);
367        servo.sample(1000.0, POLL_INTERVAL);
368        assert_eq!(servo.state(), ServoState::FrequencyLock);
369
370        // Record the FLL correction magnitude.
371        let fll_action = servo.sample(1000.0, POLL_INTERVAL);
372        // This sample transitions to PLL (active_samples = 3 > fll_samples = 2).
373        assert_eq!(servo.state(), ServoState::PhaseLock);
374
375        // Now take a PLL sample with a fresh servo to compare gain magnitudes
376        // without accumulated integral drift.
377        let mut servo_pll = servo_with_config(1, 1);
378        servo_pll.sample(1000.0, POLL_INTERVAL); // init
379        servo_pll.sample(1000.0, POLL_INTERVAL); // fll sample 1
380        // Active sample 2 > fll_samples 1, so transition to PLL.
381        let pll_action = servo_pll.sample(1000.0, POLL_INTERVAL);
382        assert_eq!(servo_pll.state(), ServoState::PhaseLock);
383
384        let fll_ppm = match fll_action {
385            ServoAction::AdjustFrequency { ppm } => ppm.abs(),
386            _ => panic!("expected AdjustFrequency"),
387        };
388        let pll_ppm = match pll_action {
389            ServoAction::AdjustFrequency { ppm } => ppm.abs(),
390            _ => panic!("expected AdjustFrequency"),
391        };
392
393        // PLL gains are much smaller than FLL gains, so the first PLL correction
394        // (with minimal integral) should be smaller than a comparable FLL correction.
395        assert!(
396            pll_ppm < fll_ppm,
397            "PLL correction ({pll_ppm}) should be smaller than FLL ({fll_ppm})"
398        );
399    }
400
401    // ---------------------------------------------------------------
402    // Frequency is clamped to max
403    // ---------------------------------------------------------------
404    #[test]
405    fn frequency_clamped_to_max() {
406        let mut servo = servo_with_config(0, 1);
407        // With init_samples=0, the very first sample enters FLL.
408        // Use a large offset (but under step threshold) to push the correction high.
409        for _ in 0..50 {
410            servo.sample(100_000_000.0, POLL_INTERVAL); // 100ms offset
411        }
412        let freq = servo.frequency().abs();
413        assert!(
414            freq <= 500.0,
415            "frequency {freq} should be clamped to 500 PPM"
416        );
417    }
418
419    // ---------------------------------------------------------------
420    // State transitions: Init -> FLL -> PLL
421    // ---------------------------------------------------------------
422    #[test]
423    fn state_transitions() {
424        let mut servo = servo_with_config(2, 3);
425        let offset = 1000.0;
426
427        // Samples 1-2: Init
428        for _ in 0..2 {
429            servo.sample(offset, POLL_INTERVAL);
430            assert_eq!(servo.state(), ServoState::Init);
431        }
432
433        // Sample 3: transitions to FLL (active_sample=1 <= fll_samples=3)
434        servo.sample(offset, POLL_INTERVAL);
435        assert_eq!(servo.state(), ServoState::FrequencyLock);
436
437        // Samples 4-5: still FLL (active 2, 3)
438        servo.sample(offset, POLL_INTERVAL);
439        assert_eq!(servo.state(), ServoState::FrequencyLock);
440        servo.sample(offset, POLL_INTERVAL);
441        assert_eq!(servo.state(), ServoState::FrequencyLock);
442
443        // Sample 6: active_sample=4 > fll_samples=3, transitions to PLL
444        servo.sample(offset, POLL_INTERVAL);
445        assert_eq!(servo.state(), ServoState::PhaseLock);
446
447        // Sample 7: stays in PLL
448        servo.sample(offset, POLL_INTERVAL);
449        assert_eq!(servo.state(), ServoState::PhaseLock);
450    }
451
452    // ---------------------------------------------------------------
453    // Step resets state to Init
454    // ---------------------------------------------------------------
455    #[test]
456    fn step_resets_to_init() {
457        let mut servo = servo_with_config(1, 2);
458        // Warm up and enter FLL.
459        servo.sample(1000.0, POLL_INTERVAL);
460        servo.sample(1000.0, POLL_INTERVAL);
461        assert_eq!(servo.state(), ServoState::FrequencyLock);
462
463        // Now feed a huge offset to trigger a step.
464        let action = servo.sample(500_000_000.0, POLL_INTERVAL);
465        assert!(matches!(action, ServoAction::Step { .. }));
466        assert_eq!(servo.state(), ServoState::Init);
467        assert_eq!(servo.sample_count(), 0);
468        assert_eq!(servo.frequency(), 0.0);
469    }
470
471    // ---------------------------------------------------------------
472    // Convergence: offset should shrink over repeated samples
473    // ---------------------------------------------------------------
474    #[test]
475    fn converges_toward_zero() {
476        // Verify the servo produces a positive frequency correction for a
477        // positive offset, which would reduce the offset over time.
478        let mut servo = servo_with_config(2, 4);
479        let offset = 50_000.0; // 50us offset
480
481        // Init phase.
482        for _ in 0..2 {
483            servo.sample(offset, POLL_INTERVAL);
484        }
485
486        // First active sample (FLL mode): should produce a positive PPM
487        // correction to counteract the positive offset.
488        let action = servo.sample(offset, POLL_INTERVAL);
489        match action {
490            ServoAction::AdjustFrequency { ppm } => {
491                assert!(
492                    ppm > 0.0,
493                    "positive offset should produce positive freq correction, got {ppm}"
494                );
495            }
496            other => panic!("expected AdjustFrequency, got {:?}", other),
497        }
498
499        // Negative offset should produce negative correction.
500        let mut servo2 = servo_with_config(2, 4);
501        let neg_offset = -50_000.0;
502        for _ in 0..2 {
503            servo2.sample(neg_offset, POLL_INTERVAL);
504        }
505        let action2 = servo2.sample(neg_offset, POLL_INTERVAL);
506        match action2 {
507            ServoAction::AdjustFrequency { ppm } => {
508                assert!(
509                    ppm < 0.0,
510                    "negative offset should produce negative freq correction, got {ppm}"
511                );
512            }
513            other => panic!("expected AdjustFrequency, got {:?}", other),
514        }
515    }
516
517    // ---------------------------------------------------------------
518    // Zero offset produces zero (or near-zero) correction
519    // ---------------------------------------------------------------
520    #[test]
521    fn zero_offset_no_correction() {
522        let mut servo = servo_with_config(1, 2);
523        servo.sample(0.0, POLL_INTERVAL); // init
524
525        let action = servo.sample(0.0, POLL_INTERVAL);
526        match action {
527            ServoAction::AdjustFrequency { ppm } => {
528                assert!(
529                    ppm.abs() < 1e-12,
530                    "zero offset should produce ~zero correction, got {ppm}"
531                );
532            }
533            other => panic!("expected AdjustFrequency, got {:?}", other),
534        }
535    }
536
537    // ---------------------------------------------------------------
538    // Negative offsets produce negative frequency adjustments
539    // ---------------------------------------------------------------
540    #[test]
541    fn negative_offset_negative_correction() {
542        let mut servo = servo_with_config(1, 4);
543        servo.sample(-5000.0, POLL_INTERVAL); // init
544
545        let action = servo.sample(-5000.0, POLL_INTERVAL);
546        match action {
547            ServoAction::AdjustFrequency { ppm } => {
548                assert!(
549                    ppm < 0.0,
550                    "negative offset should give negative ppm, got {ppm}"
551                );
552            }
553            other => panic!("expected AdjustFrequency, got {:?}", other),
554        }
555    }
556
557    // ---------------------------------------------------------------
558    // Panic threshold: implausibly large offsets are rejected, not stepped
559    // ---------------------------------------------------------------
560    #[test]
561    fn insanely_large_offset_rejected() {
562        // With the initial-step bypass disabled, a panic-exceeding offset must
563        // be rejected even on the very first sample.
564        let mut servo = PiServo::new(ServoConfig {
565            allow_initial_step: false,
566            ..ServoConfig::default()
567        });
568        // 24 million seconds -- matches the kind of bogus step seen in the
569        // year-9920 incident. Should Reject, not Step.
570        let action = servo.sample(24_621_704_000_000_000.0, POLL_INTERVAL);
571        match action {
572            ServoAction::Reject { offset_ns } => {
573                assert_eq!(offset_ns, 24_621_704_000_000_000);
574            }
575            other => panic!("expected Reject, got {:?}", other),
576        }
577        // State must be untouched so good measurements can still drive convergence.
578        assert_eq!(servo.state(), ServoState::Init);
579        assert_eq!(servo.sample_count(), 0);
580    }
581
582    #[test]
583    fn negative_insanely_large_offset_rejected() {
584        let mut servo = PiServo::new(ServoConfig {
585            allow_initial_step: false,
586            ..ServoConfig::default()
587        });
588        let action = servo.sample(-24_621_704_000_000_000.0, POLL_INTERVAL);
589        assert!(matches!(action, ServoAction::Reject { .. }));
590        assert_eq!(servo.sample_count(), 0);
591    }
592
593    #[test]
594    fn just_under_panic_threshold_still_steps() {
595        // 999ms is well over the 128ms step threshold but just under the 1s
596        // panic threshold -- it must still step (this is how NTP recovers
597        // from genuine drift after a long outage).
598        let mut servo = default_servo();
599        let action = servo.sample(999_000_000.0, POLL_INTERVAL);
600        assert!(matches!(action, ServoAction::Step { .. }));
601    }
602
603    #[test]
604    fn rejected_sample_does_not_disturb_convergence() {
605        // A single rogue measurement should not reset an already-converging
606        // servo or consume an init slot. The bypass is disabled here so we're
607        // exercising the steady-state panic path -- the bypass only ever fires
608        // before the first step, not in the middle of a converged session.
609        let mut servo = PiServo::new(ServoConfig {
610            init_samples: 2,
611            fll_samples: 4,
612            allow_initial_step: false,
613            ..ServoConfig::default()
614        });
615        servo.sample(1000.0, POLL_INTERVAL); // init sample 1
616        servo.sample(1000.0, POLL_INTERVAL); // init sample 2
617
618        // Now a rogue huge offset arrives -- must be rejected.
619        let action = servo.sample(1e18, POLL_INTERVAL);
620        assert!(matches!(action, ServoAction::Reject { .. }));
621        // Sample count unchanged, state still Init.
622        assert_eq!(servo.sample_count(), 2);
623        assert_eq!(servo.state(), ServoState::Init);
624
625        // Next real sample should transition to FLL as if the rogue never arrived.
626        let action = servo.sample(1000.0, POLL_INTERVAL);
627        assert_eq!(servo.state(), ServoState::FrequencyLock);
628        assert!(matches!(action, ServoAction::AdjustFrequency { .. }));
629    }
630
631    #[test]
632    fn custom_panic_threshold_honored() {
633        // If a user sets a tighter panic threshold, it takes effect.
634        let mut servo = PiServo::new(ServoConfig {
635            step_threshold_ns: 100_000_000.0,  // 100ms
636            panic_threshold_ns: 500_000_000.0, // 500ms
637            allow_initial_step: false,
638            ..ServoConfig::default()
639        });
640        // 600ms is > 500ms panic threshold → reject.
641        let action = servo.sample(600_000_000.0, POLL_INTERVAL);
642        assert!(matches!(action, ServoAction::Reject { .. }));
643    }
644
645    // ---------------------------------------------------------------
646    // allow_initial_step: cold-boot bypass of the panic clamp
647    // ---------------------------------------------------------------
648    #[test]
649    fn initial_step_bypasses_panic_when_enabled() {
650        // Mirrors the real-world appliance case: VM suspended for ~47 min,
651        // measured offset ~2.84e12 ns, panic threshold 1s. Default config has
652        // allow_initial_step=true so the first sample must Step, not Reject.
653        let mut servo = default_servo();
654        let huge = 2_844_849_653_081.0;
655        let action = servo.sample(huge, POLL_INTERVAL);
656        match action {
657            ServoAction::Step { offset_ns } => {
658                assert_eq!(offset_ns, huge as i64);
659            }
660            other => panic!("expected Step, got {:?}", other),
661        }
662    }
663
664    #[test]
665    fn initial_step_only_fires_once() {
666        // After the cold-boot bypass has fired, the panic clamp re-engages
667        // for all subsequent oversized samples.
668        let mut servo = default_servo();
669        // First huge offset: bypass fires, returns Step.
670        let action = servo.sample(2_844_849_653_081.0, POLL_INTERVAL);
671        assert!(matches!(action, ServoAction::Step { .. }));
672        // Second huge offset: bypass already spent → Reject.
673        let action = servo.sample(2_844_849_653_081.0, POLL_INTERVAL);
674        assert!(matches!(action, ServoAction::Reject { .. }));
675    }
676
677    #[test]
678    fn initial_step_bypass_disabled_rejects() {
679        // Opting out of the bypass restores the strict steady-state semantic
680        // (Reject even on first sample).
681        let mut servo = PiServo::new(ServoConfig {
682            allow_initial_step: false,
683            ..ServoConfig::default()
684        });
685        let action = servo.sample(2_844_849_653_081.0, POLL_INTERVAL);
686        assert!(matches!(action, ServoAction::Reject { .. }));
687    }
688
689    #[test]
690    fn normal_step_also_arms_panic_clamp() {
691        // A normal in-threshold Step (e.g., legitimate drift recovery during
692        // steady state) also flips `has_stepped`, so a later huge measurement
693        // can't sneak through under the cold-boot bypass.
694        let mut servo = default_servo();
695        // 999ms is over the 128ms step threshold but under the 1s panic
696        // threshold → normal Step, has_stepped flips true.
697        let action = servo.sample(999_000_000.0, POLL_INTERVAL);
698        assert!(matches!(action, ServoAction::Step { .. }));
699        // Now a huge offset should be Rejected, not Stepped via bypass.
700        let action = servo.sample(2_844_849_653_081.0, POLL_INTERVAL);
701        assert!(matches!(action, ServoAction::Reject { .. }));
702    }
703
704    // ---------------------------------------------------------------
705    // with_defaults constructor works
706    // ---------------------------------------------------------------
707    #[test]
708    fn with_defaults_constructor() {
709        let servo = PiServo::with_defaults();
710        assert_eq!(servo.state(), ServoState::Init);
711        assert_eq!(servo.sample_count(), 0);
712        assert_eq!(servo.frequency(), 0.0);
713    }
714}