rusty_time-core 0.1.9

Portable NTPv4 protocol and clock-discipline algorithms: packet codec, regression sample filter, falseticker selection, rate limiting and interleaved mode. No I/O, no OS clock, deny(unsafe). The engine inside the rusty_time NTP/NTS daemon.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
//! The clock-discipline loop: turn estimates into clock commands.
//!
//! The platform driver (or the simulator) executes [`ClockCommand`]s; this module
//! only decides. Frequency corrections come straight from the regression slope —
//! the estimator measures frequency directly, so no PLL time constant is needed
//! (this is the chrony approach, and the reason for its fast convergence).

use std::cmp::Ordering;

/// Configuration mirroring the chrony.conf directives we honor.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DisciplineConfig {
    /// Step (rather than slew) when |offset| exceeds this, during the first
    /// `makestep_limit` updates. `None` = never step.
    pub makestep_threshold: Option<f64>,
    pub makestep_limit: u32,
    /// Cap on offset-correction slew rate, ppm.
    pub max_slew_ppm: f64,
    /// Cap on the absolute frequency correction we will command, ppm.
    pub max_freq_ppm: f64,
    /// log2 seconds.
    pub min_poll: i8,
    pub max_poll: i8,
    /// Send an initial burst of quick polls to converge fast (chrony `iburst`).
    pub iburst: bool,
    /// Gain of the integral trim on the frequency estimate. 0 disables it,
    /// leaving a purely proportional loop. See `FREQ_INTEGRAL_GAIN`.
    pub freq_integral_gain: f64,
    /// Step the poll interval back DOWN when `|offset| > this * noise`.
    ///
    /// This is the packet budget, and the packet budget is most of the
    /// accuracy: offset error falls as 1/sqrt(N). Measured on the seeded rig,
    /// clknetsim's own packet counters, same poll bounds for both arms:
    ///
    /// ```text
    ///            mean poll   median |e| S1   per packet spent
    /// chrony        33.9 s        1.52 us    (baseline)
    /// rusty_time    40.1 s        1.47 us    x1.05
    /// ```
    ///
    /// So the estimator was never the deficit — at equal cost it is at parity
    /// on S1 and slightly ahead on S8 (x0.96). We were simply buying fewer
    /// samples. See `POLL_DOWN_NOISE_RATIO`.
    pub poll_down_noise_ratio: f64,
    /// Consecutive stable samples required before the poll interval doubles.
    ///
    /// This, not the dead band, is what sets the packet budget. Sweeping
    /// `poll_down_noise_ratio` from 10 down to 3 moved the mean poll by 0.3 s
    /// and the accuracy not at all, because the step-DOWN branch only runs
    /// when `|offset| >= 2 * noise` and a converged loop is almost never
    /// there. It is always "stable", so it always climbs, and it pins at
    /// maxpoll. The climb rate is the only term with any authority.
    pub poll_up_streak: u32,
    /// Width of the regression's weight floor, as a fraction of the minimum
    /// observed delay. See `rusty_time_core::filter::WEIGHT_FLOOR_RATIO`.
    ///
    /// This is the one knob that can improve accuracy WITHOUT spending more
    /// packets, which is why it is worth a sweep: buying accuracy with poll
    /// rate leaves per-packet efficiency exactly where it was.
    pub weight_floor_ratio: f64,
    /// Weight-floor width for the OFFSET alone; the slope keeps
    /// `weight_floor_ratio`. Equal values reproduce the single-weight fit.
    pub offset_weight_floor_ratio: f64,
    /// Half-life, seconds, of the age decay on the OFFSET weights. Infinite
    /// disables it, weighting by delay alone.
    pub offset_age_halflife_s: f64,
    /// If > 0, take the offset weight floor from measured delay dispersion
    /// rather than a fraction of the minimum delay.
    pub offset_weight_dispersion_k: f64,
    /// Weight the slope fit by the time each sample represents, so an `iburst`
    /// cluster cannot act as a high-leverage anchor on the frequency estimate.
    pub slope_density_weighting: bool,
    /// Absolute steady-state correction time, seconds. 0 keeps the default
    /// behaviour of `CORR_TIME_RATIO * poll_interval`.
    ///
    /// The drain rate is `offset / correction_time`, and tying that time to the
    /// POLL makes the loop's aggressiveness a function of how often it looks.
    /// Polling twice as fast then does not average twice as much — it halves
    /// the time constant and writes twice as much sample noise into the clock,
    /// which is why every attempt to buy accuracy with packets has failed here:
    /// the packets were spent on twitchiness, not precision.
    ///
    /// With an absolute time constant, a faster poll delivers what it should —
    /// more samples inside the same correction window.
    ///
    /// **Off by default: measured, and it does not deliver.** The diagnosis is
    /// sound — an absolute time constant plus chrony's packet rate is the only
    /// pairing that could turn per-packet parity into raw-accuracy advantage,
    /// and neither half can show it alone. Paired against chrony, forty seeded
    /// worlds each:
    ///
    /// ```text
    ///                S1      S2      S4      S6      S8     poll
    /// base        -0.63   +0.63   -1.90   -2.21   -1.26    ~40 s
    /// t=200       -0.95   +0.63   -0.63   -3.48   -1.26    ~38 s
    /// t=120,k8    +0.32   +1.90   -0.32   -2.21   -1.90    ~32 s
    /// t=200,k8    +0.32   +0.63   -0.32   -2.53   -2.85    ~31 s
    /// ```
    ///
    /// Nothing resolves ahead anywhere, and S6 stays resolved behind in every
    /// arm. The absolute constant also destabilises the poll adaptation — S2
    /// fell to a 21 s poll, spending a third more packets for no gain — because
    /// the stability test that raises the interval is calibrated against a
    /// correction time that now no longer moves with it.
    pub corr_time_s: f64,
    /// Choose the regression window length from the data. See
    /// `SampleRegister::set_adaptive_window`.
    pub adaptive_window: bool,
    /// Longest a steady-state correction may be spread over, in seconds.
    ///
    /// The correction time is `corr_time_ratio * poll`, and the standing offset
    /// of a proportional loop is `F_residual * correction_time` — so tying it to
    /// the poll makes the error grow with the poll interval. At a 64 s ceiling
    /// that is microseconds. At the DEFAULT 1024 s ceiling it is milliseconds,
    /// which is how a corpus measured entirely at `maxpoll 6` reported parity
    /// with chrony while the shipped configuration was 145x worse.
    ///
    /// Capping it decouples the two. Below the cap nothing changes, so every
    /// short-poll result stands; above it the loop stops spreading a correction
    /// over a quarter of an hour merely because that is how often it looks.
    pub corr_time_max_s: f64,
    /// How to treat a second announced by the upstream source.
    pub leap_mode: LeapMode,
    /// Largest correction this daemon will ever make, in seconds. `None`
    /// applies no limit.
    ///
    /// chrony's `maxchange`, and off by default exactly as chrony's is —
    /// because the right value is a policy question about the deployment, not
    /// something a library can guess. A machine with a dead RTC legitimately
    /// needs to move its clock by years on first sync; a mesh node that has
    /// been up for a week does not, and a source asking it to should be
    /// refused rather than obeyed.
    pub max_change_s: Option<f64>,
    /// Updates to allow before the limit applies, so a cold start can make the
    /// one large correction it genuinely needs.
    pub max_change_start: u32,
    /// How many refusals to tolerate before giving up. Negative never gives up.
    ///
    /// Giving up is the point. A daemon that refuses corrections forever and
    /// says nothing is a daemon whose clock is quietly wrong — the operator
    /// needs to find out, and an exit is how a service says so.
    pub max_change_ignore: i32,
    /// Poll intervals over which a steady-state offset is drained. Overrides
    /// `CORR_TIME_RATIO` when > 0.
    ///
    /// Poll-SCALED on purpose. An absolute constant measured well on the
    /// corpus and is unsafe to ship: the rig runs `maxpoll 6` (64 s) while the
    /// production default is `maxpoll 10` (1024 s), where a fixed 40 s
    /// correction time would drain each estimate twenty-five times faster than
    /// the loop can see, chasing jitter instead of averaging it.
    pub corr_time_ratio: f64,
}

