Skip to main content

cortex_core/
salience_v2.rs

1//! Cross-session salience primitives for schema v2.
2
3use chrono::{DateTime, Duration, Utc};
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7/// Typed relation between an outcome and a memory.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
9#[serde(rename_all = "snake_case")]
10pub enum OutcomeMemoryRelation {
11    /// Operator-acknowledged validation.
12    Validated,
13    /// Memory influenced an outcome without validation.
14    Used,
15    /// Outcome conflicts with the memory.
16    Contradicted,
17    /// Memory was replaced by newer content.
18    Superseded,
19    /// Explicit rejection/discard.
20    Rejected,
21}
22
23impl OutcomeMemoryRelation {
24    /// Only validated edges may advance salience validation freshness.
25    #[must_use]
26    pub const fn advances_validation(self) -> bool {
27        matches!(self, Self::Validated)
28    }
29}
30
31/// Cross-session salience fields added by ADR 0017.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
33pub struct CrossSessionSalience {
34    /// Number of distinct sessions in which this memory was reused.
35    pub cross_session_use_count: u32,
36    /// First use across all sessions.
37    pub first_used_at: Option<DateTime<Utc>>,
38    /// Most recent cross-session use.
39    pub last_cross_session_use_at: Option<DateTime<Utc>>,
40    /// Most recent validated outcome edge.
41    pub last_validation_at: Option<DateTime<Utc>>,
42    /// Incremented when an operator blesses or revalidates a memory.
43    pub validation_epoch: u32,
44    /// Expiring operator waiver for cross-session quarantine pressure.
45    pub blessed_until: Option<DateTime<Utc>>,
46}
47
48impl CrossSessionSalience {
49    /// True when cross-session reuse needs fresh validation or an active bless.
50    #[must_use]
51    pub fn penalty_window_applies(&self, now: DateTime<Utc>, window: Duration) -> bool {
52        if self.blessed_until.is_some_and(|until| now <= until) {
53            return false;
54        }
55
56        let Some(last_cross_session_use_at) = self.last_cross_session_use_at else {
57            return false;
58        };
59
60        self.last_validation_at
61            .is_none_or(|validated_at| validated_at < last_cross_session_use_at - window)
62    }
63
64    /// Log-scaled penalty for stale cross-session reuse.
65    #[must_use]
66    pub fn cross_session_unvalidated_penalty(
67        &self,
68        now: DateTime<Utc>,
69        threshold: u32,
70        window: Duration,
71        penalty: f64,
72    ) -> f64 {
73        if self.cross_session_use_count < threshold || !self.penalty_window_applies(now, window) {
74            return 0.0;
75        }
76
77        let excess = self.cross_session_use_count - threshold + 1;
78        penalty * f64::from(excess).ln()
79    }
80
81    /// True when ADR 0017's inclusive quarantine threshold fires.
82    #[must_use]
83    pub fn should_auto_quarantine(
84        &self,
85        now: DateTime<Utc>,
86        quarantine_threshold: u32,
87        window: Duration,
88    ) -> bool {
89        self.cross_session_use_count >= quarantine_threshold
90            && self.penalty_window_applies(now, window)
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use chrono::TimeZone;
98
99    fn salience(
100        cross_session_use_count: u32,
101        last_cross_session_use_at: Option<DateTime<Utc>>,
102        last_validation_at: Option<DateTime<Utc>>,
103        blessed_until: Option<DateTime<Utc>>,
104    ) -> CrossSessionSalience {
105        CrossSessionSalience {
106            cross_session_use_count,
107            first_used_at: None,
108            last_cross_session_use_at,
109            last_validation_at,
110            validation_epoch: 0,
111            blessed_until,
112        }
113    }
114
115    #[test]
116    fn only_validated_outcome_advances_validation() {
117        assert!(OutcomeMemoryRelation::Validated.advances_validation());
118        assert!(!OutcomeMemoryRelation::Used.advances_validation());
119        assert!(!OutcomeMemoryRelation::Contradicted.advances_validation());
120        assert!(!OutcomeMemoryRelation::Superseded.advances_validation());
121        assert!(!OutcomeMemoryRelation::Rejected.advances_validation());
122    }
123
124    #[test]
125    fn stale_validation_triggers_penalty_window() {
126        let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
127        let last_use = Utc.with_ymd_and_hms(2026, 5, 4, 0, 0, 0).unwrap();
128        let old_validation = Utc.with_ymd_and_hms(2026, 4, 1, 0, 0, 0).unwrap();
129        let state = salience(6, Some(last_use), Some(old_validation), None);
130
131        assert!(state.penalty_window_applies(now, Duration::days(14)));
132        assert!(state.cross_session_unvalidated_penalty(now, 5, Duration::days(14), 0.3) > 0.0);
133    }
134
135    #[test]
136    fn fresh_validation_suppresses_penalty_window() {
137        let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
138        let last_use = Utc.with_ymd_and_hms(2026, 5, 4, 0, 0, 0).unwrap();
139        let fresh_validation = Utc.with_ymd_and_hms(2026, 5, 3, 0, 0, 0).unwrap();
140        let state = salience(20, Some(last_use), Some(fresh_validation), None);
141
142        assert!(!state.penalty_window_applies(now, Duration::days(14)));
143        assert!(!state.should_auto_quarantine(now, 20, Duration::days(14)));
144    }
145
146    #[test]
147    fn active_bless_suppresses_auto_quarantine_without_resetting_fields() {
148        let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
149        let last_use = Utc.with_ymd_and_hms(2026, 5, 4, 0, 0, 0).unwrap();
150        let blessed_until = Utc.with_ymd_and_hms(2026, 5, 6, 0, 0, 0).unwrap();
151        let state = salience(20, Some(last_use), None, Some(blessed_until));
152
153        assert!(!state.should_auto_quarantine(now, 20, Duration::days(14)));
154        assert_eq!(state.cross_session_use_count, 20);
155    }
156}