1use cvx_core::error::AnalyticsError;
9
10#[derive(Debug, Clone)]
14pub struct DriftAttribution {
15 pub total_magnitude: f32,
17 pub dimensions: Vec<DimensionContribution>,
19}
20
21#[derive(Debug, Clone)]
23pub struct DimensionContribution {
24 pub index: usize,
26 pub absolute_change: f32,
28 pub relative_contribution: f32,
30}
31
32pub fn drift_attribution(v1: &[f32], v2: &[f32], top_k: usize) -> DriftAttribution {
36 assert_eq!(v1.len(), v2.len(), "dimension mismatch");
37
38 let changes: Vec<f32> = v1.iter().zip(v2.iter()).map(|(a, b)| b - a).collect();
39 let total_sq: f32 = changes.iter().map(|c| c * c).sum();
40 let total_magnitude = total_sq.sqrt();
41
42 let mut dims: Vec<DimensionContribution> = changes
43 .iter()
44 .enumerate()
45 .map(|(i, &c)| DimensionContribution {
46 index: i,
47 absolute_change: c.abs(),
48 relative_contribution: if total_sq > 0.0 {
49 (c * c) / total_sq
50 } else {
51 0.0
52 },
53 })
54 .collect();
55
56 dims.sort_by(|a, b| b.absolute_change.partial_cmp(&a.absolute_change).unwrap());
57 dims.truncate(top_k);
58
59 DriftAttribution {
60 total_magnitude,
61 dimensions: dims,
62 }
63}
64
65#[derive(Debug, Clone)]
69pub struct ProjectedPoint {
70 pub timestamp: i64,
72 pub x: f32,
74 pub y: f32,
76}
77
78pub fn project_trajectory_2d(
83 trajectory: &[(i64, &[f32])],
84) -> Result<Vec<ProjectedPoint>, AnalyticsError> {
85 if trajectory.len() < 2 {
86 return Err(AnalyticsError::InsufficientData {
87 needed: 2,
88 have: trajectory.len(),
89 });
90 }
91
92 let n = trajectory.len();
93 let dim = trajectory[0].1.len();
94
95 let mut mean = vec![0.0f64; dim];
97 for (_, v) in trajectory {
98 for d in 0..dim {
99 mean[d] += v[d] as f64;
100 }
101 }
102 for m in &mut mean {
103 *m /= n as f64;
104 }
105
106 let centered: Vec<Vec<f64>> = trajectory
108 .iter()
109 .map(|(_, v)| (0..dim).map(|d| v[d] as f64 - mean[d]).collect())
110 .collect();
111
112 let pc1 = power_iteration(¢ered, dim, None);
114 let pc2 = power_iteration(¢ered, dim, Some(&pc1));
115
116 let projected: Vec<ProjectedPoint> = trajectory
118 .iter()
119 .zip(centered.iter())
120 .map(|((ts, _), c)| {
121 let x: f64 = c.iter().zip(pc1.iter()).map(|(a, b)| a * b).sum();
122 let y: f64 = c.iter().zip(pc2.iter()).map(|(a, b)| a * b).sum();
123 ProjectedPoint {
124 timestamp: *ts,
125 x: x as f32,
126 y: y as f32,
127 }
128 })
129 .collect();
130
131 Ok(projected)
132}
133
134fn power_iteration(data: &[Vec<f64>], dim: usize, deflate_against: Option<&[f64]>) -> Vec<f64> {
139 let max_iter = 200;
140 let tol = 1e-10;
141
142 let mut v: Vec<f64> = if let Some(prev) = deflate_against {
144 let mut init = if data.len() > 1 {
147 data[1].clone()
148 } else {
149 let mut u = vec![0.0; dim];
150 if dim > 1 {
151 u[1] = 1.0;
152 }
153 u
154 };
155 let dot: f64 = init.iter().zip(prev.iter()).map(|(a, b)| a * b).sum();
157 for d in 0..dim {
158 init[d] -= dot * prev[d];
159 }
160 let norm: f64 = init.iter().map(|x| x * x).sum::<f64>().sqrt();
161 if norm > tol {
162 init.iter().map(|x| x / norm).collect()
163 } else {
164 let mut fallback = vec![0.0; dim];
166 for d in 0..dim {
167 fallback[d] = 1.0;
168 let dot: f64 = fallback.iter().zip(prev.iter()).map(|(a, b)| a * b).sum();
169 for dd in 0..dim {
170 fallback[dd] -= dot * prev[dd];
171 }
172 let norm: f64 = fallback.iter().map(|x| x * x).sum::<f64>().sqrt();
173 if norm > tol {
174 return fallback.iter().map(|x| x / norm).collect();
175 }
176 fallback = vec![0.0; dim];
177 }
178 fallback
179 }
180 } else if !data.is_empty() && data[0].iter().any(|&x| x != 0.0) {
181 let norm: f64 = data[0].iter().map(|x| x * x).sum::<f64>().sqrt();
182 data[0].iter().map(|x| x / norm).collect()
183 } else {
184 let mut init = vec![0.0; dim];
185 if dim > 0 {
186 init[0] = 1.0;
187 }
188 init
189 };
190
191 for _ in 0..max_iter {
192 let mut new_v = vec![0.0f64; dim];
194 for row in data {
195 let dot: f64 = row.iter().zip(v.iter()).map(|(a, b)| a * b).sum();
196 for d in 0..dim {
197 new_v[d] += row[d] * dot;
198 }
199 }
200
201 if let Some(prev) = deflate_against {
203 let dot: f64 = new_v.iter().zip(prev.iter()).map(|(a, b)| a * b).sum();
204 for d in 0..dim {
205 new_v[d] -= dot * prev[d];
206 }
207 }
208
209 let norm: f64 = new_v.iter().map(|x| x * x).sum::<f64>().sqrt();
211 if norm < tol {
212 return v;
213 }
214 for x in &mut new_v {
215 *x /= norm;
216 }
217
218 let diff: f64 = v
220 .iter()
221 .zip(new_v.iter())
222 .map(|(a, b)| (a - b) * (a - b))
223 .sum::<f64>()
224 .sqrt();
225
226 v = new_v;
227 if diff < tol {
228 break;
229 }
230 }
231
232 v
233}
234
235#[derive(Debug, Clone)]
239pub struct DimensionHeatmap {
240 pub time_bins: usize,
242 pub dimensions: usize,
244 pub timestamps: Vec<i64>,
246 pub data: Vec<f32>,
248}
249
250impl DimensionHeatmap {
251 pub fn get(&self, time_bin: usize, dim: usize) -> f32 {
253 self.data[time_bin * self.dimensions + dim]
254 }
255}
256
257pub fn dimension_heatmap(trajectory: &[(i64, &[f32])]) -> Result<DimensionHeatmap, AnalyticsError> {
261 if trajectory.len() < 2 {
262 return Err(AnalyticsError::InsufficientData {
263 needed: 2,
264 have: trajectory.len(),
265 });
266 }
267
268 let dim = trajectory[0].1.len();
269 let time_bins = trajectory.len() - 1;
270 let mut data = Vec::with_capacity(time_bins * dim);
271 let mut timestamps = Vec::with_capacity(time_bins);
272
273 for w in trajectory.windows(2) {
274 timestamps.push(w[1].0);
275 for d in 0..dim {
276 data.push((w[1].1[d] - w[0].1[d]).abs());
277 }
278 }
279
280 Ok(DimensionHeatmap {
281 time_bins,
282 dimensions: dim,
283 timestamps,
284 data,
285 })
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 fn make_trajectory(points: &[(i64, Vec<f32>)]) -> Vec<(i64, &[f32])> {
293 points.iter().map(|(t, v)| (*t, v.as_slice())).collect()
294 }
295
296 #[test]
299 fn attribution_identifies_top_dimensions() {
300 let v1 = vec![0.0; 10];
301 let mut v2 = vec![0.0; 10];
302 v2[3] = 5.0; v2[7] = 3.0; let attr = drift_attribution(&v1, &v2, 5);
306 assert_eq!(attr.dimensions[0].index, 3);
307 assert_eq!(attr.dimensions[1].index, 7);
308 assert!((attr.dimensions[0].absolute_change - 5.0).abs() < 1e-6);
309 }
310
311 #[test]
312 fn attribution_relative_contributions_sum_to_1() {
313 let v1 = vec![1.0, 2.0, 3.0, 4.0, 5.0];
314 let v2 = vec![2.0, 3.0, 1.0, 6.0, 4.0];
315
316 let attr = drift_attribution(&v1, &v2, 5);
317 let total: f32 = attr
318 .dimensions
319 .iter()
320 .map(|d| d.relative_contribution)
321 .sum();
322 assert!(
323 (total - 1.0).abs() < 1e-5,
324 "contributions should sum to 1.0, got {total}"
325 );
326 }
327
328 #[test]
329 fn attribution_zero_drift() {
330 let v = vec![1.0, 2.0, 3.0];
331 let attr = drift_attribution(&v, &v, 3);
332 assert!(attr.total_magnitude < 1e-6);
333 }
334
335 #[test]
338 fn pca_projection_produces_2d() {
339 let points: Vec<(i64, Vec<f32>)> = (0..100)
340 .map(|i| {
341 let t = i as f32 * 0.1;
342 (i as i64 * 1000, vec![t.cos(), t.sin(), t * 0.1])
343 })
344 .collect();
345 let traj = make_trajectory(&points);
346
347 let projected = project_trajectory_2d(&traj).unwrap();
348 assert_eq!(projected.len(), 100);
349
350 for p in &projected {
352 assert!(p.x.is_finite(), "x should be finite");
353 assert!(p.y.is_finite(), "y should be finite");
354 }
355 }
356
357 #[test]
358 fn pca_projection_insufficient_data() {
359 let points = vec![(0i64, vec![1.0, 2.0])];
360 let traj = make_trajectory(&points);
361 assert!(project_trajectory_2d(&traj).is_err());
362 }
363
364 #[test]
365 fn pca_preserves_variance() {
366 let points: Vec<(i64, Vec<f32>)> = (0..50)
368 .map(|i| {
369 let t = i as f32;
370 (i as i64 * 1000, vec![t * 2.0, t * 0.1, 0.0])
372 })
373 .collect();
374 let traj = make_trajectory(&points);
375
376 let projected = project_trajectory_2d(&traj).unwrap();
377 let var_x: f64 =
378 projected.iter().map(|p| (p.x as f64).powi(2)).sum::<f64>() / projected.len() as f64;
379 let var_y: f64 =
380 projected.iter().map(|p| (p.y as f64).powi(2)).sum::<f64>() / projected.len() as f64;
381
382 assert!(
384 var_x > var_y * 5.0,
385 "PC1 variance ({var_x:.2}) should dominate PC2 ({var_y:.2})"
386 );
387 }
388
389 #[test]
392 fn heatmap_dimensions_correct() {
393 let points: Vec<(i64, Vec<f32>)> = (0..10)
394 .map(|i| (i as i64 * 1000, vec![i as f32; 5]))
395 .collect();
396 let traj = make_trajectory(&points);
397
398 let heatmap = dimension_heatmap(&traj).unwrap();
399 assert_eq!(heatmap.time_bins, 9); assert_eq!(heatmap.dimensions, 5);
401 assert_eq!(heatmap.data.len(), 9 * 5);
402 assert_eq!(heatmap.timestamps.len(), 9);
403 }
404
405 #[test]
406 fn heatmap_stationary_is_zero() {
407 let points: Vec<(i64, Vec<f32>)> = (0..5)
408 .map(|i| (i as i64 * 1000, vec![1.0, 2.0, 3.0]))
409 .collect();
410 let traj = make_trajectory(&points);
411
412 let heatmap = dimension_heatmap(&traj).unwrap();
413 for &v in &heatmap.data {
414 assert!(v < 1e-6, "stationary entity heatmap should be zero");
415 }
416 }
417
418 #[test]
419 fn heatmap_detects_change_in_specific_dim() {
420 let points = vec![
421 (0i64, vec![0.0, 0.0, 0.0]),
422 (1000, vec![0.0, 5.0, 0.0]), ];
424 let traj = make_trajectory(&points);
425
426 let heatmap = dimension_heatmap(&traj).unwrap();
427 assert!(heatmap.get(0, 0) < 1e-6); assert!((heatmap.get(0, 1) - 5.0).abs() < 1e-6); assert!(heatmap.get(0, 2) < 1e-6); }
431
432 #[test]
433 fn heatmap_insufficient_data() {
434 let points = vec![(0i64, vec![1.0])];
435 let traj = make_trajectory(&points);
436 assert!(dimension_heatmap(&traj).is_err());
437 }
438}