1use crate::error::M1ndResult;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::Path;
10
11pub const TRUST_COLD_START_DEFAULT: f32 = 0.5;
15pub const RECENCY_HALF_LIFE_HOURS: f32 = 720.0;
17pub const RECENCY_FLOOR: f32 = 0.3;
19pub const RISK_MULTIPLIER_CAP: f32 = 3.0;
21pub const PRIOR_CAP: f32 = 0.95;
23
24#[derive(Clone, Debug, Serialize, Deserialize)]
28pub struct TrustEntry {
29 pub defect_count: u32,
31 pub false_alarm_count: u32,
33 pub partial_count: u32,
35 pub last_defect_timestamp: f64,
37 pub first_defect_timestamp: f64,
39 pub total_learn_events: u32,
41}
42
43#[derive(Clone, Debug, Serialize)]
45pub struct TrustScore {
46 pub trust_score: f32,
48 pub defect_density: f32,
50 pub risk_multiplier: f32,
52 pub recency_factor: f32,
54 pub tier: TrustTier,
56}
57
58#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
60pub enum TrustTier {
61 HighRisk,
63 MediumRisk,
65 LowRisk,
67 Unknown,
69}
70
71#[derive(Clone, Debug, Serialize)]
73pub struct TrustNodeOutput {
74 pub node_id: String,
76 pub label: String,
78 pub trust_score: f32,
80 pub defect_density: f32,
82 pub risk_multiplier: f32,
84 pub recency_factor: f32,
86 pub defect_count: u32,
88 pub false_alarm_count: u32,
90 pub partial_count: u32,
92 pub total_learn_events: u32,
94 pub last_defect_age_hours: f64,
96 pub tier: TrustTier,
98}
99
100#[derive(Clone, Debug, Serialize)]
102pub struct TrustSummary {
103 pub total_nodes_with_history: u32,
105 pub high_risk_count: u32,
107 pub medium_risk_count: u32,
109 pub low_risk_count: u32,
111 pub unknown_count: u32,
113 pub mean_trust: f32,
115}
116
117#[derive(Clone, Debug, Serialize)]
119pub struct TrustResult {
120 pub trust_scores: Vec<TrustNodeOutput>,
122 pub summary: TrustSummary,
124 pub scope: String,
126 pub elapsed_ms: f64,
128}
129
130#[derive(Clone, Copy, Debug, PartialEq, Eq)]
132pub enum TrustSortBy {
133 TrustAsc,
135 TrustDesc,
137 DefectsDesc,
139 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#[derive(Clone, Debug, Default)]
163pub struct TrustLedger {
164 entries: HashMap<String, TrustEntry>,
165}
166
167impl TrustLedger {
168 pub fn new() -> Self {
170 Self {
171 entries: HashMap::new(),
172 }
173 }
174
175 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 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; }
213
214 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 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 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 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 let weighted_density = raw_density * (RECENCY_FLOOR + (1.0 - RECENCY_FLOOR) * recency);
286
287 let trust_score = (1.0 - weighted_density).max(0.05);
289
290 let risk_multiplier = (1.0 + weighted_density * 2.0).min(risk_cap);
292
293 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 #[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 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 if let Some(filter) = node_filter {
354 if !external_id.contains(filter) {
355 continue;
356 }
357 }
358
359 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 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 };
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 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 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 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 score.trust_score
486 } else {
487 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 adjusted.clamp(0.0, PRIOR_CAP)
503 }
504
505 pub fn len(&self) -> usize {
507 self.entries.len()
508 }
509
510 pub fn is_empty(&self) -> bool {
512 self.entries.is_empty()
513 }
514}
515
516#[derive(Serialize, Deserialize)]
519struct TrustPersistenceFormat {
520 version: u32,
521 entries: HashMap<String, TrustEntry>,
522}
523
524pub 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 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; #[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 #[test]
578 fn trust_decreases_with_defects() {
579 let mut ledger = make_ledger();
580 let cold = ledger.compute_trust("file::new.py", NOW);
582 assert_eq!(cold.trust_score, TRUST_COLD_START_DEFAULT);
583
584 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 #[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 let old_ts = NOW - 180.0 * 24.0 * 3600.0;
605 old_ledger.record_defect("file::module.py", old_ts);
606
607 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 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 #[test]
624 fn risk_multiplier_capped() {
625 let mut ledger = make_ledger();
626 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 #[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 #[test]
672 fn sort_trust_asc_is_ordered() {
673 let mut ledger = make_ledger();
674 ledger.record_false_alarm("file::clean.py", NOW);
676 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 #[test]
700 fn adjust_prior_positive_and_negative_claims() {
701 let mut ledger = make_ledger();
702 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 assert!(
715 adj_positive <= base,
716 "Positive claim prior {} should be ≤ base {}",
717 adj_positive,
718 base
719 );
720 assert!(
722 adj_negative >= adj_positive,
723 "Negative claim {} should be ≥ positive {}",
724 adj_negative,
725 adj_positive
726 );
727 assert!(adj_positive <= PRIOR_CAP);
729 assert!(adj_negative <= PRIOR_CAP);
730 }
731
732 #[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 #[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
767pub 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 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}