impl Default for DisciplineConfig {
    fn default() -> Self {
        DisciplineConfig {
            makestep_threshold: Some(1.0),
            makestep_limit: 3,
            max_slew_ppm: 83_333.0,
            max_freq_ppm: 500.0,
            min_poll: 6,
            max_poll: 10,
            iburst: true,
            freq_integral_gain: FREQ_INTEGRAL_GAIN,
            poll_down_noise_ratio: POLL_DOWN_NOISE_RATIO,
            poll_up_streak: POLL_UP_STREAK,
            weight_floor_ratio: crate::filter::WEIGHT_FLOOR_RATIO,
            offset_weight_floor_ratio: crate::filter::OFFSET_WEIGHT_FLOOR_RATIO,
            offset_age_halflife_s: f64::INFINITY,
            offset_weight_dispersion_k: 0.0,
            slope_density_weighting: false,
            corr_time_s: 0.0,
            corr_time_ratio: 0.0,
            adaptive_window: true,
            corr_time_max_s: CORR_TIME_MAX_S,
            leap_mode: LeapMode::Slew,
            max_change_s: None,
            max_change_start: 1,
            max_change_ignore: 2,
        }
    }
}

/// What the platform driver should do right now.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ClockCommand {
    /// Add this many seconds to the clock immediately.
    Step { add_seconds: f64 },
    /// Run at `freq_ppm` (absolute correction vs the undisciplined clock) and
    /// additionally drain `drain_offset` seconds at up to `drain_rate_ppm`.
    Slew {
        freq_ppm: f64,
        drain_offset: f64,
        drain_rate_ppm: f64,
    },
}

#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Plan {
    pub command: ClockCommand,
    /// Seconds until the next poll.
    pub next_poll_s: f64,
    /// The sample register is invalid after a step; caller must shift or clear it.
    pub reset_register: bool,
    /// What the maximum-change guard made of this correction.
    pub verdict: ChangeVerdict,
}

/// What to do about a leap second the upstream source has announced.
///
/// A leap second is the one correction a time daemon can see coming. The server
/// sets the leap indicator during the UTC day it happens, and at midnight the
/// second is inserted or removed — every client sees a one-second step at the
/// same instant.
///
/// Handling it is not optional in the way it looks. Unhandled, the step arrives
/// as an ordinary offset and is corrected like any other, which takes about
/// twelve seconds at the slew ceiling and leaves the clock a whole second wrong
/// meanwhile. Worse, and this is the case that matters: with `max_change_s` set
/// below one second the guard REFUSES it, and since every node in a fleet sees
/// the same leap at the same moment, every node exhausts its allowance and exits
/// together. A safety limit turning into a synchronised outage on a date known
/// years in advance is not a hypothetical failure mode.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LeapMode {
    /// Slew the second in like any other offset, but exempt from the
    /// maximum-change guard because it is expected, bounded and announced.
    Slew,
    /// Step it, which is what a machine that cannot tolerate a slow second
    /// wants — and what most operating systems do natively.
    Step,
    /// Take no special action. The step is then an ordinary offset, and the
    /// maximum-change guard applies to it like anything else.
    Ignore,
}

/// What the maximum-change guard decided about a correction.
///
/// A time daemon's most dangerous power is that it is *believed*. On a mesh,
/// the node running your code is hardware you do not control, and a capability
/// expires by this clock — so a source that can move it can move the boundary
/// between "revoked" and "valid". Authentication proves who a server is, not
/// that it is telling the truth.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ChangeVerdict {
    /// Within the limit, or no limit configured.
    Accepted,
    /// Larger than the limit: NO correction was made. `seen` counts how many
    /// consecutive refusals have happened, so the caller can say so once
    /// rather than on every poll.
    Refused { offset_s: f64, seen: u32 },
    /// Larger than the limit, and the allowance for refusals is spent. The
    /// caller should stop rather than keep running a clock it has decided it
    /// cannot steer.
    GiveUp { offset_s: f64 },
}

