use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use super::temporal::SeasonalHistogram;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HawkesParams {
pub mu: f64,
pub alpha: f64,
pub beta: f64,
}
impl HawkesParams {
pub fn new(mu: f64, alpha: f64, beta: f64) -> Self {
debug_assert!(mu > 0.0, "mu must be positive: {mu}");
debug_assert!(alpha >= 0.0, "alpha must be non-negative: {alpha}");
debug_assert!(beta > 0.0, "beta must be positive: {beta}");
debug_assert!(
alpha < beta,
"branching ratio alpha/beta must be < 1 for stability: {alpha}/{beta}"
);
Self { mu, alpha, beta }
}
pub fn from_rate(observed_rate: f64) -> Self {
let mu = observed_rate.max(1e-8);
let beta = 1.0 / 300.0; let alpha = beta * 0.3; Self { mu, alpha, beta }
}
#[inline]
pub fn branching_ratio(&self) -> f64 {
if self.beta > 0.0 {
self.alpha / self.beta
} else {
0.0
}
}
pub fn stationary_rate(&self) -> f64 {
let br = self.branching_ratio();
if br >= 1.0 {
f64::INFINITY
} else {
self.mu / (1.0 - br)
}
}
pub fn excitation_window(&self) -> f64 {
if self.beta > 0.0 {
4.6 / self.beta
} else {
f64::INFINITY
}
}
}
impl Default for HawkesParams {
fn default() -> Self {
Self {
mu: 1.0 / 3600.0, alpha: 0.001, beta: 1.0 / 300.0, }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CircadianProfile {
pub hourly_multipliers: [f64; 24],
pub observation_count: u32,
}
impl CircadianProfile {
pub fn flat() -> Self {
Self {
hourly_multipliers: [1.0; 24],
observation_count: 0,
}
}
pub fn from_histogram(hist: &SeasonalHistogram) -> Self {
assert!(hist.num_bins == 24, "Expected 24-bin hour histogram");
let total = hist.total.max(1) as f64;
let smoothing = 1.0; let smoothed_total = total + 24.0 * smoothing;
let mut multipliers = [0.0; 24];
for i in 0..24 {
let smoothed_count = hist.counts[i] as f64 + smoothing;
multipliers[i] = (smoothed_count / smoothed_total) * 24.0;
}
Self {
hourly_multipliers: multipliers,
observation_count: hist.total,
}
}
pub fn multiplier_at(&self, timestamp: f64) -> f64 {
let secs_in_day = timestamp % 86400.0;
let fractional_hour = secs_in_day / 3600.0;
let hour_low = fractional_hour.floor() as usize % 24;
let hour_high = (hour_low + 1) % 24;
let frac = fractional_hour - fractional_hour.floor();
let m_low = self.hourly_multipliers[hour_low];
let m_high = self.hourly_multipliers[hour_high];
m_low + frac * (m_high - m_low)
}
pub fn observe(&mut self, timestamp: f64, learning_rate: f64) {
let hour = super::temporal::hour_of_day_utc(timestamp);
self.observation_count += 1;
for i in 0..24 {
if i == hour {
self.hourly_multipliers[i] += learning_rate * (2.0 - self.hourly_multipliers[i]);
} else {
self.hourly_multipliers[i] +=
learning_rate * 0.1 * (1.0 - self.hourly_multipliers[i]);
}
}
let sum: f64 = self.hourly_multipliers.iter().sum();
if sum > 0.0 {
let scale = 24.0 / sum;
for m in &mut self.hourly_multipliers {
*m *= scale;
}
}
}
}
impl Default for CircadianProfile {
fn default() -> Self {
Self::flat()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventTypeModel {
pub label: String,
pub params: HawkesParams,
pub circadian: CircadianProfile,
pub recent_events: Vec<f64>,
pub max_history: usize,
pub total_observations: u64,
pub sum_inter_event: f64,
pub sum_inter_event_sq: f64,
pub inter_event_count: u64,
pub last_refit_at: f64,
pub refit_interval: f64,
}
impl EventTypeModel {
pub fn new(label: &str) -> Self {
Self {
label: label.to_string(),
params: HawkesParams::default(),
circadian: CircadianProfile::flat(),
recent_events: Vec::new(),
max_history: 500,
total_observations: 0,
sum_inter_event: 0.0,
sum_inter_event_sq: 0.0,
inter_event_count: 0,
last_refit_at: 0.0,
refit_interval: 3600.0, }
}
pub fn from_observations(label: &str, timestamps: &[f64]) -> Self {
let mut model = Self::new(label);
if timestamps.is_empty() {
return model;
}
let mut sorted = timestamps.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let mut intervals = Vec::new();
for w in sorted.windows(2) {
let dt = w[1] - w[0];
if dt > 0.0 {
intervals.push(dt);
}
}
if !intervals.is_empty() {
let mean_interval: f64 = intervals.iter().sum::<f64>() / intervals.len() as f64;
let var_interval: f64 = intervals
.iter()
.map(|&dt| (dt - mean_interval).powi(2))
.sum::<f64>()
/ intervals.len() as f64;
let mu = if mean_interval > 0.0 {
1.0 / mean_interval
} else {
1.0 / 3600.0
};
let cv_squared = if mean_interval > 0.0 {
var_interval / (mean_interval * mean_interval)
} else {
0.0
};
let branching_ratio = (cv_squared / (1.0 + cv_squared)).clamp(0.05, 0.85);
let mut sorted_intervals = intervals.clone();
sorted_intervals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let median = sorted_intervals[sorted_intervals.len() / 2];
let beta = if median > 0.0 {
1.0 / median
} else {
1.0 / 300.0
};
let alpha = branching_ratio * beta;
model.params = HawkesParams { mu, alpha, beta };
model.sum_inter_event = intervals.iter().sum();
model.sum_inter_event_sq = intervals.iter().map(|dt| dt * dt).sum();
model.inter_event_count = intervals.len() as u64;
}
let mut hist = super::temporal::SeasonalHistogram::hour_of_day();
for &t in &sorted {
hist.add(super::temporal::hour_of_day_utc(t));
}
model.circadian = CircadianProfile::from_histogram(&hist);
let window = model.params.excitation_window();
let cutoff = sorted.last().unwrap() - window;
model.recent_events = sorted.into_iter().filter(|&t| t >= cutoff).collect();
if model.recent_events.len() > model.max_history {
let start = model.recent_events.len() - model.max_history;
model.recent_events = model.recent_events[start..].to_vec();
}
model.total_observations = timestamps.len() as u64;
model
}
pub fn observe(&mut self, timestamp: f64) {
if let Some(&last) = self.recent_events.last() {
let dt = timestamp - last;
if dt > 0.0 {
self.sum_inter_event += dt;
self.sum_inter_event_sq += dt * dt;
self.inter_event_count += 1;
}
}
self.recent_events.push(timestamp);
self.total_observations += 1;
let window = self.params.excitation_window();
let cutoff = timestamp - window;
self.recent_events.retain(|&t| t >= cutoff);
if self.recent_events.len() > self.max_history {
let start = self.recent_events.len() - self.max_history;
self.recent_events = self.recent_events[start..].to_vec();
}
self.circadian.observe(timestamp, 0.05);
if timestamp - self.last_refit_at >= self.refit_interval && self.inter_event_count >= 10 {
self.refit_parameters();
self.last_refit_at = timestamp;
}
}
fn refit_parameters(&mut self) {
if self.inter_event_count < 5 {
return;
}
let n = self.inter_event_count as f64;
let mean_dt = self.sum_inter_event / n;
let var_dt = (self.sum_inter_event_sq / n) - (mean_dt * mean_dt);
if mean_dt <= 0.0 {
return;
}
let new_mu = 1.0 / mean_dt;
self.params.mu = 0.7 * self.params.mu + 0.3 * new_mu;
self.params.mu = self.params.mu.max(1e-8);
let cv_sq = (var_dt / (mean_dt * mean_dt)).max(0.0);
let new_br = (cv_sq / (1.0 + cv_sq)).clamp(0.05, 0.85);
let new_beta = (1.0 / mean_dt).max(1e-6);
self.params.beta = 0.7 * self.params.beta + 0.3 * new_beta;
let old_br = self.params.branching_ratio();
let smoothed_br = 0.7 * old_br + 0.3 * new_br;
self.params.alpha = smoothed_br * self.params.beta;
if self.params.alpha >= self.params.beta {
self.params.alpha = self.params.beta * 0.85;
}
}
pub fn intensity(&self, t: f64) -> f64 {
let circadian = self.circadian.multiplier_at(t);
let base = self.params.mu * circadian;
let excitation: f64 = self
.recent_events
.iter()
.filter(|&&ti| ti < t)
.map(|&ti| self.params.alpha * (-self.params.beta * (t - ti)).exp())
.sum();
(base + excitation).max(0.0)
}
pub fn intensity_curve(&self, start: f64, end: f64, step_secs: f64) -> Vec<(f64, f64)> {
let mut curve = Vec::new();
let mut t = start;
while t <= end {
curve.push((t, self.intensity(t)));
t += step_secs;
}
curve
}
pub fn predict_next(
&self,
now: f64,
horizon_secs: f64,
step_secs: f64,
) -> Option<EventPrediction> {
if self.total_observations < 3 {
return None; }
let mut cumulative = 0.0;
let mut peak_intensity = 0.0_f64;
let mut peak_time = now;
let mut t = now;
let end = now + horizon_secs;
while t <= end {
let lambda = self.intensity(t);
cumulative += lambda * step_secs;
if lambda > peak_intensity {
peak_intensity = lambda;
peak_time = t;
}
if cumulative >= 1.0 {
let stationary = self.params.stationary_rate();
let confidence = if stationary > 0.0 {
(peak_intensity / stationary).clamp(0.0, 1.0)
} else {
0.5
};
return Some(EventPrediction {
predicted_time: t,
peak_intensity_time: peak_time,
peak_intensity,
confidence,
time_until: t - now,
});
}
t += step_secs;
}
if peak_intensity > 0.0 {
let stationary = self.params.stationary_rate();
let confidence = if stationary > 0.0 {
(peak_intensity / stationary * 0.3).clamp(0.0, 0.5)
} else {
0.1
};
Some(EventPrediction {
predicted_time: peak_time,
peak_intensity_time: peak_time,
peak_intensity,
confidence,
time_until: peak_time - now,
})
} else {
None
}
}
pub fn should_anticipate(&self, now: f64, threshold_multiplier: f64) -> bool {
let lambda = self.intensity(now);
let threshold = self.params.stationary_rate() * threshold_multiplier;
lambda > threshold
}
pub fn summary(&self) -> ModelSummary {
let (peak_hour, peak_mult) = self
.circadian
.hourly_multipliers
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.map(|(h, &m)| (h, m))
.unwrap_or((0, 1.0));
ModelSummary {
label: self.label.clone(),
base_rate_per_hour: self.params.mu * 3600.0,
stationary_rate_per_hour: self.params.stationary_rate() * 3600.0,
branching_ratio: self.params.branching_ratio(),
excitation_window_mins: self.params.excitation_window() / 60.0,
peak_hour,
peak_circadian_multiplier: peak_mult,
total_observations: self.total_observations,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventPrediction {
pub predicted_time: f64,
pub peak_intensity_time: f64,
pub peak_intensity: f64,
pub confidence: f64,
pub time_until: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelSummary {
pub label: String,
pub base_rate_per_hour: f64,
pub stationary_rate_per_hour: f64,
pub branching_ratio: f64,
pub excitation_window_mins: f64,
pub peak_hour: usize,
pub peak_circadian_multiplier: f64,
pub total_observations: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HawkesRegistry {
pub models: HashMap<String, EventTypeModel>,
pub config: HawkesRegistryConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HawkesRegistryConfig {
pub max_event_types: usize,
pub min_observations_for_prediction: u64,
pub prediction_horizon_secs: f64,
pub prediction_step_secs: f64,
pub anticipation_threshold: f64,
}
impl Default for HawkesRegistryConfig {
fn default() -> Self {
Self {
max_event_types: 100,
min_observations_for_prediction: 5,
prediction_horizon_secs: 3600.0 * 4.0, prediction_step_secs: 60.0, anticipation_threshold: 1.5,
}
}
}
impl HawkesRegistry {
pub fn new() -> Self {
Self {
models: HashMap::new(),
config: HawkesRegistryConfig::default(),
}
}
pub fn with_config(config: HawkesRegistryConfig) -> Self {
Self {
models: HashMap::new(),
config,
}
}
pub fn observe(&mut self, label: &str, timestamp: f64) {
if let Some(model) = self.models.get_mut(label) {
model.observe(timestamp);
} else {
if self.models.len() >= self.config.max_event_types {
self.evict_oldest();
}
let mut model = EventTypeModel::new(label);
model.observe(timestamp);
self.models.insert(label.to_string(), model);
}
}
pub fn observe_batch(&mut self, label: &str, timestamps: &[f64]) {
if timestamps.is_empty() {
return;
}
if !self.models.contains_key(label) && self.models.len() >= self.config.max_event_types {
self.evict_oldest();
}
let model = EventTypeModel::from_observations(label, timestamps);
self.models.insert(label.to_string(), model);
}
pub fn anticipate_all(&self, now: f64) -> Vec<AnticipatedEvent> {
let mut anticipated = Vec::new();
for (label, model) in &self.models {
if model.total_observations < self.config.min_observations_for_prediction {
continue;
}
if model.should_anticipate(now, self.config.anticipation_threshold) {
if let Some(pred) = model.predict_next(
now,
self.config.prediction_horizon_secs,
self.config.prediction_step_secs,
) {
anticipated.push(AnticipatedEvent {
label: label.clone(),
prediction: pred,
current_intensity: model.intensity(now),
stationary_rate: model.params.stationary_rate(),
});
}
}
}
anticipated.sort_by(|a, b| {
b.prediction
.confidence
.partial_cmp(&a.prediction.confidence)
.unwrap_or(std::cmp::Ordering::Equal)
});
anticipated
}
pub fn predict(&self, label: &str, now: f64) -> Option<EventPrediction> {
let model = self.models.get(label)?;
if model.total_observations < self.config.min_observations_for_prediction {
return None;
}
model.predict_next(
now,
self.config.prediction_horizon_secs,
self.config.prediction_step_secs,
)
}
pub fn summaries(&self) -> Vec<ModelSummary> {
let mut summaries: Vec<ModelSummary> = self.models.values().map(|m| m.summary()).collect();
summaries.sort_by(|a, b| b.total_observations.cmp(&a.total_observations));
summaries
}
pub fn model_count(&self) -> usize {
self.models.len()
}
fn evict_oldest(&mut self) {
let oldest = self
.models
.iter()
.min_by(|(_, a), (_, b)| {
let last_a = a.recent_events.last().copied().unwrap_or(0.0);
let last_b = b.recent_events.last().copied().unwrap_or(0.0);
last_a
.partial_cmp(&last_b)
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(k, _)| k.clone());
if let Some(key) = oldest {
self.models.remove(&key);
}
}
}
impl Default for HawkesRegistry {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnticipatedEvent {
pub label: String,
pub prediction: EventPrediction,
pub current_intensity: f64,
pub stationary_rate: f64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_params_stability() {
let p = HawkesParams::new(0.001, 0.002, 0.01);
assert!(p.branching_ratio() < 1.0);
assert!(p.stationary_rate() > p.mu);
assert!(p.excitation_window() > 0.0);
}
#[test]
fn test_params_from_rate() {
let p = HawkesParams::from_rate(0.001); assert!(p.mu > 0.0);
assert!(p.branching_ratio() < 1.0);
}
#[test]
fn test_stationary_rate() {
let p = HawkesParams::new(0.001, 0.005, 0.01);
let expected = 0.001 / (1.0 - 0.5);
assert!((p.stationary_rate() - expected).abs() < 1e-10);
}
#[test]
fn test_flat_profile() {
let profile = CircadianProfile::flat();
assert!((profile.multiplier_at(0.0) - 1.0).abs() < 1e-10);
assert!((profile.multiplier_at(43200.0) - 1.0).abs() < 1e-10);
}
#[test]
fn test_circadian_from_histogram() {
let mut hist = super::super::temporal::SeasonalHistogram::hour_of_day();
for _ in 0..100 {
hist.add(9);
}
let profile = CircadianProfile::from_histogram(&hist);
let mut peak_hour = 0;
let mut peak_val = 0.0;
for (i, &m) in profile.hourly_multipliers.iter().enumerate() {
if m > peak_val {
peak_val = m;
peak_hour = i;
}
}
assert_eq!(peak_hour, 9);
assert!(peak_val > 1.0, "Peak should be above average: {peak_val}");
}
#[test]
fn test_circadian_mean_normalized() {
let mut hist = super::super::temporal::SeasonalHistogram::hour_of_day();
for _ in 0..50 {
hist.add(9);
}
for _ in 0..30 {
hist.add(14);
}
for _ in 0..20 {
hist.add(21);
}
let profile = CircadianProfile::from_histogram(&hist);
let mean: f64 = profile.hourly_multipliers.iter().sum::<f64>() / 24.0;
assert!(
(mean - 1.0).abs() < 0.01,
"Mean multiplier should be ~1.0: {mean}"
);
}
#[test]
fn test_model_intensity_base() {
let model = EventTypeModel::new("test");
let lambda = model.intensity(1000.0);
assert!(lambda > 0.0);
assert!((lambda - model.params.mu).abs() < 0.01); }
#[test]
fn test_model_intensity_after_event() {
let mut model = EventTypeModel::new("test");
model.params = HawkesParams::new(0.001, 0.005, 0.01);
model.recent_events.push(1000.0);
model.total_observations = 1;
let lambda_just_after = model.intensity(1001.0);
let lambda_later = model.intensity(2000.0);
assert!(
lambda_just_after > lambda_later,
"Intensity should decay after event: {lambda_just_after} vs {lambda_later}"
);
}
#[test]
fn test_model_excitation_decay() {
let mut model = EventTypeModel::new("test");
model.params = HawkesParams::new(0.001, 0.005, 0.01);
model.recent_events.push(0.0);
model.total_observations = 1;
let lambda_0 = model.intensity(1.0);
let lambda_100 = model.intensity(100.0);
let lambda_1000 = model.intensity(1000.0);
assert!(lambda_0 > lambda_100);
assert!(lambda_100 > lambda_1000);
let base = model.params.mu; assert!(
(lambda_1000 - base).abs() < 0.001,
"Should converge to base: {lambda_1000} vs {base}"
);
}
#[test]
fn test_model_from_observations() {
let daily = 86400.0;
let base = 1_700_006_400.0; let timestamps: Vec<f64> = (0..14)
.map(|d| base + d as f64 * daily + 32400.0) .collect();
let hour = super::super::temporal::hour_of_day_utc(timestamps[0]);
let model = EventTypeModel::from_observations("daily_check", ×tamps);
assert_eq!(model.total_observations, 14);
assert!(model.params.mu > 0.0);
assert!(model.params.branching_ratio() < 1.0);
let summary = model.summary();
assert_eq!(summary.peak_hour, hour);
}
#[test]
fn test_model_observe_updates() {
let mut model = EventTypeModel::new("test");
for i in 0..20 {
model.observe(1000.0 + i as f64 * 100.0);
}
assert_eq!(model.total_observations, 20);
assert!(!model.recent_events.is_empty());
}
#[test]
fn test_model_predict_next() {
let timestamps: Vec<f64> = (0..50)
.map(|i| 1_000_000.0 + i as f64 * 600.0) .collect();
let model = EventTypeModel::from_observations("frequent", ×tamps);
let now = *timestamps.last().unwrap() + 60.0;
let pred = model.predict_next(now, 3600.0, 30.0);
assert!(pred.is_some(), "Should predict a next event");
let pred = pred.unwrap();
assert!(pred.time_until > 0.0);
assert!(pred.confidence > 0.0);
}
#[test]
fn test_model_should_anticipate() {
let mut model = EventTypeModel::new("test");
model.params = HawkesParams::new(0.001, 0.005, 0.01);
for i in 0..5 {
model.recent_events.push(1000.0 + i as f64 * 10.0);
}
model.total_observations = 5;
let should = model.should_anticipate(1060.0, 1.5);
assert!(should, "Should anticipate after burst");
}
#[test]
fn test_intensity_curve() {
let mut model = EventTypeModel::new("test");
model.params = HawkesParams::new(0.001, 0.005, 0.01);
model.recent_events.push(1000.0);
model.total_observations = 1;
let curve = model.intensity_curve(1000.0, 2000.0, 100.0);
assert!(!curve.is_empty());
let first_lambda = curve[1].1; let last_lambda = curve.last().unwrap().1;
assert!(first_lambda >= last_lambda);
}
#[test]
fn test_registry_observe() {
let mut registry = HawkesRegistry::new();
registry.observe("email_check", 1000.0);
registry.observe("email_check", 1600.0);
registry.observe("terminal_open", 1200.0);
assert_eq!(registry.model_count(), 2);
assert_eq!(
registry
.models
.get("email_check")
.unwrap()
.total_observations,
2
);
}
#[test]
fn test_registry_batch_observe() {
let mut registry = HawkesRegistry::new();
let timestamps: Vec<f64> = (0..30).map(|i| 1_000_000.0 + i as f64 * 3600.0).collect();
registry.observe_batch("hourly_task", ×tamps);
assert_eq!(registry.model_count(), 1);
let model = registry.models.get("hourly_task").unwrap();
assert_eq!(model.total_observations, 30);
}
#[test]
fn test_registry_eviction() {
let config = HawkesRegistryConfig {
max_event_types: 3,
..Default::default()
};
let mut registry = HawkesRegistry::with_config(config);
registry.observe("a", 100.0);
registry.observe("b", 200.0);
registry.observe("c", 300.0);
assert_eq!(registry.model_count(), 3);
registry.observe("d", 400.0);
assert_eq!(registry.model_count(), 3);
assert!(!registry.models.contains_key("a")); }
#[test]
fn test_registry_predict() {
let mut registry = HawkesRegistry::new();
let timestamps: Vec<f64> = (0..20).map(|i| 1_000_000.0 + i as f64 * 600.0).collect();
registry.observe_batch("check", ×tamps);
let now = *timestamps.last().unwrap() + 60.0;
let pred = registry.predict("check", now);
assert!(pred.is_some());
}
#[test]
fn test_registry_anticipate_all() {
let mut registry = HawkesRegistry::new();
for i in 0..10 {
registry.observe("active_event", 1_000_000.0 + i as f64 * 60.0);
}
for i in 0..6 {
registry.observe("rare_event", 1_000_000.0 + i as f64 * 86400.0);
}
let now = 1_000_000.0 + 660.0; let anticipated = registry.anticipate_all(now);
for ae in &anticipated {
assert!(ae.prediction.confidence >= 0.0);
assert!(ae.prediction.confidence <= 1.0);
}
}
#[test]
fn test_registry_summaries() {
let mut registry = HawkesRegistry::new();
registry.observe("email", 1000.0);
registry.observe("calendar", 2000.0);
let summaries = registry.summaries();
assert_eq!(summaries.len(), 2);
for s in &summaries {
assert!(s.base_rate_per_hour > 0.0);
assert!(s.branching_ratio < 1.0);
}
}
#[test]
fn test_refit_converges_to_true_rate() {
let mut model = EventTypeModel::new("regular");
let interval = 300.0;
for i in 0..100 {
model.observe(1_000_000.0 + i as f64 * interval);
}
let expected_mu = 1.0 / interval;
let ratio = model.params.mu / expected_mu;
assert!(
ratio > 0.5 && ratio < 2.0,
"μ should be within 2× of true rate: got {}, expected {}",
model.params.mu,
expected_mu
);
}
#[test]
fn test_clustered_events_higher_branching() {
let mut timestamps = Vec::new();
for burst in 0..10 {
let base = 1_000_000.0 + burst as f64 * 3600.0;
for j in 0..5 {
timestamps.push(base + j as f64 * 10.0); }
}
let clustered = EventTypeModel::from_observations("clustered", ×tamps);
let regular_ts: Vec<f64> = (0..50).map(|i| 1_000_000.0 + i as f64 * 720.0).collect();
let regular = EventTypeModel::from_observations("regular", ®ular_ts);
assert!(
clustered.params.branching_ratio() > regular.params.branching_ratio(),
"Clustered should have higher BR: {} vs {}",
clustered.params.branching_ratio(),
regular.params.branching_ratio()
);
}
}