use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemporalConfig {
pub age_half_life_days: f64,
pub superseded_weight: f64,
pub support_boost_per_item: f64,
pub support_boost_cap: f64,
pub contradiction_penalty_per_item: f64,
pub contradiction_floor: f64,
pub well_threshold: usize,
}
impl Default for TemporalConfig {
fn default() -> Self {
Self {
age_half_life_days: 365.0,
superseded_weight: 0.1,
support_boost_per_item: 0.1,
support_boost_cap: 2.0,
contradiction_penalty_per_item: 0.15,
contradiction_floor: 0.1,
well_threshold: 3,
}
}
}
pub fn compute_temporal_weight(
recorded_at: DateTime<Utc>,
is_superseded: bool,
now: DateTime<Utc>,
support_count: usize,
contradiction_count: usize,
config: &TemporalConfig,
) -> f64 {
let base = 1.0;
let age_duration = now.signed_duration_since(recorded_at);
let age_days = age_duration.num_seconds() as f64 / 86400.0;
let age_days = age_days.max(0.0);
let age_factor = (-age_days / config.age_half_life_days).exp();
let supersession_factor = if is_superseded {
config.superseded_weight
} else {
1.0
};
let support_factor = (1.0 + config.support_boost_per_item * support_count as f64)
.min(config.support_boost_cap);
let contradiction_factor =
(1.0 - config.contradiction_penalty_per_item * contradiction_count as f64).max(
config.contradiction_floor,
);
base * age_factor * supersession_factor * support_factor * contradiction_factor
}
#[derive(Debug, Clone)]
pub struct GraphEdgeRef {
pub source: String,
pub target: String,
pub edge_type: String,
}
pub fn detect_temporal_wells(
items: &[String],
graph_edges: &[GraphEdgeRef],
episode_links: &HashMap<String, Vec<String>>, config: &TemporalConfig,
) -> HashMap<String, f64> {
let mut support_neighbors: HashMap<String, Vec<String>> = HashMap::new();
for edge in graph_edges {
if edge.edge_type == "supports" || edge.edge_type == "supported_by" {
support_neighbors
.entry(edge.source.clone())
.or_default()
.push(edge.target.clone());
support_neighbors
.entry(edge.target.clone())
.or_default()
.push(edge.source.clone());
}
}
let mut contradiction_count: HashMap<String, usize> = HashMap::new();
for edge in graph_edges {
if edge.edge_type == "contradicts" || edge.edge_type == "contradicted_by" {
*contradiction_count.entry(edge.source.clone()).or_default() += 1;
*contradiction_count.entry(edge.target.clone()).or_default() += 1;
}
}
let mut wells = HashMap::new();
for item_id in items {
let _neighbors = support_neighbors.get(item_id).map(|v| v.len()).unwrap_or(0);
let clean_neighbors = support_neighbors
.get(item_id)
.map(|v| {
v.iter()
.filter(|n| contradiction_count.get(*n).copied().unwrap_or(0) == 0)
.count()
})
.unwrap_or(0);
let episode_item_count = episode_links
.get(item_id)
.map(|links| links.len())
.unwrap_or(0);
let total_support = clean_neighbors + episode_item_count;
if total_support > config.well_threshold {
let excess = total_support - config.well_threshold;
let boost = (1.0 + 0.1 * excess as f64).min(2.0);
wells.insert(item_id.clone(), boost);
} else {
wells.insert(item_id.clone(), 1.0);
}
}
wells
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemporalRecomputationReceipt {
pub receipt_id: String,
pub timestamp: String,
pub items_computed: usize,
pub items_updated: usize,
pub duration_ms: u64,
pub config: TemporalConfig,
}
#[derive(Debug, Clone)]
pub struct TemporalSearchItem {
pub item_id: String,
pub base_score: f64,
pub temporal_weight: f64,
pub well_boost: f64,
}
pub fn apply_temporal_boost(items: Vec<TemporalSearchItem>) -> Vec<TemporalSearchItem> {
let mut boosted: Vec<TemporalSearchItem> = items
.into_iter()
.map(|item| {
let final_score = item.base_score * item.temporal_weight * item.well_boost;
TemporalSearchItem {
item_id: item.item_id,
base_score: final_score,
temporal_weight: item.temporal_weight,
well_boost: item.well_boost,
}
})
.collect();
boosted.sort_by(|a, b| {
b.base_score
.partial_cmp(&a.base_score)
.unwrap_or(std::cmp::Ordering::Equal)
});
boosted
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn test_episode(recorded_at: DateTime<Utc>, valid_to: Option<DateTime<Utc>>) -> (DateTime<Utc>, bool) {
(recorded_at, valid_to.is_some())
}
#[test]
fn test_temporal_weight_active_item() {
let config = TemporalConfig::default();
let now = Utc::now();
let (recorded_at, is_superseded) = test_episode(now, None);
let weight = compute_temporal_weight(recorded_at, is_superseded, now, 0, 0, &config);
assert!((weight - 1.0).abs() < 0.01, "fresh active item should be ~1.0, got {weight}");
}
#[test]
fn test_temporal_weight_superseded() {
let config = TemporalConfig::default();
let now = Utc::now();
let past = now - chrono::Duration::days(1);
let (recorded_at, is_superseded) = test_episode(past, Some(now));
let weight = compute_temporal_weight(recorded_at, is_superseded, now, 0, 0, &config);
assert!(weight < 0.2, "superseded item should be < 0.2, got {weight}");
assert!(weight > 0.05, "superseded item should be > 0.05, got {weight}");
}
#[test]
fn test_temporal_weight_aged() {
let config = TemporalConfig::default();
let now = Utc::now();
let old = now - chrono::Duration::days(365 * 2); let (recorded_at, is_superseded) = test_episode(old, None);
let weight = compute_temporal_weight(recorded_at, is_superseded, now, 0, 0, &config);
assert!(weight < 0.2, "2-year-old item should be < 0.2, got {weight}");
assert!(weight > 0.05, "2-year-old item should be > 0.05, got {weight}");
}
#[test]
fn test_temporal_weight_supported() {
let config = TemporalConfig::default();
let now = Utc::now();
let (recorded_at, is_superseded) = test_episode(now, None);
let weight = compute_temporal_weight(recorded_at, is_superseded, now, 5, 0, &config);
let expected = 1.0 * 1.0 * 1.5 * 1.0; assert!((weight - expected).abs() < 0.01, "supported item: expected {expected}, got {weight}");
}
#[test]
fn test_temporal_weight_support_cap() {
let config = TemporalConfig::default();
let now = Utc::now();
let (recorded_at, is_superseded) = test_episode(now, None);
let weight = compute_temporal_weight(recorded_at, is_superseded, now, 100, 0, &config);
let expected = 1.0 * 1.0 * 2.0 * 1.0;
assert!((weight - expected).abs() < 0.01, "capped support: expected {expected}, got {weight}");
}
#[test]
fn test_temporal_weight_contradicted() {
let config = TemporalConfig::default();
let now = Utc::now();
let (recorded_at, is_superseded) = test_episode(now, None);
let weight = compute_temporal_weight(recorded_at, is_superseded, now, 0, 3, &config);
let expected = 1.0 * 1.0 * 1.0 * 0.55;
assert!((weight - expected).abs() < 0.01, "contradicted item: expected {expected}, got {weight}");
}
#[test]
fn test_temporal_weight_contradiction_floor() {
let config = TemporalConfig::default();
let now = Utc::now();
let (recorded_at, is_superseded) = test_episode(now, None);
let weight = compute_temporal_weight(recorded_at, is_superseded, now, 0, 100, &config);
let expected = 1.0 * 1.0 * 1.0 * 0.1;
assert!((weight - expected).abs() < 0.01, "floor: expected {expected}, got {weight}");
}
#[test]
fn test_temporal_well_detection() {
let config = TemporalConfig::default();
let items = vec!["a".to_string(), "b".to_string(), "c".to_string(), "d".to_string()];
let graph_edges = vec![
GraphEdgeRef { source: "a".into(), target: "b".into(), edge_type: "supports".into() },
GraphEdgeRef { source: "a".into(), target: "c".into(), edge_type: "supports".into() },
GraphEdgeRef { source: "a".into(), target: "d".into(), edge_type: "supports".into() },
GraphEdgeRef { source: "b".into(), target: "c".into(), edge_type: "supports".into() },
GraphEdgeRef { source: "b".into(), target: "d".into(), edge_type: "supports".into() },
];
let mut episode_links = HashMap::new();
episode_links.insert("a".to_string(), vec!["b".to_string(), "c".to_string()]);
let wells = detect_temporal_wells(&items, &graph_edges, &episode_links, &config);
let a_boost = wells.get("a").copied().unwrap_or(1.0);
assert!(a_boost > 1.0, "item 'a' should be in a well, got boost {a_boost}");
let d_boost = wells.get("d").copied().unwrap_or(1.0);
assert!((d_boost - 1.0).abs() < 0.01, "item 'd' should NOT be in a well, got boost {d_boost}");
}
#[test]
fn test_temporal_well_isolated() {
let config = TemporalConfig::default();
let items = vec!["x".to_string()];
let graph_edges: Vec<GraphEdgeRef> = vec![];
let episode_links = HashMap::new();
let wells = detect_temporal_wells(&items, &graph_edges, &episode_links, &config);
let x_boost = wells.get("x").copied().unwrap_or(1.0);
assert!((x_boost - 1.0).abs() < 0.01, "isolated item should have boost 1.0, got {x_boost}");
}
#[test]
fn test_temporal_boost_reorders_results() {
let items = vec![
TemporalSearchItem {
item_id: "low".into(),
base_score: 0.9,
temporal_weight: 0.1,
well_boost: 1.0,
},
TemporalSearchItem {
item_id: "high".into(),
base_score: 0.5,
temporal_weight: 2.0,
well_boost: 1.5,
},
];
let boosted = apply_temporal_boost(items);
assert_eq!(boosted[0].item_id, "high");
assert_eq!(boosted[1].item_id, "low");
}
#[test]
fn test_recompute_receipt_fields() {
let receipt = TemporalRecomputationReceipt {
receipt_id: "test".to_string(),
timestamp: "2026-06-18T00:00:00Z".to_string(),
items_computed: 100,
items_updated: 50,
duration_ms: 42,
config: TemporalConfig::default(),
};
assert_eq!(receipt.items_computed, 100);
assert_eq!(receipt.items_updated, 50);
assert_eq!(receipt.config.age_half_life_days, 365.0);
}
}