/// Number of quick polls in an iburst, and their spacing.
const IBURST_COUNT: u32 = 4;
const IBURST_SPACING_S: f64 = 2.0;
/// Drain a measured offset over roughly this many poll intervals.
///
/// **Was 3.0. Lowered to 1.0 on measurement, and this is the term that carried
/// the standing bias.**
///
/// A proportional loop settles where the drain it applies balances the drift
/// that keeps re-creating the offset, which is `offset = F_residual * corr_time`
/// (see the derivation below). The residual frequency error is what it is —
/// nine attempts to shrink it all traded one scenario against another — but
/// `corr_time` is a free parameter, and the standing offset is LINEAR in it.
///
/// The diagnosis came before the sweep, which is why this one worked where the
/// others did not. Logging what the loop believed against clknetsim's ground
/// truth showed the estimator was *right*: on S6 it reported -1.50 us where the
/// truth was -1.21 us. The loop could see the error and was not removing it.
/// That is a controller property, not an estimator defect, and it made a
/// quantitative prediction — shorten the correction time and the bias shrinks
/// in proportion.
///
/// It did. S6's standing bias went from +1.27 us to +0.24 us against chrony's
/// +0.26. Paired against the old ratio, sixty fresh seeded worlds per scenario:
///
/// ```text
///          S1        S2        S4        S6        S8
///       +0.77     +4.65     +0.77     +1.29     +3.36
/// ```
///
/// Two resolved improvements, no resolved regression, every scenario trending
/// better, and convergence untouched (S1 5 s, S6 16 s, S8 5 s in both arms).
/// Against chrony it removes the S8 loss and turns S1, S2 and S8 into resolved
/// wins per packet spent.
///
/// It stays a RATIO rather than becoming an absolute constant. An absolute 40 s
/// measured slightly better still, and is unsafe: the corpus runs `maxpoll 6`
/// (64 s) while the production default is `maxpoll 10` (1024 s), where a fixed
/// 40 s would drain each estimate twenty-five times faster than the loop can
/// see it — chasing jitter instead of averaging it.
const CORR_TIME_RATIO: f64 = 1.0;
/// Correction time, in poll intervals, for an offset that is plainly real.
/// One means "finish before the next sample arrives".
const ACQUIRE_CORR_RATIO: f64 = 1.0;
/// How far outside the noise an offset must sit to be treated as real.
const ACQUIRE_NOISE_MULTIPLE: f64 = 10.0;
/// How many updates count as acquisition.
///
/// Acquisition is a *phase*, not a magnitude. Gating the fast correction on
/// "the offset is much larger than the noise" looked equivalent and is not:
/// a loop that is confidently wrong reports a small `offset_sd` beside a large
/// error, so the test fires in steady state exactly when it should not. The
/// in-house S6 scenario is such a case — a deliberately noisy path where the
/// estimator's own confidence outruns its accuracy — and gating on magnitude
/// alone took its steady error from 2.54 ms to 10.83 ms while the low-noise
/// clknetsim rig showed only the improvement. Counting updates cannot be
/// fooled that way: after this many the loop is no longer starting up,
/// whatever it believes about itself.
const ACQUIRE_UPDATES: u32 = 8;
/// The most of the slew budget the fast correction may ask for.
///
/// Leaving headroom is the point. A correction that consumes the whole budget
/// pins the clock at maximum rate for the entire interval, and the frequency
/// estimator then has to infer a drift from samples taken while the clock was
/// being hauled — which it does badly enough to leave a permanently worse
/// steady state. A quarter keeps the fast path for offsets it can absorb and
/// hands genuinely large cold starts back to the gentle drain.
const ACQUIRE_SLEW_SHARE: f64 = 0.25;
/// How many times the noise an offset must exceed before the clock may be
/// hauled at the full slew ceiling.
const ACQUIRE_FULL_SPEED_CONFIDENCE: f64 = 10_000.0;
/// Above this share of the slew ceiling, the clock is being *hauled*, and a
/// frequency measured across that haul is not a measurement of the
/// oscillator.
///
/// The regression fits a slope through stored samples, and `slew_samples`
/// re-expresses that history for corrections already applied. That accounting
/// is exact for a gentle drain. It is not robust to a correction running at
/// most of the slew ceiling: any small mismatch between the rate commanded and
/// the rate delivered is multiplied by the poll interval and lands in the
/// slope, and the loop then carries a frequency error it never measured. The
/// offset drain has feedback and recovers; the frequency term accumulates and
/// does not.
///
/// So during a haul the offset is still corrected at full speed — the clock is
/// visibly wrong and the fix is not in doubt — but the frequency estimate is
/// left alone until the samples describe a clock that is merely running.
const FREQ_TRUST_SLEW_SHARE: f64 = 0.25;
/// Most polls the acquisition burst may take before it must slow down.
///
/// The burst normally ends after `IBURST_COUNT` samples, and the poll then
/// jumps straight to `min_poll`. That is the whole S6 gap: the offset drain is
/// sized to finish within one poll interval, so ending the burst with a large
/// correction still outstanding hands the remainder a 16 s deadline instead of
/// a 2 s one. Measured against chrony, chrony had a 500 ms cold start gone in
/// about 7 s at close to its slew ceiling, while this loop cleared 500 ms down
/// to 89 ms in the burst and then spent a further 16 s on what was left.
///
/// So the burst ends when the offset is small, not when a counter runs out.
/// The cap is what stops that becoming an unbounded fast poll against someone
/// else's server: a client that cannot converge is a client that must back off
/// anyway, not one that should keep asking every two seconds.
const MAX_ACQUIRE_BURST: u32 = 16;
/// How much of an implied frequency error to absorb per update.
///
/// **Why there is an integral term at all.** The offset drain is proportional:
/// each plan removes `offset / (CORR_TIME_RATIO * poll)` per second. Against a
/// constant unmodelled drift `F`, that settles at an equilibrium rather than at
/// zero -- removal balances accumulation when
///
/// ```text
///     offset  =  CORR_TIME_RATIO * poll * F
/// ```
///
/// which is a *standing error the loop maintains on purpose*. Measured on the
/// in-house corpus it is the whole story: S1 sat at 200 us on a 0.039 ppm
/// residual and a 1024 s poll, and 3 * 1024 * 0.039e-6 is 120 us. A
/// proportional controller cannot remove it; only integral action can.
///
/// The frequency term comes from the regression slope, which is a
/// *measurement*. If that measurement carries any bias, the equilibrium above
/// stands forever and no amount of averaging removes it. So the loop reads the
/// standing offset as evidence in its own right: invert the relation, and a
/// persistent offset **is** a frequency error, expressed in seconds.
///
/// **Measured and rejected. The default is 0 — the trim is OFF.**
///
/// The reasoning above is sound and the result still went the other way. On a
/// SEEDED rig, twenty worlds per arm, paired seed by seed:
///
/// ```text
/// S8  gain=0.0   median |e| 4.78 us    8/20 wins vs chrony   z=-0.89  not resolved
/// S8  gain=0.1   median |e| 6.20 us    5/20 wins vs chrony   z=-2.24  RESOLVED, chrony ahead
/// S1  gain=0.0   median |e| 1.47 us
/// S1  gain=0.1   median |e| 1.98 us
/// ```
///
/// Turning the trim on is the only *resolved* accuracy result in that sweep,
/// and it is a regression. An earlier single unpaired run had read it as an
/// improvement on both scenarios; it was the draw, not the code.
///
/// Why it fails, as best the data supports: the standing offset is not a
/// frequency error here. It is sampling error in the delay draws — it changes
/// SIGN with the seed. Integrating it feeds noise into the frequency estimate,
/// and on S8, whose oscillator already wanders, that is the last thing the
/// loop needs.
///
/// Kept as a field rather than deleted so re-testing costs one flag if the
/// estimator's own bias ever shrinks below this effect.
const FREQ_INTEGRAL_GAIN: f64 = 0.0;

/// How far outside the noise an offset must sit before the poll interval is
/// stepped back down — the default for `DisciplineConfig::poll_down_noise_ratio`.
///
/// An offset below `2 * noise` counts as stable and, after three such samples,
/// doubles the interval. Between that and this ratio the loop does neither, so
/// this number IS the width of the dead band, and a wide dead band pins the
/// client at maxpoll: at 10x it effectively never came back down.
///
/// The value is measured, not chosen — see the sweep in `DisciplineConfig`.
const POLL_DOWN_NOISE_RATIO: f64 = 10.0;

/// Default ceiling on the steady-state correction time, seconds.
///
/// Chosen to sit above every poll interval the corpus exercises (a 64 s poll
/// gives a 64 s correction time) so short-poll behaviour is untouched, and far
/// below the 1024 s the default poll ceiling would otherwise produce.
const CORR_TIME_MAX_S: f64 = 128.0;

/// How large a correction an announced leap second may excuse.
///
/// A leap is one second by definition, so anything materially larger is not the
/// leap — it is a source using the announcement to smuggle a correction past
/// the guard. Two seconds leaves room for the leap plus whatever ordinary error
/// had accumulated, and refuses anything that is plainly something else.
const LEAP_EXEMPTION_S: f64 = 2.0;

