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    /// Gain of the integral trim on the frequency estimate. 0 disables it,
25    /// leaving a purely proportional loop. See `FREQ_INTEGRAL_GAIN`.
26    pub freq_integral_gain: f64,
27    /// Step the poll interval back DOWN when `|offset| > this * noise`.
28    ///
29    /// This is the packet budget, and the packet budget is most of the
30    /// accuracy: offset error falls as 1/sqrt(N). Measured on the seeded rig,
31    /// clknetsim's own packet counters, same poll bounds for both arms:
32    ///
33    /// ```text
34    ///            mean poll   median |e| S1   per packet spent
35    /// chrony        33.9 s        1.52 us    (baseline)
36    /// rusty_time    40.1 s        1.47 us    x1.05
37    /// ```
38    ///
39    /// So the estimator was never the deficit — at equal cost it is at parity
40    /// on S1 and slightly ahead on S8 (x0.96). We were simply buying fewer
41    /// samples. See `POLL_DOWN_NOISE_RATIO`.
42    pub poll_down_noise_ratio: f64,
43    /// Consecutive stable samples required before the poll interval doubles.
44    ///
45    /// This, not the dead band, is what sets the packet budget. Sweeping
46    /// `poll_down_noise_ratio` from 10 down to 3 moved the mean poll by 0.3 s
47    /// and the accuracy not at all, because the step-DOWN branch only runs
48    /// when `|offset| >= 2 * noise` and a converged loop is almost never
49    /// there. It is always "stable", so it always climbs, and it pins at
50    /// maxpoll. The climb rate is the only term with any authority.
51    pub poll_up_streak: u32,
52    /// Width of the regression's weight floor, as a fraction of the minimum
53    /// observed delay. See `rusty_time_core::filter::WEIGHT_FLOOR_RATIO`.
54    ///
55    /// This is the one knob that can improve accuracy WITHOUT spending more
56    /// packets, which is why it is worth a sweep: buying accuracy with poll
57    /// rate leaves per-packet efficiency exactly where it was.
58    pub weight_floor_ratio: f64,
59    /// Weight-floor width for the OFFSET alone; the slope keeps
60    /// `weight_floor_ratio`. Equal values reproduce the single-weight fit.
61    pub offset_weight_floor_ratio: f64,
62    /// Half-life, seconds, of the age decay on the OFFSET weights. Infinite
63    /// disables it, weighting by delay alone.
64    pub offset_age_halflife_s: f64,
65    /// If > 0, take the offset weight floor from measured delay dispersion
66    /// rather than a fraction of the minimum delay.
67    pub offset_weight_dispersion_k: f64,
68    /// Weight the slope fit by the time each sample represents, so an `iburst`
69    /// cluster cannot act as a high-leverage anchor on the frequency estimate.
70    pub slope_density_weighting: bool,
71    /// Absolute steady-state correction time, seconds. 0 keeps the default
72    /// behaviour of `CORR_TIME_RATIO * poll_interval`.
73    ///
74    /// The drain rate is `offset / correction_time`, and tying that time to the
75    /// POLL makes the loop's aggressiveness a function of how often it looks.
76    /// Polling twice as fast then does not average twice as much — it halves
77    /// the time constant and writes twice as much sample noise into the clock,
78    /// which is why every attempt to buy accuracy with packets has failed here:
79    /// the packets were spent on twitchiness, not precision.
80    ///
81    /// With an absolute time constant, a faster poll delivers what it should —
82    /// more samples inside the same correction window.
83    ///
84    /// **Off by default: measured, and it does not deliver.** The diagnosis is
85    /// sound — an absolute time constant plus chrony's packet rate is the only
86    /// pairing that could turn per-packet parity into raw-accuracy advantage,
87    /// and neither half can show it alone. Paired against chrony, forty seeded
88    /// worlds each:
89    ///
90    /// ```text
91    ///                S1      S2      S4      S6      S8     poll
92    /// base        -0.63   +0.63   -1.90   -2.21   -1.26    ~40 s
93    /// t=200       -0.95   +0.63   -0.63   -3.48   -1.26    ~38 s
94    /// t=120,k8    +0.32   +1.90   -0.32   -2.21   -1.90    ~32 s
95    /// t=200,k8    +0.32   +0.63   -0.32   -2.53   -2.85    ~31 s
96    /// ```
97    ///
98    /// Nothing resolves ahead anywhere, and S6 stays resolved behind in every
99    /// arm. The absolute constant also destabilises the poll adaptation — S2
100    /// fell to a 21 s poll, spending a third more packets for no gain — because
101    /// the stability test that raises the interval is calibrated against a
102    /// correction time that now no longer moves with it.
103    pub corr_time_s: f64,
104    /// Largest correction this daemon will ever make, in seconds. `None`
105    /// applies no limit.
106    ///
107    /// chrony's `maxchange`, and off by default exactly as chrony's is —
108    /// because the right value is a policy question about the deployment, not
109    /// something a library can guess. A machine with a dead RTC legitimately
110    /// needs to move its clock by years on first sync; a mesh node that has
111    /// been up for a week does not, and a source asking it to should be
112    /// refused rather than obeyed.
113    pub max_change_s: Option<f64>,
114    /// Updates to allow before the limit applies, so a cold start can make the
115    /// one large correction it genuinely needs.
116    pub max_change_start: u32,
117    /// How many refusals to tolerate before giving up. Negative never gives up.
118    ///
119    /// Giving up is the point. A daemon that refuses corrections forever and
120    /// says nothing is a daemon whose clock is quietly wrong — the operator
121    /// needs to find out, and an exit is how a service says so.
122    pub max_change_ignore: i32,
123    /// Poll intervals over which a steady-state offset is drained. Overrides
124    /// `CORR_TIME_RATIO` when > 0.
125    ///
126    /// Poll-SCALED on purpose. An absolute constant measured well on the
127    /// corpus and is unsafe to ship: the rig runs `maxpoll 6` (64 s) while the
128    /// production default is `maxpoll 10` (1024 s), where a fixed 40 s
129    /// correction time would drain each estimate twenty-five times faster than
130    /// the loop can see, chasing jitter instead of averaging it.
131    pub corr_time_ratio: f64,
132}
133
134impl Default for DisciplineConfig {
135    fn default() -> Self {
136        DisciplineConfig {
137            makestep_threshold: Some(1.0),
138            makestep_limit: 3,
139            max_slew_ppm: 83_333.0,
140            max_freq_ppm: 500.0,
141            min_poll: 6,
142            max_poll: 10,
143            iburst: true,
144            freq_integral_gain: FREQ_INTEGRAL_GAIN,
145            poll_down_noise_ratio: POLL_DOWN_NOISE_RATIO,
146            poll_up_streak: POLL_UP_STREAK,
147            weight_floor_ratio: crate::filter::WEIGHT_FLOOR_RATIO,
148            offset_weight_floor_ratio: crate::filter::OFFSET_WEIGHT_FLOOR_RATIO,
149            offset_age_halflife_s: f64::INFINITY,
150            offset_weight_dispersion_k: 0.0,
151            slope_density_weighting: false,
152            corr_time_s: 0.0,
153            corr_time_ratio: 0.0,
154            max_change_s: None,
155            max_change_start: 1,
156            max_change_ignore: 2,
157        }
158    }
159}
160
161/// What the platform driver should do right now.
162#[derive(Clone, Copy, Debug, PartialEq)]
163pub enum ClockCommand {
164    /// Add this many seconds to the clock immediately.
165    Step { add_seconds: f64 },
166    /// Run at `freq_ppm` (absolute correction vs the undisciplined clock) and
167    /// additionally drain `drain_offset` seconds at up to `drain_rate_ppm`.
168    Slew {
169        freq_ppm: f64,
170        drain_offset: f64,
171        drain_rate_ppm: f64,
172    },
173}
174
175#[derive(Clone, Copy, Debug, PartialEq)]
176pub struct Plan {
177    pub command: ClockCommand,
178    /// Seconds until the next poll.
179    pub next_poll_s: f64,
180    /// The sample register is invalid after a step; caller must shift or clear it.
181    pub reset_register: bool,
182    /// What the maximum-change guard made of this correction.
183    pub verdict: ChangeVerdict,
184}
185
186/// What the maximum-change guard decided about a correction.
187///
188/// A time daemon's most dangerous power is that it is *believed*. On a mesh,
189/// the node running your code is hardware you do not control, and a capability
190/// expires by this clock — so a source that can move it can move the boundary
191/// between "revoked" and "valid". Authentication proves who a server is, not
192/// that it is telling the truth.
193#[derive(Clone, Copy, Debug, PartialEq)]
194pub enum ChangeVerdict {
195    /// Within the limit, or no limit configured.
196    Accepted,
197    /// Larger than the limit: NO correction was made. `seen` counts how many
198    /// consecutive refusals have happened, so the caller can say so once
199    /// rather than on every poll.
200    Refused { offset_s: f64, seen: u32 },
201    /// Larger than the limit, and the allowance for refusals is spent. The
202    /// caller should stop rather than keep running a clock it has decided it
203    /// cannot steer.
204    GiveUp { offset_s: f64 },
205}
206
207/// Number of quick polls in an iburst, and their spacing.
208const IBURST_COUNT: u32 = 4;
209const IBURST_SPACING_S: f64 = 2.0;
210/// Drain a measured offset over roughly this many poll intervals.
211///
212/// **Was 3.0. Lowered to 1.0 on measurement, and this is the term that carried
213/// the standing bias.**
214///
215/// A proportional loop settles where the drain it applies balances the drift
216/// that keeps re-creating the offset, which is `offset = F_residual * corr_time`
217/// (see the derivation below). The residual frequency error is what it is —
218/// nine attempts to shrink it all traded one scenario against another — but
219/// `corr_time` is a free parameter, and the standing offset is LINEAR in it.
220///
221/// The diagnosis came before the sweep, which is why this one worked where the
222/// others did not. Logging what the loop believed against clknetsim's ground
223/// truth showed the estimator was *right*: on S6 it reported -1.50 us where the
224/// truth was -1.21 us. The loop could see the error and was not removing it.
225/// That is a controller property, not an estimator defect, and it made a
226/// quantitative prediction — shorten the correction time and the bias shrinks
227/// in proportion.
228///
229/// It did. S6's standing bias went from +1.27 us to +0.24 us against chrony's
230/// +0.26. Paired against the old ratio, sixty fresh seeded worlds per scenario:
231///
232/// ```text
233///          S1        S2        S4        S6        S8
234///       +0.77     +4.65     +0.77     +1.29     +3.36
235/// ```
236///
237/// Two resolved improvements, no resolved regression, every scenario trending
238/// better, and convergence untouched (S1 5 s, S6 16 s, S8 5 s in both arms).
239/// Against chrony it removes the S8 loss and turns S1, S2 and S8 into resolved
240/// wins per packet spent.
241///
242/// It stays a RATIO rather than becoming an absolute constant. An absolute 40 s
243/// measured slightly better still, and is unsafe: the corpus runs `maxpoll 6`
244/// (64 s) while the production default is `maxpoll 10` (1024 s), where a fixed
245/// 40 s would drain each estimate twenty-five times faster than the loop can
246/// see it — chasing jitter instead of averaging it.
247const CORR_TIME_RATIO: f64 = 1.0;
248/// Correction time, in poll intervals, for an offset that is plainly real.
249/// One means "finish before the next sample arrives".
250const ACQUIRE_CORR_RATIO: f64 = 1.0;
251/// How far outside the noise an offset must sit to be treated as real.
252const ACQUIRE_NOISE_MULTIPLE: f64 = 10.0;
253/// How many updates count as acquisition.
254///
255/// Acquisition is a *phase*, not a magnitude. Gating the fast correction on
256/// "the offset is much larger than the noise" looked equivalent and is not:
257/// a loop that is confidently wrong reports a small `offset_sd` beside a large
258/// error, so the test fires in steady state exactly when it should not. The
259/// in-house S6 scenario is such a case — a deliberately noisy path where the
260/// estimator's own confidence outruns its accuracy — and gating on magnitude
261/// alone took its steady error from 2.54 ms to 10.83 ms while the low-noise
262/// clknetsim rig showed only the improvement. Counting updates cannot be
263/// fooled that way: after this many the loop is no longer starting up,
264/// whatever it believes about itself.
265const ACQUIRE_UPDATES: u32 = 8;
266/// The most of the slew budget the fast correction may ask for.
267///
268/// Leaving headroom is the point. A correction that consumes the whole budget
269/// pins the clock at maximum rate for the entire interval, and the frequency
270/// estimator then has to infer a drift from samples taken while the clock was
271/// being hauled — which it does badly enough to leave a permanently worse
272/// steady state. A quarter keeps the fast path for offsets it can absorb and
273/// hands genuinely large cold starts back to the gentle drain.
274const ACQUIRE_SLEW_SHARE: f64 = 0.25;
275/// How many times the noise an offset must exceed before the clock may be
276/// hauled at the full slew ceiling.
277const ACQUIRE_FULL_SPEED_CONFIDENCE: f64 = 10_000.0;
278/// Above this share of the slew ceiling, the clock is being *hauled*, and a
279/// frequency measured across that haul is not a measurement of the
280/// oscillator.
281///
282/// The regression fits a slope through stored samples, and `slew_samples`
283/// re-expresses that history for corrections already applied. That accounting
284/// is exact for a gentle drain. It is not robust to a correction running at
285/// most of the slew ceiling: any small mismatch between the rate commanded and
286/// the rate delivered is multiplied by the poll interval and lands in the
287/// slope, and the loop then carries a frequency error it never measured. The
288/// offset drain has feedback and recovers; the frequency term accumulates and
289/// does not.
290///
291/// So during a haul the offset is still corrected at full speed — the clock is
292/// visibly wrong and the fix is not in doubt — but the frequency estimate is
293/// left alone until the samples describe a clock that is merely running.
294const FREQ_TRUST_SLEW_SHARE: f64 = 0.25;
295/// Most polls the acquisition burst may take before it must slow down.
296///
297/// The burst normally ends after `IBURST_COUNT` samples, and the poll then
298/// jumps straight to `min_poll`. That is the whole S6 gap: the offset drain is
299/// sized to finish within one poll interval, so ending the burst with a large
300/// correction still outstanding hands the remainder a 16 s deadline instead of
301/// a 2 s one. Measured against chrony, chrony had a 500 ms cold start gone in
302/// about 7 s at close to its slew ceiling, while this loop cleared 500 ms down
303/// to 89 ms in the burst and then spent a further 16 s on what was left.
304///
305/// So the burst ends when the offset is small, not when a counter runs out.
306/// The cap is what stops that becoming an unbounded fast poll against someone
307/// else's server: a client that cannot converge is a client that must back off
308/// anyway, not one that should keep asking every two seconds.
309const MAX_ACQUIRE_BURST: u32 = 16;
310/// How much of an implied frequency error to absorb per update.
311///
312/// **Why there is an integral term at all.** The offset drain is proportional:
313/// each plan removes `offset / (CORR_TIME_RATIO * poll)` per second. Against a
314/// constant unmodelled drift `F`, that settles at an equilibrium rather than at
315/// zero -- removal balances accumulation when
316///
317/// ```text
318///     offset  =  CORR_TIME_RATIO * poll * F
319/// ```
320///
321/// which is a *standing error the loop maintains on purpose*. Measured on the
322/// in-house corpus it is the whole story: S1 sat at 200 us on a 0.039 ppm
323/// residual and a 1024 s poll, and 3 * 1024 * 0.039e-6 is 120 us. A
324/// proportional controller cannot remove it; only integral action can.
325///
326/// The frequency term comes from the regression slope, which is a
327/// *measurement*. If that measurement carries any bias, the equilibrium above
328/// stands forever and no amount of averaging removes it. So the loop reads the
329/// standing offset as evidence in its own right: invert the relation, and a
330/// persistent offset **is** a frequency error, expressed in seconds.
331///
332/// **Measured and rejected. The default is 0 — the trim is OFF.**
333///
334/// The reasoning above is sound and the result still went the other way. On a
335/// SEEDED rig, twenty worlds per arm, paired seed by seed:
336///
337/// ```text
338/// S8  gain=0.0   median |e| 4.78 us    8/20 wins vs chrony   z=-0.89  not resolved
339/// S8  gain=0.1   median |e| 6.20 us    5/20 wins vs chrony   z=-2.24  RESOLVED, chrony ahead
340/// S1  gain=0.0   median |e| 1.47 us
341/// S1  gain=0.1   median |e| 1.98 us
342/// ```
343///
344/// Turning the trim on is the only *resolved* accuracy result in that sweep,
345/// and it is a regression. An earlier single unpaired run had read it as an
346/// improvement on both scenarios; it was the draw, not the code.
347///
348/// Why it fails, as best the data supports: the standing offset is not a
349/// frequency error here. It is sampling error in the delay draws — it changes
350/// SIGN with the seed. Integrating it feeds noise into the frequency estimate,
351/// and on S8, whose oscillator already wanders, that is the last thing the
352/// loop needs.
353///
354/// Kept as a field rather than deleted so re-testing costs one flag if the
355/// estimator's own bias ever shrinks below this effect.
356const FREQ_INTEGRAL_GAIN: f64 = 0.0;
357
358/// How far outside the noise an offset must sit before the poll interval is
359/// stepped back down — the default for `DisciplineConfig::poll_down_noise_ratio`.
360///
361/// An offset below `2 * noise` counts as stable and, after three such samples,
362/// doubles the interval. Between that and this ratio the loop does neither, so
363/// this number IS the width of the dead band, and a wide dead band pins the
364/// client at maxpoll: at 10x it effectively never came back down.
365///
366/// The value is measured, not chosen — see the sweep in `DisciplineConfig`.
367const POLL_DOWN_NOISE_RATIO: f64 = 10.0;
368
369/// Consecutive stable samples before the poll interval doubles — the default
370/// for `DisciplineConfig::poll_up_streak`.
371const POLL_UP_STREAK: u32 = 3;
372/// Weight of the newest offset in the persistence average. Low, because the
373/// signal being extracted is the part that does *not* change.
374const OFFSET_EWMA_ALPHA: f64 = 0.25;
375
376/// The offset at which acquisition is finished and the burst may end.
377///
378/// Tied to what "converged" means rather than to a multiple of the noise. The
379/// noise-multiple test was tried first and fails at exactly the wrong moment:
380/// on S6 the burst had hauled 500 ms down to 9.8 ms, `10 x noise` came out at
381/// about 10 ms, the test went false, the poll jumped 2 s -> 16 s, and the last
382/// 9.8 ms was handed a 16 s deadline. Those 16 s were the whole difference
383/// against chrony. Floored at twice the noise so a genuinely noisy path is not
384/// polled fast in pursuit of an offset it cannot resolve.
385const ACQUIRE_DONE_S: f64 = 1e-3;
386
387#[derive(Clone, Debug)]
388pub struct Discipline {
389    cfg: DisciplineConfig,
390    freq_ppm: f64,
391    updates: u32,
392    poll: i8,
393    stable_streak: u32,
394    iburst_left: u32,
395    /// Drain rate the previous plan commanded, as a share of the ceiling.
396    /// Samples taken since then were taken while the clock moved at that rate.
397    last_drain_share: f64,
398    /// Burst polls used, including any the acquisition extension granted.
399    burst_used: u32,
400    /// Slow average of recent offset estimates.
401    ///
402    /// A *persistent* offset is the signature of a frequency error the
403    /// regression has not measured, and it is the thing that decides
404    /// steady-state accuracy. See `integral_trim`.
405    offset_ewma: f64,
406    /// Whether `offset_ewma` has been seeded.
407    ewma_seeded: bool,
408    /// Consecutive corrections refused by the maximum-change guard.
409    change_refusals: u32,
410}
411
412impl Discipline {
413    pub fn new(cfg: DisciplineConfig) -> Self {
414        let iburst_left = if cfg.iburst { IBURST_COUNT } else { 0 };
415        Discipline {
416            cfg,
417            freq_ppm: 0.0,
418            updates: 0,
419            poll: cfg.min_poll,
420            stable_streak: 0,
421            iburst_left,
422            last_drain_share: 0.0,
423            burst_used: 0,
424            offset_ewma: 0.0,
425            ewma_seeded: false,
426            change_refusals: 0,
427        }
428    }
429
430    /// How much of the slew budget an acquisition correction may use, given
431    /// how well the offset is known.
432    ///
433    /// How fast the clock may be hauled should depend on how sure we are where
434    /// it is going. A 500 ms offset on a path with microseconds of jitter is
435    /// known to five decimal places and can be cleared at the ceiling; the same
436    /// 500 ms on a path with a millisecond of jitter is a much rougher number,
437    /// and committing to it at full speed writes the roughness into the clock.
438    ///
439    /// Both rigs demanded this. On clknetsim, restricting the share left S6 at
440    /// 18 s against chrony's 12 s; on the in-house corpus, whose S6 models a
441    /// 0.74 ms-jitter path, allowing the full share took its steady error from
442    /// 1.5 ms to 5.9 ms. Neither constant satisfies both, because the two rigs
443    /// differ by two orders of magnitude in exactly the quantity that should
444    /// decide it.
445    fn acquire_share(&self, offset: f64, noise: f64) -> f64 {
446        let confidence = offset.abs() / noise.max(1e-9);
447        if confidence >= ACQUIRE_FULL_SPEED_CONFIDENCE {
448            1.0
449        } else {
450            ACQUIRE_SLEW_SHARE
451        }
452    }
453
454    /// Current commanded frequency correction, ppm.
455    pub fn freq_ppm(&self) -> f64 {
456        self.freq_ppm
457    }
458
459    pub fn poll_log2(&self) -> i8 {
460        self.poll
461    }
462
463    /// Feed the latest combined estimate.
464    ///
465    /// * `offset` — seconds to add to the local clock, now.
466    /// * `freq_ppm_meas` — residual frequency error from the regression (ppm,
467    ///   positive = local slow), if trusted.
468    /// * `offset_sd` — residual noise of the estimate.
469    pub fn on_estimate(&mut self, offset: f64, freq_ppm_meas: Option<f64>, offset_sd: f64) -> Plan {
470        self.updates += 1;
471
472        // The maximum-change guard, before anything is decided.
473        //
474        // Placed ahead of the step logic on purpose: a step is the largest and
475        // fastest way to move a clock, so a guard that ran after it would be
476        // guarding everything except the dangerous case. The allowance for
477        // early updates is what lets a cold start still make its one big
478        // legitimate correction.
479        if let Some(limit) = self.cfg.max_change_s
480            && self.updates > self.cfg.max_change_start
481            && offset.abs() > limit
482        {
483            self.change_refusals = self.change_refusals.saturating_add(1);
484            let spent = self.cfg.max_change_ignore >= 0
485                && self.change_refusals as i64 > i64::from(self.cfg.max_change_ignore);
486            self.stable_streak = 0;
487            return Plan {
488                // Hold the frequency already commanded and drain nothing: the
489                // clock keeps running as it was, which is the only honest
490                // response to an estimate this daemon has decided not to trust.
491                command: ClockCommand::Slew {
492                    freq_ppm: self.freq_ppm,
493                    drain_offset: 0.0,
494                    drain_rate_ppm: 0.0,
495                },
496                next_poll_s: self.take_poll_interval(),
497                reset_register: false,
498                verdict: if spent {
499                    ChangeVerdict::GiveUp { offset_s: offset }
500                } else {
501                    ChangeVerdict::Refused {
502                        offset_s: offset,
503                        seen: self.change_refusals,
504                    }
505                },
506            };
507        }
508        // A correction within the limit clears the run: the allowance is for
509        // CONSECUTIVE refusals, so one bad estimate among good ones does not
510        // accumulate toward giving up.
511        self.change_refusals = 0;
512
513        // Step epoch: large offsets early on are stepped away, chrony `makestep`.
514        if let Some(threshold) = self.cfg.makestep_threshold
515            && offset.abs() > threshold
516            && self.updates <= self.cfg.makestep_limit
517        {
518            self.stable_streak = 0;
519            return Plan {
520                command: ClockCommand::Step {
521                    add_seconds: offset,
522                },
523                next_poll_s: self.take_poll_interval(),
524                reset_register: true,
525                verdict: ChangeVerdict::Accepted,
526            };
527        }
528
529        // Frequency: the regression slope is a direct measurement of the residual
530        // frequency error of the *disciplined* clock, so accumulate it fully --
531        // unless these samples were taken while the clock was being hauled, in
532        // which case the slope is mostly the haul.
533        let hauling = self.last_drain_share > FREQ_TRUST_SLEW_SHARE;
534        if let Some(fm) = freq_ppm_meas
535            && !hauling
536        {
537            self.freq_ppm =
538                (self.freq_ppm + fm).clamp(-self.cfg.max_freq_ppm, self.cfg.max_freq_ppm);
539        }
540
541        // Poll adaptation first: lengthen when quiet, shorten when the offset is
542        // loud relative to the noise floor. Runs before the drain computation so
543        // the drain rate is sized for the interval the plan will actually use.
544        let noise = offset_sd.max(1e-7);
545
546        // Integral trim: read a standing offset as the frequency error it
547        // implies, and absorb a fraction of it. Only once acquisition is over
548        // -- during acquisition the offset is large for reasons that have
549        // nothing to do with drift, and feeding that in would be nonsense.
550        if self.ewma_seeded {
551            self.offset_ewma =
552                (1.0 - OFFSET_EWMA_ALPHA) * self.offset_ewma + OFFSET_EWMA_ALPHA * offset;
553        } else {
554            self.offset_ewma = offset;
555            self.ewma_seeded = true;
556        }
557        // ...and only when the standing offset is larger than the noise that
558        // could have produced it. Below that line the average is a sample of
559        // jitter, and feeding jitter into the frequency term writes it into the
560        // clock permanently -- the offset drain can recover from a bad estimate,
561        // the frequency term accumulates it. Measured: without this gate S1
562        // went from 199.7 us to 231.5 us while its frequency residual did not
563        // move at all, which is exactly what integrating noise looks like.
564        if self.cfg.freq_integral_gain != 0.0
565            && self.updates > ACQUIRE_UPDATES
566            && self.offset_ewma.abs() > noise
567        {
568            let poll_now = self.peek_poll_interval();
569            let implied_freq_ppm = (self.offset_ewma / (CORR_TIME_RATIO * poll_now)) * 1e6;
570            self.freq_ppm = (self.freq_ppm + self.cfg.freq_integral_gain * implied_freq_ppm)
571                .clamp(-self.cfg.max_freq_ppm, self.cfg.max_freq_ppm);
572        }
573        if offset.abs() < 2.0 * noise {
574            self.stable_streak += 1;
575            if self.stable_streak >= self.cfg.poll_up_streak && self.poll < self.cfg.max_poll {
576                self.poll += 1;
577                self.stable_streak = 0;
578            }
579        } else {
580            self.stable_streak = 0;
581            if offset.abs() > self.cfg.poll_down_noise_ratio * noise
582                && self.poll > self.cfg.min_poll
583            {
584                self.poll -= 1;
585            }
586        }
587
588        // Offset: drain over ~CORR_TIME_RATIO poll intervals, capped by maxslewrate.
589        //
590        // ...except while the offset is unambiguous. The loop re-plans on every
591        // sample, so a drain sized to finish in three poll intervals only ever
592        // runs for one of them before being replaced: the offset decays by a
593        // third per poll, giving a time constant three times longer than the
594        // ratio suggests. In steady state that is exactly the wanted
595        // behaviour — it is what stops sample noise being written into the
596        // clock. During acquisition it is not: a 10 ms startup offset is a
597        // hundred times the noise floor, it is not in dispute, and decaying it
598        // by a third per 16 s poll leaves the clock wrong for a minute.
599        // Measured against chrony under clknetsim, chrony had removed the same
600        // offset within about two seconds while this loop was still 40 s away.
601        //
602        // The test is the one the poll adaptation already uses: an offset far
603        // outside the noise is a real error, not a noisy reading, so correct
604        // it within the interval. Once it is comparable to the noise the
605        // gentle ratio takes over again, and steady-state accuracy — which is
606        // at parity with chrony — is untouched.
607        // Keep the acquisition burst going while a correction is still
608        // outstanding. The drain is sized to finish within one poll interval,
609        // so ending the burst early does not merely delay the next
610        // measurement — it stretches the correction itself from two seconds to
611        // sixteen. On S6 that single step was the entire gap against chrony:
612        // the burst hauled 500 ms down to 9.8 ms by t=10.5 s, then handed what
613        // was left a 16 s deadline and finished at t=26 s where chrony
614        // finished at t=12 s.
615        if self.iburst_left == 0
616            && self.cfg.iburst
617            && self.burst_used < MAX_ACQUIRE_BURST
618            && offset.abs() > ACQUIRE_DONE_S.max(2.0 * noise)
619        {
620            self.iburst_left = 1;
621        }
622
623        let poll_s = self.peek_poll_interval();
624        //
625        // The fast path's premise is "finish this correction before the next
626        // sample". If the rate that would take is above the slew ceiling, the
627        // correction cannot finish within the interval, the premise is false,
628        // and asking for it anyway just pins the clock at maximum slew for the
629        // whole interval — which is how a 500 ms cold start went from a 2.54 ms
630        // steady error to 10.83 ms on the noisy in-house rig while the
631        // low-noise one showed only the improvement. So the fast ratio applies
632        // only when it is actually achievable, and a correction too large to
633        // finish is drained gently, as before.
634        let acquiring = self.updates <= ACQUIRE_UPDATES;
635        // Rate: gentle by default, faster while acquiring.
636        //
637        // The rate stays tied to the poll interval even though drains are now
638        // budgeted and stop when spent. Untying it was tried — "clear any
639        // acquisition offset in ACQUIRE_TARGET_S seconds" — and it is worse:
640        // with a 16 s poll it corrects the whole of each noisy estimate in two
641        // seconds and then coasts for fourteen, which chases noise instead of
642        // averaging it. Scaling with the poll is what makes the correction
643        // proportional to how often the loop actually gets to look.
644        //
645        // What the budget buys is not a faster rate here. It is that the rate
646        // is now free to be chosen at all: an over-fast drain no longer sails
647        // past the offset, it stops at it. Measured on the same binary, the
648        // same discipline with budgets unenforced settles at 579 us on S6 and
649        // with them enforced at 130 us.
650        let wanted_rate_ppm = if acquiring && offset.abs() > ACQUIRE_NOISE_MULTIPLE * noise {
651            // Move at the fastest rate allowed and stop when the offset is
652            // gone. This is only expressible because the drain carries a
653            // budget: without one, a rate this high would not stop at the
654            // offset, it would sail past it, so the rate had to be "the offset
655            // divided by the poll interval" and a cold start's remainder was
656            // handed the poll's deadline. That is what put S6 at 26 s against
657            // chrony's 12 s.
658            // Scales with the poll, so a long interval gets a gentle rate and
659            // the loop averages noise instead of chasing it. A fixed clearing
660            // time was tried and is wrong for exactly that reason: at a 64 s
661            // poll, "clear it in 2 s" is thirty times more aggressive than the
662            // interval warrants, and the in-house S6 steady error went from
663            // 1.5 ms to 9 ms.
664            //
665            // The ceiling is the whole slew budget rather than a quarter of
666            // it. That is safe only because the drain stops when spent: an
667            // over-fast rate now runs out at the offset instead of sailing
668            // past it, and what it delivered is booked even if the caller wakes
669            // late. Without those two properties this cap had to stay low.
670            // Poll-scaled, with a ceiling that depends on how well the offset
671            // is known.
672            //
673            // Untying the rate from the poll entirely -- "clear it in
674            // ACQUIRE_TARGET_S" -- was tried twice and measured worse both
675            // times: 16 s on S6 against 14 s here, and on the noisy rig a fixed
676            // clearing time at a 64 s poll is thirty times more aggressive than
677            // the interval warrants, which chases jitter instead of averaging
678            // it. Scaling with the poll is what keeps the correction
679            // proportional to how often the loop gets to look.
680            ((offset.abs() / (ACQUIRE_CORR_RATIO * poll_s)) * 1e6)
681                .min(self.cfg.max_slew_ppm * self.acquire_share(offset, noise))
682        } else {
683            // Correction time: poll-scaled by default, absolute when asked.
684            let ratio = if self.cfg.corr_time_ratio > 0.0 {
685                self.cfg.corr_time_ratio
686            } else {
687                CORR_TIME_RATIO
688            };
689            let corr_time = if self.cfg.corr_time_s > 0.0 {
690                self.cfg.corr_time_s
691            } else {
692                ratio * poll_s
693            };
694            (offset.abs() / corr_time) * 1e6
695        };
696        let drain_rate_ppm = wanted_rate_ppm.min(self.cfg.max_slew_ppm);
697        self.last_drain_share = if self.cfg.max_slew_ppm > 0.0 {
698            drain_rate_ppm / self.cfg.max_slew_ppm
699        } else {
700            0.0
701        };
702
703        Plan {
704            command: ClockCommand::Slew {
705                freq_ppm: self.freq_ppm,
706                drain_offset: offset,
707                drain_rate_ppm,
708            },
709            next_poll_s: self.take_poll_interval(),
710            reset_register: false,
711            verdict: ChangeVerdict::Accepted,
712        }
713    }
714
715    /// How long to wait before trying again when an exchange yields nothing —
716    /// lost, or rejected because the server was not yet usable.
717    ///
718    /// This is the iburst spacing while the burst budget lasts, *not* the poll
719    /// interval. A server that has only just started answers its first requests
720    /// with the unsynchronised leap indicator, which a client must refuse; if
721    /// that refusal then costs a full poll interval, a cold start is delayed by
722    /// 16 seconds before the first usable sample. Measured against chrony under
723    /// clknetsim, that single wait was most of an 8x convergence gap.
724    ///
725    /// Nothing is consumed here: a failed exchange must not spend burst budget,
726    /// or a few early losses would silently end the burst.
727    pub fn retry_interval_s(&self) -> f64 {
728        self.peek_poll_interval()
729    }
730
731    /// The interval the *next* plan will use, without consuming iburst budget.
732    fn peek_poll_interval(&self) -> f64 {
733        if self.iburst_left > 0 {
734            IBURST_SPACING_S
735        } else {
736            2f64.powi(self.poll as i32)
737        }
738    }
739
740    /// Consume one poll slot — called exactly once per emitted Plan.
741    fn take_poll_interval(&mut self) -> f64 {
742        if self.iburst_left > 0 {
743            self.iburst_left -= 1;
744            self.burst_used += 1;
745            IBURST_SPACING_S
746        } else {
747            2f64.powi(self.poll as i32)
748        }
749    }
750}
751
752#[cfg(test)]
753mod acquisition_tests {
754    use super::*;
755
756    fn acquiring() -> Discipline {
757        Discipline::new(DisciplineConfig {
758            makestep_threshold: None,
759            min_poll: 4, // 16 s
760            iburst: true,
761            ..DisciplineConfig::default()
762        })
763    }
764
765    #[test]
766    fn the_burst_continues_while_a_correction_is_outstanding() {
767        // The drain is sized to finish within one poll interval, so ending the
768        // burst with an offset still outstanding does not just delay the next
769        // measurement — it stretches the correction from 2 s to 16 s. Against
770        // chrony on S6 that one step was the whole gap: 500 ms was hauled down
771        // to 9.8 ms by the burst, and the remainder then took another 16 s.
772        let mut d = acquiring();
773        let mut plan = None;
774        for _ in 0..IBURST_COUNT + 3 {
775            // A 10 ms offset, far above both the 1 ms target and the noise.
776            plan = Some(d.on_estimate(0.010, None, 1e-6));
777        }
778        let next = plan.expect("a plan").next_poll_s;
779        assert!(
780            next <= IBURST_SPACING_S,
781            "burst ended with 10 ms still outstanding: next poll {next} s"
782        );
783    }
784
785    #[test]
786    fn the_burst_ends_once_the_offset_is_small() {
787        // ...and it must end, or a converged client polls a stranger's server
788        // every two seconds forever.
789        let mut d = acquiring();
790        let mut plan = None;
791        for _ in 0..IBURST_COUNT + 3 {
792            plan = Some(d.on_estimate(1e-6, None, 1e-6));
793        }
794        let next = plan.expect("a plan").next_poll_s;
795        assert!(
796            next > IBURST_SPACING_S,
797            "burst kept running on a converged clock: next poll {next} s"
798        );
799    }
800
801    #[test]
802    fn the_extended_burst_is_bounded() {
803        // A client that never converges must back off rather than keep asking.
804        let mut d = acquiring();
805        let mut plan = None;
806        for _ in 0..MAX_ACQUIRE_BURST * 3 {
807            plan = Some(d.on_estimate(0.010, None, 1e-6));
808        }
809        let next = plan.expect("a plan").next_poll_s;
810        assert!(
811            next > IBURST_SPACING_S,
812            "burst never backed off despite never converging: next poll {next} s"
813        );
814    }
815}
816
817#[cfg(test)]
818mod max_change_tests {
819    use super::*;
820
821    fn guarded(limit: f64, start: u32, ignore: i32) -> Discipline {
822        Discipline::new(DisciplineConfig {
823            max_change_s: Some(limit),
824            max_change_start: start,
825            max_change_ignore: ignore,
826            // Stepping is what makes a hostile offset dangerous, so leave it on.
827            makestep_threshold: Some(1.0),
828            makestep_limit: 3,
829            ..DisciplineConfig::default()
830        })
831    }
832
833    /// Off unless asked for — the same default chrony ships.
834    #[test]
835    fn no_limit_by_default() {
836        let mut d = Discipline::new(DisciplineConfig::default());
837        let plan = d.on_estimate(86_400.0, None, 1e-6);
838        assert_eq!(plan.verdict, ChangeVerdict::Accepted);
839        assert!(
840            matches!(plan.command, ClockCommand::Step { .. }),
841            "with no limit configured a large offset must still be corrected"
842        );
843    }
844
845    /// The cold start a limit must not break: one big legitimate correction.
846    #[test]
847    fn the_first_correction_is_still_allowed_through() {
848        let mut d = guarded(1000.0, 1, 2);
849        let plan = d.on_estimate(50_000.0, None, 1e-6);
850        assert_eq!(
851            plan.verdict,
852            ChangeVerdict::Accepted,
853            "a machine with a dead clock must still be able to set it once"
854        );
855        assert!(matches!(plan.command, ClockCommand::Step { .. }));
856    }
857
858    /// After the allowance, a large correction is refused and the clock is left
859    /// exactly as it was running.
860    #[test]
861    fn a_large_correction_is_refused_and_changes_nothing() {
862        let mut d = guarded(1000.0, 1, 5);
863        d.on_estimate(0.0001, None, 1e-6); // update 1, within the allowance
864        let plan = d.on_estimate(50_000.0, None, 1e-6); // update 2, guarded
865        match plan.verdict {
866            ChangeVerdict::Refused { offset_s, seen } => {
867                assert_eq!(seen, 1);
868                assert!((offset_s - 50_000.0).abs() < 1e-9);
869            }
870            other => panic!("expected a refusal, got {other:?}"),
871        }
872        match plan.command {
873            ClockCommand::Slew {
874                drain_offset,
875                drain_rate_ppm,
876                ..
877            } => {
878                assert_eq!(
879                    drain_offset, 0.0,
880                    "a refused correction still moved the clock"
881                );
882                assert_eq!(drain_rate_ppm, 0.0);
883            }
884            other => panic!("a refusal must not step or drain: {other:?}"),
885        }
886    }
887
888    /// Refusals are consecutive: a good update in between clears the run, so a
889    /// single outlier cannot accumulate toward shutting the daemon down.
890    #[test]
891    fn a_good_update_clears_the_run() {
892        let mut d = guarded(1000.0, 1, 2);
893        d.on_estimate(0.0001, None, 1e-6);
894        assert!(matches!(
895            d.on_estimate(50_000.0, None, 1e-6).verdict,
896            ChangeVerdict::Refused { seen: 1, .. }
897        ));
898        assert_eq!(
899            d.on_estimate(0.0001, None, 1e-6).verdict,
900            ChangeVerdict::Accepted
901        );
902        assert!(
903            matches!(
904                d.on_estimate(50_000.0, None, 1e-6).verdict,
905                ChangeVerdict::Refused { seen: 1, .. }
906            ),
907            "the refusal count did not reset after an accepted update"
908        );
909    }
910
911    /// A source that keeps asking exhausts the allowance and the daemon stops.
912    #[test]
913    fn persistent_refusal_gives_up() {
914        let mut d = guarded(1000.0, 1, 2);
915        d.on_estimate(0.0001, None, 1e-6);
916        for expected in 1..=2 {
917            assert!(matches!(
918                d.on_estimate(50_000.0, None, 1e-6).verdict,
919                ChangeVerdict::Refused { seen, .. } if seen == expected
920            ));
921        }
922        assert!(
923            matches!(
924                d.on_estimate(50_000.0, None, 1e-6).verdict,
925                ChangeVerdict::GiveUp { .. }
926            ),
927            "the allowance was spent and the daemon did not give up"
928        );
929    }
930
931    /// A negative allowance never gives up — for an operator who would rather
932    /// have a stuck clock than a stopped daemon.
933    #[test]
934    fn a_negative_allowance_never_gives_up() {
935        let mut d = guarded(1000.0, 1, -1);
936        d.on_estimate(0.0001, None, 1e-6);
937        for _ in 0..50 {
938            assert!(matches!(
939                d.on_estimate(50_000.0, None, 1e-6).verdict,
940                ChangeVerdict::Refused { .. }
941            ));
942        }
943    }
944}
945
946#[cfg(test)]
947mod tests {
948    use super::*;
949
950    #[test]
951    fn big_initial_offset_is_stepped() {
952        let mut d = Discipline::new(DisciplineConfig::default());
953        let plan = d.on_estimate(120.0, None, 1e-4);
954        assert!(matches!(
955            plan.command,
956            ClockCommand::Step { add_seconds } if (add_seconds - 120.0).abs() < 1e-9
957        ));
958        assert!(plan.reset_register);
959    }
960
961    #[test]
962    fn step_window_closes() {
963        let mut d = Discipline::new(DisciplineConfig::default());
964        for _ in 0..3 {
965            let _ = d.on_estimate(0.0001, None, 1e-4);
966        }
967        // Fourth update: even a huge offset must slew, not step.
968        let plan = d.on_estimate(5.0, None, 1e-4);
969        assert!(matches!(plan.command, ClockCommand::Slew { .. }));
970    }
971
972    #[test]
973    fn freq_accumulates_and_clamps() {
974        let mut d = Discipline::new(DisciplineConfig::default());
975        let _ = d.on_estimate(1e-4, Some(100.0), 1e-4);
976        assert!((d.freq_ppm() - 100.0).abs() < 1e-9);
977        let _ = d.on_estimate(1e-4, Some(1000.0), 1e-4);
978        assert!((d.freq_ppm() - 500.0).abs() < 1e-9, "clamped at max_freq");
979    }
980
981    #[test]
982    fn iburst_then_normal_cadence() {
983        let mut d = Discipline::new(DisciplineConfig::default());
984        let mut intervals = Vec::new();
985        for _ in 0..6 {
986            let plan = d.on_estimate(1e-5, None, 1e-4);
987            intervals.push(plan.next_poll_s);
988        }
989        assert!(intervals[..4].iter().all(|&i| i == 2.0), "{intervals:?}");
990        assert!(intervals[4] >= 64.0, "{intervals:?}");
991    }
992
993    #[test]
994    fn closed_loop_converges() {
995        // A toy plant: local clock 40 ppm fast, 30 ms ahead. The discipline reads
996        // perfect estimates each poll; assert the loop pulls both to ~zero.
997        let mut d = Discipline::new(DisciplineConfig {
998            iburst: false,
999            makestep_threshold: None,
1000            ..DisciplineConfig::default()
1001        });
1002        let mut clock_err_s = 0.030_f64; // local - true
1003        let base_freq_ppm = 40.0;
1004        let mut t = 0.0;
1005        for _ in 0..60 {
1006            // The measured offset is what we should ADD: -(clock_err).
1007            let offset = -clock_err_s;
1008            // Perfect freq measurement: the regression slope is dθ/dt, and
1009            // θ = -err, so the slope is -(base + applied).
1010            let slope_ppm = -(base_freq_ppm + d.freq_ppm());
1011            let plan = d.on_estimate(offset, Some(slope_ppm), 1e-5);
1012            let dt = plan.next_poll_s;
1013            if let ClockCommand::Slew {
1014                freq_ppm,
1015                drain_offset,
1016                drain_rate_ppm,
1017            } = plan.command
1018            {
1019                // Plant integration over dt: positive applied freq speeds the
1020                // local clock (raises err); the drain adds θ toward zero err.
1021                let drift = (base_freq_ppm + freq_ppm) * 1e-6 * dt;
1022                let max_drain = drain_rate_ppm * 1e-6 * dt;
1023                let drain = drain_offset.abs().min(max_drain) * drain_offset.signum();
1024                clock_err_s += drift + drain;
1025            }
1026            t += dt;
1027        }
1028        assert!(
1029            clock_err_s.abs() < 1e-4,
1030            "did not converge: err {clock_err_s} at t {t}"
1031        );
1032        assert!(
1033            (d.freq_ppm() + 40.0).abs() < 2.0,
1034            "freq not learned (want ~-40): {}",
1035            d.freq_ppm()
1036        );
1037    }
1038}