Skip to main content

m1nd_core/
trust.rs

1// === m1nd-core/src/trust.rs ===
2//
3// Per-module trust scores from defect history.
4// Actuarial risk assessment: more confirmed bugs = lower trust = higher risk.
5
6use crate::error::M1ndResult;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::Path;
10
11// ── Constants ──
12
13/// Default trust score for nodes with no defect history (cold start).
14pub const TRUST_COLD_START_DEFAULT: f32 = 0.5;
15/// Default half-life for recency weighting in hours (720h = 30 days).
16pub const RECENCY_HALF_LIFE_HOURS: f32 = 720.0;
17/// Minimum contribution of old defects to weighted density (prevents decay to zero).
18pub const RECENCY_FLOOR: f32 = 0.3;
19/// Maximum risk multiplier returned (caps extreme values).
20pub const RISK_MULTIPLIER_CAP: f32 = 3.0;
21/// Maximum adjusted Bayesian prior (prevents certainty).
22pub const PRIOR_CAP: f32 = 0.95;
23
24// ── Core Types ──
25
26/// Raw defect event counters for a single node, stored in the ledger.
27#[derive(Clone, Debug, Serialize, Deserialize)]
28pub struct TrustEntry {
29    /// Number of confirmed defects (from `learn("correct")`).
30    pub defect_count: u32,
31    /// Number of false alarms (from `learn("wrong")`).
32    pub false_alarm_count: u32,
33    /// Number of partial matches (from `learn("partial")`).
34    pub partial_count: u32,
35    /// Unix timestamp (seconds) of the most recent confirmed defect.
36    pub last_defect_timestamp: f64,
37    /// Unix timestamp (seconds) of the first confirmed defect.
38    pub first_defect_timestamp: f64,
39    /// Total learn events (defect + false_alarm + partial).
40    pub total_learn_events: u32,
41}
42
43/// Computed trust score for a node at a given point in time.
44#[derive(Clone, Debug, Serialize)]
45pub struct TrustScore {
46    /// Trust score in [0.05, 1.0] — lower means riskier.
47    pub trust_score: f32,
48    /// Raw defect density: defects / total_learn_events.
49    pub defect_density: f32,
50    /// Risk multiplier in [1.0, `RISK_MULTIPLIER_CAP`].
51    pub risk_multiplier: f32,
52    /// Recency factor in [0.0, 1.0] — exponential decay since last defect.
53    pub recency_factor: f32,
54    /// Risk tier classification.
55    pub tier: TrustTier,
56}
57
58/// Risk tier for a computed trust score.
59#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
60pub enum TrustTier {
61    /// trust_score < 0.4 — high defect density, recently active.
62    HighRisk,
63    /// trust_score in [0.4, 0.7) — moderate history.
64    MediumRisk,
65    /// trust_score >= 0.7 — few or old defects.
66    LowRisk,
67    /// No defect history (cold start).
68    Unknown,
69}
70
71/// Trust score output for a single node in a trust report.
72#[derive(Clone, Debug, Serialize)]
73pub struct TrustNodeOutput {
74    /// External ID of the node.
75    pub node_id: String,
76    /// Human-readable label (last `::` segment of external_id).
77    pub label: String,
78    /// Trust score in [0.05, 1.0].
79    pub trust_score: f32,
80    /// Raw defect density.
81    pub defect_density: f32,
82    /// Risk multiplier.
83    pub risk_multiplier: f32,
84    /// Recency factor.
85    pub recency_factor: f32,
86    /// Number of confirmed defects.
87    pub defect_count: u32,
88    /// Number of false alarms.
89    pub false_alarm_count: u32,
90    /// Number of partial matches.
91    pub partial_count: u32,
92    /// Total learn events.
93    pub total_learn_events: u32,
94    /// Hours since last defect (-1.0 if no defects recorded).
95    pub last_defect_age_hours: f64,
96    /// Risk tier.
97    pub tier: TrustTier,
98}
99
100/// Aggregate trust statistics across a report scope.
101#[derive(Clone, Debug, Serialize)]
102pub struct TrustSummary {
103    /// Number of nodes with at least `min_history` learn events.
104    pub total_nodes_with_history: u32,
105    /// Count of HighRisk nodes.
106    pub high_risk_count: u32,
107    /// Count of MediumRisk nodes.
108    pub medium_risk_count: u32,
109    /// Count of LowRisk nodes.
110    pub low_risk_count: u32,
111    /// Count of Unknown nodes.
112    pub unknown_count: u32,
113    /// Mean trust score across all nodes in the report.
114    pub mean_trust: f32,
115}
116
117/// Complete trust report for a scope.
118#[derive(Clone, Debug, Serialize)]
119pub struct TrustResult {
120    /// Trust scores sorted per `sort_by`.
121    pub trust_scores: Vec<TrustNodeOutput>,
122    /// Aggregate statistics.
123    pub summary: TrustSummary,
124    /// Scope string used ("all", "file", "module", "function").
125    pub scope: String,
126    /// Wall-clock time in milliseconds.
127    pub elapsed_ms: f64,
128}
129
130/// Sort order for trust report results.
131#[derive(Clone, Copy, Debug, PartialEq, Eq)]
132pub enum TrustSortBy {
133    /// Sort by trust score ascending (riskiest first).
134    TrustAsc,
135    /// Sort by trust score descending (most trusted first).
136    TrustDesc,
137    /// Sort by defect count descending.
138    DefectsDesc,
139    /// Sort by time since last defect ascending (most recent first).
140    Recency,
141}
142
143impl std::str::FromStr for TrustSortBy {
144    type Err = std::convert::Infallible;
145
146    fn from_str(s: &str) -> Result<Self, Self::Err> {
147        Ok(match s {
148            "trust_desc" => Self::TrustDesc,
149            "defects_desc" => Self::DefectsDesc,
150            "recency" => Self::Recency,
151            _ => Self::TrustAsc,
152        })
153    }
154}
155
156// ── Ledger ──
157
158/// Actuarial defect ledger that maps node external IDs to their defect histories.
159///
160/// Accumulates `record_defect`, `record_false_alarm`, and `record_partial` events
161/// as learn feedback arrives, then computes time-weighted trust scores on demand.
162#[derive(Clone, Debug, Default)]
163pub struct TrustLedger {
164    entries: HashMap<String, TrustEntry>,
165}
166
167impl TrustLedger {
168    /// Create an empty ledger.
169    pub fn new() -> Self {
170        Self {
171            entries: HashMap::new(),
172        }
173    }
174
175    /// Record a defect (from learn("correct")).
176    pub fn record_defect(&mut self, external_id: &str, timestamp: f64) {
177        let entry = self
178            .entries
179            .entry(external_id.to_string())
180            .or_insert_with(|| TrustEntry {
181                defect_count: 0,
182                false_alarm_count: 0,
183                partial_count: 0,
184                last_defect_timestamp: 0.0,
185                first_defect_timestamp: timestamp,
186                total_learn_events: 0,
187            });
188        entry.defect_count += 1;
189        entry.total_learn_events += 1;
190        entry.last_defect_timestamp = timestamp;
191        if entry.defect_count == 1 {
192            entry.first_defect_timestamp = timestamp;
193        }
194    }
195
196    /// Record a false alarm (from learn("wrong")).
197    pub fn record_false_alarm(&mut self, external_id: &str, timestamp: f64) {
198        let entry = self
199            .entries
200            .entry(external_id.to_string())
201            .or_insert_with(|| TrustEntry {
202                defect_count: 0,
203                false_alarm_count: 0,
204                partial_count: 0,
205                last_defect_timestamp: 0.0,
206                first_defect_timestamp: 0.0,
207                total_learn_events: 0,
208            });
209        entry.false_alarm_count += 1;
210        entry.total_learn_events += 1;
211        let _ = timestamp; // false alarms don't update defect timestamps
212    }
213
214    /// Record a partial match (from learn("partial")).
215    pub fn record_partial(&mut self, external_id: &str, timestamp: f64) {
216        let entry = self
217            .entries
218            .entry(external_id.to_string())
219            .or_insert_with(|| TrustEntry {
220                defect_count: 0,
221                false_alarm_count: 0,
222                partial_count: 0,
223                last_defect_timestamp: 0.0,
224                first_defect_timestamp: 0.0,
225                total_learn_events: 0,
226            });
227        entry.partial_count += 1;
228        entry.total_learn_events += 1;
229        let _ = timestamp;
230    }
231
232    /// Compute trust score for a single node at the given time (default params).
233    pub fn compute_trust(&self, external_id: &str, now: f64) -> TrustScore {
234        self.compute_trust_with_params(
235            external_id,
236            now,
237            RECENCY_HALF_LIFE_HOURS,
238            RISK_MULTIPLIER_CAP,
239        )
240    }
241
242    /// Compute trust score with configurable half-life and risk cap.
243    pub fn compute_trust_with_params(
244        &self,
245        external_id: &str,
246        now: f64,
247        half_life_hours: f32,
248        risk_cap: f32,
249    ) -> TrustScore {
250        let entry = match self.entries.get(external_id) {
251            Some(e) => e,
252            None => {
253                return TrustScore {
254                    trust_score: TRUST_COLD_START_DEFAULT,
255                    defect_density: 0.0,
256                    risk_multiplier: 1.0,
257                    recency_factor: 0.0,
258                    tier: TrustTier::Unknown,
259                };
260            }
261        };
262
263        if entry.total_learn_events == 0 {
264            return TrustScore {
265                trust_score: TRUST_COLD_START_DEFAULT,
266                defect_density: 0.0,
267                risk_multiplier: 1.0,
268                recency_factor: 0.0,
269                tier: TrustTier::Unknown,
270            };
271        }
272
273        let raw_density = entry.defect_count as f32 / entry.total_learn_events as f32;
274
275        // Recency weighting: half-life configurable (default 720 hours = 30 days)
276        // recency = exp(-ln2 * hours_since_last_defect / half_life_hours)
277        let recency = if entry.defect_count > 0 && entry.last_defect_timestamp > 0.0 {
278            let hours_since = ((now - entry.last_defect_timestamp) / 3600.0).max(0.0) as f32;
279            (-std::f32::consts::LN_2 * hours_since / half_life_hours.max(1.0)).exp()
280        } else {
281            0.0
282        };
283
284        // Time-weighted density: even old bugs contribute 30% (RECENCY_FLOOR)
285        let weighted_density = raw_density * (RECENCY_FLOOR + (1.0 - RECENCY_FLOOR) * recency);
286
287        // Trust score: 1.0 - weighted_density, clamped to [0.05, 1.0]
288        let trust_score = (1.0 - weighted_density).max(0.05);
289
290        // Risk multiplier: 1.0 + (weighted_density * 2.0), capped at risk_cap
291        let risk_multiplier = (1.0 + weighted_density * 2.0).min(risk_cap);
292
293        // Tier classification
294        let tier = if trust_score < 0.4 {
295            TrustTier::HighRisk
296        } else if trust_score < 0.7 {
297            TrustTier::MediumRisk
298        } else {
299            TrustTier::LowRisk
300        };
301
302        TrustScore {
303            trust_score,
304            defect_density: raw_density,
305            risk_multiplier,
306            recency_factor: recency,
307            tier,
308        }
309    }
310
311    /// Generate full trust report.
312    #[allow(clippy::too_many_arguments)]
313    pub fn report(
314        &self,
315        scope: &str,
316        min_history: u32,
317        top_k: usize,
318        node_filter: Option<&str>,
319        sort_by: TrustSortBy,
320        now: f64,
321        half_life_hours: f32,
322        risk_cap: f32,
323    ) -> TrustResult {
324        let start = std::time::Instant::now();
325
326        let mut outputs: Vec<TrustNodeOutput> = Vec::new();
327        let mut high_risk_count = 0u32;
328        let mut medium_risk_count = 0u32;
329        let mut low_risk_count = 0u32;
330        let mut unknown_count = 0u32;
331        let mut trust_sum = 0.0f32;
332        let mut total_nodes_with_history = 0u32;
333
334        for (external_id, entry) in &self.entries {
335            // Scope filter: match node type prefix
336            if scope != "all" {
337                let matches_scope = match scope {
338                    "file" => external_id.starts_with("file::"),
339                    "module" => {
340                        external_id.starts_with("module::") || external_id.starts_with("dir::")
341                    }
342                    "function" => {
343                        external_id.starts_with("func::") || external_id.starts_with("function::")
344                    }
345                    _ => true,
346                };
347                if !matches_scope {
348                    continue;
349                }
350            }
351
352            // Node filter
353            if let Some(filter) = node_filter {
354                if !external_id.contains(filter) {
355                    continue;
356                }
357            }
358
359            // Min history filter
360            if entry.total_learn_events < min_history {
361                continue;
362            }
363
364            total_nodes_with_history += 1;
365
366            let score = self.compute_trust_with_params(external_id, now, half_life_hours, risk_cap);
367
368            match score.tier {
369                TrustTier::HighRisk => high_risk_count += 1,
370                TrustTier::MediumRisk => medium_risk_count += 1,
371                TrustTier::LowRisk => low_risk_count += 1,
372                TrustTier::Unknown => unknown_count += 1,
373            }
374            trust_sum += score.trust_score;
375
376            // Label: extract filename from external_id
377            let label = external_id
378                .rsplit("::")
379                .next()
380                .unwrap_or(external_id)
381                .to_string();
382
383            let last_defect_age_hours =
384                if entry.defect_count > 0 && entry.last_defect_timestamp > 0.0 {
385                    ((now - entry.last_defect_timestamp) / 3600.0).max(0.0)
386                } else {
387                    -1.0 // no defects
388                };
389
390            outputs.push(TrustNodeOutput {
391                node_id: external_id.clone(),
392                label,
393                trust_score: score.trust_score,
394                defect_density: score.defect_density,
395                risk_multiplier: score.risk_multiplier,
396                recency_factor: score.recency_factor,
397                defect_count: entry.defect_count,
398                false_alarm_count: entry.false_alarm_count,
399                partial_count: entry.partial_count,
400                total_learn_events: entry.total_learn_events,
401                last_defect_age_hours,
402                tier: score.tier,
403            });
404        }
405
406        // Sort
407        match sort_by {
408            TrustSortBy::TrustAsc => {
409                outputs.sort_by(|a, b| {
410                    a.trust_score
411                        .partial_cmp(&b.trust_score)
412                        .unwrap_or(std::cmp::Ordering::Equal)
413                });
414            }
415            TrustSortBy::TrustDesc => {
416                outputs.sort_by(|a, b| {
417                    b.trust_score
418                        .partial_cmp(&a.trust_score)
419                        .unwrap_or(std::cmp::Ordering::Equal)
420                });
421            }
422            TrustSortBy::DefectsDesc => {
423                outputs.sort_by(|a, b| b.defect_count.cmp(&a.defect_count));
424            }
425            TrustSortBy::Recency => {
426                outputs.sort_by(|a, b| {
427                    a.last_defect_age_hours
428                        .partial_cmp(&b.last_defect_age_hours)
429                        .unwrap_or(std::cmp::Ordering::Equal)
430                });
431            }
432        }
433
434        outputs.truncate(top_k);
435
436        let mean_trust = if total_nodes_with_history > 0 {
437            trust_sum / total_nodes_with_history as f32
438        } else {
439            TRUST_COLD_START_DEFAULT
440        };
441
442        let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
443
444        TrustResult {
445            trust_scores: outputs,
446            summary: TrustSummary {
447                total_nodes_with_history,
448                high_risk_count,
449                medium_risk_count,
450                low_risk_count,
451                unknown_count,
452                mean_trust,
453            },
454            scope: scope.to_string(),
455            elapsed_ms,
456        }
457    }
458
459    /// Adjust a Bayesian prior based on trust data for mentioned nodes.
460    /// Returns adjusted prior clamped to [0.0, PRIOR_CAP].
461    ///
462    /// For positive claims ("no bug" claims like NEVER_CALLS, NO_DEPENDENCY, ISOLATED):
463    ///   adjusted_prior = base_prior * trust_score (trustworthy module -> more likely true)
464    /// For negative claims ("has bug" claims):
465    ///   adjusted_prior = base_prior * risk_multiplier (buggy module -> more likely to have this bug)
466    pub fn adjust_prior(
467        &self,
468        base_prior: f32,
469        external_ids: &[String],
470        is_positive_claim: bool,
471        now: f64,
472    ) -> f32 {
473        if external_ids.is_empty() {
474            return base_prior;
475        }
476
477        // Compute average trust factor across mentioned nodes
478        let mut factor_sum = 0.0f32;
479        let mut count = 0u32;
480
481        for ext_id in external_ids {
482            let score = self.compute_trust(ext_id, now);
483            let factor = if is_positive_claim {
484                // "No bug" claim: trust increases confidence
485                score.trust_score
486            } else {
487                // "Has bug" claim: risk multiplier increases confidence
488                score.risk_multiplier
489            };
490            factor_sum += factor;
491            count += 1;
492        }
493
494        if count == 0 {
495            return base_prior;
496        }
497
498        let avg_factor = factor_sum / count as f32;
499        let adjusted = base_prior * avg_factor;
500
501        // Clamp to [0.0, PRIOR_CAP]
502        adjusted.clamp(0.0, PRIOR_CAP)
503    }
504
505    /// Number of entries in the ledger.
506    pub fn len(&self) -> usize {
507        self.entries.len()
508    }
509
510    /// Returns `true` if the ledger contains no entries.
511    pub fn is_empty(&self) -> bool {
512        self.entries.is_empty()
513    }
514}
515
516// ── Persistence ──
517
518#[derive(Serialize, Deserialize)]
519struct TrustPersistenceFormat {
520    version: u32,
521    entries: HashMap<String, TrustEntry>,
522}
523
524/// Persist a `TrustLedger` to disk using an atomic write (temp file + rename).
525///
526/// # Parameters
527/// - `ledger`: ledger to serialise.
528/// - `path`: destination file path (JSON).
529///
530/// # Errors
531/// Returns `M1ndError::Serde` if JSON serialisation fails, or `M1ndError::Io` on
532/// filesystem errors.
533pub fn save_trust_state(ledger: &TrustLedger, path: &Path) -> M1ndResult<()> {
534    let format = TrustPersistenceFormat {
535        version: 1,
536        entries: ledger.entries.clone(),
537    };
538
539    let json = serde_json::to_string_pretty(&format).map_err(crate::error::M1ndError::Serde)?;
540
541    // Atomic write: temp file + rename
542    let temp_path = path.with_extension("tmp");
543    {
544        use std::io::Write;
545        let file = std::fs::File::create(&temp_path)?;
546        let mut writer = std::io::BufWriter::new(file);
547        writer.write_all(json.as_bytes())?;
548        writer.flush()?;
549    }
550    std::fs::rename(&temp_path, path)?;
551
552    Ok(())
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558    use std::path::PathBuf;
559
560    fn make_ledger() -> TrustLedger {
561        TrustLedger::new()
562    }
563
564    const NOW: f64 = 10_000.0 * 3600.0; // 10 000 hours epoch
565
566    // 1. record_defect: count and total_learn_events increment
567    #[test]
568    fn record_defect_increments_counts() {
569        let mut ledger = make_ledger();
570        ledger.record_defect("file::foo.py", NOW);
571        let entry = ledger.entries.get("file::foo.py").unwrap();
572        assert_eq!(entry.defect_count, 1);
573        assert_eq!(entry.total_learn_events, 1);
574    }
575
576    // 2. trust_decreases: more defects → lower trust score
577    #[test]
578    fn trust_decreases_with_defects() {
579        let mut ledger = make_ledger();
580        // Cold start
581        let cold = ledger.compute_trust("file::new.py", NOW);
582        assert_eq!(cold.trust_score, TRUST_COLD_START_DEFAULT);
583
584        // Record several defects (recent)
585        for i in 0..5 {
586            ledger.record_defect("file::buggy.py", NOW - i as f64);
587        }
588        let buggy = ledger.compute_trust("file::buggy.py", NOW);
589        assert!(
590            buggy.trust_score < TRUST_COLD_START_DEFAULT,
591            "trust_score {} should be below cold start {}",
592            buggy.trust_score,
593            TRUST_COLD_START_DEFAULT
594        );
595    }
596
597    // 3. recency_decay: defects from long ago contribute less than recent ones
598    #[test]
599    fn recency_decay_reduces_old_defects_weight() {
600        let mut old_ledger = make_ledger();
601        let mut new_ledger = make_ledger();
602
603        // Old defect: 180 days ago
604        let old_ts = NOW - 180.0 * 24.0 * 3600.0;
605        old_ledger.record_defect("file::module.py", old_ts);
606
607        // Recent defect: just now
608        new_ledger.record_defect("file::module.py", NOW);
609
610        let old_score = old_ledger.compute_trust("file::module.py", NOW);
611        let new_score = new_ledger.compute_trust("file::module.py", NOW);
612
613        // Old defect → recency near floor → higher trust than very recent defect
614        assert!(
615            old_score.trust_score > new_score.trust_score,
616            "Old defect should decay: old={} new={}",
617            old_score.trust_score,
618            new_score.trust_score
619        );
620    }
621
622    // 4. risk_cap: risk_multiplier is bounded by RISK_MULTIPLIER_CAP
623    #[test]
624    fn risk_multiplier_capped() {
625        let mut ledger = make_ledger();
626        // Flood with defects (all recent) to push risk up
627        for i in 0..50 {
628            ledger.record_defect("file::broken.py", NOW - i as f64 * 0.1);
629        }
630        let score = ledger.compute_trust("file::broken.py", NOW);
631        assert!(
632            score.risk_multiplier <= RISK_MULTIPLIER_CAP,
633            "risk_multiplier {} exceeds cap {}",
634            score.risk_multiplier,
635            RISK_MULTIPLIER_CAP
636        );
637    }
638
639    // 5. report_scope: scope="file" only returns file:: nodes
640    #[test]
641    fn report_scope_filters_by_prefix() {
642        let mut ledger = make_ledger();
643        ledger.record_defect("file::routes.py", NOW);
644        ledger.record_defect("module::services", NOW);
645
646        let result = ledger.report(
647            "file",
648            1,
649            100,
650            None,
651            TrustSortBy::TrustAsc,
652            NOW,
653            RECENCY_HALF_LIFE_HOURS,
654            RISK_MULTIPLIER_CAP,
655        );
656
657        for out in &result.trust_scores {
658            assert!(
659                out.node_id.starts_with("file::"),
660                "Expected file:: prefix, got {}",
661                out.node_id
662            );
663        }
664        assert!(
665            !result.trust_scores.is_empty(),
666            "Should have at least one file:: result"
667        );
668    }
669
670    // 6. sort_trust_asc: results are in ascending trust order
671    #[test]
672    fn sort_trust_asc_is_ordered() {
673        let mut ledger = make_ledger();
674        // file::a: no defects but 1 false alarm (so it has an entry)
675        ledger.record_false_alarm("file::clean.py", NOW);
676        // file::b: many recent defects → low trust
677        for i in 0..5 {
678            ledger.record_defect("file::dirty.py", NOW - i as f64);
679        }
680
681        let result = ledger.report(
682            "all",
683            1,
684            100,
685            None,
686            TrustSortBy::TrustAsc,
687            NOW,
688            RECENCY_HALF_LIFE_HOURS,
689            RISK_MULTIPLIER_CAP,
690        );
691
692        let scores: Vec<f32> = result.trust_scores.iter().map(|o| o.trust_score).collect();
693        for w in scores.windows(2) {
694            assert!(w[0] <= w[1], "Not sorted ascending: {} > {}", w[0], w[1]);
695        }
696    }
697
698    // 7. adjust_prior: positive claim scaled by trust; negative claim scaled by risk
699    #[test]
700    fn adjust_prior_positive_and_negative_claims() {
701        let mut ledger = make_ledger();
702        // Give module a recent defect to get a non-trivial score
703        for i in 0..3 {
704            ledger.record_defect("file::risky.py", NOW - i as f64 * 60.0);
705        }
706
707        let base = 0.6f32;
708        let ids = vec!["file::risky.py".to_string()];
709
710        let adj_positive = ledger.adjust_prior(base, &ids, true, NOW);
711        let adj_negative = ledger.adjust_prior(base, &ids, false, NOW);
712
713        // Positive claim: adjusted ≤ base (trust < 1.0 scales down)
714        assert!(
715            adj_positive <= base,
716            "Positive claim prior {} should be ≤ base {}",
717            adj_positive,
718            base
719        );
720        // Negative claim: adjusted may be > or ≈ base (risk_multiplier ≥ 1.0)
721        assert!(
722            adj_negative >= adj_positive,
723            "Negative claim {} should be ≥ positive {}",
724            adj_negative,
725            adj_positive
726        );
727        // Both clamped to [0, PRIOR_CAP]
728        assert!(adj_positive <= PRIOR_CAP);
729        assert!(adj_negative <= PRIOR_CAP);
730    }
731
732    // 8. save_load: round-trip preserves defect counts
733    #[test]
734    fn save_load_round_trip() {
735        let mut ledger = make_ledger();
736        ledger.record_defect("file::persist.py", NOW);
737        ledger.record_defect("file::persist.py", NOW - 3600.0);
738        ledger.record_false_alarm("file::persist.py", NOW - 7200.0);
739
740        let dir = std::env::temp_dir();
741        let path: PathBuf = dir.join(format!("trust_test_{}.json", std::process::id()));
742
743        save_trust_state(&ledger, &path).expect("save failed");
744        let loaded = load_trust_state(&path).expect("load failed");
745
746        let orig_entry = ledger.entries.get("file::persist.py").unwrap();
747        let load_entry = loaded.entries.get("file::persist.py").unwrap();
748
749        assert_eq!(load_entry.defect_count, orig_entry.defect_count);
750        assert_eq!(load_entry.false_alarm_count, orig_entry.false_alarm_count);
751        assert_eq!(load_entry.total_learn_events, orig_entry.total_learn_events);
752
753        let _ = std::fs::remove_file(&path);
754    }
755
756    // Extra: cold start returns Unknown tier and 0.5 score
757    #[test]
758    fn cold_start_returns_unknown_tier() {
759        let ledger = make_ledger();
760        let score = ledger.compute_trust("file::never_seen.py", NOW);
761        assert_eq!(score.trust_score, TRUST_COLD_START_DEFAULT);
762        assert_eq!(score.tier, TrustTier::Unknown);
763        assert_eq!(score.risk_multiplier, 1.0);
764    }
765}
766
767/// Load a `TrustLedger` from disk, returning an empty ledger if the file does not exist.
768///
769/// Corrupt entries (non-finite timestamps) are silently dropped with a diagnostic to stderr.
770///
771/// # Parameters
772/// - `path`: source file path (JSON produced by `save_trust_state`).
773///
774/// # Errors
775/// Returns `M1ndError::Io` on read failures or `M1ndError::Serde` if the JSON is malformed.
776pub fn load_trust_state(path: &Path) -> M1ndResult<TrustLedger> {
777    if !path.exists() {
778        return Ok(TrustLedger::new());
779    }
780
781    let data = std::fs::read_to_string(path)?;
782    let format: TrustPersistenceFormat =
783        serde_json::from_str(&data).map_err(crate::error::M1ndError::Serde)?;
784
785    // Validate entries: reject corrupt (NaN/Inf) entries
786    let mut valid_entries = HashMap::new();
787    for (key, entry) in format.entries {
788        if !entry.last_defect_timestamp.is_finite() || !entry.first_defect_timestamp.is_finite() {
789            eprintln!(
790                "m1nd trust: rejecting corrupt entry for {}: non-finite timestamps",
791                key
792            );
793            continue;
794        }
795        valid_entries.insert(key, entry);
796    }
797
798    Ok(TrustLedger {
799        entries: valid_entries,
800    })
801}