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