use crate::ids::EntityId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemporalClaim {
pub entity_id: EntityId,
pub claim: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub valid_from: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub valid_until: Option<DateTime<Utc>>,
pub confidence: f32,
pub source_ids: Vec<String>,
}
impl TemporalClaim {
pub fn active_at(&self, when: &DateTime<Utc>) -> bool {
let after_start = match self.valid_from.as_ref() {
Some(from) => when >= from,
None => true,
};
let before_end = match self.valid_until.as_ref() {
Some(until) => when < until,
None => true,
};
after_start && before_end
}
pub fn overlaps(&self, other: &TemporalClaim) -> bool {
let a_before_d = match other.valid_until.as_ref() {
Some(d) => match self.valid_from.as_ref() {
Some(a) => a < d,
None => true,
},
None => true,
};
let c_before_b = match self.valid_until.as_ref() {
Some(b) => match other.valid_from.as_ref() {
Some(c) => c < b,
None => true,
},
None => true,
};
a_before_d && c_before_b
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TemporalContradictionStatus {
None,
PossibleContradiction {
description: String,
},
}
pub fn check_contradiction(a: &TemporalClaim, b: &TemporalClaim) -> TemporalContradictionStatus {
if a.entity_id != b.entity_id {
return TemporalContradictionStatus::None;
}
if !a.overlaps(b) {
return TemporalContradictionStatus::None;
}
TemporalContradictionStatus::PossibleContradiction {
description: format!(
"overlapping claims on entity {}: {:?} vs {:?}",
a.entity_id, a.claim, b.claim
),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_claim(
entity: &str,
claim: &str,
from: Option<&str>,
until: Option<&str>,
) -> TemporalClaim {
TemporalClaim {
entity_id: EntityId::new(entity),
claim: claim.to_string(),
valid_from: from.map(|s| s.parse::<DateTime<Utc>>().unwrap()),
valid_until: until.map(|s| s.parse::<DateTime<Utc>>().unwrap()),
confidence: 0.9,
source_ids: vec![],
}
}
#[test]
fn active_at_unbounded() {
let claim = make_claim("e1", "always true", None, None);
let now = Utc::now();
assert!(claim.active_at(&now));
}
#[test]
fn active_at_bounded() {
let claim = make_claim(
"e1",
"in range",
Some("2025-01-01T00:00:00Z"),
Some("2025-12-31T23:59:59Z"),
);
let mid: DateTime<Utc> = "2025-06-15T00:00:00Z".parse().unwrap();
let before: DateTime<Utc> = "2024-06-15T00:00:00Z".parse().unwrap();
assert!(claim.active_at(&mid));
assert!(!claim.active_at(&before));
}
#[test]
fn overlapping_same_entity_is_contradiction() {
let a = make_claim(
"e1",
"X is true",
Some("2025-01-01T00:00:00Z"),
Some("2025-06-01T00:00:00Z"),
);
let b = make_claim(
"e1",
"X is false",
Some("2025-03-01T00:00:00Z"),
Some("2025-09-01T00:00:00Z"),
);
assert!(matches!(
check_contradiction(&a, &b),
TemporalContradictionStatus::PossibleContradiction { .. }
));
}
#[test]
fn non_overlapping_same_entity_no_contradiction() {
let a = make_claim(
"e1",
"X is true",
Some("2025-01-01T00:00:00Z"),
Some("2025-03-01T00:00:00Z"),
);
let b = make_claim(
"e1",
"X is false",
Some("2025-06-01T00:00:00Z"),
Some("2025-09-01T00:00:00Z"),
);
assert_eq!(
check_contradiction(&a, &b),
TemporalContradictionStatus::None
);
}
#[test]
fn different_entity_no_contradiction() {
let a = make_claim(
"e1",
"X",
Some("2025-01-01T00:00:00Z"),
Some("2025-12-01T00:00:00Z"),
);
let b = make_claim(
"e2",
"Y",
Some("2025-01-01T00:00:00Z"),
Some("2025-12-01T00:00:00Z"),
);
assert_eq!(
check_contradiction(&a, &b),
TemporalContradictionStatus::None
);
}
}