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}