/// Consecutive stable samples before the poll interval doubles — the default
/// for `DisciplineConfig::poll_up_streak`.
const POLL_UP_STREAK: u32 = 3;
/// Weight of the newest offset in the persistence average. Low, because the
/// signal being extracted is the part that does *not* change.
const OFFSET_EWMA_ALPHA: f64 = 0.25;

/// The offset at which acquisition is finished and the burst may end.
///
/// Tied to what "converged" means rather than to a multiple of the noise. The
/// noise-multiple test was tried first and fails at exactly the wrong moment:
/// on S6 the burst had hauled 500 ms down to 9.8 ms, `10 x noise` came out at
/// about 10 ms, the test went false, the poll jumped 2 s -> 16 s, and the last
/// 9.8 ms was handed a 16 s deadline. Those 16 s were the whole difference
/// against chrony. Floored at twice the noise so a genuinely noisy path is not
/// polled fast in pursuit of an offset it cannot resolve.
const ACQUIRE_DONE_S: f64 = 1e-3;

#[derive(Clone, Debug)]
pub struct Discipline {
    cfg: DisciplineConfig,
    freq_ppm: f64,
    updates: u32,
    poll: i8,
    stable_streak: u32,
    iburst_left: u32,
    /// Drain rate the previous plan commanded, as a share of the ceiling.
    /// Samples taken since then were taken while the clock moved at that rate.
    last_drain_share: f64,
    /// Burst polls used, including any the acquisition extension granted.
    burst_used: u32,
    /// Slow average of recent offset estimates.
    ///
    /// A *persistent* offset is the signature of a frequency error the
    /// regression has not measured, and it is the thing that decides
    /// steady-state accuracy. See `integral_trim`.
    offset_ewma: f64,
    /// Whether `offset_ewma` has been seeded.
    ewma_seeded: bool,
    /// Consecutive corrections refused by the maximum-change guard.
    change_refusals: u32,
}

impl Discipline {
    pub fn new(cfg: DisciplineConfig) -> Self {
        let iburst_left = if cfg.iburst { IBURST_COUNT } else { 0 };
        Discipline {
            cfg,
            freq_ppm: 0.0,
            updates: 0,
            poll: cfg.min_poll,
            stable_streak: 0,
            iburst_left,
            last_drain_share: 0.0,
            burst_used: 0,
            offset_ewma: 0.0,
            ewma_seeded: false,
            change_refusals: 0,
        }
    }

    /// How much of the slew budget an acquisition correction may use, given
    /// how well the offset is known.
    ///
    /// How fast the clock may be hauled should depend on how sure we are where
    /// it is going. A 500 ms offset on a path with microseconds of jitter is
    /// known to five decimal places and can be cleared at the ceiling; the same
    /// 500 ms on a path with a millisecond of jitter is a much rougher number,
    /// and committing to it at full speed writes the roughness into the clock.
    ///
    /// Both rigs demanded this. On clknetsim, restricting the share left S6 at
    /// 18 s against chrony's 12 s; on the in-house corpus, whose S6 models a
    /// 0.74 ms-jitter path, allowing the full share took its steady error from
    /// 1.5 ms to 5.9 ms. Neither constant satisfies both, because the two rigs
    /// differ by two orders of magnitude in exactly the quantity that should
    /// decide it.
    fn acquire_share(&self, offset: f64, noise: f64) -> f64 {
        let confidence = offset.abs() / noise.max(1e-9);
        if confidence >= ACQUIRE_FULL_SPEED_CONFIDENCE {
            1.0
        } else {
            ACQUIRE_SLEW_SHARE
        }
    }

    /// Current commanded frequency correction, ppm.
    pub fn freq_ppm(&self) -> f64 {
        self.freq_ppm
    }

    pub fn poll_log2(&self) -> i8 {
        self.poll
    }

    /// Feed the latest combined estimate.
    ///
    /// * `offset` — seconds to add to the local clock, now.
    /// * `freq_ppm_meas` — residual frequency error from the regression (ppm,
    ///   positive = local slow), if trusted.
    /// * `offset_sd` — residual noise of the estimate.
    pub fn on_estimate(&mut self, offset: f64, freq_ppm_meas: Option<f64>, offset_sd: f64) -> Plan {
        self.on_estimate_with_leap(offset, freq_ppm_meas, offset_sd, false)
    }

