Skip to main content

attuned_infer/
delta.rs

1//! Delta analysis for detecting deviation from baseline behavior.
2//!
3//! Instead of asking "who is this person?", delta analysis asks
4//! "how is this person different right now than usual?"
5//!
6//! This catches transient states (stress, urgency, frustration) without
7//! needing to model stable personality traits.
8
9use crate::features::LinguisticFeatures;
10use serde::{Deserialize, Serialize};
11use std::collections::VecDeque;
12
13/// Signals derived from deviation analysis.
14#[derive(Clone, Debug, Default, Serialize, Deserialize)]
15pub struct DeltaSignals {
16    /// Z-score for message length (positive = longer than usual).
17    pub length_z: f32,
18    /// Z-score for complexity (positive = more complex than usual).
19    pub complexity_z: f32,
20    /// Z-score for emotional intensity.
21    pub emotional_z: f32,
22    /// Z-score for formality.
23    pub formality_z: f32,
24    /// Z-score for urgency indicators.
25    pub urgency_z: f32,
26    /// Z-score for uncertainty/hedging.
27    pub uncertainty_z: f32,
28    /// Number of messages in the baseline.
29    pub baseline_size: usize,
30}
31
32impl DeltaSignals {
33    /// Check if any signal is significantly elevated (|z| > threshold).
34    pub fn has_significant_deviation(&self, threshold: f32) -> bool {
35        self.length_z.abs() > threshold
36            || self.complexity_z.abs() > threshold
37            || self.emotional_z.abs() > threshold
38            || self.formality_z.abs() > threshold
39            || self.urgency_z.abs() > threshold
40            || self.uncertainty_z.abs() > threshold
41    }
42
43    /// Get the most extreme deviation.
44    pub fn max_deviation(&self) -> (&'static str, f32) {
45        let signals = [
46            ("length", self.length_z),
47            ("complexity", self.complexity_z),
48            ("emotional", self.emotional_z),
49            ("formality", self.formality_z),
50            ("urgency", self.urgency_z),
51            ("uncertainty", self.uncertainty_z),
52        ];
53
54        signals
55            .into_iter()
56            .max_by(|a, b| a.1.abs().partial_cmp(&b.1.abs()).unwrap())
57            .unwrap_or(("none", 0.0))
58    }
59}
60
61/// Running statistics for a single metric.
62#[derive(Clone, Debug, Default, Serialize, Deserialize)]
63struct RunningStat {
64    count: usize,
65    mean: f64,
66    m2: f64, // For Welford's algorithm
67}
68
69impl RunningStat {
70    fn new() -> Self {
71        Self::default()
72    }
73
74    /// Update with a new value using Welford's online algorithm.
75    fn update(&mut self, value: f64) {
76        self.count += 1;
77        let delta = value - self.mean;
78        self.mean += delta / self.count as f64;
79        let delta2 = value - self.mean;
80        self.m2 += delta * delta2;
81    }
82
83    /// Remove an old value (approximate - assumes FIFO).
84    fn remove(&mut self, value: f64) {
85        if self.count <= 1 {
86            *self = Self::new();
87            return;
88        }
89
90        // Inverse Welford (approximate)
91        let delta = value - self.mean;
92        self.mean = (self.mean * self.count as f64 - value) / (self.count - 1) as f64;
93        let delta2 = value - self.mean;
94        self.m2 -= delta * delta2;
95        self.m2 = self.m2.max(0.0); // Numerical stability
96        self.count -= 1;
97    }
98
99    /// Get current variance.
100    fn variance(&self) -> f64 {
101        if self.count < 2 {
102            return 1.0; // High uncertainty with few samples
103        }
104        (self.m2 / (self.count - 1) as f64).max(0.001)
105    }
106
107    /// Get standard deviation.
108    fn std_dev(&self) -> f64 {
109        self.variance().sqrt()
110    }
111
112    /// Calculate z-score for a new value.
113    fn z_score(&self, value: f64) -> f64 {
114        if self.count < 3 {
115            return 0.0; // Not enough data
116        }
117        (value - self.mean) / self.std_dev()
118    }
119}
120
121/// A point in the baseline history.
122#[derive(Clone, Debug, Serialize, Deserialize)]
123struct BaselinePoint {
124    length: f64,
125    complexity: f64,
126    emotional: f64,
127    formality: f64,
128    urgency: f64,
129    uncertainty: f64,
130}
131
132impl From<&LinguisticFeatures> for BaselinePoint {
133    fn from(f: &LinguisticFeatures) -> Self {
134        Self {
135            length: f.word_count as f64,
136            complexity: f.complexity_score() as f64,
137            emotional: f.emotional_intensity() as f64,
138            formality: f.formality_score() as f64,
139            urgency: f.urgency_score() as f64,
140            uncertainty: f.uncertainty_score() as f64,
141        }
142    }
143}
144
145/// Baseline statistics for a user.
146#[derive(Clone, Debug, Serialize, Deserialize)]
147pub struct Baseline {
148    /// Maximum number of messages to track.
149    max_size: usize,
150    /// Historical values (FIFO queue).
151    history: VecDeque<BaselinePoint>,
152    /// Running statistics for each metric.
153    length_stat: RunningStat,
154    complexity_stat: RunningStat,
155    emotional_stat: RunningStat,
156    formality_stat: RunningStat,
157    urgency_stat: RunningStat,
158    uncertainty_stat: RunningStat,
159}
160
161impl Baseline {
162    /// Create a new baseline tracker.
163    pub fn new(max_size: usize) -> Self {
164        Self {
165            max_size,
166            history: VecDeque::with_capacity(max_size),
167            length_stat: RunningStat::new(),
168            complexity_stat: RunningStat::new(),
169            emotional_stat: RunningStat::new(),
170            formality_stat: RunningStat::new(),
171            urgency_stat: RunningStat::new(),
172            uncertainty_stat: RunningStat::new(),
173        }
174    }
175
176    /// Add a new message to the baseline.
177    pub fn add(&mut self, features: &LinguisticFeatures) {
178        let point = BaselinePoint::from(features);
179
180        // If at capacity, remove oldest
181        if self.history.len() >= self.max_size {
182            if let Some(old) = self.history.pop_front() {
183                self.length_stat.remove(old.length);
184                self.complexity_stat.remove(old.complexity);
185                self.emotional_stat.remove(old.emotional);
186                self.formality_stat.remove(old.formality);
187                self.urgency_stat.remove(old.urgency);
188                self.uncertainty_stat.remove(old.uncertainty);
189            }
190        }
191
192        // Update running statistics
193        self.length_stat.update(point.length);
194        self.complexity_stat.update(point.complexity);
195        self.emotional_stat.update(point.emotional);
196        self.formality_stat.update(point.formality);
197        self.urgency_stat.update(point.urgency);
198        self.uncertainty_stat.update(point.uncertainty);
199
200        self.history.push_back(point);
201    }
202
203    /// Number of messages in the baseline.
204    pub fn len(&self) -> usize {
205        self.history.len()
206    }
207
208    /// Check if baseline is empty.
209    pub fn is_empty(&self) -> bool {
210        self.history.is_empty()
211    }
212
213    /// Check if we have enough data for meaningful analysis.
214    pub fn is_ready(&self) -> bool {
215        self.history.len() >= 5 // Need at least 5 messages
216    }
217
218    /// Get baseline mean for message length.
219    pub fn mean_length(&self) -> f64 {
220        self.length_stat.mean
221    }
222
223    /// Get baseline mean for complexity.
224    pub fn mean_complexity(&self) -> f64 {
225        self.complexity_stat.mean
226    }
227}
228
229impl Default for Baseline {
230    fn default() -> Self {
231        Self::new(50) // Default to 50-message window
232    }
233}
234
235/// Delta analyzer for detecting deviations from baseline.
236#[derive(Clone, Debug, Default)]
237pub struct DeltaAnalyzer {
238    /// Z-score threshold for significance.
239    pub significance_threshold: f32,
240}
241
242impl DeltaAnalyzer {
243    /// Create a new analyzer.
244    pub fn new() -> Self {
245        Self {
246            significance_threshold: 1.5, // ~13% of distribution
247        }
248    }
249
250    /// Create analyzer with custom threshold.
251    pub fn with_threshold(threshold: f32) -> Self {
252        Self {
253            significance_threshold: threshold,
254        }
255    }
256
257    /// Analyze a message against the baseline.
258    ///
259    /// Returns delta signals (z-scores) for each metric.
260    /// Positive z-score = higher than usual, negative = lower than usual.
261    pub fn analyze(&self, baseline: &Baseline, features: &LinguisticFeatures) -> DeltaSignals {
262        if !baseline.is_ready() {
263            return DeltaSignals {
264                baseline_size: baseline.len(),
265                ..Default::default()
266            };
267        }
268
269        let point = BaselinePoint::from(features);
270
271        DeltaSignals {
272            length_z: baseline.length_stat.z_score(point.length) as f32,
273            complexity_z: baseline.complexity_stat.z_score(point.complexity) as f32,
274            emotional_z: baseline.emotional_stat.z_score(point.emotional) as f32,
275            formality_z: baseline.formality_stat.z_score(point.formality) as f32,
276            urgency_z: baseline.urgency_stat.z_score(point.urgency) as f32,
277            uncertainty_z: baseline.uncertainty_stat.z_score(point.uncertainty) as f32,
278            baseline_size: baseline.len(),
279        }
280    }
281
282    /// Analyze and update baseline in one step.
283    ///
284    /// Analyzes against current baseline, then adds to baseline.
285    pub fn analyze_and_update(
286        &self,
287        baseline: &mut Baseline,
288        features: &LinguisticFeatures,
289    ) -> DeltaSignals {
290        let signals = self.analyze(baseline, features);
291        baseline.add(features);
292        signals
293    }
294
295    /// Map delta signals to axis adjustments.
296    ///
297    /// Returns (axis_name, adjustment) pairs where adjustment is in [-0.3, 0.3].
298    pub fn to_axis_adjustments(&self, signals: &DeltaSignals) -> Vec<(&'static str, f32)> {
299        let mut adjustments = Vec::new();
300
301        // Helper to convert z-score to bounded adjustment
302        let z_to_adj = |z: f32| -> f32 {
303            // Sigmoid-like mapping: z=2 → ~0.2, z=3 → ~0.27
304            (z / (1.0 + z.abs()) * 0.3).clamp(-0.3, 0.3)
305        };
306
307        // Length deviation → cognitive_load (shorter = higher load)
308        if signals.length_z.abs() > self.significance_threshold {
309            adjustments.push(("cognitive_load", z_to_adj(-signals.length_z)));
310        }
311
312        // Complexity deviation
313        if signals.complexity_z.abs() > self.significance_threshold {
314            adjustments.push(("tolerance_for_complexity", z_to_adj(signals.complexity_z)));
315        }
316
317        // Emotional intensity deviation
318        if signals.emotional_z.abs() > self.significance_threshold {
319            adjustments.push(("emotional_intensity", z_to_adj(signals.emotional_z)));
320            // High emotional intensity often correlates with low stability
321            adjustments.push(("emotional_stability", z_to_adj(-signals.emotional_z * 0.5)));
322        }
323
324        // Formality deviation
325        if signals.formality_z.abs() > self.significance_threshold {
326            adjustments.push(("formality", z_to_adj(signals.formality_z)));
327        }
328
329        // Urgency deviation
330        if signals.urgency_z.abs() > self.significance_threshold {
331            adjustments.push(("urgency_sensitivity", z_to_adj(signals.urgency_z)));
332        }
333
334        // Uncertainty deviation
335        if signals.uncertainty_z.abs() > self.significance_threshold {
336            adjustments.push(("anxiety_level", z_to_adj(signals.uncertainty_z)));
337            adjustments.push(("assertiveness", z_to_adj(-signals.uncertainty_z)));
338        }
339
340        adjustments
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use crate::features::LinguisticExtractor;
348
349    fn make_features(text: &str) -> LinguisticFeatures {
350        LinguisticExtractor::new().extract(text)
351    }
352
353    #[test]
354    fn test_baseline_building() {
355        let mut baseline = Baseline::new(10);
356
357        for _ in 0..5 {
358            baseline.add(&make_features("This is a normal message."));
359        }
360
361        assert_eq!(baseline.len(), 5);
362        assert!(baseline.is_ready());
363    }
364
365    #[test]
366    fn test_baseline_fifo() {
367        let mut baseline = Baseline::new(3);
368
369        baseline.add(&make_features("Message one."));
370        baseline.add(&make_features("Message two."));
371        baseline.add(&make_features("Message three."));
372        baseline.add(&make_features("Message four.")); // Should evict first
373
374        assert_eq!(baseline.len(), 3);
375    }
376
377    #[test]
378    fn test_detect_urgency_spike() {
379        let mut baseline = Baseline::default();
380        let analyzer = DeltaAnalyzer::new();
381
382        // Build baseline with calm messages
383        for _ in 0..10 {
384            baseline.add(&make_features(
385                "Here is my regular question about the product.",
386            ));
387        }
388
389        // Analyze urgent message
390        let urgent = make_features("URGENT!!! I need help RIGHT NOW! This is critical!!!");
391        let signals = analyzer.analyze(&baseline, &urgent);
392
393        // Should detect elevated urgency and emotional intensity
394        assert!(signals.urgency_z > 1.0);
395        assert!(signals.emotional_z > 1.0);
396    }
397
398    #[test]
399    fn test_detect_terseness() {
400        let mut baseline = Baseline::default();
401        let analyzer = DeltaAnalyzer::new();
402
403        // Build baseline with longer messages
404        for _ in 0..10 {
405            baseline.add(&make_features(
406                "I wanted to ask about the features of your product and understand \
407                 how it might help with my specific use case in data processing.",
408            ));
409        }
410
411        // Analyze terse message
412        let terse = make_features("ok");
413        let signals = analyzer.analyze(&baseline, &terse);
414
415        // Should detect shortened length
416        assert!(signals.length_z < -1.0);
417    }
418
419    #[test]
420    fn test_not_enough_baseline() {
421        let baseline = Baseline::default();
422        let analyzer = DeltaAnalyzer::new();
423
424        let features = make_features("Hello world");
425        let signals = analyzer.analyze(&baseline, &features);
426
427        // Should return zeros when not enough data
428        assert_eq!(signals.length_z, 0.0);
429        assert!(!baseline.is_ready());
430    }
431
432    #[test]
433    fn test_axis_adjustments() {
434        let analyzer = DeltaAnalyzer::new();
435
436        let signals = DeltaSignals {
437            urgency_z: 2.5,
438            emotional_z: 0.5, // Below threshold
439            ..Default::default()
440        };
441
442        let adjustments = analyzer.to_axis_adjustments(&signals);
443
444        // Should have urgency adjustment
445        assert!(adjustments
446            .iter()
447            .any(|(axis, _)| *axis == "urgency_sensitivity"));
448        // Should not have emotional adjustment (below threshold)
449        assert!(!adjustments
450            .iter()
451            .any(|(axis, _)| *axis == "emotional_intensity"));
452    }
453
454    #[test]
455    fn test_max_deviation() {
456        let signals = DeltaSignals {
457            length_z: 0.5,
458            urgency_z: 3.0,
459            emotional_z: -2.0,
460            ..Default::default()
461        };
462
463        let (metric, z) = signals.max_deviation();
464        assert_eq!(metric, "urgency");
465        assert_eq!(z, 3.0);
466    }
467}