cvx_analytics/
anchor.rs

1//! Anchor-relative trajectory analysis.
2//!
3//! Projects trajectories from absolute embedding space (ℝᴰ) into a reference
4//! frame defined by K anchor vectors, producing trajectories in ℝᴷ where each
5//! dimension is the cosine distance to an anchor.
6//!
7//! This enables all existing CVX analytics (velocity, Hurst, changepoints,
8//! signatures) to operate on clinically or semantically meaningful coordinates
9//! instead of opaque embedding dimensions.
10//!
11//! # Example
12//!
13//! ```
14//! use cvx_analytics::anchor::{project_to_anchors, AnchorMetric};
15//!
16//! let trajectory = vec![
17//!     (1000_i64, vec![1.0_f32, 0.0, 0.0]),
18//!     (2000, vec![0.9, 0.1, 0.0]),
19//!     (3000, vec![0.5, 0.5, 0.0]),
20//! ];
21//! let anchors = vec![
22//!     vec![1.0_f32, 0.0, 0.0],  // anchor A
23//!     vec![0.0, 1.0, 0.0],      // anchor B
24//! ];
25//!
26//! let traj_refs: Vec<(i64, &[f32])> = trajectory.iter().map(|(t, v)| (*t, v.as_slice())).collect();
27//! let anchor_refs: Vec<&[f32]> = anchors.iter().map(|a| a.as_slice()).collect();
28//!
29//! let projected = project_to_anchors(&traj_refs, &anchor_refs, AnchorMetric::Cosine);
30//! // projected[0] = (1000, [0.0, 1.0])   -- close to A, far from B
31//! // projected[2] = (3000, [~0.3, ~0.3]) -- equidistant
32//! ```
33
34use crate::calculus;
35
36/// Distance metric for anchor projection.
37#[derive(Debug, Clone, Copy, PartialEq)]
38pub enum AnchorMetric {
39    /// Cosine distance: 1 - cos(v, anchor). Range [0, 2], typically [0, 1].
40    Cosine,
41    /// L2 (Euclidean) distance.
42    L2,
43}
44
45/// Project a trajectory into anchor-relative coordinates.
46///
47/// For a trajectory of N points in ℝᴰ and K anchors, produces N points in ℝᴷ
48/// where dimension k = distance(point, anchor_k).
49///
50/// The resulting trajectory can be fed into any CVX analytics function
51/// (velocity, Hurst, changepoints, signatures) for anchor-relative analysis.
52pub fn project_to_anchors(
53    trajectory: &[(i64, &[f32])],
54    anchors: &[&[f32]],
55    metric: AnchorMetric,
56) -> Vec<(i64, Vec<f32>)> {
57    trajectory
58        .iter()
59        .map(|&(ts, vec)| {
60            let distances: Vec<f32> = anchors
61                .iter()
62                .map(|anchor| match metric {
63                    AnchorMetric::Cosine => calculus::drift_magnitude_cosine(vec, anchor),
64                    AnchorMetric::L2 => calculus::drift_magnitude_l2(vec, anchor),
65                })
66                .collect();
67            (ts, distances)
68        })
69        .collect()
70}
71
72/// Summary statistics for anchor proximity over a trajectory.
73#[derive(Debug, Clone)]
74pub struct AnchorSummary {
75    /// Mean distance to each anchor over the trajectory.
76    pub mean: Vec<f32>,
77    /// Minimum distance (closest approach) to each anchor.
78    pub min: Vec<f32>,
79    /// Linear trend (slope) of distance to each anchor.
80    /// Negative = approaching the anchor over time.
81    pub trend: Vec<f32>,
82    /// Distance at the last time point.
83    pub last: Vec<f32>,
84}
85
86/// Compute summary statistics of anchor proximity over a trajectory.
87pub fn anchor_summary(projected: &[(i64, Vec<f32>)]) -> AnchorSummary {
88    if projected.is_empty() {
89        return AnchorSummary {
90            mean: vec![],
91            min: vec![],
92            trend: vec![],
93            last: vec![],
94        };
95    }
96
97    let k = projected[0].1.len();
98    let n = projected.len();
99
100    let mut mean = vec![0.0f32; k];
101    let mut min = vec![f32::INFINITY; k];
102    let mut last = vec![0.0f32; k];
103
104    for (_, dists) in projected {
105        for (j, &d) in dists.iter().enumerate() {
106            mean[j] += d;
107            if d < min[j] {
108                min[j] = d;
109            }
110        }
111    }
112
113    for item in mean.iter_mut().take(k) {
114        *item /= n as f32;
115    }
116
117    if let Some((_, d)) = projected.last() {
118        last = d.clone();
119    }
120
121    // Linear trend via least squares: slope = (Σ(x-x̄)(y-ȳ)) / Σ(x-x̄)²
122    let x_mean = (n as f64 - 1.0) / 2.0;
123    let x_var: f64 = (0..n).map(|i| (i as f64 - x_mean).powi(2)).sum();
124
125    let trend = if x_var > 0.0 {
126        (0..k)
127            .map(|j| {
128                let y_mean = mean[j] as f64;
129                let covar: f64 = projected
130                    .iter()
131                    .enumerate()
132                    .map(|(i, (_, dists))| (i as f64 - x_mean) * (dists[j] as f64 - y_mean))
133                    .sum();
134                (covar / x_var) as f32
135            })
136            .collect()
137    } else {
138        vec![0.0; k]
139    };
140
141    AnchorSummary {
142        mean,
143        min,
144        trend,
145        last,
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn project_cosine_identity() {
155        // A vector projected to itself should have distance 0
156        let traj = vec![(0i64, [1.0f32, 0.0, 0.0].as_slice())];
157        let anchors = vec![[1.0f32, 0.0, 0.0].as_slice()];
158        let result = project_to_anchors(&traj, &anchors, AnchorMetric::Cosine);
159        assert!(result[0].1[0] < 1e-6, "self-distance should be ~0");
160    }
161
162    #[test]
163    fn project_cosine_orthogonal() {
164        let traj = vec![(0i64, [1.0f32, 0.0, 0.0].as_slice())];
165        let anchors = vec![[0.0f32, 1.0, 0.0].as_slice()];
166        let result = project_to_anchors(&traj, &anchors, AnchorMetric::Cosine);
167        assert!(
168            (result[0].1[0] - 1.0).abs() < 1e-6,
169            "orthogonal should be 1.0"
170        );
171    }
172
173    #[test]
174    fn project_multiple_anchors() {
175        let traj = vec![(0i64, [1.0f32, 0.0].as_slice()), (1, [0.0, 1.0].as_slice())];
176        let anchors = vec![[1.0f32, 0.0].as_slice(), [0.0, 1.0].as_slice()];
177        let result = project_to_anchors(&traj, &anchors, AnchorMetric::Cosine);
178        // t=0: close to anchor 0, far from anchor 1
179        assert!(result[0].1[0] < 0.1);
180        assert!(result[0].1[1] > 0.9);
181        // t=1: far from anchor 0, close to anchor 1
182        assert!(result[1].1[0] > 0.9);
183        assert!(result[1].1[1] < 0.1);
184    }
185
186    #[test]
187    fn summary_trend_approaching() {
188        // Trajectory approaching anchor over time
189        let projected = vec![
190            (0i64, vec![1.0f32]),
191            (1, vec![0.8]),
192            (2, vec![0.6]),
193            (3, vec![0.4]),
194            (4, vec![0.2]),
195        ];
196        let summary = anchor_summary(&projected);
197        assert!(
198            summary.trend[0] < 0.0,
199            "should have negative trend (approaching)"
200        );
201        assert!((summary.mean[0] - 0.6).abs() < 0.01);
202        assert!((summary.min[0] - 0.2).abs() < 0.01);
203    }
204
205    #[test]
206    fn summary_empty() {
207        let summary = anchor_summary(&[]);
208        assert!(summary.mean.is_empty());
209    }
210}