    /// As [`Discipline::on_estimate`], told whether the source has announced a
    /// leap second for the current UTC day.
    pub fn on_estimate_with_leap(
        &mut self,
        offset: f64,
        freq_ppm_meas: Option<f64>,
        offset_sd: f64,
        leap_pending: bool,
    ) -> Plan {
        self.updates += 1;

        // An announced leap is expected, bounded and about a second. Exempting
        // it from the maximum-change guard is the whole reason the daemon is
        // told about it: otherwise a limit below one second turns a scheduled,
        // fleet-wide event into a scheduled, fleet-wide shutdown.
        let leap_exempt = leap_pending
            && self.cfg.leap_mode != LeapMode::Ignore
            && offset.abs() <= LEAP_EXEMPTION_S;
        if leap_exempt && self.cfg.leap_mode == LeapMode::Step {
            self.stable_streak = 0;
            self.change_refusals = 0;
            return Plan {
                command: ClockCommand::Step {
                    add_seconds: offset,
                },
                next_poll_s: self.take_poll_interval(),
                reset_register: true,
                verdict: ChangeVerdict::Accepted,
            };
        }

        // The maximum-change guard, before anything is decided.
        //
        // The test is "refuse unless the offset is DEFINITELY within the
        // limit", spelled through `partial_cmp` so the third case is visible.
        // Written the natural way, as `|offset| > limit`, a NaN estimate would
        // be waved through — every comparison against NaN is false, so the one
        // value that is certainly not a time would go straight to the clock,
        // past the guard whose whole job is to refuse a correction it cannot
        // vouch for. Nothing downstream re-checks: the command reaches
        // `clock_adjtime` through an `as i64` conversion that saturates rather
        // than trapping, so a NaN silently becomes zero.
        //
        // Placed ahead of the step logic on purpose: a step is the largest and
        // fastest way to move a clock, so a guard that ran after it would be
        // guarding everything except the dangerous case. The allowance for
        // early updates is what lets a cold start still make its one big
        // legitimate correction.
        if let Some(limit) = self.cfg.max_change_s
            && !leap_exempt
            && self.updates > self.cfg.max_change_start
            && !matches!(
                offset.abs().partial_cmp(&limit),
                Some(Ordering::Less | Ordering::Equal)
            )
        {
            self.change_refusals = self.change_refusals.saturating_add(1);
            let spent = self.cfg.max_change_ignore >= 0
                && self.change_refusals as i64 > i64::from(self.cfg.max_change_ignore);
            self.stable_streak = 0;
            return Plan {
                // Hold the frequency already commanded and drain nothing: the
                // clock keeps running as it was, which is the only honest
                // response to an estimate this daemon has decided not to trust.
                command: ClockCommand::Slew {
                    freq_ppm: self.freq_ppm,
                    drain_offset: 0.0,
                    drain_rate_ppm: 0.0,
                },
                next_poll_s: self.take_poll_interval(),
                reset_register: false,
                verdict: if spent {
                    ChangeVerdict::GiveUp { offset_s: offset }
                } else {
                    ChangeVerdict::Refused {
                        offset_s: offset,
                        seen: self.change_refusals,
                    }
                },
            };
        }
        // A correction within the limit clears the run: the allowance is for
        // CONSECUTIVE refusals, so one bad estimate among good ones does not
        // accumulate toward giving up.
        self.change_refusals = 0;

        // Step epoch: large offsets early on are stepped away, chrony `makestep`.
        if let Some(threshold) = self.cfg.makestep_threshold
            && offset.abs() > threshold
            && self.updates <= self.cfg.makestep_limit
        {
            self.stable_streak = 0;
            return Plan {
                command: ClockCommand::Step {
                    add_seconds: offset,
                },
                next_poll_s: self.take_poll_interval(),
                reset_register: true,
                verdict: ChangeVerdict::Accepted,
            };
        }

        // Frequency: the regression slope is a direct measurement of the residual
        // frequency error of the *disciplined* clock, so accumulate it fully --
        // unless these samples were taken while the clock was being hauled, in
        // which case the slope is mostly the haul.
        let hauling = self.last_drain_share > FREQ_TRUST_SLEW_SHARE;
        if let Some(fm) = freq_ppm_meas
            && !hauling
        {
            self.freq_ppm =
                (self.freq_ppm + fm).clamp(-self.cfg.max_freq_ppm, self.cfg.max_freq_ppm);
        }

        // Poll adaptation first: lengthen when quiet, shorten when the offset is
        // loud relative to the noise floor. Runs before the drain computation so
        // the drain rate is sized for the interval the plan will actually use.
        let noise = offset_sd.max(1e-7);

        // Integral trim: read a standing offset as the frequency error it
        // implies, and absorb a fraction of it. Only once acquisition is over
        // -- during acquisition the offset is large for reasons that have
        // nothing to do with drift, and feeding that in would be nonsense.
        if self.ewma_seeded {
            self.offset_ewma =
                (1.0 - OFFSET_EWMA_ALPHA) * self.offset_ewma + OFFSET_EWMA_ALPHA * offset;
        } else {
            self.offset_ewma = offset;
            self.ewma_seeded = true;
        }
        // ...and only when the standing offset is larger than the noise that
        // could have produced it. Below that line the average is a sample of
        // jitter, and feeding jitter into the frequency term writes it into the
        // clock permanently -- the offset drain can recover from a bad estimate,
        // the frequency term accumulates it. Measured: without this gate S1
        // went from 199.7 us to 231.5 us while its frequency residual did not
        // move at all, which is exactly what integrating noise looks like.
        if self.cfg.freq_integral_gain != 0.0
            && self.updates > ACQUIRE_UPDATES
            && self.offset_ewma.abs() > noise
        {
            let poll_now = self.peek_poll_interval();
            let implied_freq_ppm = (self.offset_ewma / (CORR_TIME_RATIO * poll_now)) * 1e6;
            self.freq_ppm = (self.freq_ppm + self.cfg.freq_integral_gain * implied_freq_ppm)
                .clamp(-self.cfg.max_freq_ppm, self.cfg.max_freq_ppm);
        }
        if offset.abs() < 2.0 * noise {
            self.stable_streak += 1;
            if self.stable_streak >= self.cfg.poll_up_streak && self.poll < self.cfg.max_poll {
                self.poll += 1;
                self.stable_streak = 0;
            }
        } else {
            self.stable_streak = 0;
            if offset.abs() > self.cfg.poll_down_noise_ratio * noise
                && self.poll > self.cfg.min_poll
            {
                self.poll -= 1;
            }
        }

        // Offset: drain over ~CORR_TIME_RATIO poll intervals, capped by maxslewrate.
        //
        // ...except while the offset is unambiguous. The loop re-plans on every
        // sample, so a drain sized to finish in three poll intervals only ever
        // runs for one of them before being replaced: the offset decays by a
        // third per poll, giving a time constant three times longer than the
        // ratio suggests. In steady state that is exactly the wanted
        // behaviour — it is what stops sample noise being written into the
        // clock. During acquisition it is not: a 10 ms startup offset is a
        // hundred times the noise floor, it is not in dispute, and decaying it
        // by a third per 16 s poll leaves the clock wrong for a minute.
        // Measured against chrony under clknetsim, chrony had removed the same
        // offset within about two seconds while this loop was still 40 s away.
        //
        // The test is the one the poll adaptation already uses: an offset far
        // outside the noise is a real error, not a noisy reading, so correct
        // it within the interval. Once it is comparable to the noise the
        // gentle ratio takes over again, and steady-state accuracy — which is
        // at parity with chrony — is untouched.
        // Keep the acquisition burst going while a correction is still
        // outstanding. The drain is sized to finish within one poll interval,
        // so ending the burst early does not merely delay the next
        // measurement — it stretches the correction itself from two seconds to
        // sixteen. On S6 that single step was the entire gap against chrony:
        // the burst hauled 500 ms down to 9.8 ms by t=10.5 s, then handed what
        // was left a 16 s deadline and finished at t=26 s where chrony
        // finished at t=12 s.
        if self.iburst_left == 0
            && self.cfg.iburst
            && self.burst_used < MAX_ACQUIRE_BURST
            && offset.abs() > ACQUIRE_DONE_S.max(2.0 * noise)
        {
            self.iburst_left = 1;
        }

        let poll_s = self.peek_poll_interval();
        //
        // The fast path's premise is "finish this correction before the next
        // sample". If the rate that would take is above the slew ceiling, the
        // correction cannot finish within the interval, the premise is false,
        // and asking for it anyway just pins the clock at maximum slew for the
        // whole interval — which is how a 500 ms cold start went from a 2.54 ms
        // steady error to 10.83 ms on the noisy in-house rig while the
        // low-noise one showed only the improvement. So the fast ratio applies
        // only when it is actually achievable, and a correction too large to
        // finish is drained gently, as before.
        let acquiring = self.updates <= ACQUIRE_UPDATES;
        // Rate: gentle by default, faster while acquiring.
        //
        // The rate stays tied to the poll interval even though drains are now
        // budgeted and stop when spent. Untying it was tried — "clear any
        // acquisition offset in ACQUIRE_TARGET_S seconds" — and it is worse:
        // with a 16 s poll it corrects the whole of each noisy estimate in two
        // seconds and then coasts for fourteen, which chases noise instead of
        // averaging it. Scaling with the poll is what makes the correction
        // proportional to how often the loop actually gets to look.
        //
        // What the budget buys is not a faster rate here. It is that the rate
        // is now free to be chosen at all: an over-fast drain no longer sails
        // past the offset, it stops at it. Measured on the same binary, the
        // same discipline with budgets unenforced settles at 579 us on S6 and
        // with them enforced at 130 us.
        let wanted_rate_ppm = if acquiring && offset.abs() > ACQUIRE_NOISE_MULTIPLE * noise {
            // Move at the fastest rate allowed and stop when the offset is
            // gone. This is only expressible because the drain carries a
            // budget: without one, a rate this high would not stop at the
            // offset, it would sail past it, so the rate had to be "the offset
            // divided by the poll interval" and a cold start's remainder was
            // handed the poll's deadline. That is what put S6 at 26 s against
            // chrony's 12 s.
            // Scales with the poll, so a long interval gets a gentle rate and
            // the loop averages noise instead of chasing it. A fixed clearing
            // time was tried and is wrong for exactly that reason: at a 64 s
            // poll, "clear it in 2 s" is thirty times more aggressive than the
            // interval warrants, and the in-house S6 steady error went from
            // 1.5 ms to 9 ms.
            //
            // The ceiling is the whole slew budget rather than a quarter of
            // it. That is safe only because the drain stops when spent: an
            // over-fast rate now runs out at the offset instead of sailing
            // past it, and what it delivered is booked even if the caller wakes
            // late. Without those two properties this cap had to stay low.
            // Poll-scaled, with a ceiling that depends on how well the offset
            // is known.
            //
            // Untying the rate from the poll entirely -- "clear it in
            // ACQUIRE_TARGET_S" -- was tried twice and measured worse both
            // times: 16 s on S6 against 14 s here, and on the noisy rig a fixed
            // clearing time at a 64 s poll is thirty times more aggressive than
            // the interval warrants, which chases jitter instead of averaging
            // it. Scaling with the poll is what keeps the correction
            // proportional to how often the loop gets to look.
            ((offset.abs() / (ACQUIRE_CORR_RATIO * poll_s)) * 1e6)
                .min(self.cfg.max_slew_ppm * self.acquire_share(offset, noise))
        } else {
            // Correction time: poll-scaled by default, absolute when asked.
            let ratio = if self.cfg.corr_time_ratio > 0.0 {
                self.cfg.corr_time_ratio
            } else {
                CORR_TIME_RATIO
            };
            let corr_time = if self.cfg.corr_time_s > 0.0 {
                self.cfg.corr_time_s
            } else {
                (ratio * poll_s).min(self.cfg.corr_time_max_s)
            };
            (offset.abs() / corr_time) * 1e6
        };
        let drain_rate_ppm = wanted_rate_ppm.min(self.cfg.max_slew_ppm);
        self.last_drain_share = if self.cfg.max_slew_ppm > 0.0 {
            drain_rate_ppm / self.cfg.max_slew_ppm
        } else {
            0.0
        };

        Plan {
            command: ClockCommand::Slew {
                freq_ppm: self.freq_ppm,
                drain_offset: offset,
                drain_rate_ppm,
            },
            next_poll_s: self.take_poll_interval(),
            reset_register: false,
            verdict: ChangeVerdict::Accepted,
        }
    }

