Skip to main content

clawft_kernel/
eml_kernel.rs

1//! EML learned functions for kernel subsystems.
2//!
3//! Each model replaces a hardcoded heuristic with a trainable
4//! [`eml_core::EmlModel`], falling back to the original fixed logic
5//! when the model has not yet been trained.
6//!
7//! # Models
8//!
9//! | Model | Replaces | Inputs | Outputs |
10//! |-------|----------|--------|---------|
11//! | [`GovernanceScorerModel`] | EffectVector L2 norm | 5 dimensions | 1 composite score |
12//! | [`RestartStrategyModel`] | Fixed backoff delays | 4 features | 2 (delay, should_retry) |
13//! | [`HealthThresholdModel`] | Fixed probe thresholds | 3 features | 2 (degraded, failed) |
14//! | [`DeadLetterModel`] | Fixed retry policy | 3 features | 2 (delay, should_discard) |
15//! | [`GossipTimingModel`] | Fixed gossip intervals | 3 features | 1 interval |
16//! | [`ComplexityModel`] | 500-line threshold | 3 features | 1 threshold |
17
18use eml_core::{EmlEvent, EmlModel};
19use serde::{Deserialize, Serialize};
20
21// ---------------------------------------------------------------------------
22// 1. Governance EffectVector Scoring
23// ---------------------------------------------------------------------------
24
25/// Learned governance scorer replacing the L2 norm on EffectVector.
26///
27/// Inputs (5): risk, fairness, privacy, novelty, security
28/// Output (1): composite governance score
29///
30/// Fallback: standard L2 norm (sqrt of sum of squares).
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct GovernanceScorerModel {
33    inner: EmlModel,
34}
35
36impl Default for GovernanceScorerModel {
37    fn default() -> Self {
38        Self::new()
39    }
40}
41
42impl GovernanceScorerModel {
43    /// Create a new untrained governance scorer.
44    pub fn new() -> Self {
45        let mut inner = EmlModel::new(3, 5, 1);
46        inner.set_model_name("governance_scorer");
47        Self { inner }
48    }
49
50    /// Drain accumulated EML lifecycle events for ExoChain forwarding.
51    pub fn drain_events(&mut self) -> Vec<EmlEvent> {
52        self.inner.drain_events()
53    }
54
55    /// Whether the model has been trained.
56    pub fn is_trained(&self) -> bool {
57        self.inner.is_trained()
58    }
59
60    /// Number of training samples collected.
61    pub fn training_sample_count(&self) -> usize {
62        self.inner.training_sample_count()
63    }
64
65    /// Predict a composite governance score from effect dimensions.
66    ///
67    /// When untrained, falls back to L2 norm (sqrt of sum of squares).
68    pub fn predict(
69        &self,
70        risk: f64,
71        fairness: f64,
72        privacy: f64,
73        novelty: f64,
74        security: f64,
75    ) -> f64 {
76        if !self.inner.is_trained() {
77            return (risk * risk
78                + fairness * fairness
79                + privacy * privacy
80                + novelty * novelty
81                + security * security)
82                .sqrt();
83        }
84        let inputs = [risk, fairness, privacy, novelty, security];
85        self.inner.predict_primary(&inputs).max(0.0)
86    }
87
88    /// Record a governance decision for training.
89    ///
90    /// `score` is the expert-reviewed composite importance of this decision.
91    pub fn record(
92        &mut self,
93        risk: f64,
94        fairness: f64,
95        privacy: f64,
96        novelty: f64,
97        security: f64,
98        score: f64,
99    ) {
100        let inputs = [risk, fairness, privacy, novelty, security];
101        self.inner.record(&inputs, &[Some(score)]);
102    }
103
104    /// Train the model from recorded samples.
105    ///
106    /// Returns `true` if the model converged.
107    pub fn train(&mut self) -> bool {
108        self.inner.train()
109    }
110}
111
112// ---------------------------------------------------------------------------
113// 2. Agent Restart Strategy
114// ---------------------------------------------------------------------------
115
116/// Learned restart strategy replacing fixed backoff delays and retry decisions.
117///
118/// Inputs (4): failure_count, failure_type_ordinal, uptime_before_failure_secs,
119///             system_load (0.0-1.0)
120/// Outputs (2): optimal_delay_ms, should_retry (>0.5 = yes)
121///
122/// Fallback: exponential backoff 100ms * 2^(n-1), capped at 30s; always retry.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct RestartStrategyModel {
125    inner: EmlModel,
126}
127
128impl Default for RestartStrategyModel {
129    fn default() -> Self {
130        Self::new()
131    }
132}
133
134impl RestartStrategyModel {
135    /// Create a new untrained restart strategy model.
136    pub fn new() -> Self {
137        let mut inner = EmlModel::new(2, 4, 2);
138        inner.set_model_name("restart_strategy");
139        Self { inner }
140    }
141
142    /// Drain accumulated EML lifecycle events for ExoChain forwarding.
143    pub fn drain_events(&mut self) -> Vec<EmlEvent> {
144        self.inner.drain_events()
145    }
146
147    /// Whether the model has been trained.
148    pub fn is_trained(&self) -> bool {
149        self.inner.is_trained()
150    }
151
152    /// Number of training samples collected.
153    pub fn training_sample_count(&self) -> usize {
154        self.inner.training_sample_count()
155    }
156
157    /// Predict restart parameters.
158    ///
159    /// Returns `(delay_ms, should_retry)` where `should_retry` is true
160    /// when the model's second head exceeds 0.5.
161    ///
162    /// When untrained, uses exponential backoff: 100 * 2^(failure_count-1),
163    /// capped at 30_000ms, and always retries.
164    pub fn predict(
165        &self,
166        failure_count: u32,
167        failure_type: u32,
168        uptime_before_failure_secs: f64,
169        system_load: f64,
170    ) -> (u64, bool) {
171        if !self.inner.is_trained() {
172            let base: u64 = 100;
173            let exponent = failure_count.saturating_sub(1);
174            let delay = base.saturating_mul(1u64 << exponent.min(20));
175            return (delay.min(30_000), true);
176        }
177
178        let inputs = [
179            failure_count as f64,
180            failure_type as f64,
181            uptime_before_failure_secs,
182            system_load,
183        ];
184        let heads = self.inner.predict(&inputs);
185        let delay_ms = (heads[0].max(0.0) * 1000.0) as u64;
186        let should_retry = heads[1] > 0.5;
187        (delay_ms.min(60_000), should_retry)
188    }
189
190    /// Record a restart outcome for training.
191    ///
192    /// `actual_delay_ms` is the delay that led to successful recovery.
193    /// `recovery_succeeded` indicates whether the restart succeeded.
194    pub fn record(
195        &mut self,
196        failure_count: u32,
197        failure_type: u32,
198        uptime_before_failure_secs: f64,
199        system_load: f64,
200        actual_delay_ms: u64,
201        recovery_succeeded: bool,
202    ) {
203        let inputs = [
204            failure_count as f64,
205            failure_type as f64,
206            uptime_before_failure_secs,
207            system_load,
208        ];
209        let targets = [
210            Some(actual_delay_ms as f64 / 1000.0),
211            Some(if recovery_succeeded { 1.0 } else { 0.0 }),
212        ];
213        self.inner.record(&inputs, &targets);
214    }
215
216    /// Train the model from recorded samples.
217    pub fn train(&mut self) -> bool {
218        self.inner.train()
219    }
220}
221
222// ---------------------------------------------------------------------------
223// 3. Health Check Thresholds
224// ---------------------------------------------------------------------------
225
226/// Learned health check thresholds replacing fixed probe configuration.
227///
228/// Inputs (3): service_type_ordinal, history_depth (sample count), recent_latency_ms
229/// Outputs (2): degraded_threshold (consecutive failures), failed_threshold
230///
231/// Fallback: degraded = 1 failure, failed = 3 failures (from ProbeConfig defaults).
232#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct HealthThresholdModel {
234    inner: EmlModel,
235}
236
237impl Default for HealthThresholdModel {
238    fn default() -> Self {
239        Self::new()
240    }
241}
242
243impl HealthThresholdModel {
244    /// Create a new untrained health threshold model.
245    pub fn new() -> Self {
246        let mut inner = EmlModel::new(2, 3, 2);
247        inner.set_model_name("health_threshold");
248        Self { inner }
249    }
250
251    /// Drain accumulated EML lifecycle events for ExoChain forwarding.
252    pub fn drain_events(&mut self) -> Vec<EmlEvent> {
253        self.inner.drain_events()
254    }
255
256    /// Whether the model has been trained.
257    pub fn is_trained(&self) -> bool {
258        self.inner.is_trained()
259    }
260
261    /// Number of training samples collected.
262    pub fn training_sample_count(&self) -> usize {
263        self.inner.training_sample_count()
264    }
265
266    /// Predict health thresholds: (degraded_threshold, failed_threshold).
267    ///
268    /// Both values are clamped to [1, 20] and rounded to integers.
269    ///
270    /// When untrained, returns (1, 3) matching ProbeConfig defaults.
271    pub fn predict(
272        &self,
273        service_type: u32,
274        history_depth: u32,
275        recent_latency_ms: f64,
276    ) -> (u32, u32) {
277        if !self.inner.is_trained() {
278            return (1, 3);
279        }
280
281        let inputs = [
282            service_type as f64,
283            history_depth as f64,
284            recent_latency_ms / 1000.0, // normalize to seconds
285        ];
286        let heads = self.inner.predict(&inputs);
287        let degraded = heads[0].clamp(1.0, 20.0).round() as u32;
288        let failed = heads[1].clamp(1.0, 20.0).round() as u32;
289        // Ensure failed >= degraded
290        (degraded, failed.max(degraded))
291    }
292
293    /// Record a confirmed health alert for training.
294    ///
295    /// `was_true_positive`: true if the alert correctly identified a problem.
296    /// `optimal_degraded` / `optimal_failed`: retrospective best thresholds.
297    pub fn record(
298        &mut self,
299        service_type: u32,
300        history_depth: u32,
301        recent_latency_ms: f64,
302        optimal_degraded: u32,
303        optimal_failed: u32,
304    ) {
305        let inputs = [
306            service_type as f64,
307            history_depth as f64,
308            recent_latency_ms / 1000.0,
309        ];
310        let targets = [Some(optimal_degraded as f64), Some(optimal_failed as f64)];
311        self.inner.record(&inputs, &targets);
312    }
313
314    /// Train the model from recorded samples.
315    pub fn train(&mut self) -> bool {
316        self.inner.train()
317    }
318}
319
320// ---------------------------------------------------------------------------
321// 4. Dead Letter Policy
322// ---------------------------------------------------------------------------
323
324/// Learned dead letter retry policy replacing fixed backoff and discard rules.
325///
326/// Inputs (3): retry_count, message_age_ms, queue_depth
327/// Outputs (2): retry_delay_ms, should_discard (>0.5 = discard)
328///
329/// Fallback: retry_delay = 1000 * 2^retry_count (capped at 60s), discard after 5 retries.
330#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct DeadLetterModel {
332    inner: EmlModel,
333}
334
335impl Default for DeadLetterModel {
336    fn default() -> Self {
337        Self::new()
338    }
339}
340
341impl DeadLetterModel {
342    /// Create a new untrained dead letter policy model.
343    pub fn new() -> Self {
344        let mut inner = EmlModel::new(2, 3, 2);
345        inner.set_model_name("dead_letter");
346        Self { inner }
347    }
348
349    /// Drain accumulated EML lifecycle events for ExoChain forwarding.
350    pub fn drain_events(&mut self) -> Vec<EmlEvent> {
351        self.inner.drain_events()
352    }
353
354    /// Whether the model has been trained.
355    pub fn is_trained(&self) -> bool {
356        self.inner.is_trained()
357    }
358
359    /// Number of training samples collected.
360    pub fn training_sample_count(&self) -> usize {
361        self.inner.training_sample_count()
362    }
363
364    /// Predict retry policy: (retry_delay_ms, should_discard).
365    ///
366    /// When untrained, uses exponential backoff: 1000 * 2^retry_count
367    /// capped at 60s, discards after 5 retries.
368    pub fn predict(
369        &self,
370        retry_count: u32,
371        message_age_ms: u64,
372        queue_depth: usize,
373    ) -> (u64, bool) {
374        if !self.inner.is_trained() {
375            let delay = (1000u64).saturating_mul(1u64 << retry_count.min(20));
376            return (delay.min(60_000), retry_count >= 5);
377        }
378
379        let inputs = [
380            retry_count as f64,
381            message_age_ms as f64 / 1000.0, // normalize to seconds
382            queue_depth as f64,
383        ];
384        let heads = self.inner.predict(&inputs);
385        let delay_ms = (heads[0].max(0.0) * 1000.0) as u64;
386        let should_discard = heads[1] > 0.5;
387        (delay_ms.min(120_000), should_discard)
388    }
389
390    /// Record a retry outcome for training.
391    ///
392    /// `delivery_succeeded`: whether the retry eventually delivered.
393    /// `actual_delay_ms`: the delay used before the retry attempt.
394    pub fn record(
395        &mut self,
396        retry_count: u32,
397        message_age_ms: u64,
398        queue_depth: usize,
399        actual_delay_ms: u64,
400        delivery_succeeded: bool,
401    ) {
402        let inputs = [
403            retry_count as f64,
404            message_age_ms as f64 / 1000.0,
405            queue_depth as f64,
406        ];
407        let targets = [
408            Some(actual_delay_ms as f64 / 1000.0),
409            Some(if delivery_succeeded { 0.0 } else { 1.0 }), // discard if not successful
410        ];
411        self.inner.record(&inputs, &targets);
412    }
413
414    /// Train the model from recorded samples.
415    pub fn train(&mut self) -> bool {
416        self.inner.train()
417    }
418}
419
420// ---------------------------------------------------------------------------
421// 5. Mesh Assessment Gossip Timing
422// ---------------------------------------------------------------------------
423
424/// Learned gossip interval replacing fixed timing constants.
425///
426/// Inputs (3): peer_count, network_latency_ms, update_frequency (Hz)
427/// Output (1): optimal gossip interval in seconds
428///
429/// Fallback: 5 seconds (matching cluster heartbeat default).
430#[derive(Debug, Clone, Serialize, Deserialize)]
431pub struct GossipTimingModel {
432    inner: EmlModel,
433}
434
435impl Default for GossipTimingModel {
436    fn default() -> Self {
437        Self::new()
438    }
439}
440
441impl GossipTimingModel {
442    /// Create a new untrained gossip timing model.
443    pub fn new() -> Self {
444        let mut inner = EmlModel::new(2, 3, 1);
445        inner.set_model_name("gossip_timing");
446        Self { inner }
447    }
448
449    /// Drain accumulated EML lifecycle events for ExoChain forwarding.
450    pub fn drain_events(&mut self) -> Vec<EmlEvent> {
451        self.inner.drain_events()
452    }
453
454    /// Whether the model has been trained.
455    pub fn is_trained(&self) -> bool {
456        self.inner.is_trained()
457    }
458
459    /// Number of training samples collected.
460    pub fn training_sample_count(&self) -> usize {
461        self.inner.training_sample_count()
462    }
463
464    /// Predict optimal gossip interval in seconds.
465    ///
466    /// Result is clamped to [1, 60] seconds.
467    ///
468    /// When untrained, returns 5s (default heartbeat interval).
469    pub fn predict(
470        &self,
471        peer_count: usize,
472        network_latency_ms: f64,
473        update_frequency_hz: f64,
474    ) -> f64 {
475        if !self.inner.is_trained() {
476            return 5.0;
477        }
478
479        let inputs = [
480            peer_count as f64,
481            network_latency_ms / 1000.0, // normalize to seconds
482            update_frequency_hz,
483        ];
484        self.inner.predict_primary(&inputs).clamp(1.0, 60.0)
485    }
486
487    /// Record an observed gossip interval and its effectiveness.
488    ///
489    /// `optimal_interval_secs`: the interval that provided the best
490    /// freshness/bandwidth trade-off in this context.
491    pub fn record(
492        &mut self,
493        peer_count: usize,
494        network_latency_ms: f64,
495        update_frequency_hz: f64,
496        optimal_interval_secs: f64,
497    ) {
498        let inputs = [
499            peer_count as f64,
500            network_latency_ms / 1000.0,
501            update_frequency_hz,
502        ];
503        self.inner.record(&inputs, &[Some(optimal_interval_secs)]);
504    }
505
506    /// Train the model from recorded samples.
507    pub fn train(&mut self) -> bool {
508        self.inner.train()
509    }
510}
511
512// ---------------------------------------------------------------------------
513// 6b. Tick Interval Recommender (Finding #7)
514// ---------------------------------------------------------------------------
515
516/// Learned tick-interval recommender replacing the four-tier
517/// step-function in [`crate::weaver::WeaverEngine::recommend_tick_interval`].
518///
519/// Inputs (3): `cpm` (changes per minute), `idle_ticks`,
520/// `variance` (a free-form change-rate variance signal in `[0, 1]`).
521/// Output (1): recommended tick interval in milliseconds.
522///
523/// Fallback: returns 1000 ms (the steady-state default the old
524/// step-function returned for moderate change rates). When the model
525/// is not yet trained, callers must keep the original step-function
526/// reachable so the historical thresholds (`200 / 1000 / 3000 / 5000`)
527/// continue to apply — see [`Self::recommend_or`] for the helper that
528/// implements that policy.
529///
530/// NOTE(eml-swap): wired — Finding #7 (TickIntervalModel).
531#[derive(Debug, Clone, Serialize, Deserialize)]
532pub struct TickIntervalModel {
533    inner: EmlModel,
534}
535
536impl Default for TickIntervalModel {
537    fn default() -> Self {
538        Self::new()
539    }
540}
541
542impl TickIntervalModel {
543    /// Create a new untrained tick-interval recommender.
544    pub fn new() -> Self {
545        let mut inner = EmlModel::new(2, 3, 1);
546        inner.set_model_name("tick_interval");
547        Self { inner }
548    }
549
550    /// Drain accumulated EML lifecycle events for ExoChain forwarding.
551    pub fn drain_events(&mut self) -> Vec<EmlEvent> {
552        self.inner.drain_events()
553    }
554
555    /// Whether the model has been trained.
556    pub fn is_trained(&self) -> bool {
557        self.inner.is_trained()
558    }
559
560    /// Number of training samples collected.
561    pub fn training_sample_count(&self) -> usize {
562        self.inner.training_sample_count()
563    }
564
565    /// Predict the recommended tick interval in milliseconds.
566    ///
567    /// Result is clamped to `[100, 60_000]` ms. When untrained,
568    /// returns 1000 ms (the central tier of the legacy step-function).
569    pub fn predict(&self, cpm: f64, idle_ticks: u64, variance: f64) -> u32 {
570        if !self.inner.is_trained() {
571            return 1000;
572        }
573        let inputs = [cpm, idle_ticks as f64, variance];
574        let raw = self.inner.predict_primary(&inputs);
575        raw.clamp(100.0, 60_000.0).round() as u32
576    }
577
578    /// Recommend a tick interval: when trained returns
579    /// [`Self::predict`]; otherwise returns the caller-provided
580    /// `fallback` (typically the existing step-function's choice).
581    pub fn recommend_or(&self, cpm: f64, idle_ticks: u64, variance: f64, fallback: u32) -> u32 {
582        if self.inner.is_trained() {
583            self.predict(cpm, idle_ticks, variance)
584        } else {
585            fallback
586        }
587    }
588
589    /// Record an observed (cpm, idle_ticks, variance, recommended_ms)
590    /// tuple for training.
591    pub fn record(&mut self, cpm: f64, idle_ticks: u64, variance: f64, recommended_ms: u32) {
592        let inputs = [cpm, idle_ticks as f64, variance];
593        self.inner.record(&inputs, &[Some(recommended_ms as f64)]);
594    }
595
596    /// Train the model from recorded samples.
597    pub fn train(&mut self) -> bool {
598        self.inner.train()
599    }
600}
601
602// ---------------------------------------------------------------------------
603// 6. Assessment Complexity Threshold
604// ---------------------------------------------------------------------------
605
606/// Learned complexity threshold replacing the fixed 500-line limit.
607///
608/// Inputs (3): language_type_ordinal, avg_file_size_in_project, team_size_proxy
609/// Output (1): per-language complexity threshold (line count)
610///
611/// Fallback: 500 lines (matching ComplexityAnalyzer default).
612#[derive(Debug, Clone, Serialize, Deserialize)]
613pub struct ComplexityModel {
614    inner: EmlModel,
615}
616
617impl Default for ComplexityModel {
618    fn default() -> Self {
619        Self::new()
620    }
621}
622
623impl ComplexityModel {
624    /// Create a new untrained complexity threshold model.
625    pub fn new() -> Self {
626        let mut inner = EmlModel::new(2, 3, 1);
627        inner.set_model_name("complexity");
628        Self { inner }
629    }
630
631    /// Drain accumulated EML lifecycle events for ExoChain forwarding.
632    pub fn drain_events(&mut self) -> Vec<EmlEvent> {
633        self.inner.drain_events()
634    }
635
636    /// Whether the model has been trained.
637    pub fn is_trained(&self) -> bool {
638        self.inner.is_trained()
639    }
640
641    /// Number of training samples collected.
642    pub fn training_sample_count(&self) -> usize {
643        self.inner.training_sample_count()
644    }
645
646    /// Predict the complexity threshold (line count) for a given context.
647    ///
648    /// Result is clamped to [100, 5000] lines.
649    ///
650    /// When untrained, returns 500 lines.
651    pub fn predict(&self, language_type: u32, avg_file_size: f64, team_size_proxy: f64) -> usize {
652        if !self.inner.is_trained() {
653            return 500;
654        }
655
656        let inputs = [
657            language_type as f64,
658            avg_file_size / 1000.0, // normalize
659            team_size_proxy,
660        ];
661        let raw = self.inner.predict_primary(&inputs);
662        (raw.clamp(100.0, 5000.0)).round() as usize
663    }
664
665    /// Record a complexity threshold observation for training.
666    ///
667    /// `optimal_threshold`: the line count threshold that best separates
668    /// "too complex" from "acceptable" for this language and project context.
669    pub fn record(
670        &mut self,
671        language_type: u32,
672        avg_file_size: f64,
673        team_size_proxy: f64,
674        optimal_threshold: usize,
675    ) {
676        let inputs = [
677            language_type as f64,
678            avg_file_size / 1000.0,
679            team_size_proxy,
680        ];
681        self.inner
682            .record(&inputs, &[Some(optimal_threshold as f64)]);
683    }
684
685    /// Train the model from recorded samples.
686    pub fn train(&mut self) -> bool {
687        self.inner.train()
688    }
689}
690
691// ---------------------------------------------------------------------------
692// Tests
693// ---------------------------------------------------------------------------
694
695#[cfg(test)]
696mod tests {
697    use super::*;
698
699    // ── GovernanceScorerModel ───────────────────────────────────────
700
701    #[test]
702    fn governance_scorer_fallback_matches_l2_norm() {
703        let model = GovernanceScorerModel::new();
704        assert!(!model.is_trained());
705
706        let score = model.predict(0.5, 0.3, 0.2, 0.1, 0.4);
707        let expected = (0.5_f64.powi(2)
708            + 0.3_f64.powi(2)
709            + 0.2_f64.powi(2)
710            + 0.1_f64.powi(2)
711            + 0.4_f64.powi(2))
712        .sqrt();
713        assert!((score - expected).abs() < 1e-10);
714    }
715
716    #[test]
717    fn governance_scorer_zero_vector() {
718        let model = GovernanceScorerModel::new();
719        assert_eq!(model.predict(0.0, 0.0, 0.0, 0.0, 0.0), 0.0);
720    }
721
722    #[test]
723    fn governance_scorer_record_increments_count() {
724        let mut model = GovernanceScorerModel::new();
725        assert_eq!(model.training_sample_count(), 0);
726        model.record(0.5, 0.3, 0.2, 0.1, 0.4, 0.75);
727        assert_eq!(model.training_sample_count(), 1);
728    }
729
730    #[test]
731    fn governance_scorer_serde_roundtrip() {
732        let model = GovernanceScorerModel::new();
733        let json = serde_json::to_string(&model).unwrap();
734        let restored: GovernanceScorerModel = serde_json::from_str(&json).unwrap();
735        assert!(!restored.is_trained());
736        assert_eq!(restored.predict(1.0, 0.0, 0.0, 0.0, 0.0), 1.0);
737    }
738
739    // ── RestartStrategyModel ────────────────────────────────────────
740
741    #[test]
742    fn restart_strategy_fallback_exponential_backoff() {
743        let model = RestartStrategyModel::new();
744        assert!(!model.is_trained());
745
746        // failure_count=1: 100ms * 2^0 = 100ms
747        let (delay, retry) = model.predict(1, 0, 60.0, 0.5);
748        assert_eq!(delay, 100);
749        assert!(retry);
750
751        // failure_count=3: 100ms * 2^2 = 400ms
752        let (delay, _) = model.predict(3, 0, 60.0, 0.5);
753        assert_eq!(delay, 400);
754
755        // failure_count=10: 100ms * 2^9 = 51200ms, capped at 30000
756        let (delay, _) = model.predict(10, 0, 60.0, 0.5);
757        assert_eq!(delay, 30_000);
758    }
759
760    #[test]
761    fn restart_strategy_record_increments_count() {
762        let mut model = RestartStrategyModel::new();
763        model.record(1, 0, 60.0, 0.5, 100, true);
764        assert_eq!(model.training_sample_count(), 1);
765    }
766
767    #[test]
768    fn restart_strategy_serde_roundtrip() {
769        let model = RestartStrategyModel::new();
770        let json = serde_json::to_string(&model).unwrap();
771        let restored: RestartStrategyModel = serde_json::from_str(&json).unwrap();
772        assert!(!restored.is_trained());
773    }
774
775    // ── HealthThresholdModel ────────────────────────────────────────
776
777    #[test]
778    fn health_threshold_fallback_defaults() {
779        let model = HealthThresholdModel::new();
780        assert!(!model.is_trained());
781
782        let (degraded, failed) = model.predict(0, 100, 50.0);
783        assert_eq!(degraded, 1);
784        assert_eq!(failed, 3);
785    }
786
787    #[test]
788    fn health_threshold_record_increments_count() {
789        let mut model = HealthThresholdModel::new();
790        model.record(0, 100, 50.0, 2, 5);
791        assert_eq!(model.training_sample_count(), 1);
792    }
793
794    #[test]
795    fn health_threshold_serde_roundtrip() {
796        let model = HealthThresholdModel::new();
797        let json = serde_json::to_string(&model).unwrap();
798        let restored: HealthThresholdModel = serde_json::from_str(&json).unwrap();
799        assert!(!restored.is_trained());
800    }
801
802    // ── DeadLetterModel ─────────────────────────────────────────────
803
804    #[test]
805    fn dead_letter_fallback_exponential() {
806        let model = DeadLetterModel::new();
807        assert!(!model.is_trained());
808
809        // retry_count=0: 1000 * 2^0 = 1000ms, no discard
810        let (delay, discard) = model.predict(0, 5000, 100);
811        assert_eq!(delay, 1000);
812        assert!(!discard);
813
814        // retry_count=3: 1000 * 2^3 = 8000ms, no discard
815        let (delay, discard) = model.predict(3, 10000, 100);
816        assert_eq!(delay, 8000);
817        assert!(!discard);
818
819        // retry_count=5: discard
820        let (_, discard) = model.predict(5, 30000, 100);
821        assert!(discard);
822
823        // retry_count=7: 1000 * 2^7 = 128000, capped at 60000
824        let (delay, _) = model.predict(7, 60000, 100);
825        assert_eq!(delay, 60_000);
826    }
827
828    #[test]
829    fn dead_letter_record_increments_count() {
830        let mut model = DeadLetterModel::new();
831        model.record(1, 5000, 50, 2000, true);
832        assert_eq!(model.training_sample_count(), 1);
833    }
834
835    #[test]
836    fn dead_letter_serde_roundtrip() {
837        let model = DeadLetterModel::new();
838        let json = serde_json::to_string(&model).unwrap();
839        let restored: DeadLetterModel = serde_json::from_str(&json).unwrap();
840        assert!(!restored.is_trained());
841    }
842
843    // ── GossipTimingModel ───────────────────────────────────────────
844
845    #[test]
846    fn gossip_timing_fallback_5s() {
847        let model = GossipTimingModel::new();
848        assert!(!model.is_trained());
849
850        let interval = model.predict(10, 50.0, 1.0);
851        assert!((interval - 5.0).abs() < 1e-10);
852    }
853
854    #[test]
855    fn gossip_timing_record_increments_count() {
856        let mut model = GossipTimingModel::new();
857        model.record(10, 50.0, 1.0, 3.0);
858        assert_eq!(model.training_sample_count(), 1);
859    }
860
861    #[test]
862    fn gossip_timing_serde_roundtrip() {
863        let model = GossipTimingModel::new();
864        let json = serde_json::to_string(&model).unwrap();
865        let restored: GossipTimingModel = serde_json::from_str(&json).unwrap();
866        assert!(!restored.is_trained());
867    }
868
869    // ── TickIntervalModel (Finding #7) ──────────────────────────────
870
871    #[test]
872    fn tick_interval_fallback_returns_1000ms() {
873        let model = TickIntervalModel::new();
874        assert!(!model.is_trained());
875        assert_eq!(model.predict(5.0, 10, 0.2), 1000);
876    }
877
878    #[test]
879    fn tick_interval_recommend_or_uses_fallback_when_untrained() {
880        let model = TickIntervalModel::new();
881        // 1234 is the caller's step-function choice.
882        assert_eq!(model.recommend_or(5.0, 10, 0.2, 1234), 1234);
883    }
884
885    #[test]
886    fn tick_interval_record_increments_count() {
887        let mut model = TickIntervalModel::new();
888        model.record(5.0, 10, 0.2, 1000);
889        assert_eq!(model.training_sample_count(), 1);
890    }
891
892    #[test]
893    fn tick_interval_serde_roundtrip() {
894        let model = TickIntervalModel::new();
895        let json = serde_json::to_string(&model).unwrap();
896        let restored: TickIntervalModel = serde_json::from_str(&json).unwrap();
897        assert!(!restored.is_trained());
898        assert_eq!(restored.predict(0.0, 0, 0.0), 1000);
899    }
900
901    // ── ComplexityModel ─────────────────────────────────────────────
902
903    #[test]
904    fn complexity_fallback_500() {
905        let model = ComplexityModel::new();
906        assert!(!model.is_trained());
907
908        let threshold = model.predict(0, 200.0, 5.0);
909        assert_eq!(threshold, 500);
910    }
911
912    #[test]
913    fn complexity_record_increments_count() {
914        let mut model = ComplexityModel::new();
915        model.record(0, 200.0, 5.0, 600);
916        assert_eq!(model.training_sample_count(), 1);
917    }
918
919    #[test]
920    fn complexity_serde_roundtrip() {
921        let model = ComplexityModel::new();
922        let json = serde_json::to_string(&model).unwrap();
923        let restored: ComplexityModel = serde_json::from_str(&json).unwrap();
924        assert!(!restored.is_trained());
925        assert_eq!(restored.predict(0, 200.0, 5.0), 500);
926    }
927
928    // ── Cross-model tests ───────────────────────────────────────────
929
930    #[test]
931    fn all_models_default_untrained() {
932        assert!(!GovernanceScorerModel::default().is_trained());
933        assert!(!RestartStrategyModel::default().is_trained());
934        assert!(!HealthThresholdModel::default().is_trained());
935        assert!(!DeadLetterModel::default().is_trained());
936        assert!(!GossipTimingModel::default().is_trained());
937        assert!(!ComplexityModel::default().is_trained());
938    }
939
940    #[test]
941    fn all_models_zero_initial_samples() {
942        assert_eq!(GovernanceScorerModel::new().training_sample_count(), 0);
943        assert_eq!(RestartStrategyModel::new().training_sample_count(), 0);
944        assert_eq!(HealthThresholdModel::new().training_sample_count(), 0);
945        assert_eq!(DeadLetterModel::new().training_sample_count(), 0);
946        assert_eq!(GossipTimingModel::new().training_sample_count(), 0);
947        assert_eq!(ComplexityModel::new().training_sample_count(), 0);
948    }
949}