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