    /// How long to wait before trying again when an exchange yields nothing —
    /// lost, or rejected because the server was not yet usable.
    ///
    /// This is the iburst spacing while the burst budget lasts, *not* the poll
    /// interval. A server that has only just started answers its first requests
    /// with the unsynchronised leap indicator, which a client must refuse; if
    /// that refusal then costs a full poll interval, a cold start is delayed by
    /// 16 seconds before the first usable sample. Measured against chrony under
    /// clknetsim, that single wait was most of an 8x convergence gap.
    ///
    /// Nothing is consumed here: a failed exchange must not spend burst budget,
    /// or a few early losses would silently end the burst.
    pub fn retry_interval_s(&self) -> f64 {
        self.peek_poll_interval()
    }

    /// The interval the *next* plan will use, without consuming iburst budget.
    fn peek_poll_interval(&self) -> f64 {
        if self.iburst_left > 0 {
            IBURST_SPACING_S
        } else {
            2f64.powi(self.poll as i32)
        }
    }

    /// Consume one poll slot — called exactly once per emitted Plan.
    fn take_poll_interval(&mut self) -> f64 {
        if self.iburst_left > 0 {
            self.iburst_left -= 1;
            self.burst_used += 1;
            IBURST_SPACING_S
        } else {
            2f64.powi(self.poll as i32)
        }
    }
}

#[cfg(test)]
mod acquisition_tests {
    use super::*;

    fn acquiring() -> Discipline {
        Discipline::new(DisciplineConfig {
            makestep_threshold: None,
            min_poll: 4, // 16 s
            iburst: true,
            ..DisciplineConfig::default()
        })
    }

    #[test]
    fn the_burst_continues_while_a_correction_is_outstanding() {
        // The drain is sized to finish within one poll interval, so ending the
        // burst with an offset still outstanding does not just delay the next
        // measurement — it stretches the correction from 2 s to 16 s. Against
        // chrony on S6 that one step was the whole gap: 500 ms was hauled down
        // to 9.8 ms by the burst, and the remainder then took another 16 s.
        let mut d = acquiring();
        let mut plan = None;
        for _ in 0..IBURST_COUNT + 3 {
            // A 10 ms offset, far above both the 1 ms target and the noise.
            plan = Some(d.on_estimate(0.010, None, 1e-6));
        }
        let next = plan.expect("a plan").next_poll_s;
        assert!(
            next <= IBURST_SPACING_S,
            "burst ended with 10 ms still outstanding: next poll {next} s"
        );
    }

    #[test]
    fn the_burst_ends_once_the_offset_is_small() {
        // ...and it must end, or a converged client polls a stranger's server
        // every two seconds forever.
        let mut d = acquiring();
        let mut plan = None;
        for _ in 0..IBURST_COUNT + 3 {
            plan = Some(d.on_estimate(1e-6, None, 1e-6));
        }
        let next = plan.expect("a plan").next_poll_s;
        assert!(
            next > IBURST_SPACING_S,
            "burst kept running on a converged clock: next poll {next} s"
        );
    }

    #[test]
    fn the_extended_burst_is_bounded() {
        // A client that never converges must back off rather than keep asking.
        let mut d = acquiring();
        let mut plan = None;
        for _ in 0..MAX_ACQUIRE_BURST * 3 {
            plan = Some(d.on_estimate(0.010, None, 1e-6));
        }
        let next = plan.expect("a plan").next_poll_s;
        assert!(
            next > IBURST_SPACING_S,
            "burst never backed off despite never converging: next poll {next} s"
        );
    }
}

#[cfg(test)]
mod leap_tests {
    use super::*;

    fn cfg(mode: LeapMode, max_change: Option<f64>) -> DisciplineConfig {
        DisciplineConfig {
            leap_mode: mode,
            max_change_s: max_change,
            max_change_start: 1,
            max_change_ignore: 2,
            makestep_threshold: Some(1.0),
            makestep_limit: 3,
            ..DisciplineConfig::default()
        }
    }

    /// The failure this exists to prevent.
    ///
    /// A leap second arrives at every node in a fleet at the same instant. With
    /// a maximum-change limit below one second and no leap handling, every node
    /// refuses it, exhausts its allowance, and exits **together** — a safety
    /// limit turning into a synchronised outage on a date known years ahead.
    #[test]
    fn an_announced_leap_does_not_trip_the_change_guard() {
        let mut d = Discipline::new(cfg(LeapMode::Slew, Some(0.1)));
        d.on_estimate_with_leap(0.0001, None, 1e-6, false); // settle past the allowance
        for _ in 0..5 {
            let plan = d.on_estimate_with_leap(1.0, None, 1e-6, true);
            assert_eq!(
                plan.verdict,
                ChangeVerdict::Accepted,
                "an announced leap second was refused by the change guard"
            );
        }
    }

