Skip to main content

fortress_rollback/
time_sync.rs

1use crate::report_violation;
2use crate::telemetry::{ViolationKind, ViolationSeverity};
3use crate::Frame;
4
5/// Default window size for time synchronization frame advantage calculation.
6const DEFAULT_FRAME_WINDOW_SIZE: usize = 30;
7
8/// Configuration for time synchronization behavior.
9///
10/// The time sync system tracks local and remote frame advantages over a
11/// sliding window to calculate how fast/slow this peer should run relative
12/// to the other peer(s).
13///
14/// # Example
15///
16/// ```
17/// use fortress_rollback::TimeSyncConfig;
18///
19/// // For more responsive sync (may cause more fluctuation)
20/// let responsive_config = TimeSyncConfig {
21///     window_size: 15,
22///     ..TimeSyncConfig::default()
23/// };
24///
25/// // For smoother sync (slower to adapt to changes)
26/// let smooth_config = TimeSyncConfig {
27///     window_size: 60,
28///     ..TimeSyncConfig::default()
29/// };
30/// ```
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
32#[must_use = "TimeSyncConfig has no effect unless passed to SessionBuilder::with_time_sync_config()"]
33pub struct TimeSyncConfig {
34    /// The number of frames to average when calculating frame advantage.
35    /// A larger window provides a more stable (less jittery) sync but
36    /// is slower to react to network changes. A smaller window reacts
37    /// faster but may cause more fluctuation in game speed.
38    ///
39    /// Default: 30 frames (0.5 seconds at 60 FPS)
40    pub window_size: usize,
41}
42
43impl Default for TimeSyncConfig {
44    fn default() -> Self {
45        Self {
46            window_size: DEFAULT_FRAME_WINDOW_SIZE,
47        }
48    }
49}
50
51impl std::fmt::Display for TimeSyncConfig {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        // Destructure to ensure all fields are included when new fields are added.
54        let Self { window_size } = self;
55        write!(f, "TimeSyncConfig {{ window_size: {} }}", window_size)
56    }
57}
58
59impl TimeSyncConfig {
60    /// Creates a new `TimeSyncConfig` with default values.
61    pub fn new() -> Self {
62        Self::default()
63    }
64
65    /// Configuration preset for responsive synchronization.
66    ///
67    /// Uses a smaller window to react quickly to network changes,
68    /// at the cost of potentially more fluctuation in game speed.
69    pub fn responsive() -> Self {
70        Self { window_size: 15 }
71    }
72
73    /// Configuration preset for smooth synchronization.
74    ///
75    /// Uses a larger window to provide stable, smooth synchronization,
76    /// at the cost of slower adaptation to network changes.
77    pub fn smooth() -> Self {
78        Self { window_size: 60 }
79    }
80
81    /// Configuration preset for LAN play.
82    ///
83    /// Uses a small window since LAN connections are typically stable.
84    pub fn lan() -> Self {
85        Self { window_size: 10 }
86    }
87
88    /// Configuration preset for mobile/cellular networks.
89    ///
90    /// Uses a very large window to smooth out the high jitter and
91    /// variability typical of mobile connections. This prevents
92    /// constant speed adjustments that would feel jarring to players.
93    ///
94    /// Trade-off: Slower adaptation to actual network condition changes,
95    /// but much smoother gameplay during normal mobile network variance.
96    pub fn mobile() -> Self {
97        Self { window_size: 90 }
98    }
99
100    /// Configuration preset for competitive/esports scenarios.
101    ///
102    /// Uses a smaller window for faster adaptation to network changes,
103    /// prioritizing accurate sync over smooth speed transitions.
104    /// Assumes good, stable network conditions.
105    pub fn competitive() -> Self {
106        Self { window_size: 20 }
107    }
108}
109
110/// Handles time synchronization between peers.
111///
112/// TimeSync tracks frame advantage differentials between local and remote peers,
113/// using a rolling window average to smooth out network jitter.
114///
115/// # Note
116///
117/// This type is re-exported in [`__internal`](crate::__internal) for testing and fuzzing.
118/// It is not part of the stable public API.
119#[derive(Debug)]
120pub struct TimeSync {
121    local: Vec<i32>,
122    remote: Vec<i32>,
123    window_size: usize,
124}
125
126impl Default for TimeSync {
127    fn default() -> Self {
128        Self::with_config(TimeSyncConfig::default())
129    }
130}
131
132impl TimeSync {
133    /// Creates a new TimeSync with default configuration.
134    #[must_use]
135    pub fn new() -> Self {
136        Self::default()
137    }
138
139    /// Creates a new TimeSync with the given configuration.
140    #[must_use]
141    pub fn with_config(config: TimeSyncConfig) -> Self {
142        let window_size = config.window_size.max(1); // Ensure at least 1
143        Self {
144            local: vec![0; window_size],
145            remote: vec![0; window_size],
146            window_size,
147        }
148    }
149
150    /// Advances the time sync state for a frame.
151    pub fn advance_frame(&mut self, frame: Frame, local_adv: i32, remote_adv: i32) {
152        // Handle NULL or negative frames gracefully - this can happen if input serialization
153        // fails (returns Frame::NULL), or in edge cases during initialization.
154        // We skip the update rather than panic on invalid array index.
155        if frame.is_null() || frame.as_i32() < 0 {
156            report_violation!(
157                ViolationSeverity::Warning,
158                ViolationKind::FrameSync,
159                "TimeSync::advance_frame called with invalid frame {:?}, skipping update",
160                frame
161            );
162            return;
163        }
164        let index = frame.as_i32() as usize % self.window_size;
165        if let Some(local_slot) = self.local.get_mut(index) {
166            *local_slot = local_adv;
167        }
168        if let Some(remote_slot) = self.remote.get_mut(index) {
169            *remote_slot = remote_adv;
170        }
171    }
172
173    /// Calculates the average frame advantage between local and remote peers.
174    ///
175    /// Uses integer-only arithmetic for determinism across platforms.
176    /// The formula `(remote_sum - local_sum) / (2 * count)` is mathematically
177    /// equivalent to `((remote_avg - local_avg) / 2)` but avoids floating-point
178    /// operations that could produce different results due to compiler optimizations,
179    /// FPU rounding modes, or platform-specific implementations.
180    #[must_use]
181    pub fn average_frame_advantage(&self) -> i32 {
182        let local_sum: i32 = self.local.iter().sum();
183        let remote_sum: i32 = self.remote.iter().sum();
184        // local and remote have the same length (both initialized with window_size)
185        let count = self.local.len() as i32;
186
187        // Integer division: (remote_sum - local_sum) / (2 * count)
188        // This avoids floating-point non-determinism while producing equivalent results.
189        (remote_sum - local_sum) / (2 * count)
190    }
191}
192
193// #########
194// # TESTS #
195// #########
196
197#[cfg(test)]
198#[allow(
199    clippy::panic,
200    clippy::unwrap_used,
201    clippy::expect_used,
202    clippy::indexing_slicing
203)]
204mod sync_layer_tests {
205
206    use super::*;
207
208    /// Default window size for tests (matches TimeSyncConfig::default())
209    const FRAME_WINDOW_SIZE: usize = 30;
210
211    #[test]
212    fn test_advance_frame_no_advantage() {
213        let mut time_sync = TimeSync::default();
214
215        for i in 0..60i32 {
216            let local_adv = 0;
217            let remote_adv = 0;
218            time_sync.advance_frame(Frame::new(i), local_adv, remote_adv)
219        }
220
221        assert_eq!(time_sync.average_frame_advantage(), 0);
222    }
223
224    #[test]
225    fn test_advance_frame_local_advantage() {
226        let mut time_sync = TimeSync::default();
227
228        for i in 0..60i32 {
229            let local_adv = 5;
230            let remote_adv = -5;
231            time_sync.advance_frame(Frame::new(i), local_adv, remote_adv)
232        }
233
234        assert_eq!(time_sync.average_frame_advantage(), -5);
235    }
236
237    #[test]
238    fn test_advance_frame_small_remote_advantage() {
239        let mut time_sync = TimeSync::default();
240
241        for i in 0..60i32 {
242            let local_adv = -1;
243            let remote_adv = 1;
244            time_sync.advance_frame(Frame::new(i), local_adv, remote_adv)
245        }
246
247        assert_eq!(time_sync.average_frame_advantage(), 1);
248    }
249
250    #[test]
251    fn test_advance_frame_remote_advantage() {
252        let mut time_sync = TimeSync::default();
253
254        for i in 0..60i32 {
255            let local_adv = -4;
256            let remote_adv = 4;
257            time_sync.advance_frame(Frame::new(i), local_adv, remote_adv)
258        }
259
260        assert_eq!(time_sync.average_frame_advantage(), 4);
261    }
262
263    #[test]
264    fn test_advance_frame_big_remote_advantage() {
265        let mut time_sync = TimeSync::default();
266
267        for i in 0..60i32 {
268            let local_adv = -40;
269            let remote_adv = 40;
270            time_sync.advance_frame(Frame::new(i), local_adv, remote_adv)
271        }
272
273        assert_eq!(time_sync.average_frame_advantage(), 40);
274    }
275
276    #[test]
277    fn test_new_creates_default() {
278        let time_sync = TimeSync::new();
279        // All values should be zero initially
280        assert_eq!(time_sync.average_frame_advantage(), 0);
281    }
282
283    #[test]
284    fn test_window_sliding_behavior() {
285        let mut time_sync = TimeSync::default();
286
287        // Fill window with local advantage of 10
288        for i in 0..FRAME_WINDOW_SIZE {
289            time_sync.advance_frame(Frame::new(i as i32), 10, -10);
290        }
291        assert_eq!(time_sync.average_frame_advantage(), -10);
292
293        // Now fill window with remote advantage of 10 (overwriting old values)
294        for i in FRAME_WINDOW_SIZE..(FRAME_WINDOW_SIZE * 2) {
295            time_sync.advance_frame(Frame::new(i as i32), -10, 10);
296        }
297        // Should now show remote advantage
298        assert_eq!(time_sync.average_frame_advantage(), 10);
299    }
300
301    #[test]
302    fn test_partial_window_fill() {
303        let mut time_sync = TimeSync::default();
304
305        // Only fill half the window with values
306        for i in 0..(FRAME_WINDOW_SIZE / 2) {
307            time_sync.advance_frame(Frame::new(i as i32), 10, -10);
308        }
309
310        // Average should be diluted by zeros in other half
311        // (10 * 15 + 0 * 15) / 30 = 5 for local
312        // (-10 * 15 + 0 * 15) / 30 = -5 for remote
313        // (remote_avg - local_avg) / 2 = (-5 - 5) / 2 = -5
314        assert_eq!(time_sync.average_frame_advantage(), -5);
315    }
316
317    #[test]
318    fn test_asymmetric_advantages() {
319        let mut time_sync = TimeSync::default();
320
321        // Asymmetric case: local is 0, remote is ahead
322        for i in 0..FRAME_WINDOW_SIZE {
323            time_sync.advance_frame(Frame::new(i as i32), 0, 6);
324        }
325
326        // remote_avg = 6, local_avg = 0
327        // (6 - 0) / 2 = 3
328        assert_eq!(time_sync.average_frame_advantage(), 3);
329    }
330
331    #[test]
332    fn test_frame_wraparound_modulo() {
333        let mut time_sync = TimeSync::default();
334
335        // Use frame numbers larger than window size to test modulo
336        let large_frame = Frame::new(1000);
337        time_sync.advance_frame(large_frame, 5, -5);
338
339        // The value should be stored at position 1000 % 30 = 10
340        assert_eq!(time_sync.local[10], 5);
341        assert_eq!(time_sync.remote[10], -5);
342    }
343
344    #[test]
345    fn test_advance_frame_null_frame_skipped() {
346        let mut time_sync = TimeSync::default();
347
348        // Initialize with some known values first
349        time_sync.advance_frame(Frame::new(0), 10, 20);
350        assert_eq!(time_sync.local[0], 10);
351        assert_eq!(time_sync.remote[0], 20);
352
353        // Advance with NULL frame - should be skipped
354        time_sync.advance_frame(Frame::NULL, 99, 99);
355
356        // Values should not have changed (NULL frame is skipped)
357        // Note: NULL is -1, which would wrap to a large index if not handled
358        // The test passes if we don't panic
359        assert_eq!(time_sync.local[0], 10); // unchanged
360    }
361
362    #[test]
363    fn test_advance_frame_negative_frame_skipped() {
364        let mut time_sync = TimeSync::default();
365
366        // Initialize with some known values first
367        time_sync.advance_frame(Frame::new(5), 10, 20);
368
369        // Advance with negative frame - should be skipped (no panic)
370        time_sync.advance_frame(Frame::new(-5), 99, 99);
371
372        // Test passes if we don't panic from invalid array index
373    }
374
375    // ==========================================================================
376    // Data-Driven Tests for TimeSyncConfig Presets
377    // ==========================================================================
378
379    /// Test data structure for config preset verification
380    struct ConfigPresetTestCase {
381        name: &'static str,
382        config: TimeSyncConfig,
383        expected_window_size: usize,
384    }
385
386    /// Data-driven test for all configuration presets
387    #[test]
388    fn test_config_presets_data_driven() {
389        let test_cases = [
390            ConfigPresetTestCase {
391                name: "default",
392                config: TimeSyncConfig::default(),
393                expected_window_size: 30,
394            },
395            ConfigPresetTestCase {
396                name: "new",
397                config: TimeSyncConfig::new(),
398                expected_window_size: 30,
399            },
400            ConfigPresetTestCase {
401                name: "responsive",
402                config: TimeSyncConfig::responsive(),
403                expected_window_size: 15,
404            },
405            ConfigPresetTestCase {
406                name: "smooth",
407                config: TimeSyncConfig::smooth(),
408                expected_window_size: 60,
409            },
410            ConfigPresetTestCase {
411                name: "lan",
412                config: TimeSyncConfig::lan(),
413                expected_window_size: 10,
414            },
415            ConfigPresetTestCase {
416                name: "mobile",
417                config: TimeSyncConfig::mobile(),
418                expected_window_size: 90,
419            },
420            ConfigPresetTestCase {
421                name: "competitive",
422                config: TimeSyncConfig::competitive(),
423                expected_window_size: 20,
424            },
425        ];
426
427        for test_case in &test_cases {
428            let ts = TimeSync::with_config(test_case.config);
429            assert_eq!(
430                ts.window_size, test_case.expected_window_size,
431                "Config preset '{}' should have window_size={}, got={}",
432                test_case.name, test_case.expected_window_size, ts.window_size
433            );
434            assert_eq!(
435                ts.local.len(),
436                test_case.expected_window_size,
437                "Config preset '{}' should have local.len()={}, got={}",
438                test_case.name,
439                test_case.expected_window_size,
440                ts.local.len()
441            );
442            assert_eq!(
443                ts.remote.len(),
444                test_case.expected_window_size,
445                "Config preset '{}' should have remote.len()={}, got={}",
446                test_case.name,
447                test_case.expected_window_size,
448                ts.remote.len()
449            );
450            // Initial average should always be 0
451            assert_eq!(
452                ts.average_frame_advantage(),
453                0,
454                "Config preset '{}' should have initial average=0, got={}",
455                test_case.name,
456                ts.average_frame_advantage()
457            );
458        }
459    }
460
461    // ==========================================================================
462    // TimeSyncConfig Display Tests
463    // ==========================================================================
464
465    #[test]
466    fn test_time_sync_config_display() {
467        let config = TimeSyncConfig { window_size: 30 };
468        assert_eq!(config.to_string(), "TimeSyncConfig { window_size: 30 }");
469
470        let config = TimeSyncConfig { window_size: 60 };
471        assert_eq!(config.to_string(), "TimeSyncConfig { window_size: 60 }");
472    }
473
474    // ==========================================================================
475    // Edge Case Tests
476    // ==========================================================================
477
478    /// Test window_size of 0 is corrected to 1
479    #[test]
480    fn test_window_size_zero_corrected_to_one() {
481        let config = TimeSyncConfig { window_size: 0 };
482        let ts = TimeSync::with_config(config);
483
484        assert_eq!(ts.window_size, 1, "Window size 0 should be corrected to 1");
485        assert_eq!(ts.local.len(), 1, "Local vec should have length 1");
486        assert_eq!(ts.remote.len(), 1, "Remote vec should have length 1");
487    }
488
489    /// Test window_size of 1 (minimum valid)
490    #[test]
491    fn test_window_size_minimum_one() {
492        let config = TimeSyncConfig { window_size: 1 };
493        let mut ts = TimeSync::with_config(config);
494
495        // With window size 1, every frame overwrites the same index
496        ts.advance_frame(Frame::new(0), 10, 5);
497        assert_eq!(ts.average_frame_advantage(), -2); // (5 - 10) / 2 = -2.5 truncated
498
499        ts.advance_frame(Frame::new(1), -10, 10);
500        assert_eq!(ts.average_frame_advantage(), 10); // (10 - (-10)) / 2 = 10
501
502        // Verify all frames map to index 0
503        assert_eq!(ts.local[0], -10);
504        assert_eq!(ts.remote[0], 10);
505    }
506
507    /// Test i32::MAX frame number doesn't cause panic
508    #[test]
509    fn test_large_frame_number() {
510        let mut ts = TimeSync::default();
511
512        // Very large frame number should work (modulo wraps)
513        let large_frame = Frame::new(i32::MAX);
514        ts.advance_frame(large_frame, 5, 10);
515
516        // Should be stored at i32::MAX % 30 = 7
517        let expected_index = (i32::MAX as usize) % 30;
518        assert_eq!(
519            ts.local[expected_index], 5,
520            "Large frame value should map to index {}, got local[{}]={}",
521            expected_index, expected_index, ts.local[expected_index]
522        );
523    }
524
525    /// Test that extreme advantage values don't cause overflow in average calculation
526    #[test]
527    fn test_extreme_advantage_values() {
528        let mut ts = TimeSync::default();
529
530        // Fill with large but not overflowing values
531        // 30 * 1000 = 30000, well within i32 range
532        for i in 0..30 {
533            ts.advance_frame(Frame::new(i), 1000, -1000);
534        }
535
536        let avg = ts.average_frame_advantage();
537        assert_eq!(
538            avg, -1000,
539            "Average should be -1000 for local=1000, remote=-1000"
540        );
541    }
542
543    /// Test mixed positive and negative values average correctly
544    #[test]
545    fn test_mixed_advantage_values() {
546        let mut ts = TimeSync::default();
547
548        // Alternate between positive and negative values
549        for i in 0..30 {
550            if i % 2 == 0 {
551                ts.advance_frame(Frame::new(i), 10, -10);
552            } else {
553                ts.advance_frame(Frame::new(i), -10, 10);
554            }
555        }
556
557        // Should average to 0 (equal positive and negative)
558        let avg = ts.average_frame_advantage();
559        assert_eq!(avg, 0, "Mixed values should average to 0");
560    }
561
562    /// Test frame=0 specifically (boundary case)
563    #[test]
564    fn test_frame_zero() {
565        let mut ts = TimeSync::default();
566
567        ts.advance_frame(Frame::new(0), 42, -42);
568
569        assert_eq!(ts.local[0], 42, "Frame 0 should map to index 0");
570        assert_eq!(ts.remote[0], -42, "Frame 0 should map to index 0");
571    }
572}
573
574// =============================================================================
575// Property-Based Tests
576//
577// These tests use proptest to verify invariants hold under random inputs.
578// They are critical for ensuring the TimeSync implementation handles all
579// edge cases correctly.
580// =============================================================================
581
582#[cfg(test)]
583#[allow(
584    clippy::panic,
585    clippy::unwrap_used,
586    clippy::expect_used,
587    clippy::indexing_slicing
588)]
589mod property_tests {
590    use super::*;
591    use crate::test_config::miri_case_count;
592    use proptest::prelude::*;
593
594    /// Maximum frame value for property tests (keep tractable)
595    const MAX_FRAME: i32 = 10_000;
596
597    /// Maximum advantage value for property tests
598    const MAX_ADVANTAGE: i32 = 100;
599
600    /// Generate valid frame numbers (non-negative)
601    fn valid_frame() -> impl Strategy<Value = Frame> {
602        (0..MAX_FRAME).prop_map(Frame::new)
603    }
604
605    /// Generate advantage values (can be negative)
606    fn advantage_value() -> impl Strategy<Value = i32> {
607        -MAX_ADVANTAGE..=MAX_ADVANTAGE
608    }
609
610    /// Generate window sizes (reasonable range)
611    fn window_size() -> impl Strategy<Value = usize> {
612        1..=100usize
613    }
614
615    proptest! {
616        #![proptest_config(ProptestConfig {
617            cases: miri_case_count(),
618            ..ProptestConfig::default()
619        })]
620        /// Property: Window index is always in bounds.
621        ///
622        /// For any valid frame number and window size, the computed index
623        /// (frame % window_size) must be within [0, window_size).
624        #[test]
625        fn prop_window_index_in_bounds(
626            frame in valid_frame(),
627            local_adv in advantage_value(),
628            remote_adv in advantage_value(),
629            window_size in window_size(),
630        ) {
631            let config = TimeSyncConfig { window_size };
632            let mut ts = TimeSync::with_config(config);
633
634            // This should not panic due to out-of-bounds access
635            ts.advance_frame(frame, local_adv, remote_adv);
636
637            // Verify the index computation
638            let expected_index = frame.as_i32() as usize % window_size;
639            prop_assert!(expected_index < window_size);
640            prop_assert_eq!(ts.local[expected_index], local_adv);
641            prop_assert_eq!(ts.remote[expected_index], remote_adv);
642        }
643
644        /// Property: Average is bounded by min/max of window values.
645        ///
646        /// The average frame advantage should be within a reasonable range
647        /// given the input values.
648        #[test]
649        fn prop_average_bounded_by_inputs(
650            local_adv in advantage_value(),
651            remote_adv in advantage_value(),
652        ) {
653            let mut ts = TimeSync::default();
654
655            // Fill entire window with same values
656            for i in 0..30 {
657                ts.advance_frame(Frame::new(i), local_adv, remote_adv);
658            }
659
660            let avg = ts.average_frame_advantage();
661            let expected = (remote_adv - local_adv) / 2;
662
663            // Should be exactly the expected value when window is uniform
664            prop_assert_eq!(avg, expected);
665        }
666
667        /// Property: Average is deterministic.
668        ///
669        /// Same sequence of inputs produces same average.
670        #[test]
671        fn prop_average_deterministic(
672            frames in proptest::collection::vec(
673                (valid_frame(), advantage_value(), advantage_value()),
674                1..100
675            ),
676        ) {
677            let mut ts1 = TimeSync::default();
678            let mut ts2 = TimeSync::default();
679
680            for (frame, local, remote) in &frames {
681                ts1.advance_frame(*frame, *local, *remote);
682                ts2.advance_frame(*frame, *local, *remote);
683            }
684
685            prop_assert_eq!(
686                ts1.average_frame_advantage(),
687                ts2.average_frame_advantage(),
688                "Same inputs should produce same average"
689            );
690        }
691
692        /// Property: NULL frames don't modify state.
693        ///
694        /// Calling advance_frame with Frame::NULL should leave the window unchanged.
695        #[test]
696        fn prop_null_frame_no_effect(
697            initial_frames in proptest::collection::vec(
698                (0..30i32, advantage_value(), advantage_value()),
699                10..30
700            ),
701        ) {
702            let mut ts = TimeSync::default();
703
704            // Initialize with known values
705            for (frame_val, local, remote) in &initial_frames {
706                ts.advance_frame(Frame::new(*frame_val), *local, *remote);
707            }
708
709            let avg_before = ts.average_frame_advantage();
710
711            // Attempt update with NULL frame
712            ts.advance_frame(Frame::NULL, 999, 999);
713
714            let avg_after = ts.average_frame_advantage();
715
716            // Average should be unchanged (NULL frame is skipped)
717            prop_assert_eq!(avg_before, avg_after, "NULL frame should not modify state");
718        }
719
720        /// Property: Negative frames don't modify state.
721        ///
722        /// Calling advance_frame with negative frame should leave the window unchanged.
723        #[test]
724        fn prop_negative_frame_no_effect(
725            initial_frames in proptest::collection::vec(
726                (0..30i32, advantage_value(), advantage_value()),
727                10..30
728            ),
729            neg_frame in -1000..-1i32,
730        ) {
731            let mut ts = TimeSync::default();
732
733            // Initialize with known values
734            for (frame_val, local, remote) in &initial_frames {
735                ts.advance_frame(Frame::new(*frame_val), *local, *remote);
736            }
737
738            let avg_before = ts.average_frame_advantage();
739
740            // Attempt update with negative frame
741            ts.advance_frame(Frame::new(neg_frame), 999, 999);
742
743            let avg_after = ts.average_frame_advantage();
744
745            prop_assert_eq!(avg_before, avg_after, "Negative frame should not modify state");
746        }
747
748        /// Property: Window slides correctly.
749        ///
750        /// Older values should be overwritten as new frames advance beyond the window.
751        #[test]
752        fn prop_window_slides(window_size in 5..50usize) {
753            let config = TimeSyncConfig { window_size };
754            let mut ts = TimeSync::with_config(config);
755
756            // Fill window with local advantage = 10
757            for i in 0..window_size {
758                ts.advance_frame(Frame::new(i as i32), 10, -10);
759            }
760
761            let avg_initial = ts.average_frame_advantage();
762
763            // Now overwrite with local advantage = -10 (remote advantage)
764            for i in 0..window_size {
765                ts.advance_frame(Frame::new((window_size + i) as i32), -10, 10);
766            }
767
768            let avg_after = ts.average_frame_advantage();
769
770            // The window should have completely different values now
771            // Initial: local=10, remote=-10 => avg = (-10 - 10) / 2 = -10
772            // After: local=-10, remote=10 => avg = (10 - (-10)) / 2 = 10
773            prop_assert_eq!(avg_initial, -10, "Initial average incorrect");
774            prop_assert_eq!(avg_after, 10, "After-slide average incorrect");
775        }
776
777        /// Property: Average formula is mathematically correct.
778        ///
779        /// average = (remote_avg - local_avg) / 2
780        #[test]
781        fn prop_average_formula_correct(
782            local_adv in advantage_value(),
783            remote_adv in advantage_value(),
784        ) {
785            let mut ts = TimeSync::default();
786
787            // Fill with uniform values
788            for i in 0..30 {
789                ts.advance_frame(Frame::new(i), local_adv, remote_adv);
790            }
791
792            let avg = ts.average_frame_advantage();
793
794            // Expected: (remote - local) / 2
795            // Note: integer division truncates toward zero
796            let expected = (remote_adv - local_adv) / 2;
797            prop_assert_eq!(avg, expected);
798        }
799
800        /// Property: Custom window size is respected.
801        #[test]
802        fn prop_custom_window_size_respected(window_size in 1..100usize) {
803            let config = TimeSyncConfig { window_size };
804            let ts = TimeSync::with_config(config);
805
806            prop_assert_eq!(ts.window_size, window_size);
807            prop_assert_eq!(ts.local.len(), window_size);
808            prop_assert_eq!(ts.remote.len(), window_size);
809        }
810    }
811}
812
813// =============================================================================
814// Kani Formal Verification Proofs
815//
816// These proofs use Kani (https://model-checking.github.io/kani/) to formally
817// verify safety properties of the TimeSync implementation. They prove:
818//
819// 1. No integer overflow in sum/average calculations
820// 2. Window index is always in bounds
821// 3. Division by zero is impossible
822//
823// Run with: cargo kani --tests
824// =============================================================================
825
826#[cfg(kani)]
827mod kani_proofs {
828    use super::*;
829
830    /// Proof: Window index computation is always in bounds.
831    ///
832    /// For any valid frame number and window size >= 1, the modulo operation
833    /// produces an index that is always < window_size.
834    ///
835    /// - Tier: 1 (Fast, <30s)
836    /// - Verifies: Modulo index bounds (INV-5 for TimeSync)
837    /// - Related: proof_index_wrapping_consistent, proof_advance_frame_safe
838    #[kani::proof]
839    fn proof_window_index_in_bounds() {
840        let frame_val: i32 = kani::any();
841        kani::assume(frame_val >= 0);
842
843        let window_size: usize = kani::any();
844        kani::assume(window_size >= 1 && window_size <= 1000);
845
846        let index = (frame_val as usize) % window_size;
847
848        kani::assert(index < window_size, "Index must be less than window size");
849    }
850
851    /// Proof: Sum of window values does not overflow.
852    ///
853    /// With bounded advantage values, the sum of up to 1000 values
854    /// should not overflow i32.
855    ///
856    /// - Tier: 1 (Fast, <30s)
857    /// - Verifies: Arithmetic overflow safety in sum calculation
858    /// - Related: proof_division_safe, proof_advance_frame_safe
859    #[kani::proof]
860    fn proof_sum_no_overflow() {
861        // Simulate a small window for tractability
862        let window_size: usize = kani::any();
863        kani::assume(window_size >= 1 && window_size <= 10);
864
865        // Bounded advantage values (realistic for frame advantages)
866        let advantage: i32 = kani::any();
867        kani::assume(advantage >= -1000 && advantage <= 1000);
868
869        // Check that summing `window_size` copies of `advantage` doesn't overflow
870        let sum = advantage.checked_mul(window_size as i32);
871        kani::assert(sum.is_some(), "Multiplication should not overflow");
872    }
873
874    /// Proof: Division in average calculation is safe.
875    ///
876    /// Division by window.len() is always safe because window_size >= 1.
877    ///
878    /// - Tier: 1 (Fast, <30s)
879    /// - Verifies: Division by zero is impossible
880    /// - Related: proof_sum_no_overflow, proof_window_size_minimum
881    #[kani::proof]
882    fn proof_division_safe() {
883        let window_size: usize = kani::any();
884        kani::assume(window_size >= 1 && window_size <= 1000);
885
886        let config = TimeSyncConfig { window_size };
887        let ts = TimeSync::with_config(config);
888
889        // The window length is guaranteed to be >= 1
890        kani::assert(ts.local.len() >= 1, "Window length must be at least 1");
891        kani::assert(ts.remote.len() >= 1, "Window length must be at least 1");
892    }
893
894    /// Proof: advance_frame with valid frame doesn't panic.
895    ///
896    /// For any valid (non-negative) frame, advance_frame should not panic.
897    ///
898    /// - Tier: 2 (Medium, 30s-2min)
899    /// - Verifies: advance_frame safety with valid inputs
900    /// - Related: proof_negative_frame_safe, proof_window_index_in_bounds
901    #[kani::proof]
902    fn proof_advance_frame_safe() {
903        let frame_val: i32 = kani::any();
904        kani::assume(frame_val >= 0 && frame_val < 10000);
905
906        let local_adv: i32 = kani::any();
907        let remote_adv: i32 = kani::any();
908        kani::assume(local_adv >= -1000 && local_adv <= 1000);
909        kani::assume(remote_adv >= -1000 && remote_adv <= 1000);
910
911        let config = TimeSyncConfig { window_size: 30 };
912        let mut ts = TimeSync::with_config(config);
913
914        // This should not panic
915        ts.advance_frame(Frame::new(frame_val), local_adv, remote_adv);
916
917        // Verify the value was stored correctly
918        let idx = (frame_val as usize) % 30;
919        kani::assert(ts.local[idx] == local_adv, "Local value should be stored");
920        kani::assert(
921            ts.remote[idx] == remote_adv,
922            "Remote value should be stored",
923        );
924    }
925
926    /// Proof: Window size of 0 is corrected to 1.
927    ///
928    /// The with_config function ensures window_size is at least 1.
929    /// We bound window_size to realistic values to avoid capacity overflow
930    /// during Vec allocation - this is not a production bug but a verification
931    /// tractability constraint. Real users won't pass usize::MAX as window_size.
932    ///
933    /// - Tier: 1 (Fast, <30s)
934    /// - Verifies: Window size minimum bound enforcement
935    /// - Related: proof_zero_window_size_corrected, proof_division_safe
936    #[kani::proof]
937    fn proof_window_size_minimum() {
938        let window_size: usize = kani::any();
939        // Bound to realistic window sizes to avoid capacity overflow in Vec allocation.
940        // In production, window sizes > 10000 would be unreasonable (> 2 minutes at 60fps).
941        // This constraint exists because Kani explores all possible usize values including
942        // those that would exhaust memory when creating Vec<i32> of that size.
943        kani::assume(window_size <= 10000);
944        // Even if user passes 0, it should be corrected
945        let config = TimeSyncConfig { window_size };
946        let ts = TimeSync::with_config(config);
947
948        kani::assert(ts.window_size >= 1, "Window size must be at least 1");
949        kani::assert(
950            ts.local.len() >= 1,
951            "Local vec must have at least 1 element",
952        );
953        kani::assert(
954            ts.remote.len() >= 1,
955            "Remote vec must have at least 1 element",
956        );
957    }
958
959    /// Proof: Default configuration is valid.
960    ///
961    /// Note: We need unwind(32) because the default window size is 30,
962    /// and average_frame_advantage() iterates over the window using .iter().sum().
963    /// Kani needs to unroll the loop at least window_size+1 times.
964    ///
965    /// - Tier: 1 (Fast, <30s)
966    /// - Verifies: Default TimeSync state validity
967    /// - Related: proof_preset_configs_valid, proof_window_size_minimum
968    #[kani::proof]
969    #[kani::unwind(32)]
970    fn proof_default_valid() {
971        let ts = TimeSync::default();
972
973        kani::assert(ts.window_size == 30, "Default window size should be 30");
974        kani::assert(
975            ts.local.len() == 30,
976            "Default local vec length should be 30",
977        );
978        kani::assert(
979            ts.remote.len() == 30,
980            "Default remote vec length should be 30",
981        );
982        kani::assert(
983            ts.average_frame_advantage() == 0,
984            "Initial average should be 0",
985        );
986    }
987
988    /// Proof: All config presets create valid TimeSync instances.
989    ///
990    /// Tests that all factory methods produce TimeSync with:
991    /// - window_size >= 1
992    /// - local/remote vec lengths match window_size
993    /// - initial average is 0
994    ///
995    /// - Tier: 1 (Fast, <30s)
996    /// - Verifies: All preset configurations produce valid state
997    /// - Related: proof_default_valid, proof_zero_window_size_corrected
998    #[kani::proof]
999    fn proof_preset_configs_valid() {
1000        // Use symbolic choice to test all presets
1001        let preset_choice: u8 = kani::any();
1002        kani::assume(preset_choice < 5);
1003
1004        let config = match preset_choice {
1005            0 => TimeSyncConfig::responsive(),
1006            1 => TimeSyncConfig::smooth(),
1007            2 => TimeSyncConfig::lan(),
1008            3 => TimeSyncConfig::competitive(),
1009            _ => TimeSyncConfig::mobile(),
1010        };
1011
1012        let ts = TimeSync::with_config(config);
1013
1014        // All presets must result in valid TimeSync
1015        kani::assert(
1016            ts.window_size >= 1,
1017            "All presets must have window_size >= 1",
1018        );
1019        kani::assert(
1020            ts.local.len() == ts.window_size,
1021            "local vec length must match window_size",
1022        );
1023        kani::assert(
1024            ts.remote.len() == ts.window_size,
1025            "remote vec length must match window_size",
1026        );
1027    }
1028
1029    /// Proof: Window size 0 is always corrected to 1.
1030    ///
1031    /// Explicitly verifies the edge case where window_size = 0.
1032    ///
1033    /// - Tier: 1 (Fast, <30s)
1034    /// - Verifies: Zero window size edge case handling
1035    /// - Related: proof_window_size_minimum, proof_division_safe
1036    #[kani::proof]
1037    fn proof_zero_window_size_corrected() {
1038        let config = TimeSyncConfig { window_size: 0 };
1039        let ts = TimeSync::with_config(config);
1040
1041        kani::assert(ts.window_size == 1, "window_size 0 must be corrected to 1");
1042        kani::assert(ts.local.len() == 1, "local vec must have exactly 1 element");
1043        kani::assert(
1044            ts.remote.len() == 1,
1045            "remote vec must have exactly 1 element",
1046        );
1047    }
1048
1049    /// Proof: Frame index wrapping is consistent.
1050    ///
1051    /// Verifies that for any frame f and window size w,
1052    /// the index (f % w) is always in [0, w).
1053    ///
1054    /// Note: We bound frame_val to [0, 10000] to keep verification tractable.
1055    /// The mathematical property (modulo determinism and bounds) holds for all
1056    /// non-negative integers, so testing a representative range is sufficient.
1057    ///
1058    /// - Tier: 3 (Slow, >2min)
1059    /// - Verifies: Modulo index determinism and bounds
1060    /// - Related: proof_window_index_in_bounds, proof_advance_frame_safe
1061    #[kani::proof]
1062    fn proof_index_wrapping_consistent() {
1063        let frame_val: i32 = kani::any();
1064        // Bound to representative range - the math property is universal
1065        kani::assume(frame_val >= 0 && frame_val <= 10_000);
1066
1067        let window_size: usize = kani::any();
1068        kani::assume(window_size >= 1 && window_size <= 100);
1069
1070        // First calculation
1071        let index1 = (frame_val as usize) % window_size;
1072
1073        // Second calculation with same inputs (should be deterministic)
1074        let index2 = (frame_val as usize) % window_size;
1075
1076        kani::assert(index1 == index2, "Index calculation must be deterministic");
1077        kani::assert(
1078            index1 < window_size,
1079            "Index must be strictly less than window_size",
1080        );
1081    }
1082
1083    /// Proof: Negative frame values are correctly rejected.
1084    ///
1085    /// Verifies that advance_frame with negative frames doesn't modify state.
1086    /// Note: We can't directly test this in Kani without state comparison,
1087    /// so we verify the safety property that it doesn't panic.
1088    ///
1089    /// - Tier: 1 (Fast, <30s)
1090    /// - Verifies: Negative frame rejection safety
1091    /// - Related: proof_advance_frame_safe, proof_window_index_in_bounds
1092    #[kani::proof]
1093    fn proof_negative_frame_safe() {
1094        let frame_val: i32 = kani::any();
1095        kani::assume(frame_val < 0);
1096
1097        let local_adv: i32 = kani::any();
1098        let remote_adv: i32 = kani::any();
1099        kani::assume(local_adv >= -1000 && local_adv <= 1000);
1100        kani::assume(remote_adv >= -1000 && remote_adv <= 1000);
1101
1102        let config = TimeSyncConfig { window_size: 10 };
1103        let mut ts = TimeSync::with_config(config);
1104
1105        // This should not panic even with negative frame
1106        ts.advance_frame(Frame::new(frame_val), local_adv, remote_adv);
1107
1108        // Verify state wasn't modified (local[0] should still be 0)
1109        kani::assert(
1110            ts.local[0] == 0,
1111            "Negative frame should not modify local values",
1112        );
1113        kani::assert(
1114            ts.remote[0] == 0,
1115            "Negative frame should not modify remote values",
1116        );
1117    }
1118}