Skip to main content

ftui_runtime/tick_strategy/
predictive.rs

1//! [`Predictive`] strategy: Markov-chain-driven tick allocation.
2//!
3//! The crown jewel of the tick strategy system. Uses a [`MarkovPredictor`] to
4//! learn screen transition patterns and a [`TickAllocation`] to convert
5//! predicted probabilities into tick divisors. Screens the user is likely to
6//! switch to get ticked more frequently; unlikely screens get throttled.
7//!
8//! Gracefully degrades to uniform behavior during cold start (insufficient
9//! data) via confidence-weighted blending in [`MarkovPredictor`].
10
11use std::collections::HashMap;
12#[cfg(feature = "state-persistence")]
13use std::path::PathBuf;
14
15use tracing::{debug, info, trace};
16
17use super::{MarkovPredictor, TickAllocation, TickDecision, TickStrategy, TransitionCounter};
18
19/// Configuration for the [`Predictive`] tick strategy.
20#[derive(Debug, Clone)]
21pub struct PredictiveStrategyConfig {
22    /// How probabilities map to tick divisors.
23    pub allocation: TickAllocation,
24    /// Divisor used for unknown screens (not in the predictor's vocabulary).
25    pub fallback_divisor: u64,
26    /// Minimum observations before predictions are fully trusted.
27    pub min_observations: u64,
28    /// Temporal decay factor applied during maintenance (0.0..1.0).
29    pub decay_factor: f64,
30    /// How many ticks between decay cycles.
31    pub decay_interval: u64,
32    /// How many ticks between auto-save cycles (0 = disabled).
33    pub auto_save_interval: u64,
34}
35
36impl Default for PredictiveStrategyConfig {
37    fn default() -> Self {
38        Self {
39            allocation: TickAllocation::default(),
40            fallback_divisor: 5,
41            min_observations: 20,
42            decay_factor: 0.85,
43            decay_interval: 500,
44            auto_save_interval: 3000,
45        }
46    }
47}
48
49/// Markov-chain-driven tick strategy.
50///
51/// Learns screen transition patterns and allocates tick budget proportionally
52/// to transition probability. High-probability next screens tick at near-full
53/// rate; low-probability screens are aggressively throttled.
54///
55/// See module-level docs for architecture details.
56#[derive(Debug, Clone)]
57pub struct Predictive {
58    predictor: MarkovPredictor<String>,
59    allocation: TickAllocation,
60    fallback_divisor: u64,
61    decay_factor: f64,
62    decay_interval: u64,
63    auto_save_interval: u64,
64    /// Cache: recomputed only when active screen changes.
65    cached_divisors: HashMap<String, u64>,
66    /// The active screen the cache was computed for.
67    cached_for_screen: Option<String>,
68    /// Tick counter for maintenance scheduling.
69    ticks_since_decay: u64,
70    /// Tick counter for auto-save scheduling.
71    ticks_since_save: u64,
72    /// Whether there's unsaved data (transitions recorded since last save).
73    dirty: bool,
74    /// Path for periodic auto-save. `None` disables auto-save I/O.
75    #[cfg(feature = "state-persistence")]
76    persistence_path: Option<PathBuf>,
77}
78
79impl Predictive {
80    /// Create a new predictive strategy with the given config.
81    #[must_use]
82    pub fn new(config: PredictiveStrategyConfig) -> Self {
83        info!(
84            strategy = "Predictive",
85            fallback_divisor = config.fallback_divisor,
86            min_observations = config.min_observations,
87            decay_factor = config.decay_factor,
88            decay_interval = config.decay_interval,
89            "tick_strategy.init"
90        );
91        Self {
92            predictor: MarkovPredictor::with_min_observations(config.min_observations),
93            allocation: config.allocation,
94            fallback_divisor: config.fallback_divisor.max(1),
95            decay_factor: config.decay_factor,
96            decay_interval: config.decay_interval.max(1),
97            auto_save_interval: config.auto_save_interval,
98            cached_divisors: HashMap::new(),
99            cached_for_screen: None,
100            ticks_since_decay: 0,
101            ticks_since_save: 0,
102            dirty: false,
103            #[cfg(feature = "state-persistence")]
104            persistence_path: None,
105        }
106    }
107
108    /// Create with pre-loaded transition data (e.g., from persistence).
109    #[must_use]
110    pub fn with_counter(
111        config: PredictiveStrategyConfig,
112        counter: TransitionCounter<String>,
113    ) -> Self {
114        info!(
115            strategy = "Predictive",
116            fallback_divisor = config.fallback_divisor,
117            min_observations = config.min_observations,
118            loaded_transitions = %counter.total(),
119            known_screens = counter.state_ids().len(),
120            "tick_strategy.init (with pre-loaded data)"
121        );
122        Self {
123            predictor: MarkovPredictor::with_counter(counter, config.min_observations),
124            allocation: config.allocation,
125            fallback_divisor: config.fallback_divisor.max(1),
126            decay_factor: config.decay_factor,
127            decay_interval: config.decay_interval.max(1),
128            auto_save_interval: config.auto_save_interval,
129            cached_divisors: HashMap::new(),
130            cached_for_screen: None,
131            ticks_since_decay: 0,
132            ticks_since_save: 0,
133            dirty: false,
134            #[cfg(feature = "state-persistence")]
135            persistence_path: None,
136        }
137    }
138
139    /// Create with persistence: load historical transitions from a file.
140    ///
141    /// - **Missing file**: cold start (empty counter), no error.
142    /// - **Corrupted file**: logs a warning, falls back to cold start.
143    /// - **Successful load**: applies a single decay (`load_decay_factor`)
144    ///   to prevent historical data from permanently dominating.
145    #[cfg(feature = "state-persistence")]
146    #[must_use]
147    pub fn with_persistence(
148        config: PredictiveStrategyConfig,
149        path: &std::path::Path,
150        load_decay_factor: f64,
151    ) -> Self {
152        let counter = match super::persistence::load_transitions(path) {
153            Ok(c) => {
154                info!(
155                    path = %path.display(),
156                    loaded_transitions = %c.total(),
157                    known_screens = c.state_ids().len(),
158                    "tick_strategy.persistence_loaded"
159                );
160                c
161            }
162            Err(e) => {
163                tracing::warn!(
164                    path = %path.display(),
165                    error = %e,
166                    "tick_strategy.persistence_load_failed (falling back to cold start)"
167                );
168                TransitionCounter::new()
169            }
170        };
171
172        let mut strategy = Self::with_counter(config, counter);
173
174        // Apply load decay to down-weight historical data.
175        let factor = load_decay_factor.clamp(0.0, 1.0);
176        if factor < 1.0 {
177            let total_before = strategy.predictor.counter().total();
178            strategy.predictor.counter_mut().decay(factor);
179            let total_after = strategy.predictor.counter().total();
180            if (total_after - total_before).abs() > f64::EPSILON {
181                strategy.dirty = true;
182            }
183            info!(
184                load_decay_factor = factor,
185                remaining_total = %total_after,
186                "tick_strategy.load_decay_applied"
187            );
188        }
189
190        // Remember the path for periodic auto-save.
191        strategy.persistence_path = Some(path.to_path_buf());
192        strategy
193    }
194
195    /// Access the underlying predictor.
196    #[must_use]
197    pub fn predictor(&self) -> &MarkovPredictor<String> {
198        &self.predictor
199    }
200
201    /// Access the underlying transition counter.
202    #[must_use]
203    pub fn counter(&self) -> &TransitionCounter<String> {
204        self.predictor.counter()
205    }
206
207    /// Whether there is unsaved transition data.
208    #[must_use]
209    pub fn is_dirty(&self) -> bool {
210        self.dirty
211    }
212
213    /// Save transitions to disk if dirty and a persistence path is configured.
214    ///
215    /// IO errors are logged and swallowed — auto-save must never crash the
216    /// runtime.
217    #[cfg(feature = "state-persistence")]
218    fn save_if_dirty(&mut self) {
219        if !self.dirty {
220            return;
221        }
222        let Some(path) = self.persistence_path.as_deref() else {
223            return;
224        };
225        match super::persistence::save_transitions(self.predictor.counter(), path) {
226            Ok(()) => {
227                self.dirty = false;
228                self.ticks_since_save = 0;
229                info!(
230                    path = %path.display(),
231                    total = %self.predictor.counter().total(),
232                    "tick_strategy.auto_save"
233                );
234            }
235            Err(e) => {
236                tracing::warn!(
237                    path = %path.display(),
238                    error = %e,
239                    "tick_strategy.auto_save_failed"
240                );
241            }
242        }
243    }
244
245    /// Recompute the cached divisor map for predictions from `active`.
246    fn refresh_cache(&mut self, active: &str) {
247        if self.cached_for_screen.as_deref() == Some(active) {
248            return;
249        }
250
251        self.cached_divisors.clear();
252        let predictions = self.predictor.predict(&active.to_owned());
253        let is_cold = self.predictor.is_cold_start(&active.to_owned());
254
255        if is_cold {
256            let obs = self.predictor.counter().total_from(&active.to_owned()) as u64;
257            info!(
258                screen = active,
259                observations = obs,
260                min_required = self.predictor.min_observations(),
261                using_fallback = true,
262                "tick_strategy.cold_start"
263            );
264        }
265
266        for p in &predictions {
267            let divisor = self.allocation.divisor_for(p.probability);
268            trace!(
269                screen = %p.screen,
270                divisor,
271                probability = %p.probability,
272                confidence = %p.confidence,
273                "tick_strategy.screen_divisor"
274            );
275            self.cached_divisors.insert(p.screen.clone(), divisor);
276        }
277
278        debug!(
279            strategy = "Predictive",
280            active_screen = active,
281            num_screens = predictions.len(),
282            cold_start = is_cold,
283            "tick_strategy.cache_refresh"
284        );
285
286        self.cached_for_screen = Some(active.to_owned());
287    }
288}
289
290impl TickStrategy for Predictive {
291    fn should_tick(
292        &mut self,
293        screen_id: &str,
294        tick_count: u64,
295        active_screen: &str,
296    ) -> TickDecision {
297        // Ensure cache is fresh for current active screen.
298        self.refresh_cache(active_screen);
299
300        let divisor = self
301            .cached_divisors
302            .get(screen_id)
303            .copied()
304            .unwrap_or(self.fallback_divisor);
305
306        if tick_count.is_multiple_of(divisor) {
307            TickDecision::Tick
308        } else {
309            TickDecision::Skip
310        }
311    }
312
313    fn on_screen_transition(&mut self, from: &str, to: &str) {
314        self.predictor
315            .record_transition(from.to_owned(), to.to_owned());
316        self.dirty = true;
317        debug!(
318            from,
319            to,
320            total_transitions = %self.predictor.counter().total(),
321            "tick_strategy.transition"
322        );
323        // Force cache refresh for the new active screen.
324        self.cached_for_screen = None;
325        self.refresh_cache(to);
326    }
327
328    fn maintenance_tick(&mut self, _tick_count: u64) {
329        self.ticks_since_decay += 1;
330        self.ticks_since_save += 1;
331
332        // Periodic decay.
333        if self.ticks_since_decay >= self.decay_interval {
334            let entries_before = self.predictor.counter().state_ids().len();
335            let total_before = self.predictor.counter().total();
336            self.predictor.counter_mut().decay(self.decay_factor);
337            let entries_after = self.predictor.counter().state_ids().len();
338            let total_after = self.predictor.counter().total();
339            debug!(
340                factor = self.decay_factor,
341                entries_before,
342                entries_after,
343                total_before = %total_before,
344                total_after = %total_after,
345                pruned = entries_before.saturating_sub(entries_after),
346                "tick_strategy.decay"
347            );
348            self.ticks_since_decay = 0;
349            // Invalidate cache since probabilities changed.
350            self.cached_for_screen = None;
351            if entries_after != entries_before || (total_after - total_before).abs() > f64::EPSILON
352            {
353                self.dirty = true;
354            }
355        }
356
357        // Periodic auto-save.
358        if self.auto_save_interval > 0 && self.ticks_since_save >= self.auto_save_interval {
359            #[cfg(feature = "state-persistence")]
360            self.save_if_dirty();
361            #[cfg(not(feature = "state-persistence"))]
362            {
363                self.ticks_since_save = 0;
364            }
365        }
366    }
367
368    fn shutdown(&mut self) {
369        #[cfg(feature = "state-persistence")]
370        self.save_if_dirty();
371    }
372
373    fn name(&self) -> &str {
374        "Predictive"
375    }
376
377    fn debug_stats(&self) -> Vec<(String, String)> {
378        let confidence = self
379            .cached_for_screen
380            .as_ref()
381            .map(|s| self.predictor.confidence(s))
382            .unwrap_or(0.0);
383
384        // Build top prediction string if cache is populated.
385        let top_prediction = self
386            .cached_for_screen
387            .as_ref()
388            .and_then(|screen| {
389                let preds = self.predictor.predict(screen);
390                preds.first().map(|p| {
391                    let divisor = self
392                        .cached_divisors
393                        .get(&p.screen)
394                        .copied()
395                        .unwrap_or(self.fallback_divisor);
396                    format!("{}:{:.2}/div={}", p.screen, p.probability, divisor)
397                })
398            })
399            .unwrap_or_else(|| "(none)".to_owned());
400
401        let decay_next_at = self.decay_interval.saturating_sub(self.ticks_since_decay);
402
403        vec![
404            ("strategy".into(), "Predictive".into()),
405            (
406                "total_transitions".into(),
407                format!("{:.0}", self.predictor.counter().total()),
408            ),
409            (
410                "known_screens".into(),
411                self.predictor.counter().state_ids().len().to_string(),
412            ),
413            (
414                "cached_divisors".into(),
415                self.cached_divisors.len().to_string(),
416            ),
417            (
418                "active_screen".into(),
419                self.cached_for_screen
420                    .as_deref()
421                    .unwrap_or("(none)")
422                    .to_owned(),
423            ),
424            ("confidence".into(), format!("{confidence:.2}")),
425            ("top_prediction".into(), top_prediction),
426            ("fallback_divisor".into(), self.fallback_divisor.to_string()),
427            ("decay_factor".into(), format!("{:.2}", self.decay_factor)),
428            ("decay_next_at".into(), decay_next_at.to_string()),
429            ("dirty".into(), self.dirty.to_string()),
430        ]
431    }
432}
433
434// =============================================================================
435// Tests
436// =============================================================================
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    fn default_config() -> PredictiveStrategyConfig {
443        PredictiveStrategyConfig {
444            min_observations: 5, // low threshold for test speed
445            fallback_divisor: 10,
446            decay_interval: 100,
447            ..PredictiveStrategyConfig::default()
448        }
449    }
450
451    #[test]
452    fn cold_start_uses_fallback_divisor() {
453        let mut s = Predictive::new(default_config());
454        // No transition data → unknown screen → fallback divisor
455        assert_eq!(s.should_tick("x", 0, "a"), TickDecision::Tick); // 0 % 10 == 0
456        assert_eq!(s.should_tick("x", 1, "a"), TickDecision::Skip);
457        assert_eq!(s.should_tick("x", 9, "a"), TickDecision::Skip);
458        assert_eq!(s.should_tick("x", 10, "a"), TickDecision::Tick); // 10 % 10 == 0
459    }
460
461    #[test]
462    fn learns_and_adjusts_divisors() {
463        let config = PredictiveStrategyConfig {
464            min_observations: 5,
465            fallback_divisor: 20,
466            ..PredictiveStrategyConfig::default()
467        };
468        let mut s = Predictive::new(config);
469
470        // Record many a→b transitions
471        for _ in 0..20 {
472            s.on_screen_transition("a", "b");
473        }
474        // Record few a→c transitions
475        for _ in 0..2 {
476            s.on_screen_transition("a", "c");
477        }
478
479        // Now when active is "a", "b" should have a lower divisor than "c"
480        s.refresh_cache("a");
481        let b_div = s.cached_divisors.get("b").copied().unwrap_or(99);
482        let c_div = s.cached_divisors.get("c").copied().unwrap_or(99);
483        assert!(
484            b_div < c_div,
485            "b should tick more: b_div={b_div}, c_div={c_div}"
486        );
487    }
488
489    #[test]
490    fn cache_refreshes_on_screen_transition() {
491        let mut s = Predictive::new(default_config());
492        s.on_screen_transition("a", "b");
493        assert_eq!(s.cached_for_screen.as_deref(), Some("b"));
494
495        s.on_screen_transition("b", "c");
496        assert_eq!(s.cached_for_screen.as_deref(), Some("c"));
497    }
498
499    #[test]
500    fn cache_reused_for_same_screen() {
501        let mut s = Predictive::new(default_config());
502        s.on_screen_transition("a", "b");
503
504        // First call refreshes cache
505        s.should_tick("x", 1, "b");
506        let cached = s.cached_for_screen.clone();
507
508        // Second call reuses cache (same active screen)
509        s.should_tick("x", 2, "b");
510        assert_eq!(s.cached_for_screen, cached);
511    }
512
513    #[test]
514    fn unknown_screen_uses_fallback() {
515        let mut s = Predictive::new(default_config());
516        s.on_screen_transition("a", "b");
517
518        // "unknown" not in any prediction → fallback_divisor
519        let div = s
520            .cached_divisors
521            .get("unknown")
522            .copied()
523            .unwrap_or(s.fallback_divisor);
524        assert_eq!(div, 10); // default_config fallback
525    }
526
527    #[test]
528    fn decay_triggers_at_interval() {
529        let config = PredictiveStrategyConfig {
530            decay_interval: 10,
531            decay_factor: 0.5,
532            min_observations: 5,
533            ..PredictiveStrategyConfig::default()
534        };
535        let mut s = Predictive::new(config);
536        s.on_screen_transition("a", "b");
537        let before = s.predictor.counter().total();
538
539        // Simulate maintenance ticks
540        for _ in 0..10 {
541            s.maintenance_tick(0);
542        }
543
544        let after = s.predictor.counter().total();
545        assert!(
546            after < before,
547            "decay should reduce total: {after} < {before}"
548        );
549    }
550
551    #[test]
552    fn dirty_flag_set_on_transition() {
553        let mut s = Predictive::new(default_config());
554        assert!(!s.is_dirty());
555
556        s.on_screen_transition("a", "b");
557        assert!(s.is_dirty());
558    }
559
560    #[test]
561    fn name_is_stable() {
562        let s = Predictive::new(default_config());
563        assert_eq!(s.name(), "Predictive");
564    }
565
566    #[test]
567    fn debug_stats_populated() {
568        let mut s = Predictive::new(default_config());
569        s.on_screen_transition("a", "b");
570
571        let stats = s.debug_stats();
572        assert!(!stats.is_empty());
573        assert!(stats.iter().any(|(k, _)| k == "strategy"));
574        assert!(stats.iter().any(|(k, _)| k == "total_transitions"));
575        assert!(stats.iter().any(|(k, _)| k == "confidence"));
576        assert!(stats.iter().any(|(k, _)| k == "top_prediction"));
577        assert!(stats.iter().any(|(k, _)| k == "decay_factor"));
578        assert!(stats.iter().any(|(k, _)| k == "decay_next_at"));
579    }
580
581    #[test]
582    fn with_counter_preloads_data() {
583        let mut counter = TransitionCounter::new();
584        for _ in 0..50 {
585            counter.record("a".to_owned(), "b".to_owned());
586        }
587
588        let s = Predictive::with_counter(default_config(), counter);
589        assert!(!s.predictor().is_cold_start(&"a".to_owned()));
590    }
591
592    #[test]
593    fn high_probability_screen_ticks_more() {
594        let config = PredictiveStrategyConfig {
595            min_observations: 5,
596            fallback_divisor: 20,
597            ..PredictiveStrategyConfig::default()
598        };
599        let mut s = Predictive::new(config);
600
601        // Build strong signal: a→b is very likely
602        for _ in 0..30 {
603            s.on_screen_transition("a", "b");
604        }
605        s.on_screen_transition("a", "c");
606
607        // Count ticks over 100 frames for each screen
608        let mut b_ticks = 0u64;
609        let mut c_ticks = 0u64;
610        for tick in 0..100 {
611            if s.should_tick("b", tick, "a") == TickDecision::Tick {
612                b_ticks += 1;
613            }
614            if s.should_tick("c", tick, "a") == TickDecision::Tick {
615                c_ticks += 1;
616            }
617        }
618
619        assert!(
620            b_ticks > c_ticks,
621            "b should tick more than c: b={b_ticks}, c={c_ticks}"
622        );
623    }
624
625    // ========================================================================
626    // Persistence integration tests (E.2 coverage)
627    // ========================================================================
628
629    #[cfg(feature = "state-persistence")]
630    mod persistence_tests {
631        use super::*;
632
633        #[test]
634        fn with_persistence_loads_from_file() {
635            use crate::tick_strategy::persistence::save_transitions;
636
637            let dir = tempfile::tempdir().unwrap();
638            let path = dir.path().join("transitions.json");
639
640            // Create and save historical data
641            let mut counter = TransitionCounter::new();
642            for _ in 0..50 {
643                counter.record("a".to_owned(), "b".to_owned());
644            }
645            for _ in 0..20 {
646                counter.record("a".to_owned(), "c".to_owned());
647            }
648            save_transitions(&counter, &path).unwrap();
649
650            // Load with no decay
651            let s = Predictive::with_persistence(default_config(), &path, 1.0);
652            assert!(!s.predictor().is_cold_start(&"a".to_owned()));
653            assert_eq!(s.counter().total(), 70.0);
654        }
655
656        #[test]
657        fn with_persistence_applies_load_decay() {
658            use crate::tick_strategy::persistence::save_transitions;
659
660            let dir = tempfile::tempdir().unwrap();
661            let path = dir.path().join("transitions.json");
662
663            let mut counter = TransitionCounter::new();
664            for _ in 0..100 {
665                counter.record("a".to_owned(), "b".to_owned());
666            }
667            save_transitions(&counter, &path).unwrap();
668
669            // Load with 0.5 decay
670            let s = Predictive::with_persistence(default_config(), &path, 0.5);
671            let total = s.counter().total();
672            eprintln!("total after load_decay(0.5): {total}");
673            assert!(
674                (total - 50.0).abs() < 1e-9,
675                "expected ~50.0 after 0.5 decay, got {total}"
676            );
677        }
678
679        #[test]
680        fn with_persistence_missing_file_is_cold_start() {
681            let dir = tempfile::tempdir().unwrap();
682            let path = dir.path().join("nonexistent.json");
683
684            let s = Predictive::with_persistence(default_config(), &path, 0.9);
685            assert_eq!(s.counter().total(), 0.0);
686            assert!(s.predictor().is_cold_start(&"a".to_owned()));
687        }
688
689        #[test]
690        fn with_persistence_corrupted_file_is_cold_start() {
691            let dir = tempfile::tempdir().unwrap();
692            let path = dir.path().join("bad.json");
693            std::fs::write(&path, "not valid json {{{").unwrap();
694
695            let s = Predictive::with_persistence(default_config(), &path, 0.9);
696            assert_eq!(s.counter().total(), 0.0);
697        }
698
699        // ====================================================================
700        // Auto-save tests (E.3 coverage)
701        // ====================================================================
702
703        #[test]
704        fn auto_save_fires_at_interval() {
705            let dir = tempfile::tempdir().unwrap();
706            let path = dir.path().join("auto.json");
707
708            let config = PredictiveStrategyConfig {
709                auto_save_interval: 10,
710                min_observations: 5,
711                decay_interval: 9999, // disable decay
712                ..PredictiveStrategyConfig::default()
713            };
714            let mut s = Predictive::with_persistence(config, &path, 1.0);
715
716            // Record a transition to make it dirty.
717            s.on_screen_transition("a", "b");
718            assert!(s.is_dirty());
719            assert!(!path.exists());
720
721            // Pump maintenance ticks up to the interval.
722            for _ in 0..10 {
723                s.maintenance_tick(0);
724            }
725
726            // File should now exist and dirty flag should be cleared.
727            assert!(path.exists(), "auto-save should have written the file");
728            assert!(!s.is_dirty(), "dirty flag should be cleared after save");
729        }
730
731        #[test]
732        fn auto_save_writes_valid_json() {
733            use crate::tick_strategy::persistence::load_transitions;
734
735            let dir = tempfile::tempdir().unwrap();
736            let path = dir.path().join("valid.json");
737
738            let config = PredictiveStrategyConfig {
739                auto_save_interval: 5,
740                min_observations: 5,
741                decay_interval: 9999,
742                ..PredictiveStrategyConfig::default()
743            };
744            let mut s = Predictive::with_persistence(config, &path, 1.0);
745
746            for _ in 0..10 {
747                s.on_screen_transition("x", "y");
748            }
749
750            // Trigger auto-save.
751            for _ in 0..5 {
752                s.maintenance_tick(0);
753            }
754
755            // Load the file and verify contents.
756            let loaded = load_transitions(&path).unwrap();
757            assert_eq!(loaded.count(&"x".to_owned(), &"y".to_owned()), 10.0);
758        }
759
760        #[test]
761        fn auto_save_skips_when_not_dirty() {
762            let dir = tempfile::tempdir().unwrap();
763            let path = dir.path().join("nodirty.json");
764
765            let config = PredictiveStrategyConfig {
766                auto_save_interval: 5,
767                min_observations: 5,
768                decay_interval: 9999,
769                ..PredictiveStrategyConfig::default()
770            };
771            let mut s = Predictive::with_persistence(config, &path, 1.0);
772
773            // No transitions → not dirty. Pump past the interval.
774            for _ in 0..10 {
775                s.maintenance_tick(0);
776            }
777
778            assert!(!path.exists(), "no file should be written when not dirty");
779        }
780
781        #[test]
782        fn shutdown_triggers_save() {
783            let dir = tempfile::tempdir().unwrap();
784            let path = dir.path().join("shutdown.json");
785
786            let config = PredictiveStrategyConfig {
787                auto_save_interval: 99999, // won't fire during test
788                min_observations: 5,
789                decay_interval: 9999,
790                ..PredictiveStrategyConfig::default()
791            };
792            let mut s = Predictive::with_persistence(config, &path, 1.0);
793
794            s.on_screen_transition("p", "q");
795            assert!(s.is_dirty());
796
797            s.shutdown();
798
799            assert!(path.exists(), "shutdown should trigger a save");
800            assert!(!s.is_dirty(), "dirty flag cleared after shutdown save");
801        }
802
803        #[test]
804        fn auto_save_no_path_is_noop() {
805            // Strategy created without persistence → no crash, no save.
806            let config = PredictiveStrategyConfig {
807                auto_save_interval: 1,
808                min_observations: 5,
809                decay_interval: 9999,
810                ..PredictiveStrategyConfig::default()
811            };
812            let mut s = Predictive::new(config);
813
814            s.on_screen_transition("a", "b");
815            assert!(s.is_dirty());
816
817            // Pump past interval — should not panic.
818            for _ in 0..5 {
819                s.maintenance_tick(0);
820            }
821            s.shutdown();
822
823            // Still dirty because no path was configured.
824            assert!(s.is_dirty());
825        }
826
827        #[test]
828        fn auto_save_bad_path_does_not_crash() {
829            let config = PredictiveStrategyConfig {
830                auto_save_interval: 5,
831                min_observations: 5,
832                decay_interval: 9999,
833                ..PredictiveStrategyConfig::default()
834            };
835            let dir = tempfile::tempdir().unwrap();
836            // Point at a child of a directory that does not exist.
837            let bad_path = dir.path().join("missing-parent").join("transitions.json");
838            let mut s = Predictive::with_persistence(config, &bad_path, 1.0);
839
840            s.on_screen_transition("a", "b");
841
842            // Trigger auto-save — should log error but not panic.
843            for _ in 0..5 {
844                s.maintenance_tick(0);
845            }
846
847            // dirty remains true because save failed.
848            assert!(s.is_dirty());
849        }
850
851        #[test]
852        fn maintenance_decay_marks_strategy_dirty_and_persists_on_shutdown() {
853            use crate::tick_strategy::persistence::load_transitions;
854
855            let dir = tempfile::tempdir().unwrap();
856            let path = dir.path().join("decayed-on-shutdown.json");
857
858            let config = PredictiveStrategyConfig {
859                auto_save_interval: 99999,
860                min_observations: 5,
861                decay_interval: 1,
862                decay_factor: 0.5,
863                ..PredictiveStrategyConfig::default()
864            };
865            let mut s = Predictive::with_persistence(config, &path, 1.0);
866
867            for _ in 0..20 {
868                s.on_screen_transition("a", "b");
869            }
870            s.shutdown();
871            assert!(
872                !s.is_dirty(),
873                "initial shutdown should flush transition data"
874            );
875
876            s.maintenance_tick(0);
877            assert!(
878                s.is_dirty(),
879                "maintenance decay mutates persisted state and must mark it dirty"
880            );
881
882            s.shutdown();
883            let loaded = load_transitions(&path).unwrap();
884            assert!(
885                (loaded.total() - 10.0).abs() < 1e-9,
886                "expected decayed total to persist after shutdown, got {}",
887                loaded.total()
888            );
889        }
890
891        #[test]
892        fn load_decay_marks_strategy_dirty_until_persisted() {
893            use crate::tick_strategy::persistence::{load_transitions, save_transitions};
894
895            let dir = tempfile::tempdir().unwrap();
896            let path = dir.path().join("load-decay.json");
897
898            let mut counter = TransitionCounter::new();
899            for _ in 0..12 {
900                counter.record("a".to_owned(), "b".to_owned());
901            }
902            save_transitions(&counter, &path).unwrap();
903
904            let config = PredictiveStrategyConfig {
905                auto_save_interval: 99999,
906                min_observations: 5,
907                decay_interval: 9999,
908                ..PredictiveStrategyConfig::default()
909            };
910            let mut s = Predictive::with_persistence(config, &path, 0.5);
911            assert!(
912                s.is_dirty(),
913                "load-time decay changes the in-memory counter and should be persisted"
914            );
915
916            s.shutdown();
917            let loaded = load_transitions(&path).unwrap();
918            assert!(
919                (loaded.total() - 6.0).abs() < 1e-9,
920                "expected shutdown to persist the decayed total, got {}",
921                loaded.total()
922            );
923        }
924    }
925}