    /// Without the announcement the same offset is refused, which is what makes
    /// the exemption meaningful rather than a hole.
    #[test]
    fn the_same_offset_unannounced_is_still_refused() {
        let mut d = Discipline::new(cfg(LeapMode::Slew, Some(0.1)));
        d.on_estimate_with_leap(0.0001, None, 1e-6, false);
        assert!(matches!(
            d.on_estimate_with_leap(1.0, None, 1e-6, false).verdict,
            ChangeVerdict::Refused { .. }
        ));
    }

    /// The announcement excuses a leap, not an arbitrary correction. A source
    /// that sets the bit and then asks for an hour is not describing a leap.
    #[test]
    fn an_announcement_does_not_excuse_an_arbitrary_correction() {
        let mut d = Discipline::new(cfg(LeapMode::Slew, Some(0.1)));
        d.on_estimate_with_leap(0.0001, None, 1e-6, false);
        assert!(
            matches!(
                d.on_estimate_with_leap(3600.0, None, 1e-6, true).verdict,
                ChangeVerdict::Refused { .. }
            ),
            "the leap bit was used to smuggle a correction past the guard"
        );
    }

    /// `step` mode steps the second rather than slewing it in over ~12 s.
    #[test]
    fn step_mode_steps_the_second() {
        let mut d = Discipline::new(cfg(LeapMode::Step, None));
        for _ in 0..6 {
            d.on_estimate_with_leap(0.0001, None, 1e-6, false);
        }
        let plan = d.on_estimate_with_leap(1.0, None, 1e-6, true);
        match plan.command {
            ClockCommand::Step { add_seconds } => {
                assert!((add_seconds - 1.0).abs() < 1e-9);
                assert!(plan.reset_register, "a step invalidates stored samples");
            }
            other => panic!("expected a step, got {other:?}"),
        }
    }

    /// `ignore` restores the old behaviour exactly: no exemption, no step.
    #[test]
    fn ignore_mode_treats_a_leap_as_an_ordinary_offset() {
        let mut d = Discipline::new(cfg(LeapMode::Ignore, Some(0.1)));
        d.on_estimate_with_leap(0.0001, None, 1e-6, false);
        assert!(matches!(
            d.on_estimate_with_leap(1.0, None, 1e-6, true).verdict,
            ChangeVerdict::Refused { .. }
        ));
    }

    /// Slew is the default, and a leap under it is corrected like any offset —
    /// just without the guard firing.
    #[test]
    fn slew_is_the_default_and_does_not_step() {
        assert_eq!(DisciplineConfig::default().leap_mode, LeapMode::Slew);
        let mut d = Discipline::new(cfg(LeapMode::Slew, None));
        for _ in 0..6 {
            d.on_estimate_with_leap(0.0001, None, 1e-6, false);
        }
        let plan = d.on_estimate_with_leap(1.0, None, 1e-6, true);
        assert!(
            matches!(plan.command, ClockCommand::Slew { .. }),
            "slew mode stepped the clock"
        );
    }

    /// A daemon told nothing behaves exactly as before.
    #[test]
    fn no_announcement_is_the_old_behaviour() {
        let mut a = Discipline::new(cfg(LeapMode::Slew, None));
        let mut b = Discipline::new(cfg(LeapMode::Slew, None));
        for i in 0..8 {
            let off = 0.001 * f64::from(i);
            let p = a.on_estimate(off, None, 1e-6);
            let q = b.on_estimate_with_leap(off, None, 1e-6, false);
            assert_eq!(p.command, q.command);
            assert_eq!(p.next_poll_s, q.next_poll_s);
        }
    }
}

#[cfg(test)]
mod max_change_tests {
    use super::*;

    fn guarded(limit: f64, start: u32, ignore: i32) -> Discipline {
        Discipline::new(DisciplineConfig {
            max_change_s: Some(limit),
            max_change_start: start,
            max_change_ignore: ignore,
            // Stepping is what makes a hostile offset dangerous, so leave it on.
            makestep_threshold: Some(1.0),
            makestep_limit: 3,
            ..DisciplineConfig::default()
        })
    }

    /// Off unless asked for — the same default chrony ships.
    #[test]
    fn no_limit_by_default() {
        let mut d = Discipline::new(DisciplineConfig::default());
        let plan = d.on_estimate(86_400.0, None, 1e-6);
        assert_eq!(plan.verdict, ChangeVerdict::Accepted);
        assert!(
            matches!(plan.command, ClockCommand::Step { .. }),
            "with no limit configured a large offset must still be corrected"
        );
    }

    /// The cold start a limit must not break: one big legitimate correction.
    #[test]
    fn the_first_correction_is_still_allowed_through() {
        let mut d = guarded(1000.0, 1, 2);
        let plan = d.on_estimate(50_000.0, None, 1e-6);
        assert_eq!(
            plan.verdict,
            ChangeVerdict::Accepted,
            "a machine with a dead clock must still be able to set it once"
        );
        assert!(matches!(plan.command, ClockCommand::Step { .. }));
    }

    /// After the allowance, a large correction is refused and the clock is left
    /// exactly as it was running.
    #[test]
    fn a_large_correction_is_refused_and_changes_nothing() {
        let mut d = guarded(1000.0, 1, 5);
        d.on_estimate(0.0001, None, 1e-6); // update 1, within the allowance
        let plan = d.on_estimate(50_000.0, None, 1e-6); // update 2, guarded
        match plan.verdict {
            ChangeVerdict::Refused { offset_s, seen } => {
                assert_eq!(seen, 1);
                assert!((offset_s - 50_000.0).abs() < 1e-9);
            }
            other => panic!("expected a refusal, got {other:?}"),
        }
        match plan.command {
            ClockCommand::Slew {
                drain_offset,
                drain_rate_ppm,
                ..
            } => {
                assert_eq!(
                    drain_offset, 0.0,
                    "a refused correction still moved the clock"
                );
                assert_eq!(drain_rate_ppm, 0.0);
            }
            other => panic!("a refusal must not step or drain: {other:?}"),
        }
    }

    /// Refusals are consecutive: a good update in between clears the run, so a
    /// single outlier cannot accumulate toward shutting the daemon down.
    #[test]
    fn a_good_update_clears_the_run() {
        let mut d = guarded(1000.0, 1, 2);
        d.on_estimate(0.0001, None, 1e-6);
        assert!(matches!(
            d.on_estimate(50_000.0, None, 1e-6).verdict,
            ChangeVerdict::Refused { seen: 1, .. }
        ));
        assert_eq!(
            d.on_estimate(0.0001, None, 1e-6).verdict,
            ChangeVerdict::Accepted
        );
        assert!(
            matches!(
                d.on_estimate(50_000.0, None, 1e-6).verdict,
                ChangeVerdict::Refused { seen: 1, .. }
            ),
            "the refusal count did not reset after an accepted update"
        );
    }

