Skip to main content

stt_optimize/analysis/
temporal.rs

1//! Temporal distribution and bucketing analysis
2//!
3//! Analyzes the temporal distribution of features to recommend
4//! temporal bucketing and identify patterns.
5
6use crate::loader::LoadedData;
7use anyhow::Result;
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12/// Temporal analysis results
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct TemporalAnalysis {
15    /// Time range start (Unix ms)
16    pub time_start: u64,
17    /// Time range end (Unix ms)
18    pub time_end: u64,
19    /// Duration in milliseconds
20    pub duration_ms: u64,
21    /// Duration as human-readable string
22    pub duration_human: String,
23    /// Number of unique timestamps
24    pub unique_timestamps: usize,
25    /// Temporal distribution classification
26    pub distribution: TemporalDistribution,
27    /// Recommended temporal bucket size in milliseconds
28    pub recommended_bucket_ms: u64,
29    /// Recommended bucket size as human-readable string
30    pub recommended_bucket_human: String,
31    /// Hourly distribution (24 buckets, 0-23)
32    pub hourly_distribution: Vec<u32>,
33    /// Daily distribution (7 buckets, 0=Sunday)
34    pub daily_distribution: Vec<u32>,
35    /// Monthly distribution (12 buckets)
36    pub monthly_distribution: Vec<u32>,
37    /// Events per day statistics
38    pub events_per_day: EventsPerDayStats,
39}
40
41impl TemporalAnalysis {
42    /// Get time range description
43    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/// Events per day statistics
55#[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/// Classification of temporal distribution
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub enum TemporalDistribution {
67    /// Events distributed uniformly over time
68    Uniform,
69    /// Events clustered in bursts
70    Bursty,
71    /// Events follow a periodic pattern (daily, weekly, etc.)
72    Periodic,
73    /// Events are sparse with long gaps
74    Sparse,
75    /// Single point in time or very short duration
76    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
91/// Analyze temporal characteristics of the dataset
92pub 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    // Collect all timestamps
102    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    // Calculate hourly, daily, monthly distributions
111    let (hourly, daily, monthly) = calculate_distributions(&timestamps);
112
113    // Calculate events per day stats
114    let events_per_day = calculate_events_per_day(&timestamps, duration_ms);
115
116    // Classify distribution
117    let distribution = classify_distribution(&events_per_day, &hourly, unique_timestamps, duration_ms);
118
119    // Recommend bucket size
120    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
168/// Calculate hourly (0-23), daily (0-6), and monthly (0-11) distributions
169fn 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
195/// Calculate events per day statistics
196fn 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    // Group by day
208    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
249/// Classify temporal distribution based on statistics
250fn classify_distribution(
251    events_per_day: &EventsPerDayStats,
252    hourly: &[u32],
253    unique_timestamps: usize,
254    duration_ms: u64,
255) -> TemporalDistribution {
256    // Very short duration = instantaneous
257    let one_day_ms = 86_400_000u64;
258    if duration_ms < one_day_ms {
259        return TemporalDistribution::Instantaneous;
260    }
261
262    // Few unique timestamps over long period = sparse
263    let _expected_unique = duration_ms / 60_000; // One per minute
264    if unique_timestamps < 100 && duration_ms > one_day_ms * 30 {
265        return TemporalDistribution::Sparse;
266    }
267
268    // High coefficient of variation = bursty
269    if events_per_day.std_dev > events_per_day.avg * 1.5 {
270        return TemporalDistribution::Bursty;
271    }
272
273    // Check for periodicity in hourly distribution
274    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
288/// Recommend a temporal bucket size
289fn recommend_bucket_size(
290    duration_ms: u64,
291    _unique_timestamps: usize,
292    _feature_count: usize,
293    distribution: &TemporalDistribution,
294) -> (u64, String) {
295    // Target: aim for 1000-2000 unique temporal buckets
296
297    if duration_ms == 0 {
298        return (0, "N/A".to_string());
299    }
300
301    // Standard bucket sizes in milliseconds
302    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    // Calculate target bucket count
320    let target_buckets = match distribution {
321        TemporalDistribution::Bursty => 2000, // More buckets for bursty data
322        TemporalDistribution::Sparse => 500,  // Fewer buckets for sparse
323        _ => 1500,
324    };
325
326    // Find bucket size that gives us close to target buckets
327    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    // Default to 1 day if nothing else fits
335    (86_400_000, "1 day".to_string())
336}
337
338/// Format duration as human-readable string
339fn 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        // 1 year duration
387        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); // At least 1 hour
390    }
391
392    #[test]
393    fn test_recommend_bucket_targets_1500_buckets() {
394        // For a uniform distribution the target is ~1500 buckets. Over a 30-day
395        // span the chosen bucket should land at-or-under that target (the picker
396        // walks bucket sizes up until bucket_count <= target).
397        let span = 30 * 86_400_000u64; // 30 days
398        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        // 30 days / 1500 buckets ~= 28.8 min, so the picker should land on the
411        // 30-minute bucket (the first standard size at-or-under target).
412        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        // Sparse distribution targets only 500 buckets, so for the same span it
426        // should choose a coarser (>=) bucket than a uniform distribution.
427        let span = 365 * 86_400_000u64; // 1 year
428        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