1use crate::loader::LoadedData;
7use anyhow::Result;
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct TemporalAnalysis {
15 pub time_start: u64,
17 pub time_end: u64,
19 pub duration_ms: u64,
21 pub duration_human: String,
23 pub unique_timestamps: usize,
25 pub distribution: TemporalDistribution,
27 pub recommended_bucket_ms: u64,
29 pub recommended_bucket_human: String,
31 pub hourly_distribution: Vec<u32>,
33 pub daily_distribution: Vec<u32>,
35 pub monthly_distribution: Vec<u32>,
37 pub events_per_day: EventsPerDayStats,
39}
40
41impl TemporalAnalysis {
42 pub fn time_range_description(&self) -> String {
44 let start = DateTime::<Utc>::from_timestamp_millis(self.time_start as i64)
45 .map(|dt| dt.format("%Y-%m-%d").to_string())
46 .unwrap_or_else(|| "unknown".to_string());
47 let end = DateTime::<Utc>::from_timestamp_millis(self.time_end as i64)
48 .map(|dt| dt.format("%Y-%m-%d").to_string())
49 .unwrap_or_else(|| "unknown".to_string());
50 format!("{} to {} ({})", start, end, self.duration_human)
51 }
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct EventsPerDayStats {
57 pub min: f64,
58 pub max: f64,
59 pub avg: f64,
60 pub median: f64,
61 pub std_dev: f64,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66pub enum TemporalDistribution {
67 Uniform,
69 Bursty,
71 Periodic,
73 Sparse,
75 Instantaneous,
77}
78
79impl std::fmt::Display for TemporalDistribution {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 match self {
82 TemporalDistribution::Uniform => write!(f, "Uniform (evenly distributed)"),
83 TemporalDistribution::Bursty => write!(f, "Bursty (clustered in time)"),
84 TemporalDistribution::Periodic => write!(f, "Periodic (regular pattern)"),
85 TemporalDistribution::Sparse => write!(f, "Sparse (long gaps between events)"),
86 TemporalDistribution::Instantaneous => write!(f, "Instantaneous (single moment)"),
87 }
88 }
89}
90
91pub fn analyze(data: &LoadedData) -> Result<TemporalAnalysis> {
93 if data.features.is_empty() {
94 return Ok(empty_analysis());
95 }
96
97 let time_start = data.time_range.start;
98 let time_end = data.time_range.end;
99 let duration_ms = time_end.saturating_sub(time_start);
100
101 let timestamps: Vec<u64> = data.features.iter().map(|f| f.timestamp).collect();
103 let unique_timestamps = {
104 let mut ts = timestamps.clone();
105 ts.sort();
106 ts.dedup();
107 ts.len()
108 };
109
110 let (hourly, daily, monthly) = calculate_distributions(×tamps);
112
113 let events_per_day = calculate_events_per_day(×tamps, duration_ms);
115
116 let distribution = classify_distribution(&events_per_day, &hourly, unique_timestamps, duration_ms);
118
119 let (bucket_ms, bucket_human) = recommend_bucket_size(
121 duration_ms,
122 unique_timestamps,
123 data.features.len(),
124 &distribution,
125 );
126
127 let duration_human = format_duration(duration_ms);
128
129 Ok(TemporalAnalysis {
130 time_start,
131 time_end,
132 duration_ms,
133 duration_human,
134 unique_timestamps,
135 distribution,
136 recommended_bucket_ms: bucket_ms,
137 recommended_bucket_human: bucket_human,
138 hourly_distribution: hourly,
139 daily_distribution: daily,
140 monthly_distribution: monthly,
141 events_per_day,
142 })
143}
144
145fn empty_analysis() -> TemporalAnalysis {
146 TemporalAnalysis {
147 time_start: 0,
148 time_end: 0,
149 duration_ms: 0,
150 duration_human: "0".to_string(),
151 unique_timestamps: 0,
152 distribution: TemporalDistribution::Instantaneous,
153 recommended_bucket_ms: 0,
154 recommended_bucket_human: "N/A".to_string(),
155 hourly_distribution: vec![0; 24],
156 daily_distribution: vec![0; 7],
157 monthly_distribution: vec![0; 12],
158 events_per_day: EventsPerDayStats {
159 min: 0.0,
160 max: 0.0,
161 avg: 0.0,
162 median: 0.0,
163 std_dev: 0.0,
164 },
165 }
166}
167
168fn calculate_distributions(timestamps: &[u64]) -> (Vec<u32>, Vec<u32>, Vec<u32>) {
170 let mut hourly = vec![0u32; 24];
171 let mut daily = vec![0u32; 7];
172 let mut monthly = vec![0u32; 12];
173
174 for &ts in timestamps {
175 if let Some(dt) = DateTime::<Utc>::from_timestamp_millis(ts as i64) {
176 let hour = dt.format("%H").to_string().parse::<usize>().unwrap_or(0);
177 let weekday = dt.format("%w").to_string().parse::<usize>().unwrap_or(0);
178 let month = dt.format("%m").to_string().parse::<usize>().unwrap_or(1) - 1;
179
180 if hour < 24 {
181 hourly[hour] += 1;
182 }
183 if weekday < 7 {
184 daily[weekday] += 1;
185 }
186 if month < 12 {
187 monthly[month] += 1;
188 }
189 }
190 }
191
192 (hourly, daily, monthly)
193}
194
195fn calculate_events_per_day(timestamps: &[u64], duration_ms: u64) -> EventsPerDayStats {
197 if timestamps.is_empty() || duration_ms == 0 {
198 return EventsPerDayStats {
199 min: 0.0,
200 max: 0.0,
201 avg: 0.0,
202 median: 0.0,
203 std_dev: 0.0,
204 };
205 }
206
207 let mut daily_counts: HashMap<i64, u32> = HashMap::new();
209 let ms_per_day: i64 = 86_400_000;
210
211 for &ts in timestamps {
212 let day = ts as i64 / ms_per_day;
213 *daily_counts.entry(day).or_insert(0) += 1;
214 }
215
216 if daily_counts.is_empty() {
217 return EventsPerDayStats {
218 min: 0.0,
219 max: 0.0,
220 avg: timestamps.len() as f64,
221 median: timestamps.len() as f64,
222 std_dev: 0.0,
223 };
224 }
225
226 let counts: Vec<f64> = daily_counts.values().map(|&c| c as f64).collect();
227 let n = counts.len() as f64;
228
229 let min = counts.iter().cloned().fold(f64::MAX, f64::min);
230 let max = counts.iter().cloned().fold(f64::MIN, f64::max);
231 let avg = counts.iter().sum::<f64>() / n;
232
233 let mut sorted_counts = counts.clone();
234 sorted_counts.sort_by(|a, b| a.partial_cmp(b).unwrap());
235 let median = sorted_counts[sorted_counts.len() / 2];
236
237 let variance = counts.iter().map(|c| (c - avg).powi(2)).sum::<f64>() / n;
238 let std_dev = variance.sqrt();
239
240 EventsPerDayStats {
241 min,
242 max,
243 avg,
244 median,
245 std_dev,
246 }
247}
248
249fn classify_distribution(
251 events_per_day: &EventsPerDayStats,
252 hourly: &[u32],
253 unique_timestamps: usize,
254 duration_ms: u64,
255) -> TemporalDistribution {
256 let one_day_ms = 86_400_000u64;
258 if duration_ms < one_day_ms {
259 return TemporalDistribution::Instantaneous;
260 }
261
262 let _expected_unique = duration_ms / 60_000; if unique_timestamps < 100 && duration_ms > one_day_ms * 30 {
265 return TemporalDistribution::Sparse;
266 }
267
268 if events_per_day.std_dev > events_per_day.avg * 1.5 {
270 return TemporalDistribution::Bursty;
271 }
272
273 let hourly_max = hourly.iter().max().copied().unwrap_or(0) as f64;
275 let hourly_min = hourly.iter().min().copied().unwrap_or(0) as f64;
276 let hourly_avg = hourly.iter().map(|&h| h as f64).sum::<f64>() / 24.0;
277
278 if hourly_avg > 0.0 {
279 let hourly_variation = (hourly_max - hourly_min) / hourly_avg;
280 if hourly_variation > 2.0 {
281 return TemporalDistribution::Periodic;
282 }
283 }
284
285 TemporalDistribution::Uniform
286}
287
288fn recommend_bucket_size(
290 duration_ms: u64,
291 _unique_timestamps: usize,
292 _feature_count: usize,
293 distribution: &TemporalDistribution,
294) -> (u64, String) {
295 if duration_ms == 0 {
298 return (0, "N/A".to_string());
299 }
300
301 let bucket_sizes = [
303 (1_000, "1 second"),
304 (60_000, "1 minute"),
305 (300_000, "5 minutes"),
306 (600_000, "10 minutes"),
307 (900_000, "15 minutes"),
308 (1_800_000, "30 minutes"),
309 (3_600_000, "1 hour"),
310 (7_200_000, "2 hours"),
311 (14_400_000, "4 hours"),
312 (21_600_000, "6 hours"),
313 (43_200_000, "12 hours"),
314 (86_400_000, "1 day"),
315 (604_800_000, "1 week"),
316 (2_592_000_000, "30 days"),
317 ];
318
319 let target_buckets = match distribution {
321 TemporalDistribution::Bursty => 2000, TemporalDistribution::Sparse => 500, _ => 1500,
324 };
325
326 for (size_ms, name) in bucket_sizes.iter() {
328 let bucket_count = duration_ms / size_ms;
329 if bucket_count <= target_buckets as u64 {
330 return (*size_ms, name.to_string());
331 }
332 }
333
334 (86_400_000, "1 day".to_string())
336}
337
338fn format_duration(ms: u64) -> String {
340 let seconds = ms / 1000;
341 let minutes = seconds / 60;
342 let hours = minutes / 60;
343 let days = hours / 24;
344 let months = days / 30;
345 let years = days / 365;
346
347 if years > 0 {
348 let remaining_months = (days - years * 365) / 30;
349 if remaining_months > 0 {
350 format!("{} years, {} months", years, remaining_months)
351 } else {
352 format!("{} years", years)
353 }
354 } else if months > 0 {
355 let remaining_days = days - months * 30;
356 if remaining_days > 0 {
357 format!("{} months, {} days", months, remaining_days)
358 } else {
359 format!("{} months", months)
360 }
361 } else if days > 0 {
362 format!("{} days", days)
363 } else if hours > 0 {
364 format!("{} hours", hours)
365 } else if minutes > 0 {
366 format!("{} minutes", minutes)
367 } else {
368 format!("{} seconds", seconds)
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 #[test]
377 fn test_format_duration() {
378 assert_eq!(format_duration(1000), "1 seconds");
379 assert_eq!(format_duration(3600000), "1 hours");
380 assert_eq!(format_duration(86400000), "1 days");
381 assert_eq!(format_duration(86400000 * 365), "1 years");
382 }
383
384 #[test]
385 fn test_recommend_bucket_size() {
386 let one_year = 365 * 86_400_000u64;
388 let (bucket, _) = recommend_bucket_size(one_year, 10000, 100000, &TemporalDistribution::Uniform);
389 assert!(bucket >= 3_600_000); }
391
392 #[test]
393 fn test_recommend_bucket_targets_1500_buckets() {
394 let span = 30 * 86_400_000u64; let target = 1500u64;
399 let (bucket, name) =
400 recommend_bucket_size(span, 5000, 50000, &TemporalDistribution::Uniform);
401 assert!(bucket > 0, "bucket must be non-zero for a real span");
402 let bucket_count = span / bucket;
403 assert!(
404 bucket_count <= target,
405 "30-day span chose {} ({} buckets), exceeds target {}",
406 name,
407 bucket_count,
408 target
409 );
410 assert_eq!(bucket, 1_800_000, "expected 30-minute bucket, got {}", name);
413 }
414
415 #[test]
416 fn test_recommend_bucket_zero_for_empty_span() {
417 let (bucket, name) =
418 recommend_bucket_size(0, 0, 0, &TemporalDistribution::Instantaneous);
419 assert_eq!(bucket, 0);
420 assert_eq!(name, "N/A");
421 }
422
423 #[test]
424 fn test_recommend_bucket_sparse_uses_fewer_buckets() {
425 let span = 365 * 86_400_000u64; let (uniform, _) =
429 recommend_bucket_size(span, 1000, 10000, &TemporalDistribution::Uniform);
430 let (sparse, _) =
431 recommend_bucket_size(span, 1000, 10000, &TemporalDistribution::Sparse);
432 assert!(
433 sparse >= uniform,
434 "sparse bucket {} should be >= uniform bucket {}",
435 sparse,
436 uniform
437 );
438 }
439}
440