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