    /// A source that keeps asking exhausts the allowance and the daemon stops.
    #[test]
    fn persistent_refusal_gives_up() {
        let mut d = guarded(1000.0, 1, 2);
        d.on_estimate(0.0001, None, 1e-6);
        for expected in 1..=2 {
            assert!(matches!(
                d.on_estimate(50_000.0, None, 1e-6).verdict,
                ChangeVerdict::Refused { seen, .. } if seen == expected
            ));
        }
        assert!(
            matches!(
                d.on_estimate(50_000.0, None, 1e-6).verdict,
                ChangeVerdict::GiveUp { .. }
            ),
            "the allowance was spent and the daemon did not give up"
        );
    }

    /// A negative allowance never gives up — for an operator who would rather
    /// have a stuck clock than a stopped daemon.
    #[test]
    fn a_negative_allowance_never_gives_up() {
        let mut d = guarded(1000.0, 1, -1);
        d.on_estimate(0.0001, None, 1e-6);
        for _ in 0..50 {
            assert!(matches!(
                d.on_estimate(50_000.0, None, 1e-6).verdict,
                ChangeVerdict::Refused { .. }
            ));
        }
    }

    /// The boundary is inclusive: a correction exactly at the limit is allowed.
    /// An operator who writes `--maxchange 1000 …` means "one thousand is
    /// fine", not "one thousand is too much".
    #[test]
    fn a_correction_exactly_at_the_limit_is_allowed() {
        let mut d = guarded(1000.0, 1, 2);
        d.on_estimate(0.0001, None, 1e-6);
        assert_eq!(
            d.on_estimate(1000.0, None, 1e-6).verdict,
            ChangeVerdict::Accepted
        );
        assert_eq!(
            d.on_estimate(-1000.0, None, 1e-6).verdict,
            ChangeVerdict::Accepted,
            "the limit is on the magnitude, so it must be symmetric"
        );
    }

    /// A NaN estimate is refused, not waved through.
    ///
    /// Every comparison against NaN is false, so the obvious `|offset| > limit`
    /// would ACCEPT the one value that is certainly not a time. Nothing
    /// downstream re-checks: the command reaches `clock_adjtime` through an
    /// `as i64` conversion that saturates rather than trapping.
    #[test]
    fn a_nonsense_estimate_is_refused() {
        for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
            let mut d = guarded(1000.0, 1, 5);
            d.on_estimate(0.0001, None, 1e-6);
            let plan = d.on_estimate(bad, None, 1e-6);
            assert!(
                matches!(plan.verdict, ChangeVerdict::Refused { .. }),
                "an estimate of {bad} was not refused"
            );
            match plan.command {
                ClockCommand::Slew {
                    freq_ppm,
                    drain_offset,
                    drain_rate_ppm,
                } => {
                    assert!(freq_ppm.is_finite(), "a refusal emitted {freq_ppm} ppm");
                    assert_eq!(drain_offset, 0.0);
                    assert_eq!(drain_rate_ppm, 0.0);
                }
                other => panic!("expected a hold, got {other:?}"),
            }
        }
    }

    /// `start = 0` guards from the very first update, for a node that should
    /// never be making a large correction at all.
    #[test]
    fn a_zero_start_guards_immediately() {
        let mut d = guarded(1.0, 0, 5);
        assert!(
            matches!(
                d.on_estimate(500.0, None, 1e-6).verdict,
                ChangeVerdict::Refused { seen: 1, .. }
            ),
            "with start = 0 even the first correction must be checked"
        );
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn big_initial_offset_is_stepped() {
        let mut d = Discipline::new(DisciplineConfig::default());
        let plan = d.on_estimate(120.0, None, 1e-4);
        assert!(matches!(
            plan.command,
            ClockCommand::Step { add_seconds } if (add_seconds - 120.0).abs() < 1e-9
        ));
        assert!(plan.reset_register);
    }

    #[test]
    fn step_window_closes() {
        let mut d = Discipline::new(DisciplineConfig::default());
        for _ in 0..3 {
            let _ = d.on_estimate(0.0001, None, 1e-4);
        }
        // Fourth update: even a huge offset must slew, not step.
        let plan = d.on_estimate(5.0, None, 1e-4);
        assert!(matches!(plan.command, ClockCommand::Slew { .. }));
    }

    #[test]
    fn freq_accumulates_and_clamps() {
        let mut d = Discipline::new(DisciplineConfig::default());
        let _ = d.on_estimate(1e-4, Some(100.0), 1e-4);
        assert!((d.freq_ppm() - 100.0).abs() < 1e-9);
        let _ = d.on_estimate(1e-4, Some(1000.0), 1e-4);
        assert!((d.freq_ppm() - 500.0).abs() < 1e-9, "clamped at max_freq");
    }

    #[test]
    fn iburst_then_normal_cadence() {
        let mut d = Discipline::new(DisciplineConfig::default());
        let mut intervals = Vec::new();
        for _ in 0..6 {
            let plan = d.on_estimate(1e-5, None, 1e-4);
            intervals.push(plan.next_poll_s);
        }
        assert!(intervals[..4].iter().all(|&i| i == 2.0), "{intervals:?}");
        assert!(intervals[4] >= 64.0, "{intervals:?}");
    }

    #[test]
    fn closed_loop_converges() {
        // A toy plant: local clock 40 ppm fast, 30 ms ahead. The discipline reads
        // perfect estimates each poll; assert the loop pulls both to ~zero.
        let mut d = Discipline::new(DisciplineConfig {
            iburst: false,
            makestep_threshold: None,
            ..DisciplineConfig::default()
        });
        let mut clock_err_s = 0.030_f64; // local - true
        let base_freq_ppm = 40.0;
        let mut t = 0.0;
        for _ in 0..60 {
            // The measured offset is what we should ADD: -(clock_err).
            let offset = -clock_err_s;
            // Perfect freq measurement: the regression slope is dθ/dt, and
            // θ = -err, so the slope is -(base + applied).
            let slope_ppm = -(base_freq_ppm + d.freq_ppm());
            let plan = d.on_estimate(offset, Some(slope_ppm), 1e-5);
            let dt = plan.next_poll_s;
            if let ClockCommand::Slew {
                freq_ppm,
                drain_offset,
                drain_rate_ppm,
            } = plan.command
            {
                // Plant integration over dt: positive applied freq speeds the
                // local clock (raises err); the drain adds θ toward zero err.
                let drift = (base_freq_ppm + freq_ppm) * 1e-6 * dt;
                let max_drain = drain_rate_ppm * 1e-6 * dt;
                let drain = drain_offset.abs().min(max_drain) * drain_offset.signum();
                clock_err_s += drift + drain;
            }
            t += dt;
        }
        assert!(
            clock_err_s.abs() < 1e-4,
            "did not converge: err {clock_err_s} at t {t}"
        );
        assert!(
            (d.freq_ppm() + 40.0).abs() < 2.0,
            "freq not learned (want ~-40): {}",
            d.freq_ppm()
        );
    }
}