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