Skip to main content

knowledge_runtime/temporal/
claims.rs

1//! Temporal claims: time-bounded assertions about entities with overlap and contradiction checks.
2
3use crate::ids::EntityId;
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7/// A temporal claim: an assertion about an entity that holds during a time interval.
8///
9/// Claims are derived projections — they do NOT own source truth. They are
10/// assembled from facts, episodes, and document chunks found via
11/// `semantic-memory`.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct TemporalClaim {
14    /// The entity this claim is about.
15    pub entity_id: EntityId,
16    /// The assertion text.
17    pub claim: String,
18    /// When the claim became valid (inclusive).
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub valid_from: Option<DateTime<Utc>>,
21    /// When the claim ceased to be valid (exclusive).
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub valid_until: Option<DateTime<Utc>>,
24    /// Confidence in the claim's validity (0.0 to 1.0).
25    pub confidence: f32,
26    /// Source IDs from `semantic-memory` backing this claim.
27    pub source_ids: Vec<String>,
28}
29
30impl TemporalClaim {
31    /// Check whether this claim is active at a given point in time.
32    pub fn active_at(&self, when: &DateTime<Utc>) -> bool {
33        let after_start = match self.valid_from.as_ref() {
34            Some(from) => when >= from,
35            None => true,
36        };
37        let before_end = match self.valid_until.as_ref() {
38            Some(until) => when < until,
39            None => true,
40        };
41        after_start && before_end
42    }
43
44    /// Check whether this claim overlaps with another claim's interval.
45    pub fn overlaps(&self, other: &TemporalClaim) -> bool {
46        // Two intervals [a, b) and [c, d) overlap iff a < d and c < b
47        // (with None treated as unbounded)
48        let a_before_d = match other.valid_until.as_ref() {
49            Some(d) => match self.valid_from.as_ref() {
50                Some(a) => a < d,
51                None => true,
52            },
53            None => true,
54        };
55        let c_before_b = match self.valid_until.as_ref() {
56            Some(b) => match other.valid_from.as_ref() {
57                Some(c) => c < b,
58                None => true,
59            },
60            None => true,
61        };
62        a_before_d && c_before_b
63    }
64}
65
66/// Contradiction status between two temporal claims about the same entity.
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum TemporalContradictionStatus {
70    /// No contradiction detected.
71    None,
72    /// Claims overlap in time and may contradict each other.
73    /// Resolution is deferred to the caller / future Forge layer.
74    PossibleContradiction {
75        /// Human-readable description of the potential conflict.
76        description: String,
77    },
78}
79
80/// Check two claims for temporal contradiction.
81///
82/// This is a structural check only — semantic contradiction detection
83/// (whether two claims actually conflict in meaning) is deferred to
84/// the future Forge causal layer.
85pub fn check_contradiction(a: &TemporalClaim, b: &TemporalClaim) -> TemporalContradictionStatus {
86    if a.entity_id != b.entity_id {
87        return TemporalContradictionStatus::None;
88    }
89    if !a.overlaps(b) {
90        return TemporalContradictionStatus::None;
91    }
92    TemporalContradictionStatus::PossibleContradiction {
93        description: format!(
94            "overlapping claims on entity {}: {:?} vs {:?}",
95            a.entity_id, a.claim, b.claim
96        ),
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    fn make_claim(
105        entity: &str,
106        claim: &str,
107        from: Option<&str>,
108        until: Option<&str>,
109    ) -> TemporalClaim {
110        TemporalClaim {
111            entity_id: EntityId::new(entity),
112            claim: claim.to_string(),
113            valid_from: from.map(|s| s.parse::<DateTime<Utc>>().unwrap()),
114            valid_until: until.map(|s| s.parse::<DateTime<Utc>>().unwrap()),
115            confidence: 0.9,
116            source_ids: vec![],
117        }
118    }
119
120    #[test]
121    fn active_at_unbounded() {
122        let claim = make_claim("e1", "always true", None, None);
123        let now = Utc::now();
124        assert!(claim.active_at(&now));
125    }
126
127    #[test]
128    fn active_at_bounded() {
129        let claim = make_claim(
130            "e1",
131            "in range",
132            Some("2025-01-01T00:00:00Z"),
133            Some("2025-12-31T23:59:59Z"),
134        );
135        let mid: DateTime<Utc> = "2025-06-15T00:00:00Z".parse().unwrap();
136        let before: DateTime<Utc> = "2024-06-15T00:00:00Z".parse().unwrap();
137        assert!(claim.active_at(&mid));
138        assert!(!claim.active_at(&before));
139    }
140
141    #[test]
142    fn overlapping_same_entity_is_contradiction() {
143        let a = make_claim(
144            "e1",
145            "X is true",
146            Some("2025-01-01T00:00:00Z"),
147            Some("2025-06-01T00:00:00Z"),
148        );
149        let b = make_claim(
150            "e1",
151            "X is false",
152            Some("2025-03-01T00:00:00Z"),
153            Some("2025-09-01T00:00:00Z"),
154        );
155        assert!(matches!(
156            check_contradiction(&a, &b),
157            TemporalContradictionStatus::PossibleContradiction { .. }
158        ));
159    }
160
161    #[test]
162    fn non_overlapping_same_entity_no_contradiction() {
163        let a = make_claim(
164            "e1",
165            "X is true",
166            Some("2025-01-01T00:00:00Z"),
167            Some("2025-03-01T00:00:00Z"),
168        );
169        let b = make_claim(
170            "e1",
171            "X is false",
172            Some("2025-06-01T00:00:00Z"),
173            Some("2025-09-01T00:00:00Z"),
174        );
175        assert_eq!(
176            check_contradiction(&a, &b),
177            TemporalContradictionStatus::None
178        );
179    }
180
181    #[test]
182    fn different_entity_no_contradiction() {
183        let a = make_claim(
184            "e1",
185            "X",
186            Some("2025-01-01T00:00:00Z"),
187            Some("2025-12-01T00:00:00Z"),
188        );
189        let b = make_claim(
190            "e2",
191            "Y",
192            Some("2025-01-01T00:00:00Z"),
193            Some("2025-12-01T00:00:00Z"),
194        );
195        assert_eq!(
196            check_contradiction(&a, &b),
197            TemporalContradictionStatus::None
198        );
199    }
200}