1use 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#[derive(Debug, Clone)]
21pub struct PredictiveStrategyConfig {
22 pub allocation: TickAllocation,
24 pub fallback_divisor: u64,
26 pub min_observations: u64,
28 pub decay_factor: f64,
30 pub decay_interval: u64,
32 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#[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 cached_divisors: HashMap<String, u64>,
66 cached_for_screen: Option<String>,
68 ticks_since_decay: u64,
70 ticks_since_save: u64,
72 dirty: bool,
74 #[cfg(feature = "state-persistence")]
76 persistence_path: Option<PathBuf>,
77}
78
79impl Predictive {
80 #[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 #[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 #[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 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 strategy.persistence_path = Some(path.to_path_buf());
192 strategy
193 }
194
195 #[must_use]
197 pub fn predictor(&self) -> &MarkovPredictor<String> {
198 &self.predictor
199 }
200
201 #[must_use]
203 pub fn counter(&self) -> &TransitionCounter<String> {
204 self.predictor.counter()
205 }
206
207 #[must_use]
209 pub fn is_dirty(&self) -> bool {
210 self.dirty
211 }
212
213 #[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 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 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 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 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 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 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 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#[cfg(test)]
439mod tests {
440 use super::*;
441
442 fn default_config() -> PredictiveStrategyConfig {
443 PredictiveStrategyConfig {
444 min_observations: 5, 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 assert_eq!(s.should_tick("x", 0, "a"), TickDecision::Tick); 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); }
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 for _ in 0..20 {
472 s.on_screen_transition("a", "b");
473 }
474 for _ in 0..2 {
476 s.on_screen_transition("a", "c");
477 }
478
479 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 s.should_tick("x", 1, "b");
506 let cached = s.cached_for_screen.clone();
507
508 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 let div = s
520 .cached_divisors
521 .get("unknown")
522 .copied()
523 .unwrap_or(s.fallback_divisor);
524 assert_eq!(div, 10); }
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 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 for _ in 0..30 {
603 s.on_screen_transition("a", "b");
604 }
605 s.on_screen_transition("a", "c");
606
607 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 #[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 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 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 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 #[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, ..PredictiveStrategyConfig::default()
713 };
714 let mut s = Predictive::with_persistence(config, &path, 1.0);
715
716 s.on_screen_transition("a", "b");
718 assert!(s.is_dirty());
719 assert!(!path.exists());
720
721 for _ in 0..10 {
723 s.maintenance_tick(0);
724 }
725
726 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 for _ in 0..5 {
752 s.maintenance_tick(0);
753 }
754
755 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 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, 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 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 for _ in 0..5 {
819 s.maintenance_tick(0);
820 }
821 s.shutdown();
822
823 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 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 for _ in 0..5 {
844 s.maintenance_tick(0);
845 }
846
847 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}