crtx-core 0.1.1

Core IDs, errors, and schema constants for Cortex.
Documentation
//! Cross-session salience primitives for schema v2.

use chrono::{DateTime, Duration, Utc};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

/// Typed relation between an outcome and a memory.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum OutcomeMemoryRelation {
    /// Operator-acknowledged validation.
    Validated,
    /// Memory influenced an outcome without validation.
    Used,
    /// Outcome conflicts with the memory.
    Contradicted,
    /// Memory was replaced by newer content.
    Superseded,
    /// Explicit rejection/discard.
    Rejected,
}

impl OutcomeMemoryRelation {
    /// Only validated edges may advance salience validation freshness.
    #[must_use]
    pub const fn advances_validation(self) -> bool {
        matches!(self, Self::Validated)
    }
}

/// Cross-session salience fields added by ADR 0017.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct CrossSessionSalience {
    /// Number of distinct sessions in which this memory was reused.
    pub cross_session_use_count: u32,
    /// First use across all sessions.
    pub first_used_at: Option<DateTime<Utc>>,
    /// Most recent cross-session use.
    pub last_cross_session_use_at: Option<DateTime<Utc>>,
    /// Most recent validated outcome edge.
    pub last_validation_at: Option<DateTime<Utc>>,
    /// Incremented when an operator blesses or revalidates a memory.
    pub validation_epoch: u32,
    /// Expiring operator waiver for cross-session quarantine pressure.
    pub blessed_until: Option<DateTime<Utc>>,
}

impl CrossSessionSalience {
    /// True when cross-session reuse needs fresh validation or an active bless.
    #[must_use]
    pub fn penalty_window_applies(&self, now: DateTime<Utc>, window: Duration) -> bool {
        if self.blessed_until.is_some_and(|until| now <= until) {
            return false;
        }

        let Some(last_cross_session_use_at) = self.last_cross_session_use_at else {
            return false;
        };

        self.last_validation_at
            .is_none_or(|validated_at| validated_at < last_cross_session_use_at - window)
    }

    /// Log-scaled penalty for stale cross-session reuse.
    #[must_use]
    pub fn cross_session_unvalidated_penalty(
        &self,
        now: DateTime<Utc>,
        threshold: u32,
        window: Duration,
        penalty: f64,
    ) -> f64 {
        if self.cross_session_use_count < threshold || !self.penalty_window_applies(now, window) {
            return 0.0;
        }

        let excess = self.cross_session_use_count - threshold + 1;
        penalty * f64::from(excess).ln()
    }

    /// True when ADR 0017's inclusive quarantine threshold fires.
    #[must_use]
    pub fn should_auto_quarantine(
        &self,
        now: DateTime<Utc>,
        quarantine_threshold: u32,
        window: Duration,
    ) -> bool {
        self.cross_session_use_count >= quarantine_threshold
            && self.penalty_window_applies(now, window)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::TimeZone;

    fn salience(
        cross_session_use_count: u32,
        last_cross_session_use_at: Option<DateTime<Utc>>,
        last_validation_at: Option<DateTime<Utc>>,
        blessed_until: Option<DateTime<Utc>>,
    ) -> CrossSessionSalience {
        CrossSessionSalience {
            cross_session_use_count,
            first_used_at: None,
            last_cross_session_use_at,
            last_validation_at,
            validation_epoch: 0,
            blessed_until,
        }
    }

    #[test]
    fn only_validated_outcome_advances_validation() {
        assert!(OutcomeMemoryRelation::Validated.advances_validation());
        assert!(!OutcomeMemoryRelation::Used.advances_validation());
        assert!(!OutcomeMemoryRelation::Contradicted.advances_validation());
        assert!(!OutcomeMemoryRelation::Superseded.advances_validation());
        assert!(!OutcomeMemoryRelation::Rejected.advances_validation());
    }

    #[test]
    fn stale_validation_triggers_penalty_window() {
        let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
        let last_use = Utc.with_ymd_and_hms(2026, 5, 4, 0, 0, 0).unwrap();
        let old_validation = Utc.with_ymd_and_hms(2026, 4, 1, 0, 0, 0).unwrap();
        let state = salience(6, Some(last_use), Some(old_validation), None);

        assert!(state.penalty_window_applies(now, Duration::days(14)));
        assert!(state.cross_session_unvalidated_penalty(now, 5, Duration::days(14), 0.3) > 0.0);
    }

    #[test]
    fn fresh_validation_suppresses_penalty_window() {
        let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
        let last_use = Utc.with_ymd_and_hms(2026, 5, 4, 0, 0, 0).unwrap();
        let fresh_validation = Utc.with_ymd_and_hms(2026, 5, 3, 0, 0, 0).unwrap();
        let state = salience(20, Some(last_use), Some(fresh_validation), None);

        assert!(!state.penalty_window_applies(now, Duration::days(14)));
        assert!(!state.should_auto_quarantine(now, 20, Duration::days(14)));
    }

    #[test]
    fn active_bless_suppresses_auto_quarantine_without_resetting_fields() {
        let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
        let last_use = Utc.with_ymd_and_hms(2026, 5, 4, 0, 0, 0).unwrap();
        let blessed_until = Utc.with_ymd_and_hms(2026, 5, 6, 0, 0, 0).unwrap();
        let state = salience(20, Some(last_use), None, Some(blessed_until));

        assert!(!state.should_auto_quarantine(now, 20, Duration::days(14)));
        assert_eq!(state.cross_session_use_count, 20);
    }
}