use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct TimeInterval {
pub start: f64,
pub end: f64,
}
impl TimeInterval {
pub fn new(start: f64, end: f64) -> Self {
debug_assert!(start <= end, "TimeInterval: start ({start}) > end ({end})");
Self { start, end }
}
pub fn point(t: f64) -> Self {
Self { start: t, end: t }
}
#[inline]
pub fn duration(&self) -> f64 {
self.end - self.start
}
#[inline]
pub fn midpoint(&self) -> f64 {
(self.start + self.end) / 2.0
}
#[inline]
pub fn contains_point(&self, t: f64) -> bool {
t >= self.start && t <= self.end
}
#[inline]
pub fn contains_interval(&self, other: &TimeInterval) -> bool {
self.start <= other.start && self.end >= other.end
}
pub fn relation_to(&self, other: &TimeInterval) -> IntervalRelation {
const EPSILON: f64 = 0.5;
if (self.end - other.start).abs() < EPSILON
&& self.start < other.start
{
return IntervalRelation::Meets;
}
if (other.end - self.start).abs() < EPSILON
&& other.start < self.start
{
return IntervalRelation::MetBy;
}
if self.end < other.start {
IntervalRelation::Before
} else if other.end < self.start {
IntervalRelation::After
} else if self.start == other.start && self.end == other.end {
IntervalRelation::Equals
} else if self.start == other.start && self.end < other.end {
IntervalRelation::Starts
} else if self.start == other.start && self.end > other.end {
IntervalRelation::StartedBy
} else if self.end == other.end && self.start > other.start {
IntervalRelation::Finishes
} else if self.end == other.end && self.start < other.start {
IntervalRelation::FinishedBy
} else if self.start > other.start && self.end < other.end {
IntervalRelation::During
} else if other.start > self.start && other.end < self.end {
IntervalRelation::Contains
} else if self.start < other.start && self.end > other.start && self.end < other.end {
IntervalRelation::Overlaps
} else if other.start < self.start && other.end > self.start && other.end < self.end {
IntervalRelation::OverlappedBy
} else {
IntervalRelation::Overlaps
}
}
pub fn overlap_duration(&self, other: &TimeInterval) -> f64 {
let overlap_start = self.start.max(other.start);
let overlap_end = self.end.min(other.end);
(overlap_end - overlap_start).max(0.0)
}
pub fn merge(&self, other: &TimeInterval, tolerance: f64) -> Option<TimeInterval> {
if self.start <= other.end + tolerance && other.start <= self.end + tolerance {
Some(TimeInterval::new(
self.start.min(other.start),
self.end.max(other.end),
))
} else {
None
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum IntervalRelation {
Before,
After,
Meets,
MetBy,
Overlaps,
OverlappedBy,
Starts,
StartedBy,
Finishes,
FinishedBy,
During,
Contains,
Equals,
}
impl IntervalRelation {
pub fn has_overlap(self) -> bool {
!matches!(self, Self::Before | Self::After | Self::Meets | Self::MetBy)
}
pub fn inverse(self) -> Self {
match self {
Self::Before => Self::After,
Self::After => Self::Before,
Self::Meets => Self::MetBy,
Self::MetBy => Self::Meets,
Self::Overlaps => Self::OverlappedBy,
Self::OverlappedBy => Self::Overlaps,
Self::Starts => Self::StartedBy,
Self::StartedBy => Self::Starts,
Self::Finishes => Self::FinishedBy,
Self::FinishedBy => Self::Finishes,
Self::During => Self::Contains,
Self::Contains => Self::During,
Self::Equals => Self::Equals,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecencyConfig {
pub half_life_secs: f64,
pub floor: f64,
}
impl Default for RecencyConfig {
fn default() -> Self {
Self {
half_life_secs: 86400.0,
floor: 0.01,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DomainRecencyMap {
pub entries: HashMap<String, f64>,
pub default_half_life: f64,
}
impl Default for DomainRecencyMap {
fn default() -> Self {
let mut entries = HashMap::new();
entries.insert("conversation".to_string(), 3600.0 * 4.0);
entries.insert("task".to_string(), 86400.0 * 3.0);
entries.insert("goal".to_string(), 86400.0 * 30.0);
entries.insert("belief".to_string(), 86400.0 * 90.0);
entries.insert("routine".to_string(), 86400.0 * 60.0);
entries.insert("preference".to_string(), 86400.0 * 180.0);
entries.insert("episode".to_string(), 86400.0 * 7.0);
Self {
entries,
default_half_life: 86400.0,
}
}
}
impl DomainRecencyMap {
pub fn half_life_for(&self, domain: &str) -> f64 {
self.entries
.get(domain)
.copied()
.unwrap_or(self.default_half_life)
}
}
pub fn recency_relevance(event_time: f64, now: f64, config: &RecencyConfig) -> f64 {
let age = (now - event_time).max(0.0);
let decay = f64::powf(2.0, -age / config.half_life_secs);
decay.max(config.floor)
}
pub fn recency_relevance_domain(
event_time: f64,
now: f64,
domain: &str,
domain_map: &DomainRecencyMap,
) -> f64 {
let half_life = domain_map.half_life_for(domain);
let config = RecencyConfig {
half_life_secs: half_life,
floor: 0.01,
};
recency_relevance(event_time, now, &config)
}
pub fn rank_by_recency(
events: &[(u64, f64)],
now: f64,
config: &RecencyConfig,
) -> Vec<(u64, f64)> {
let mut scored: Vec<(u64, f64)> = events
.iter()
.map(|&(id, time)| (id, recency_relevance(time, now, config)))
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
scored
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PeriodicityConfig {
pub candidate_periods: Vec<f64>,
pub min_events: usize,
pub correlation_threshold: f64,
pub tolerance_fraction: f64,
}
impl Default for PeriodicityConfig {
fn default() -> Self {
Self {
candidate_periods: vec![
3600.0, 3600.0 * 4.0, 86400.0, 86400.0 * 7.0, 86400.0 * 14.0, 86400.0 * 30.0, 86400.0 * 90.0, ],
min_events: 5,
correlation_threshold: 0.3,
tolerance_fraction: 0.15,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DetectedPeriod {
pub period_secs: f64,
pub correlation: f64,
pub phase_offset_secs: f64,
pub hit_rate: f64,
pub label: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PeriodicityResult {
pub detected: Vec<DetectedPeriod>,
pub event_count: usize,
pub span_secs: f64,
}
pub fn detect_periodicity(
timestamps: &[f64],
config: &PeriodicityConfig,
) -> PeriodicityResult {
if timestamps.len() < config.min_events {
return PeriodicityResult {
detected: vec![],
event_count: timestamps.len(),
span_secs: 0.0,
};
}
let mut sorted = timestamps.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let span = sorted.last().unwrap() - sorted.first().unwrap();
if span <= 0.0 {
return PeriodicityResult {
detected: vec![],
event_count: timestamps.len(),
span_secs: 0.0,
};
}
let n = sorted.len() as f64;
let mut detected = Vec::new();
for &period in &config.candidate_periods {
if span < period * 1.5 {
continue;
}
let two_pi = std::f64::consts::TAU;
let phases: Vec<f64> = sorted
.iter()
.map(|&t| ((t % period) / period) * two_pi)
.collect();
let cos_sum: f64 = phases.iter().map(|&p| p.cos()).sum();
let sin_sum: f64 = phases.iter().map(|&p| p.sin()).sum();
let r = (cos_sum * cos_sum + sin_sum * sin_sum).sqrt() / n;
let mean_phase = sin_sum.atan2(cos_sum);
let phase_offset = ((mean_phase / two_pi) * period + period) % period;
let tolerance = period * config.tolerance_fraction;
let hits = sorted.iter().filter(|&&t| {
let offset = (t - phase_offset) % period;
let offset = if offset < 0.0 { offset + period } else { offset };
offset < tolerance || (period - offset) < tolerance
}).count();
let hit_rate = hits as f64 / n;
if r >= config.correlation_threshold {
detected.push(DetectedPeriod {
period_secs: period,
correlation: r,
phase_offset_secs: phase_offset,
hit_rate,
label: period_label(period),
});
}
}
detected.sort_by(|a, b| b.correlation.partial_cmp(&a.correlation).unwrap_or(std::cmp::Ordering::Equal));
PeriodicityResult {
detected,
event_count: timestamps.len(),
span_secs: span,
}
}
fn period_label(period_secs: f64) -> String {
let hours = period_secs / 3600.0;
if hours < 2.0 {
format!("{:.0}min", period_secs / 60.0)
} else if hours < 48.0 {
format!("{:.0}h", hours)
} else {
let days = hours / 24.0;
if (days - 7.0).abs() < 1.0 {
"weekly".to_string()
} else if (days - 14.0).abs() < 2.0 {
"biweekly".to_string()
} else if (days - 30.0).abs() < 5.0 {
"monthly".to_string()
} else if (days - 90.0).abs() < 10.0 {
"quarterly".to_string()
} else {
format!("{:.0}d", days)
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EwmaTracker {
pub alpha: f64,
pub value: f64,
pub variance: f64,
pub count: u64,
pub last_time: f64,
}
impl EwmaTracker {
pub fn new(alpha: f64) -> Self {
assert!(alpha > 0.0 && alpha <= 1.0, "alpha must be in (0, 1]");
Self {
alpha,
value: 0.0,
variance: 0.0,
count: 0,
last_time: 0.0,
}
}
pub fn with_initial(alpha: f64, initial_value: f64) -> Self {
assert!(alpha > 0.0 && alpha <= 1.0, "alpha must be in (0, 1]");
Self {
alpha,
value: initial_value,
variance: 0.0,
count: 1,
last_time: 0.0,
}
}
pub fn update(&mut self, observation: f64, timestamp: f64) {
if self.count == 0 {
self.value = observation;
self.variance = 0.0;
} else {
let diff = observation - self.value;
self.value += self.alpha * diff;
self.variance = (1.0 - self.alpha) * (self.variance + self.alpha * diff * diff);
}
self.count += 1;
self.last_time = timestamp;
}
#[inline]
pub fn current(&self) -> f64 {
self.value
}
#[inline]
pub fn std_dev(&self) -> f64 {
self.variance.sqrt()
}
pub fn z_score(&self, observation: f64) -> f64 {
let sd = self.std_dev();
if sd < 1e-10 || self.count < 3 {
0.0
} else {
(observation - self.value) / sd
}
}
pub fn is_anomaly(&self, observation: f64, z_threshold: f64) -> bool {
self.z_score(observation).abs() > z_threshold
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BurstConfig {
pub window_secs: f64,
pub z_threshold: f64,
pub min_windows: usize,
pub ewma_alpha: f64,
}
impl Default for BurstConfig {
fn default() -> Self {
Self {
window_secs: 3600.0,
z_threshold: 2.5,
min_windows: 5,
ewma_alpha: 0.2,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DetectedBurst {
pub window_start: f64,
pub window_end: f64,
pub event_count: usize,
pub z_score: f64,
pub baseline_rate: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BurstResult {
pub bursts: Vec<DetectedBurst>,
pub overall_rate: f64,
pub windows_analyzed: usize,
}
pub fn detect_bursts(timestamps: &[f64], config: &BurstConfig) -> BurstResult {
if timestamps.is_empty() {
return BurstResult {
bursts: vec![],
overall_rate: 0.0,
windows_analyzed: 0,
};
}
let mut sorted = timestamps.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let first = sorted[0];
let last = sorted[sorted.len() - 1];
let span = last - first;
if span < config.window_secs {
return BurstResult {
bursts: vec![],
overall_rate: sorted.len() as f64,
windows_analyzed: 1,
};
}
let num_windows = ((span / config.window_secs).ceil() as usize).max(1);
let mut window_counts = vec![0usize; num_windows];
for &t in &sorted {
let idx = ((t - first) / config.window_secs).floor() as usize;
let idx = idx.min(num_windows - 1);
window_counts[idx] += 1;
}
let overall_rate = sorted.len() as f64 / num_windows as f64;
let mut tracker = EwmaTracker::new(config.ewma_alpha);
let mut bursts = Vec::new();
for (i, &count) in window_counts.iter().enumerate() {
let rate = count as f64;
let window_start = first + i as f64 * config.window_secs;
let window_end = window_start + config.window_secs;
if tracker.count >= config.min_windows as u64 {
let z = tracker.z_score(rate);
if z > config.z_threshold {
bursts.push(DetectedBurst {
window_start,
window_end,
event_count: count,
z_score: z,
baseline_rate: tracker.current(),
});
}
}
tracker.update(rate, window_start);
}
BurstResult {
bursts,
overall_rate,
windows_analyzed: num_windows,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LabeledEvent {
pub label: String,
pub timestamp: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemporalMotif {
pub sequence: Vec<String>,
pub occurrences: u32,
pub avg_span_secs: f64,
pub avg_gaps: Vec<f64>,
pub confidence: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MotifConfig {
pub max_gap_secs: f64,
pub min_length: usize,
pub max_length: usize,
pub min_occurrences: u32,
}
impl Default for MotifConfig {
fn default() -> Self {
Self {
max_gap_secs: 7200.0,
min_length: 2,
max_length: 5,
min_occurrences: 3,
}
}
}
pub fn mine_temporal_motifs(
events: &[LabeledEvent],
config: &MotifConfig,
) -> Vec<TemporalMotif> {
if events.len() < config.min_length {
return vec![];
}
let mut sorted: Vec<&LabeledEvent> = events.iter().collect();
sorted.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap_or(std::cmp::Ordering::Equal));
let mut pattern_instances: HashMap<Vec<String>, Vec<(f64, Vec<f64>)>> = HashMap::new();
for start_idx in 0..sorted.len() {
let mut sequence = vec![sorted[start_idx].label.clone()];
let mut gaps = Vec::new();
for end_idx in (start_idx + 1)..sorted.len() {
let gap = sorted[end_idx].timestamp - sorted[end_idx - 1].timestamp;
if gap > config.max_gap_secs {
break;
}
if gap < 0.0 {
break; }
gaps.push(gap);
sequence.push(sorted[end_idx].label.clone());
if sequence.len() >= config.min_length && sequence.len() <= config.max_length {
let span = sorted[end_idx].timestamp - sorted[start_idx].timestamp;
pattern_instances
.entry(sequence.clone())
.or_default()
.push((span, gaps.clone()));
}
if sequence.len() >= config.max_length {
break;
}
}
}
let mut motifs: Vec<TemporalMotif> = pattern_instances
.into_iter()
.filter(|(_, instances)| instances.len() >= config.min_occurrences as usize)
.map(|(sequence, instances)| {
let occurrences = instances.len() as u32;
let avg_span = instances.iter().map(|(s, _)| s).sum::<f64>() / instances.len() as f64;
let gap_count = sequence.len() - 1;
let mut avg_gaps = vec![0.0; gap_count];
let mut gap_variances = vec![0.0; gap_count];
for (_, gaps) in &instances {
for (i, &g) in gaps.iter().enumerate() {
if i < gap_count {
avg_gaps[i] += g;
}
}
}
for g in &mut avg_gaps {
*g /= instances.len() as f64;
}
for (_, gaps) in &instances {
for (i, &g) in gaps.iter().enumerate() {
if i < gap_count {
let diff = g - avg_gaps[i];
gap_variances[i] += diff * diff;
}
}
}
for v in &mut gap_variances {
*v /= instances.len() as f64;
}
let confidence = if gap_count == 0 {
1.0
} else {
let mean_cv: f64 = avg_gaps
.iter()
.zip(gap_variances.iter())
.map(|(&mean, &var)| {
if mean < 1.0 {
1.0 } else {
let cv = var.sqrt() / mean;
(1.0 - cv).max(0.0) }
})
.sum::<f64>()
/ gap_count as f64;
mean_cv.clamp(0.0, 1.0)
};
TemporalMotif {
sequence,
occurrences,
avg_span_secs: avg_span,
avg_gaps,
confidence,
}
})
.collect();
motifs.sort_by(|a, b| {
let score_a = a.occurrences as f64 * a.confidence;
let score_b = b.occurrences as f64 * b.confidence;
score_b.partial_cmp(&score_a).unwrap_or(std::cmp::Ordering::Equal)
});
motifs
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeadlineUrgencyConfig {
pub steepness: f64,
pub ramp_start_before: f64,
pub at_deadline: f64,
pub overdue: f64,
pub floor: f64,
}
impl Default for DeadlineUrgencyConfig {
fn default() -> Self {
Self {
steepness: 10.0,
ramp_start_before: 86400.0,
at_deadline: 0.95,
overdue: 1.0,
floor: 0.02,
}
}
}
pub fn deadline_urgency(deadline: f64, now: f64, config: &DeadlineUrgencyConfig) -> f64 {
let time_remaining = deadline - now;
if time_remaining <= 0.0 {
return config.overdue;
}
if time_remaining > config.ramp_start_before {
return config.floor;
}
let fraction = 1.0 - (time_remaining / config.ramp_start_before);
let k = config.steepness;
let raw = 1.0 / (1.0 + (-k * (fraction - 0.5)).exp());
let sig_low = 1.0 / (1.0 + (k * 0.5).exp()); let sig_high = 1.0 / (1.0 + (-k * 0.5).exp()); let normalized = (raw - sig_low) / (sig_high - sig_low);
let urgency = config.floor + (config.at_deadline - config.floor) * normalized;
urgency.clamp(0.0, 1.0)
}
pub fn compound_urgency(
deadline: f64,
now: f64,
priority_weight: f64,
config: &DeadlineUrgencyConfig,
) -> f64 {
let scale = 0.5 + 1.5 * priority_weight; let adjusted_config = DeadlineUrgencyConfig {
ramp_start_before: config.ramp_start_before * scale,
..*config
};
deadline_urgency(deadline, now, &adjusted_config)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemporalEvent {
pub node_id: super::state::NodeId,
pub timestamp: f64,
pub predecessors: Vec<super::state::NodeId>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemporalOrder {
pub ordered: Vec<super::state::NodeId>,
pub cycles: Vec<super::state::NodeId>,
pub depths: HashMap<u32, usize>,
}
pub fn topological_order(events: &[TemporalEvent]) -> TemporalOrder {
if events.is_empty() {
return TemporalOrder {
ordered: vec![],
cycles: vec![],
depths: HashMap::new(),
};
}
let mut adj: HashMap<u32, Vec<u32>> = HashMap::new();
let mut in_degree: HashMap<u32, usize> = HashMap::new();
let mut all_nodes: Vec<u32> = Vec::new();
for event in events {
let nid = event.node_id.to_raw();
all_nodes.push(nid);
in_degree.entry(nid).or_insert(0);
adj.entry(nid).or_default();
for &pred in &event.predecessors {
let pid = pred.to_raw();
adj.entry(pid).or_default().push(nid);
*in_degree.entry(nid).or_insert(0) += 1;
in_degree.entry(pid).or_insert(0);
}
}
let mut queue: Vec<u32> = in_degree
.iter()
.filter(|(_, °)| deg == 0)
.map(|(&nid, _)| nid)
.collect();
let ts_map: HashMap<u32, f64> = events
.iter()
.map(|e| (e.node_id.to_raw(), e.timestamp))
.collect();
queue.sort_by(|a, b| {
let ta = ts_map.get(a).copied().unwrap_or(0.0);
let tb = ts_map.get(b).copied().unwrap_or(0.0);
ta.partial_cmp(&tb).unwrap_or(std::cmp::Ordering::Equal)
});
let mut ordered = Vec::new();
let mut depths: HashMap<u32, usize> = HashMap::new();
let mut processed = std::collections::HashSet::new();
while let Some(nid) = queue.first().copied() {
queue.remove(0);
if processed.contains(&nid) {
continue;
}
processed.insert(nid);
ordered.push(super::state::NodeId::from_raw(nid));
let depth = *depths.get(&nid).unwrap_or(&0);
if let Some(successors) = adj.get(&nid) {
for &succ in successors {
if let Some(deg) = in_degree.get_mut(&succ) {
*deg = deg.saturating_sub(1);
let succ_depth = depth + 1;
let existing = depths.entry(succ).or_insert(0);
*existing = (*existing).max(succ_depth);
if *deg == 0 {
queue.push(succ);
}
}
}
}
queue.sort_by(|a, b| {
let ta = ts_map.get(a).copied().unwrap_or(0.0);
let tb = ts_map.get(b).copied().unwrap_or(0.0);
ta.partial_cmp(&tb).unwrap_or(std::cmp::Ordering::Equal)
});
}
let ordered_set: std::collections::HashSet<u32> =
ordered.iter().map(|n| n.to_raw()).collect();
let cycles: Vec<super::state::NodeId> = all_nodes
.iter()
.filter(|n| !ordered_set.contains(n))
.map(|&n| super::state::NodeId::from_raw(n))
.collect();
TemporalOrder {
ordered,
cycles,
depths,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SeasonalHistogram {
pub num_bins: usize,
pub counts: Vec<u32>,
pub total: u32,
pub label: String,
}
impl SeasonalHistogram {
pub fn new(num_bins: usize, label: &str) -> Self {
Self {
num_bins,
counts: vec![0; num_bins],
total: 0,
label: label.to_string(),
}
}
pub fn hour_of_day() -> Self {
Self::new(24, "hour_of_day")
}
pub fn day_of_week() -> Self {
Self::new(7, "day_of_week")
}
pub fn add(&mut self, bin: usize) {
if bin < self.num_bins {
self.counts[bin] += 1;
self.total += 1;
}
}
pub fn add_timestamps(&mut self, timestamps: &[f64], bin_fn: impl Fn(f64) -> usize) {
for &t in timestamps {
let bin = bin_fn(t);
self.add(bin);
}
}
pub fn distribution(&self) -> Vec<f64> {
if self.total == 0 {
return vec![0.0; self.num_bins];
}
self.counts
.iter()
.map(|&c| c as f64 / self.total as f64)
.collect()
}
pub fn peak(&self) -> (usize, u32) {
self.counts
.iter()
.enumerate()
.max_by_key(|(_, &c)| c)
.map(|(i, &c)| (i, c))
.unwrap_or((0, 0))
}
pub fn top_k(&self, k: usize) -> Vec<(usize, u32)> {
let mut indexed: Vec<(usize, u32)> = self
.counts
.iter()
.enumerate()
.map(|(i, &c)| (i, c))
.collect();
indexed.sort_by(|a, b| b.1.cmp(&a.1));
indexed.truncate(k);
indexed
}
pub fn entropy(&self) -> f64 {
let dist = self.distribution();
dist.iter()
.filter(|&&p| p > 0.0)
.map(|&p| -p * p.log2())
.sum()
}
pub fn concentration(&self, top_k: usize) -> f64 {
if self.total == 0 {
return 0.0;
}
let top_sum: u32 = self.top_k(top_k).iter().map(|(_, c)| c).sum();
top_sum as f64 / self.total as f64
}
}
pub fn hour_of_day_utc(timestamp: f64) -> usize {
let secs_in_day = timestamp % 86400.0;
let hour = (secs_in_day / 3600.0).floor() as usize;
hour.min(23)
}
pub fn day_of_week_utc(timestamp: f64) -> usize {
let days_since_epoch = (timestamp / 86400.0).floor() as i64;
let dow = ((days_since_epoch % 7 + 3) % 7) as usize; dow.min(6)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemporalRelevanceConfig {
pub recency_weight: f64,
pub periodicity_weight: f64,
pub deadline_weight: f64,
pub recency_half_life: f64,
}
impl Default for TemporalRelevanceConfig {
fn default() -> Self {
Self {
recency_weight: 0.4,
periodicity_weight: 0.3,
deadline_weight: 0.3,
recency_half_life: 86400.0,
}
}
}
pub fn temporal_relevance_composite(
last_updated: f64,
now: f64,
next_occurrence: Option<f64>,
deadline: Option<f64>,
config: &TemporalRelevanceConfig,
) -> f64 {
let recency_config = RecencyConfig {
half_life_secs: config.recency_half_life,
floor: 0.01,
};
let recency_score = recency_relevance(last_updated, now, &recency_config);
let periodicity_score = match next_occurrence {
Some(next) => {
let time_until = (next - now).max(0.0);
1.0 / (1.0 + (time_until / 3600.0)) }
None => 0.0,
};
let deadline_score = match deadline {
Some(dl) => deadline_urgency(dl, now, &DeadlineUrgencyConfig::default()),
None => 0.0,
};
let total_weight = config.recency_weight + config.periodicity_weight + config.deadline_weight;
if total_weight < 1e-10 {
return 0.0;
}
let composite = (config.recency_weight * recency_score
+ config.periodicity_weight * periodicity_score
+ config.deadline_weight * deadline_score)
/ total_weight;
composite.clamp(0.0, 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_interval_before_after() {
let a = TimeInterval::new(10.0, 20.0);
let b = TimeInterval::new(30.0, 40.0);
assert_eq!(a.relation_to(&b), IntervalRelation::Before);
assert_eq!(b.relation_to(&a), IntervalRelation::After);
assert!(!a.relation_to(&b).has_overlap());
}
#[test]
fn test_interval_meets() {
let a = TimeInterval::new(10.0, 20.0);
let b = TimeInterval::new(20.0, 30.0);
assert_eq!(a.relation_to(&b), IntervalRelation::Meets);
assert_eq!(b.relation_to(&a), IntervalRelation::MetBy);
}
#[test]
fn test_interval_overlaps() {
let a = TimeInterval::new(10.0, 25.0);
let b = TimeInterval::new(20.0, 35.0);
assert_eq!(a.relation_to(&b), IntervalRelation::Overlaps);
assert_eq!(b.relation_to(&a), IntervalRelation::OverlappedBy);
assert!(a.relation_to(&b).has_overlap());
}
#[test]
fn test_interval_during_contains() {
let a = TimeInterval::new(15.0, 25.0);
let b = TimeInterval::new(10.0, 30.0);
assert_eq!(a.relation_to(&b), IntervalRelation::During);
assert_eq!(b.relation_to(&a), IntervalRelation::Contains);
}
#[test]
fn test_interval_starts_finishes() {
let a = TimeInterval::new(10.0, 20.0);
let b = TimeInterval::new(10.0, 30.0);
assert_eq!(a.relation_to(&b), IntervalRelation::Starts);
assert_eq!(b.relation_to(&a), IntervalRelation::StartedBy);
let c = TimeInterval::new(20.0, 30.0);
let d = TimeInterval::new(10.0, 30.0);
assert_eq!(c.relation_to(&d), IntervalRelation::Finishes);
assert_eq!(d.relation_to(&c), IntervalRelation::FinishedBy);
}
#[test]
fn test_interval_equals() {
let a = TimeInterval::new(10.0, 20.0);
let b = TimeInterval::new(10.0, 20.0);
assert_eq!(a.relation_to(&b), IntervalRelation::Equals);
}
#[test]
fn test_interval_overlap_duration() {
let a = TimeInterval::new(10.0, 30.0);
let b = TimeInterval::new(20.0, 40.0);
assert!((a.overlap_duration(&b) - 10.0).abs() < 1e-10);
let c = TimeInterval::new(50.0, 60.0);
assert!((a.overlap_duration(&c) - 0.0).abs() < 1e-10);
}
#[test]
fn test_interval_merge() {
let a = TimeInterval::new(10.0, 25.0);
let b = TimeInterval::new(20.0, 35.0);
let merged = a.merge(&b, 0.0).unwrap();
assert!((merged.start - 10.0).abs() < 1e-10);
assert!((merged.end - 35.0).abs() < 1e-10);
let c = TimeInterval::new(50.0, 60.0);
assert!(a.merge(&c, 0.0).is_none());
}
#[test]
fn test_interval_inverse_symmetry() {
for rel in [
IntervalRelation::Before, IntervalRelation::After,
IntervalRelation::Meets, IntervalRelation::MetBy,
IntervalRelation::Overlaps, IntervalRelation::OverlappedBy,
IntervalRelation::Starts, IntervalRelation::StartedBy,
IntervalRelation::Finishes, IntervalRelation::FinishedBy,
IntervalRelation::During, IntervalRelation::Contains,
IntervalRelation::Equals,
] {
assert_eq!(rel.inverse().inverse(), rel);
}
}
#[test]
fn test_recency_just_happened() {
let config = RecencyConfig::default();
let score = recency_relevance(1000.0, 1000.0, &config);
assert!((score - 1.0).abs() < 1e-10);
}
#[test]
fn test_recency_one_halflife_ago() {
let config = RecencyConfig {
half_life_secs: 100.0,
floor: 0.0,
};
let score = recency_relevance(0.0, 100.0, &config);
assert!((score - 0.5).abs() < 1e-10);
}
#[test]
fn test_recency_floor() {
let config = RecencyConfig {
half_life_secs: 100.0,
floor: 0.05,
};
let score = recency_relevance(0.0, 1_000_000.0, &config);
assert!((score - 0.05).abs() < 0.01);
}
#[test]
fn test_recency_ranking() {
let config = RecencyConfig::default();
let events = vec![(1, 100.0), (2, 500.0), (3, 300.0)];
let ranked = rank_by_recency(&events, 1000.0, &config);
assert_eq!(ranked[0].0, 2); assert_eq!(ranked[1].0, 3);
assert_eq!(ranked[2].0, 1); }
#[test]
fn test_domain_recency() {
let map = DomainRecencyMap::default();
let conv_half = map.half_life_for("conversation");
let goal_half = map.half_life_for("goal");
assert!(conv_half < goal_half);
}
#[test]
fn test_ewma_converges() {
let mut tracker = EwmaTracker::new(0.3);
for i in 0..100 {
tracker.update(10.0, i as f64);
}
assert!((tracker.current() - 10.0).abs() < 0.01);
}
#[test]
fn test_ewma_tracks_change() {
let mut tracker = EwmaTracker::new(0.5);
for i in 0..20 {
tracker.update(5.0, i as f64);
}
for i in 20..40 {
tracker.update(15.0, i as f64);
}
assert!((tracker.current() - 15.0).abs() < 0.5);
}
#[test]
fn test_ewma_z_score() {
let mut tracker = EwmaTracker::new(0.2);
for i in 0..50 {
tracker.update(10.0 + (i as f64 * 0.01), i as f64);
}
let z = tracker.z_score(50.0);
assert!(z > 2.0);
}
#[test]
fn test_ewma_anomaly_detection() {
let mut tracker = EwmaTracker::new(0.1);
for i in 0..100 {
tracker.update(5.0 + (i % 2) as f64, i as f64);
}
assert!(!tracker.is_anomaly(5.5, 3.0)); assert!(tracker.is_anomaly(100.0, 3.0)); }
#[test]
fn test_detect_daily_periodicity() {
let config = PeriodicityConfig {
min_events: 3,
correlation_threshold: 0.3,
..Default::default()
};
let base = 1000000.0;
let daily = 86400.0;
let timestamps: Vec<f64> = (0..14)
.map(|d| base + d as f64 * daily + 100.0) .collect();
let result = detect_periodicity(×tamps, &config);
assert!(!result.detected.is_empty());
let has_daily = result.detected.iter().any(|p| {
(p.period_secs - 86400.0).abs() < 1.0
});
assert!(has_daily, "Should detect daily period: {:?}", result.detected);
}
#[test]
fn test_periodicity_insufficient_data() {
let config = PeriodicityConfig {
min_events: 10,
..Default::default()
};
let timestamps = vec![100.0, 200.0, 300.0];
let result = detect_periodicity(×tamps, &config);
assert!(result.detected.is_empty());
}
#[test]
fn test_periodicity_random_noise() {
let config = PeriodicityConfig {
min_events: 5,
correlation_threshold: 0.5, ..Default::default()
};
let timestamps = vec![100.0, 357.0, 981.0, 1234.0, 2001.0, 3500.0, 5600.0, 8900.0, 14000.0, 22000.0];
let result = detect_periodicity(×tamps, &config);
for p in &result.detected {
assert!(p.correlation < 0.9, "Unexpected strong periodicity in noise: {:?}", p);
}
}
#[test]
fn test_detect_burst() {
let config = BurstConfig {
window_secs: 100.0,
z_threshold: 2.0,
min_windows: 3,
ewma_alpha: 0.3,
};
let mut timestamps = Vec::new();
for i in 0..6 {
timestamps.push(i as f64 * 100.0 + 10.0);
timestamps.push(i as f64 * 100.0 + 50.0);
}
for j in 0..20 {
timestamps.push(600.0 + j as f64 * 4.0);
}
let result = detect_bursts(×tamps, &config);
assert!(!result.bursts.is_empty(), "Should detect burst");
let burst = &result.bursts[0];
assert!(burst.event_count > 5);
assert!(burst.z_score > 2.0);
}
#[test]
fn test_no_burst_uniform() {
let config = BurstConfig::default();
let timestamps: Vec<f64> = (0..100).map(|i| i as f64 * 100.0).collect();
let result = detect_bursts(×tamps, &config);
assert!(result.bursts.is_empty(), "Uniform rate should not trigger burst");
}
#[test]
fn test_urgency_overdue() {
let config = DeadlineUrgencyConfig::default();
let urgency = deadline_urgency(1000.0, 2000.0, &config);
assert!((urgency - 1.0).abs() < 1e-10);
}
#[test]
fn test_urgency_far_away() {
let config = DeadlineUrgencyConfig::default();
let urgency = deadline_urgency(1_000_000.0, 0.0, &config);
assert!((urgency - 0.02).abs() < 1e-10, "Far-away deadline should equal floor: {urgency}");
}
#[test]
fn test_urgency_monotonic_increase() {
let config = DeadlineUrgencyConfig::default();
let deadline = 100_000.0;
let mut prev = 0.0;
for t in (0..100_000).step_by(10_000) {
let u = deadline_urgency(deadline, t as f64, &config);
assert!(u >= prev - 1e-10, "Urgency should increase: {prev} -> {u} at t={t}");
prev = u;
}
}
#[test]
fn test_compound_urgency_priority_scaling() {
let config = DeadlineUrgencyConfig::default();
let deadline = 100_000.0;
let now = 50_000.0;
let low = compound_urgency(deadline, now, 0.25, &config);
let high = compound_urgency(deadline, now, 1.0, &config);
assert!(high >= low, "Higher priority should mean higher urgency: low={low}, high={high}");
}
#[test]
fn test_mine_recurring_sequence() {
let config = MotifConfig {
max_gap_secs: 3600.0,
min_length: 2,
max_length: 3,
min_occurrences: 3,
};
let mut events = Vec::new();
for day in 0..3 {
let base = day as f64 * 86400.0;
events.push(LabeledEvent { label: "email".to_string(), timestamp: base + 100.0 });
events.push(LabeledEvent { label: "calendar".to_string(), timestamp: base + 600.0 });
events.push(LabeledEvent { label: "standup".to_string(), timestamp: base + 1200.0 });
}
let motifs = mine_temporal_motifs(&events, &config);
assert!(!motifs.is_empty());
let has_email_cal = motifs.iter().any(|m| {
m.sequence.len() >= 2
&& m.sequence[0] == "email"
&& m.sequence[1] == "calendar"
});
assert!(has_email_cal, "Should find email→calendar motif: {:?}", motifs);
}
#[test]
fn test_motif_gap_constraint() {
let config = MotifConfig {
max_gap_secs: 100.0,
min_length: 2,
max_length: 3,
min_occurrences: 2,
};
let events: Vec<LabeledEvent> = (0..6).map(|i| {
LabeledEvent {
label: if i % 2 == 0 { "a".to_string() } else { "b".to_string() },
timestamp: i as f64 * 1000.0, }
}).collect();
let motifs = mine_temporal_motifs(&events, &config);
assert!(motifs.is_empty(), "Large gaps should prevent motif detection");
}
#[test]
fn test_topological_order_simple() {
use super::super::state::{NodeId, NodeKind};
let a = NodeId::new(NodeKind::Episode, 1);
let b = NodeId::new(NodeKind::Episode, 2);
let c = NodeId::new(NodeKind::Episode, 3);
let events = vec![
TemporalEvent { node_id: a, timestamp: 100.0, predecessors: vec![] },
TemporalEvent { node_id: b, timestamp: 200.0, predecessors: vec![a] },
TemporalEvent { node_id: c, timestamp: 300.0, predecessors: vec![b] },
];
let order = topological_order(&events);
assert_eq!(order.ordered.len(), 3);
assert_eq!(order.ordered[0], a);
assert_eq!(order.ordered[1], b);
assert_eq!(order.ordered[2], c);
assert!(order.cycles.is_empty());
}
#[test]
fn test_topological_order_with_cycle() {
use super::super::state::{NodeId, NodeKind};
let a = NodeId::new(NodeKind::Episode, 1);
let b = NodeId::new(NodeKind::Episode, 2);
let events = vec![
TemporalEvent { node_id: a, timestamp: 100.0, predecessors: vec![b] },
TemporalEvent { node_id: b, timestamp: 200.0, predecessors: vec![a] },
];
let order = topological_order(&events);
assert!(!order.cycles.is_empty());
}
#[test]
fn test_topological_depth() {
use super::super::state::{NodeId, NodeKind};
let a = NodeId::new(NodeKind::Episode, 1);
let b = NodeId::new(NodeKind::Episode, 2);
let c = NodeId::new(NodeKind::Episode, 3);
let events = vec![
TemporalEvent { node_id: a, timestamp: 100.0, predecessors: vec![] },
TemporalEvent { node_id: b, timestamp: 200.0, predecessors: vec![a] },
TemporalEvent { node_id: c, timestamp: 300.0, predecessors: vec![a, b] },
];
let order = topological_order(&events);
assert_eq!(*order.depths.get(&b.to_raw()).unwrap_or(&0), 1);
assert_eq!(*order.depths.get(&c.to_raw()).unwrap_or(&0), 2);
}
#[test]
fn test_seasonal_histogram_peak() {
let mut hist = SeasonalHistogram::hour_of_day();
for _ in 0..20 { hist.add(9); }
for _ in 0..5 { hist.add(14); }
for _ in 0..3 { hist.add(22); }
let (peak_bin, peak_count) = hist.peak();
assert_eq!(peak_bin, 9);
assert_eq!(peak_count, 20);
}
#[test]
fn test_seasonal_entropy() {
let mut uniform = SeasonalHistogram::new(4, "test");
for _ in 0..25 { uniform.add(0); }
for _ in 0..25 { uniform.add(1); }
for _ in 0..25 { uniform.add(2); }
for _ in 0..25 { uniform.add(3); }
let mut concentrated = SeasonalHistogram::new(4, "test");
for _ in 0..100 { concentrated.add(0); }
assert!(uniform.entropy() > concentrated.entropy());
}
#[test]
fn test_concentration_ratio() {
let mut hist = SeasonalHistogram::new(10, "test");
for _ in 0..50 { hist.add(0); }
for _ in 0..30 { hist.add(1); }
for _ in 0..20 { hist.add(2); }
let c2 = hist.concentration(2);
assert!((c2 - 0.80).abs() < 1e-10); }
#[test]
fn test_composite_all_recent() {
let config = TemporalRelevanceConfig::default();
let now = 1000.0;
let score = temporal_relevance_composite(
now, now, None, None, &config,
);
assert!((score - 0.4).abs() < 0.05);
}
#[test]
fn test_composite_with_deadline() {
let config = TemporalRelevanceConfig::default();
let now = 1000.0;
let deadline = 1001.0; let score = temporal_relevance_composite(
now, now, None, Some(deadline), &config,
);
assert!(score > 0.5);
}
#[test]
fn test_day_of_week_utc() {
let monday = 1704067200.0;
assert_eq!(day_of_week_utc(monday), 0); }
#[test]
fn test_hour_of_day_utc() {
let midnight = 86400.0 * 100.0; assert_eq!(hour_of_day_utc(midnight), 0);
let nine_am = midnight + 9.0 * 3600.0;
assert_eq!(hour_of_day_utc(nine_am), 9);
}
}