Skip to main content

rusty_time_core/
discipline.rs

1//! The clock-discipline loop: turn estimates into clock commands.
2//!
3//! The platform driver (or the simulator) executes [`ClockCommand`]s; this module
4//! only decides. Frequency corrections come straight from the regression slope —
5//! the estimator measures frequency directly, so no PLL time constant is needed
6//! (this is the chrony approach, and the reason for its fast convergence).
7
8/// Configuration mirroring the chrony.conf directives we honor.
9#[derive(Clone, Copy, Debug, PartialEq)]
10pub struct DisciplineConfig {
11    /// Step (rather than slew) when |offset| exceeds this, during the first
12    /// `makestep_limit` updates. `None` = never step.
13    pub makestep_threshold: Option<f64>,
14    pub makestep_limit: u32,
15    /// Cap on offset-correction slew rate, ppm.
16    pub max_slew_ppm: f64,
17    /// Cap on the absolute frequency correction we will command, ppm.
18    pub max_freq_ppm: f64,
19    /// log2 seconds.
20    pub min_poll: i8,
21    pub max_poll: i8,
22    /// Send an initial burst of quick polls to converge fast (chrony `iburst`).
23    pub iburst: bool,
24}
25
26impl Default for DisciplineConfig {
27    fn default() -> Self {
28        DisciplineConfig {
29            makestep_threshold: Some(1.0),
30            makestep_limit: 3,
31            max_slew_ppm: 83_333.0,
32            max_freq_ppm: 500.0,
33            min_poll: 6,
34            max_poll: 10,
35            iburst: true,
36        }
37    }
38}
39
40/// What the platform driver should do right now.
41#[derive(Clone, Copy, Debug, PartialEq)]
42pub enum ClockCommand {
43    /// Add this many seconds to the clock immediately.
44    Step { add_seconds: f64 },
45    /// Run at `freq_ppm` (absolute correction vs the undisciplined clock) and
46    /// additionally drain `drain_offset` seconds at up to `drain_rate_ppm`.
47    Slew {
48        freq_ppm: f64,
49        drain_offset: f64,
50        drain_rate_ppm: f64,
51    },
52}
53
54#[derive(Clone, Copy, Debug, PartialEq)]
55pub struct Plan {
56    pub command: ClockCommand,
57    /// Seconds until the next poll.
58    pub next_poll_s: f64,
59    /// The sample register is invalid after a step; caller must shift or clear it.
60    pub reset_register: bool,
61}
62
63/// Number of quick polls in an iburst, and their spacing.
64const IBURST_COUNT: u32 = 4;
65const IBURST_SPACING_S: f64 = 2.0;
66/// Drain a measured offset over roughly this many poll intervals.
67const CORR_TIME_RATIO: f64 = 3.0;
68/// Correction time, in poll intervals, for an offset that is plainly real.
69/// One means "finish before the next sample arrives".
70const ACQUIRE_CORR_RATIO: f64 = 1.0;
71/// How far outside the noise an offset must sit to be treated as real.
72const ACQUIRE_NOISE_MULTIPLE: f64 = 10.0;
73/// How many updates count as acquisition.
74///
75/// Acquisition is a *phase*, not a magnitude. Gating the fast correction on
76/// "the offset is much larger than the noise" looked equivalent and is not:
77/// a loop that is confidently wrong reports a small `offset_sd` beside a large
78/// error, so the test fires in steady state exactly when it should not. The
79/// in-house S6 scenario is such a case — a deliberately noisy path where the
80/// estimator's own confidence outruns its accuracy — and gating on magnitude
81/// alone took its steady error from 2.54 ms to 10.83 ms while the low-noise
82/// clknetsim rig showed only the improvement. Counting updates cannot be
83/// fooled that way: after this many the loop is no longer starting up,
84/// whatever it believes about itself.
85const ACQUIRE_UPDATES: u32 = 8;
86/// The most of the slew budget the fast correction may ask for.
87///
88/// Leaving headroom is the point. A correction that consumes the whole budget
89/// pins the clock at maximum rate for the entire interval, and the frequency
90/// estimator then has to infer a drift from samples taken while the clock was
91/// being hauled — which it does badly enough to leave a permanently worse
92/// steady state. A quarter keeps the fast path for offsets it can absorb and
93/// hands genuinely large cold starts back to the gentle drain.
94const ACQUIRE_SLEW_SHARE: f64 = 0.25;
95/// How many times the noise an offset must exceed before the clock may be
96/// hauled at the full slew ceiling.
97const ACQUIRE_FULL_SPEED_CONFIDENCE: f64 = 10_000.0;
98/// Above this share of the slew ceiling, the clock is being *hauled*, and a
99/// frequency measured across that haul is not a measurement of the
100/// oscillator.
101///
102/// The regression fits a slope through stored samples, and `slew_samples`
103/// re-expresses that history for corrections already applied. That accounting
104/// is exact for a gentle drain. It is not robust to a correction running at
105/// most of the slew ceiling: any small mismatch between the rate commanded and
106/// the rate delivered is multiplied by the poll interval and lands in the
107/// slope, and the loop then carries a frequency error it never measured. The
108/// offset drain has feedback and recovers; the frequency term accumulates and
109/// does not.
110///
111/// So during a haul the offset is still corrected at full speed — the clock is
112/// visibly wrong and the fix is not in doubt — but the frequency estimate is
113/// left alone until the samples describe a clock that is merely running.
114const FREQ_TRUST_SLEW_SHARE: f64 = 0.25;
115/// Most polls the acquisition burst may take before it must slow down.
116///
117/// The burst normally ends after `IBURST_COUNT` samples, and the poll then
118/// jumps straight to `min_poll`. That is the whole S6 gap: the offset drain is
119/// sized to finish within one poll interval, so ending the burst with a large
120/// correction still outstanding hands the remainder a 16 s deadline instead of
121/// a 2 s one. Measured against chrony, chrony had a 500 ms cold start gone in
122/// about 7 s at close to its slew ceiling, while this loop cleared 500 ms down
123/// to 89 ms in the burst and then spent a further 16 s on what was left.
124///
125/// So the burst ends when the offset is small, not when a counter runs out.
126/// The cap is what stops that becoming an unbounded fast poll against someone
127/// else's server: a client that cannot converge is a client that must back off
128/// anyway, not one that should keep asking every two seconds.
129const MAX_ACQUIRE_BURST: u32 = 16;
130/// The offset at which acquisition is finished and the burst may end.
131///
132/// Tied to what "converged" means rather than to a multiple of the noise. The
133/// noise-multiple test was tried first and fails at exactly the wrong moment:
134/// on S6 the burst had hauled 500 ms down to 9.8 ms, `10 x noise` came out at
135/// about 10 ms, the test went false, the poll jumped 2 s -> 16 s, and the last
136/// 9.8 ms was handed a 16 s deadline. Those 16 s were the whole difference
137/// against chrony. Floored at twice the noise so a genuinely noisy path is not
138/// polled fast in pursuit of an offset it cannot resolve.
139const ACQUIRE_DONE_S: f64 = 1e-3;
140
141#[derive(Clone, Debug)]
142pub struct Discipline {
143    cfg: DisciplineConfig,
144    freq_ppm: f64,
145    updates: u32,
146    poll: i8,
147    stable_streak: u32,
148    iburst_left: u32,
149    /// Drain rate the previous plan commanded, as a share of the ceiling.
150    /// Samples taken since then were taken while the clock moved at that rate.
151    last_drain_share: f64,
152    /// Burst polls used, including any the acquisition extension granted.
153    burst_used: u32,
154}
155
156impl Discipline {
157    pub fn new(cfg: DisciplineConfig) -> Self {
158        let iburst_left = if cfg.iburst { IBURST_COUNT } else { 0 };
159        Discipline {
160            cfg,
161            freq_ppm: 0.0,
162            updates: 0,
163            poll: cfg.min_poll,
164            stable_streak: 0,
165            iburst_left,
166            last_drain_share: 0.0,
167            burst_used: 0,
168        }
169    }
170
171    /// How much of the slew budget an acquisition correction may use, given
172    /// how well the offset is known.
173    ///
174    /// How fast the clock may be hauled should depend on how sure we are where
175    /// it is going. A 500 ms offset on a path with microseconds of jitter is
176    /// known to five decimal places and can be cleared at the ceiling; the same
177    /// 500 ms on a path with a millisecond of jitter is a much rougher number,
178    /// and committing to it at full speed writes the roughness into the clock.
179    ///
180    /// Both rigs demanded this. On clknetsim, restricting the share left S6 at
181    /// 18 s against chrony's 12 s; on the in-house corpus, whose S6 models a
182    /// 0.74 ms-jitter path, allowing the full share took its steady error from
183    /// 1.5 ms to 5.9 ms. Neither constant satisfies both, because the two rigs
184    /// differ by two orders of magnitude in exactly the quantity that should
185    /// decide it.
186    fn acquire_share(&self, offset: f64, noise: f64) -> f64 {
187        let confidence = offset.abs() / noise.max(1e-9);
188        if confidence >= ACQUIRE_FULL_SPEED_CONFIDENCE {
189            1.0
190        } else {
191            ACQUIRE_SLEW_SHARE
192        }
193    }
194
195    /// Current commanded frequency correction, ppm.
196    pub fn freq_ppm(&self) -> f64 {
197        self.freq_ppm
198    }
199
200    pub fn poll_log2(&self) -> i8 {
201        self.poll
202    }
203
204    /// Feed the latest combined estimate.
205    ///
206    /// * `offset` — seconds to add to the local clock, now.
207    /// * `freq_ppm_meas` — residual frequency error from the regression (ppm,
208    ///   positive = local slow), if trusted.
209    /// * `offset_sd` — residual noise of the estimate.
210    pub fn on_estimate(&mut self, offset: f64, freq_ppm_meas: Option<f64>, offset_sd: f64) -> Plan {
211        self.updates += 1;
212
213        // Step epoch: large offsets early on are stepped away, chrony `makestep`.
214        if let Some(threshold) = self.cfg.makestep_threshold
215            && offset.abs() > threshold
216            && self.updates <= self.cfg.makestep_limit
217        {
218            self.stable_streak = 0;
219            return Plan {
220                command: ClockCommand::Step {
221                    add_seconds: offset,
222                },
223                next_poll_s: self.take_poll_interval(),
224                reset_register: true,
225            };
226        }
227
228        // Frequency: the regression slope is a direct measurement of the residual
229        // frequency error of the *disciplined* clock, so accumulate it fully --
230        // unless these samples were taken while the clock was being hauled, in
231        // which case the slope is mostly the haul.
232        let hauling = self.last_drain_share > FREQ_TRUST_SLEW_SHARE;
233        if let Some(fm) = freq_ppm_meas
234            && !hauling
235        {
236            self.freq_ppm =
237                (self.freq_ppm + fm).clamp(-self.cfg.max_freq_ppm, self.cfg.max_freq_ppm);
238        }
239
240        // Poll adaptation first: lengthen when quiet, shorten when the offset is
241        // loud relative to the noise floor. Runs before the drain computation so
242        // the drain rate is sized for the interval the plan will actually use.
243        let noise = offset_sd.max(1e-7);
244        if offset.abs() < 2.0 * noise {
245            self.stable_streak += 1;
246            if self.stable_streak >= 3 && self.poll < self.cfg.max_poll {
247                self.poll += 1;
248                self.stable_streak = 0;
249            }
250        } else {
251            self.stable_streak = 0;
252            if offset.abs() > 10.0 * noise && self.poll > self.cfg.min_poll {
253                self.poll -= 1;
254            }
255        }
256
257        // Offset: drain over ~CORR_TIME_RATIO poll intervals, capped by maxslewrate.
258        //
259        // ...except while the offset is unambiguous. The loop re-plans on every
260        // sample, so a drain sized to finish in three poll intervals only ever
261        // runs for one of them before being replaced: the offset decays by a
262        // third per poll, giving a time constant three times longer than the
263        // ratio suggests. In steady state that is exactly the wanted
264        // behaviour — it is what stops sample noise being written into the
265        // clock. During acquisition it is not: a 10 ms startup offset is a
266        // hundred times the noise floor, it is not in dispute, and decaying it
267        // by a third per 16 s poll leaves the clock wrong for a minute.
268        // Measured against chrony under clknetsim, chrony had removed the same
269        // offset within about two seconds while this loop was still 40 s away.
270        //
271        // The test is the one the poll adaptation already uses: an offset far
272        // outside the noise is a real error, not a noisy reading, so correct
273        // it within the interval. Once it is comparable to the noise the
274        // gentle ratio takes over again, and steady-state accuracy — which is
275        // at parity with chrony — is untouched.
276        // Keep the acquisition burst going while a correction is still
277        // outstanding. The drain is sized to finish within one poll interval,
278        // so ending the burst early does not merely delay the next
279        // measurement — it stretches the correction itself from two seconds to
280        // sixteen. On S6 that single step was the entire gap against chrony:
281        // the burst hauled 500 ms down to 9.8 ms by t=10.5 s, then handed what
282        // was left a 16 s deadline and finished at t=26 s where chrony
283        // finished at t=12 s.
284        if self.iburst_left == 0
285            && self.cfg.iburst
286            && self.burst_used < MAX_ACQUIRE_BURST
287            && offset.abs() > ACQUIRE_DONE_S.max(2.0 * noise)
288        {
289            self.iburst_left = 1;
290        }
291
292        let poll_s = self.peek_poll_interval();
293        //
294        // The fast path's premise is "finish this correction before the next
295        // sample". If the rate that would take is above the slew ceiling, the
296        // correction cannot finish within the interval, the premise is false,
297        // and asking for it anyway just pins the clock at maximum slew for the
298        // whole interval — which is how a 500 ms cold start went from a 2.54 ms
299        // steady error to 10.83 ms on the noisy in-house rig while the
300        // low-noise one showed only the improvement. So the fast ratio applies
301        // only when it is actually achievable, and a correction too large to
302        // finish is drained gently, as before.
303        let acquiring = self.updates <= ACQUIRE_UPDATES;
304        // Rate: gentle by default, faster while acquiring.
305        //
306        // The rate stays tied to the poll interval even though drains are now
307        // budgeted and stop when spent. Untying it was tried — "clear any
308        // acquisition offset in ACQUIRE_TARGET_S seconds" — and it is worse:
309        // with a 16 s poll it corrects the whole of each noisy estimate in two
310        // seconds and then coasts for fourteen, which chases noise instead of
311        // averaging it. Scaling with the poll is what makes the correction
312        // proportional to how often the loop actually gets to look.
313        //
314        // What the budget buys is not a faster rate here. It is that the rate
315        // is now free to be chosen at all: an over-fast drain no longer sails
316        // past the offset, it stops at it. Measured on the same binary, the
317        // same discipline with budgets unenforced settles at 579 us on S6 and
318        // with them enforced at 130 us.
319        let wanted_rate_ppm = if acquiring && offset.abs() > ACQUIRE_NOISE_MULTIPLE * noise {
320            // Move at the fastest rate allowed and stop when the offset is
321            // gone. This is only expressible because the drain carries a
322            // budget: without one, a rate this high would not stop at the
323            // offset, it would sail past it, so the rate had to be "the offset
324            // divided by the poll interval" and a cold start's remainder was
325            // handed the poll's deadline. That is what put S6 at 26 s against
326            // chrony's 12 s.
327            // Scales with the poll, so a long interval gets a gentle rate and
328            // the loop averages noise instead of chasing it. A fixed clearing
329            // time was tried and is wrong for exactly that reason: at a 64 s
330            // poll, "clear it in 2 s" is thirty times more aggressive than the
331            // interval warrants, and the in-house S6 steady error went from
332            // 1.5 ms to 9 ms.
333            //
334            // The ceiling is the whole slew budget rather than a quarter of
335            // it. That is safe only because the drain stops when spent: an
336            // over-fast rate now runs out at the offset instead of sailing
337            // past it, and what it delivered is booked even if the caller wakes
338            // late. Without those two properties this cap had to stay low.
339            // Poll-scaled, with a ceiling that depends on how well the offset
340            // is known.
341            //
342            // Untying the rate from the poll entirely -- "clear it in
343            // ACQUIRE_TARGET_S" -- was tried twice and measured worse both
344            // times: 16 s on S6 against 14 s here, and on the noisy rig a fixed
345            // clearing time at a 64 s poll is thirty times more aggressive than
346            // the interval warrants, which chases jitter instead of averaging
347            // it. Scaling with the poll is what keeps the correction
348            // proportional to how often the loop gets to look.
349            ((offset.abs() / (ACQUIRE_CORR_RATIO * poll_s)) * 1e6)
350                .min(self.cfg.max_slew_ppm * self.acquire_share(offset, noise))
351        } else {
352            (offset.abs() / (CORR_TIME_RATIO * poll_s)) * 1e6
353        };
354        let drain_rate_ppm = wanted_rate_ppm.min(self.cfg.max_slew_ppm);
355        self.last_drain_share = if self.cfg.max_slew_ppm > 0.0 {
356            drain_rate_ppm / self.cfg.max_slew_ppm
357        } else {
358            0.0
359        };
360
361        Plan {
362            command: ClockCommand::Slew {
363                freq_ppm: self.freq_ppm,
364                drain_offset: offset,
365                drain_rate_ppm,
366            },
367            next_poll_s: self.take_poll_interval(),
368            reset_register: false,
369        }
370    }
371
372    /// How long to wait before trying again when an exchange yields nothing —
373    /// lost, or rejected because the server was not yet usable.
374    ///
375    /// This is the iburst spacing while the burst budget lasts, *not* the poll
376    /// interval. A server that has only just started answers its first requests
377    /// with the unsynchronised leap indicator, which a client must refuse; if
378    /// that refusal then costs a full poll interval, a cold start is delayed by
379    /// 16 seconds before the first usable sample. Measured against chrony under
380    /// clknetsim, that single wait was most of an 8x convergence gap.
381    ///
382    /// Nothing is consumed here: a failed exchange must not spend burst budget,
383    /// or a few early losses would silently end the burst.
384    pub fn retry_interval_s(&self) -> f64 {
385        self.peek_poll_interval()
386    }
387
388    /// The interval the *next* plan will use, without consuming iburst budget.
389    fn peek_poll_interval(&self) -> f64 {
390        if self.iburst_left > 0 {
391            IBURST_SPACING_S
392        } else {
393            2f64.powi(self.poll as i32)
394        }
395    }
396
397    /// Consume one poll slot — called exactly once per emitted Plan.
398    fn take_poll_interval(&mut self) -> f64 {
399        if self.iburst_left > 0 {
400            self.iburst_left -= 1;
401            self.burst_used += 1;
402            IBURST_SPACING_S
403        } else {
404            2f64.powi(self.poll as i32)
405        }
406    }
407}
408
409#[cfg(test)]
410mod acquisition_tests {
411    use super::*;
412
413    fn acquiring() -> Discipline {
414        Discipline::new(DisciplineConfig {
415            makestep_threshold: None,
416            min_poll: 4, // 16 s
417            iburst: true,
418            ..DisciplineConfig::default()
419        })
420    }
421
422    #[test]
423    fn the_burst_continues_while_a_correction_is_outstanding() {
424        // The drain is sized to finish within one poll interval, so ending the
425        // burst with an offset still outstanding does not just delay the next
426        // measurement — it stretches the correction from 2 s to 16 s. Against
427        // chrony on S6 that one step was the whole gap: 500 ms was hauled down
428        // to 9.8 ms by the burst, and the remainder then took another 16 s.
429        let mut d = acquiring();
430        let mut plan = None;
431        for _ in 0..IBURST_COUNT + 3 {
432            // A 10 ms offset, far above both the 1 ms target and the noise.
433            plan = Some(d.on_estimate(0.010, None, 1e-6));
434        }
435        let next = plan.expect("a plan").next_poll_s;
436        assert!(
437            next <= IBURST_SPACING_S,
438            "burst ended with 10 ms still outstanding: next poll {next} s"
439        );
440    }
441
442    #[test]
443    fn the_burst_ends_once_the_offset_is_small() {
444        // ...and it must end, or a converged client polls a stranger's server
445        // every two seconds forever.
446        let mut d = acquiring();
447        let mut plan = None;
448        for _ in 0..IBURST_COUNT + 3 {
449            plan = Some(d.on_estimate(1e-6, None, 1e-6));
450        }
451        let next = plan.expect("a plan").next_poll_s;
452        assert!(
453            next > IBURST_SPACING_S,
454            "burst kept running on a converged clock: next poll {next} s"
455        );
456    }
457
458    #[test]
459    fn the_extended_burst_is_bounded() {
460        // A client that never converges must back off rather than keep asking.
461        let mut d = acquiring();
462        let mut plan = None;
463        for _ in 0..MAX_ACQUIRE_BURST * 3 {
464            plan = Some(d.on_estimate(0.010, None, 1e-6));
465        }
466        let next = plan.expect("a plan").next_poll_s;
467        assert!(
468            next > IBURST_SPACING_S,
469            "burst never backed off despite never converging: next poll {next} s"
470        );
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    #[test]
479    fn big_initial_offset_is_stepped() {
480        let mut d = Discipline::new(DisciplineConfig::default());
481        let plan = d.on_estimate(120.0, None, 1e-4);
482        assert!(matches!(
483            plan.command,
484            ClockCommand::Step { add_seconds } if (add_seconds - 120.0).abs() < 1e-9
485        ));
486        assert!(plan.reset_register);
487    }
488
489    #[test]
490    fn step_window_closes() {
491        let mut d = Discipline::new(DisciplineConfig::default());
492        for _ in 0..3 {
493            let _ = d.on_estimate(0.0001, None, 1e-4);
494        }
495        // Fourth update: even a huge offset must slew, not step.
496        let plan = d.on_estimate(5.0, None, 1e-4);
497        assert!(matches!(plan.command, ClockCommand::Slew { .. }));
498    }
499
500    #[test]
501    fn freq_accumulates_and_clamps() {
502        let mut d = Discipline::new(DisciplineConfig::default());
503        let _ = d.on_estimate(1e-4, Some(100.0), 1e-4);
504        assert!((d.freq_ppm() - 100.0).abs() < 1e-9);
505        let _ = d.on_estimate(1e-4, Some(1000.0), 1e-4);
506        assert!((d.freq_ppm() - 500.0).abs() < 1e-9, "clamped at max_freq");
507    }
508
509    #[test]
510    fn iburst_then_normal_cadence() {
511        let mut d = Discipline::new(DisciplineConfig::default());
512        let mut intervals = Vec::new();
513        for _ in 0..6 {
514            let plan = d.on_estimate(1e-5, None, 1e-4);
515            intervals.push(plan.next_poll_s);
516        }
517        assert!(intervals[..4].iter().all(|&i| i == 2.0), "{intervals:?}");
518        assert!(intervals[4] >= 64.0, "{intervals:?}");
519    }
520
521    #[test]
522    fn closed_loop_converges() {
523        // A toy plant: local clock 40 ppm fast, 30 ms ahead. The discipline reads
524        // perfect estimates each poll; assert the loop pulls both to ~zero.
525        let mut d = Discipline::new(DisciplineConfig {
526            iburst: false,
527            makestep_threshold: None,
528            ..DisciplineConfig::default()
529        });
530        let mut clock_err_s = 0.030_f64; // local - true
531        let base_freq_ppm = 40.0;
532        let mut t = 0.0;
533        for _ in 0..60 {
534            // The measured offset is what we should ADD: -(clock_err).
535            let offset = -clock_err_s;
536            // Perfect freq measurement: the regression slope is dθ/dt, and
537            // θ = -err, so the slope is -(base + applied).
538            let slope_ppm = -(base_freq_ppm + d.freq_ppm());
539            let plan = d.on_estimate(offset, Some(slope_ppm), 1e-5);
540            let dt = plan.next_poll_s;
541            if let ClockCommand::Slew {
542                freq_ppm,
543                drain_offset,
544                drain_rate_ppm,
545            } = plan.command
546            {
547                // Plant integration over dt: positive applied freq speeds the
548                // local clock (raises err); the drain adds θ toward zero err.
549                let drift = (base_freq_ppm + freq_ppm) * 1e-6 * dt;
550                let max_drain = drain_rate_ppm * 1e-6 * dt;
551                let drain = drain_offset.abs().min(max_drain) * drain_offset.signum();
552                clock_err_s += drift + drain;
553            }
554            t += dt;
555        }
556        assert!(
557            clock_err_s.abs() < 1e-4,
558            "did not converge: err {clock_err_s} at t {t}"
559        );
560        assert!(
561            (d.freq_ppm() + 40.0).abs() < 2.0,
562            "freq not learned (want ~-40): {}",
563            d.freq_ppm()
564        );
565    }
566}