Skip to main content

oxihuman_morph/
emotion_space.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! PAD (Pleasure-Arousal-Dominance) emotion space mapping to facial expression weights.
5//!
6//! This module provides a three-dimensional emotion space following the
7//! Mehrabian & Russell PAD model, with interpolation methods (IDW and RBF/Gaussian)
8//! to blend morph target weights across the space.
9
10#![allow(dead_code)]
11
12use std::collections::HashMap;
13
14// ---------------------------------------------------------------------------
15// PadPoint
16// ---------------------------------------------------------------------------
17
18/// A point in PAD (Pleasure-Arousal-Dominance) space.
19///
20/// All dimensions are in the range `[-1.0, 1.0]`.
21#[derive(Debug, Clone, Copy, PartialEq)]
22pub struct PadPoint {
23    /// Pleasure/Valence: -1 = very negative affect, +1 = very positive affect.
24    pub pleasure: f32,
25    /// Arousal: -1 = completely calm/sleepy, +1 = highly excited/stimulated.
26    pub arousal: f32,
27    /// Dominance: -1 = submissive/controlled, +1 = dominant/in-control.
28    pub dominance: f32,
29}
30
31impl PadPoint {
32    /// Construct a new PAD point.
33    pub fn new(p: f32, a: f32, d: f32) -> Self {
34        Self {
35            pleasure: p,
36            arousal: a,
37            dominance: d,
38        }
39    }
40
41    /// The neutral origin `(0, 0, 0)`.
42    pub fn neutral() -> Self {
43        Self::new(0.0, 0.0, 0.0)
44    }
45
46    /// Euclidean distance to another PAD point.
47    pub fn distance(&self, other: &PadPoint) -> f32 {
48        let dp = self.pleasure - other.pleasure;
49        let da = self.arousal - other.arousal;
50        let dd = self.dominance - other.dominance;
51        (dp * dp + da * da + dd * dd).sqrt()
52    }
53
54    /// Linear interpolation between `self` and `other` by factor `t` (0 = self, 1 = other).
55    pub fn lerp(&self, other: &PadPoint, t: f32) -> PadPoint {
56        PadPoint {
57            pleasure: self.pleasure + (other.pleasure - self.pleasure) * t,
58            arousal: self.arousal + (other.arousal - self.arousal) * t,
59            dominance: self.dominance + (other.dominance - self.dominance) * t,
60        }
61    }
62
63    /// Clamp all dimensions to `[-1.0, 1.0]`.
64    pub fn clamp(&self) -> PadPoint {
65        PadPoint {
66            pleasure: self.pleasure.clamp(-1.0, 1.0),
67            arousal: self.arousal.clamp(-1.0, 1.0),
68            dominance: self.dominance.clamp(-1.0, 1.0),
69        }
70    }
71
72    // -----------------------------------------------------------------------
73    // Named emotion anchors (Mehrabian PAD coordinates)
74    // -----------------------------------------------------------------------
75
76    /// Happy: high pleasure, moderate arousal, slightly dominant.
77    pub fn happy() -> Self {
78        Self::new(0.8, 0.5, 0.3)
79    }
80
81    /// Sad: negative pleasure, low arousal, slightly submissive.
82    pub fn sad() -> Self {
83        Self::new(-0.6, -0.4, -0.4)
84    }
85
86    /// Angry: negative pleasure, high arousal, dominant.
87    pub fn angry() -> Self {
88        Self::new(-0.5, 0.8, 0.7)
89    }
90
91    /// Fearful: negative pleasure, high arousal, submissive.
92    pub fn fearful() -> Self {
93        Self::new(-0.6, 0.7, -0.6)
94    }
95
96    /// Surprised: mildly positive pleasure, high arousal, slightly submissive.
97    pub fn surprised() -> Self {
98        Self::new(0.1, 0.8, -0.3)
99    }
100
101    /// Disgusted: negative pleasure, moderate arousal, slightly dominant.
102    pub fn disgusted() -> Self {
103        Self::new(-0.7, 0.3, 0.4)
104    }
105
106    /// Contemptuous: mildly negative pleasure, low arousal, dominant.
107    pub fn contemptuous() -> Self {
108        Self::new(-0.1, 0.2, 0.6)
109    }
110}
111
112// ---------------------------------------------------------------------------
113// EmotionAnchor
114// ---------------------------------------------------------------------------
115
116/// A named anchor point in PAD space with associated facial morph target weights.
117pub struct EmotionAnchor {
118    /// Human-readable emotion name (e.g. `"happy"`, `"sad"`).
119    pub name: String,
120    /// Position in PAD space.
121    pub pad: PadPoint,
122    /// Map from morph target name → weight `[0.0, 1.0]`.
123    pub morph_weights: HashMap<String, f32>,
124}
125
126impl EmotionAnchor {
127    /// Create a new anchor.
128    pub fn new(name: &str, pad: PadPoint, morph_weights: HashMap<String, f32>) -> Self {
129        Self {
130            name: name.to_string(),
131            pad,
132            morph_weights,
133        }
134    }
135}
136
137// ---------------------------------------------------------------------------
138// EmotionSpace
139// ---------------------------------------------------------------------------
140
141/// A collection of [`EmotionAnchor`]s that defines an interpolatable emotion space.
142pub struct EmotionSpace {
143    anchors: Vec<EmotionAnchor>,
144}
145
146impl EmotionSpace {
147    /// Create an empty emotion space.
148    pub fn new() -> Self {
149        Self {
150            anchors: Vec::new(),
151        }
152    }
153
154    /// Add an anchor to the space.
155    pub fn add_anchor(&mut self, anchor: EmotionAnchor) {
156        self.anchors.push(anchor);
157    }
158
159    /// Return the number of anchors.
160    pub fn anchor_count(&self) -> usize {
161        self.anchors.len()
162    }
163
164    /// Find an anchor by name (case-sensitive).
165    pub fn find_anchor(&self, name: &str) -> Option<&EmotionAnchor> {
166        self.anchors.iter().find(|a| a.name == name)
167    }
168
169    /// Build the default space with Ekman's seven basic emotions mapped to PAD space.
170    ///
171    /// Each emotion has a canonical set of facial morph target weights drawn from
172    /// common MakeHuman-style expression unit names.
173    pub fn default_space() -> Self {
174        let mut space = Self::new();
175
176        // Neutral
177        space.add_anchor(EmotionAnchor::new(
178            "neutral",
179            PadPoint::neutral(),
180            HashMap::new(),
181        ));
182
183        // Happy
184        space.add_anchor(EmotionAnchor::new(
185            "happy",
186            PadPoint::happy(),
187            [
188                ("mouth-corner-puller", 0.85),
189                ("mouth-elevation", 0.6),
190                ("cheek-raiser", 0.5),
191                ("eye-squint", 0.3),
192            ]
193            .iter()
194            .map(|(k, v)| (k.to_string(), *v))
195            .collect(),
196        ));
197
198        // Sad
199        space.add_anchor(EmotionAnchor::new(
200            "sad",
201            PadPoint::sad(),
202            [
203                ("brow-lowerer", 0.4),
204                ("inner-brow-raiser", 0.7),
205                ("lip-corner-depressor", 0.8),
206                ("lower-lip-depressor", 0.4),
207            ]
208            .iter()
209            .map(|(k, v)| (k.to_string(), *v))
210            .collect(),
211        ));
212
213        // Angry
214        space.add_anchor(EmotionAnchor::new(
215            "angry",
216            PadPoint::angry(),
217            [
218                ("brow-lowerer", 0.9),
219                ("nose-wrinkler", 0.5),
220                ("upper-lip-raiser", 0.6),
221                ("lip-tightener", 0.7),
222                ("jaw-drop", 0.2),
223            ]
224            .iter()
225            .map(|(k, v)| (k.to_string(), *v))
226            .collect(),
227        ));
228
229        // Fearful
230        space.add_anchor(EmotionAnchor::new(
231            "fearful",
232            PadPoint::fearful(),
233            [
234                ("inner-brow-raiser", 0.8),
235                ("brow-raiser", 0.6),
236                ("eye-widener", 0.7),
237                ("lip-corner-puller", 0.4),
238                ("jaw-drop", 0.5),
239            ]
240            .iter()
241            .map(|(k, v)| (k.to_string(), *v))
242            .collect(),
243        ));
244
245        // Surprised
246        space.add_anchor(EmotionAnchor::new(
247            "surprised",
248            PadPoint::surprised(),
249            [
250                ("brow-raiser", 0.9),
251                ("eye-widener", 0.8),
252                ("jaw-drop", 0.7),
253                ("upper-lip-raiser", 0.3),
254            ]
255            .iter()
256            .map(|(k, v)| (k.to_string(), *v))
257            .collect(),
258        ));
259
260        // Disgusted
261        space.add_anchor(EmotionAnchor::new(
262            "disgusted",
263            PadPoint::disgusted(),
264            [
265                ("nose-wrinkler", 0.9),
266                ("upper-lip-raiser", 0.8),
267                ("brow-lowerer", 0.4),
268                ("lower-lip-depressor", 0.3),
269            ]
270            .iter()
271            .map(|(k, v)| (k.to_string(), *v))
272            .collect(),
273        ));
274
275        // Contemptuous
276        space.add_anchor(EmotionAnchor::new(
277            "contemptuous",
278            PadPoint::contemptuous(),
279            [
280                ("lip-corner-puller", 0.5), // unilateral
281                ("brow-lowerer", 0.3),
282                ("eye-narrower", 0.4),
283            ]
284            .iter()
285            .map(|(k, v)| (k.to_string(), *v))
286            .collect(),
287        ));
288
289        space
290    }
291
292    // -----------------------------------------------------------------------
293    // Evaluation
294    // -----------------------------------------------------------------------
295
296    /// Evaluate expression weights at `pad` using inverse-distance weighting (IDW).
297    ///
298    /// Uses `power = 2.0` (standard Shepard's method).
299    pub fn evaluate(&self, pad: &PadPoint) -> HashMap<String, f32> {
300        self.evaluate_idw(pad, 2.0)
301    }
302
303    /// Evaluate with configurable IDW power.
304    fn evaluate_idw(&self, pad: &PadPoint, power: f32) -> HashMap<String, f32> {
305        if self.anchors.is_empty() {
306            return HashMap::new();
307        }
308
309        // Collect (weight, morph_weights) pairs
310        let mut weighted: Vec<(f32, &HashMap<String, f32>)> =
311            Vec::with_capacity(self.anchors.len());
312        let mut weight_sum = 0.0_f32;
313        let mut exact_match: Option<&HashMap<String, f32>> = None;
314
315        for anchor in &self.anchors {
316            let d = pad.distance(&anchor.pad);
317            if d < 1e-7 {
318                exact_match = Some(&anchor.morph_weights);
319                break;
320            }
321            let w = idw_weight(d, power);
322            weight_sum += w;
323            weighted.push((w, &anchor.morph_weights));
324        }
325
326        if let Some(exact) = exact_match {
327            return exact.clone();
328        }
329
330        if weight_sum < 1e-12 {
331            return HashMap::new();
332        }
333
334        let mut result: HashMap<String, f32> = HashMap::new();
335        for (w, mw) in &weighted {
336            for (key, val) in *mw {
337                *result.entry(key.clone()).or_insert(0.0) += w * val;
338            }
339        }
340        for v in result.values_mut() {
341            *v /= weight_sum;
342            *v = v.clamp(0.0, 1.0);
343        }
344        result
345    }
346
347    /// Evaluate expression weights using Gaussian RBF interpolation.
348    ///
349    /// `sigma` controls the width of influence: smaller = sharper, larger = broader.
350    pub fn evaluate_rbf(&self, pad: &PadPoint, sigma: f32) -> HashMap<String, f32> {
351        if self.anchors.is_empty() {
352            return HashMap::new();
353        }
354
355        let sigma2 = sigma * sigma;
356        let mut weights: Vec<f32> = Vec::with_capacity(self.anchors.len());
357        let mut weight_sum = 0.0_f32;
358
359        for anchor in &self.anchors {
360            let d2 = {
361                let dp = pad.pleasure - anchor.pad.pleasure;
362                let da = pad.arousal - anchor.pad.arousal;
363                let dd = pad.dominance - anchor.pad.dominance;
364                dp * dp + da * da + dd * dd
365            };
366            let w = (-d2 / (2.0 * sigma2)).exp();
367            weights.push(w);
368            weight_sum += w;
369        }
370
371        if weight_sum < 1e-12 {
372            return HashMap::new();
373        }
374
375        let mut result: HashMap<String, f32> = HashMap::new();
376        for (anchor, w) in self.anchors.iter().zip(weights.iter()) {
377            for (key, val) in &anchor.morph_weights {
378                *result.entry(key.clone()).or_insert(0.0) += w * val;
379            }
380        }
381        for v in result.values_mut() {
382            *v /= weight_sum;
383            *v = v.clamp(0.0, 1.0);
384        }
385        result
386    }
387
388    /// Find the nearest anchor to `pad` in PAD space.
389    pub fn nearest_anchor(&self, pad: &PadPoint) -> Option<&EmotionAnchor> {
390        self.anchors.iter().min_by(|a, b| {
391            pad.distance(&a.pad)
392                .partial_cmp(&pad.distance(&b.pad))
393                .unwrap_or(std::cmp::Ordering::Equal)
394        })
395    }
396
397    /// Blend expression weights along the valence (pleasure) axis at `valence ∈ [-1, 1]`.
398    ///
399    /// Arousal and dominance are held at 0.
400    pub fn valence_blend(&self, valence: f32) -> HashMap<String, f32> {
401        let pad = PadPoint::new(valence, 0.0, 0.0);
402        self.evaluate(&pad)
403    }
404
405    /// Blend expression weights along the arousal axis at `arousal ∈ [-1, 1]`.
406    ///
407    /// Pleasure and dominance are held at 0.
408    pub fn arousal_blend(&self, arousal: f32) -> HashMap<String, f32> {
409        let pad = PadPoint::new(0.0, arousal, 0.0);
410        self.evaluate(&pad)
411    }
412}
413
414impl Default for EmotionSpace {
415    fn default() -> Self {
416        Self::new()
417    }
418}
419
420// ---------------------------------------------------------------------------
421// EmotionTransition
422// ---------------------------------------------------------------------------
423
424/// A time-parameterised transition between two PAD points.
425pub struct EmotionTransition {
426    /// Starting PAD state.
427    pub from: PadPoint,
428    /// Target PAD state.
429    pub to: PadPoint,
430    /// Total transition duration in seconds.
431    pub duration_seconds: f32,
432}
433
434impl EmotionTransition {
435    /// Create a new transition.
436    pub fn new(from: PadPoint, to: PadPoint, duration: f32) -> Self {
437        Self {
438            from,
439            to,
440            duration_seconds: duration.max(1e-6),
441        }
442    }
443
444    /// Evaluate the transition at `t_seconds` using **linear** interpolation.
445    ///
446    /// `t_seconds` is clamped to `[0, duration_seconds]`.
447    pub fn evaluate(&self, t_seconds: f32) -> PadPoint {
448        let t = (t_seconds / self.duration_seconds).clamp(0.0, 1.0);
449        self.from.lerp(&self.to, t)
450    }
451
452    /// Evaluate the transition at `t_seconds` using **smoothstep** interpolation.
453    ///
454    /// Provides ease-in / ease-out behaviour.  `t_seconds` is clamped to the
455    /// duration range.
456    pub fn evaluate_smooth(&self, t_seconds: f32) -> PadPoint {
457        let t = (t_seconds / self.duration_seconds).clamp(0.0, 1.0);
458        let s = t * t * (3.0 - 2.0 * t); // smoothstep
459        self.from.lerp(&self.to, s)
460    }
461}
462
463// ---------------------------------------------------------------------------
464// Free functions
465// ---------------------------------------------------------------------------
466
467/// Return a human-readable description of a PAD point based on its quadrant.
468pub fn pad_to_description(pad: &PadPoint) -> &'static str {
469    match (
470        pad.pleasure >= 0.0,
471        pad.arousal >= 0.0,
472        pad.dominance >= 0.0,
473    ) {
474        (true, true, true) => "happy/excited/dominant",
475        (true, true, false) => "happy/excited/submissive",
476        (true, false, true) => "happy/calm/dominant",
477        (true, false, false) => "happy/calm/submissive",
478        (false, true, true) => "unhappy/excited/dominant",
479        (false, true, false) => "unhappy/excited/submissive",
480        (false, false, true) => "unhappy/calm/dominant",
481        (false, false, false) => "unhappy/calm/submissive",
482    }
483}
484
485/// Compute the inverse-distance weighting kernel value for a given `distance` and `power`.
486///
487/// Returns `1.0 / distance.powf(power)`. Caller should guard against `distance ≈ 0`.
488pub fn idw_weight(distance: f32, power: f32) -> f32 {
489    if distance < 1e-12 {
490        f32::MAX
491    } else {
492        1.0 / distance.powf(power)
493    }
494}
495
496/// Linearly mix two expression weight maps.
497///
498/// For each key present in either map: `result[k] = a[k] * (1 - t) + b[k] * t`.
499/// The result is then clamped to `[0.0, 1.0]`.
500pub fn mix_expressions(
501    a: &HashMap<String, f32>,
502    b: &HashMap<String, f32>,
503    t: f32,
504) -> HashMap<String, f32> {
505    let t = t.clamp(0.0, 1.0);
506    let one_minus_t = 1.0 - t;
507
508    let mut result: HashMap<String, f32> = HashMap::new();
509
510    for (k, va) in a {
511        let vb = b.get(k).copied().unwrap_or(0.0);
512        result.insert(k.clone(), (va * one_minus_t + vb * t).clamp(0.0, 1.0));
513    }
514    for (k, vb) in b {
515        if !result.contains_key(k) {
516            let va = a.get(k).copied().unwrap_or(0.0);
517            result.insert(k.clone(), (va * one_minus_t + vb * t).clamp(0.0, 1.0));
518        }
519    }
520    result
521}
522
523// ---------------------------------------------------------------------------
524// Tests
525// ---------------------------------------------------------------------------
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530    use std::fs;
531
532    fn write_tmp(name: &str, content: &str) {
533        let path = format!("/tmp/{name}");
534        fs::write(&path, content).expect("write tmp file");
535    }
536
537    // 1. PadPoint::neutral is (0,0,0)
538    #[test]
539    fn test_pad_neutral() {
540        let n = PadPoint::neutral();
541        assert_eq!(n.pleasure, 0.0);
542        assert_eq!(n.arousal, 0.0);
543        assert_eq!(n.dominance, 0.0);
544        write_tmp("test_pad_neutral.txt", "ok");
545    }
546
547    // 2. Named emotion constructors return correct values
548    #[test]
549    fn test_named_emotions() {
550        let h = PadPoint::happy();
551        assert!((h.pleasure - 0.8).abs() < 1e-6);
552        assert!((h.arousal - 0.5).abs() < 1e-6);
553        assert!((h.dominance - 0.3).abs() < 1e-6);
554
555        let s = PadPoint::sad();
556        assert!(s.pleasure < 0.0);
557
558        let a = PadPoint::angry();
559        assert!(a.arousal > 0.5);
560        assert!(a.dominance > 0.5);
561        write_tmp("test_named_emotions.txt", "ok");
562    }
563
564    // 3. Distance between two equal points is 0
565    #[test]
566    fn test_distance_same() {
567        let p = PadPoint::new(0.3, -0.1, 0.5);
568        assert!(p.distance(&p) < 1e-6);
569        write_tmp("test_distance_same.txt", "ok");
570    }
571
572    // 4. Distance between happy and sad is nonzero
573    #[test]
574    fn test_distance_nonzero() {
575        let d = PadPoint::happy().distance(&PadPoint::sad());
576        assert!(d > 0.5, "distance should be substantial: {d}");
577        write_tmp("test_distance_nonzero.txt", "ok");
578    }
579
580    // 5. lerp midpoint
581    #[test]
582    fn test_lerp_midpoint() {
583        let a = PadPoint::new(0.0, 0.0, 0.0);
584        let b = PadPoint::new(1.0, 1.0, 1.0);
585        let mid = a.lerp(&b, 0.5);
586        assert!((mid.pleasure - 0.5).abs() < 1e-6);
587        assert!((mid.arousal - 0.5).abs() < 1e-6);
588        assert!((mid.dominance - 0.5).abs() < 1e-6);
589        write_tmp("test_lerp_midpoint.txt", "ok");
590    }
591
592    // 6. lerp endpoints
593    #[test]
594    fn test_lerp_endpoints() {
595        let a = PadPoint::happy();
596        let b = PadPoint::sad();
597        let at0 = a.lerp(&b, 0.0);
598        let at1 = a.lerp(&b, 1.0);
599        assert!((at0.pleasure - a.pleasure).abs() < 1e-6);
600        assert!((at1.pleasure - b.pleasure).abs() < 1e-6);
601        write_tmp("test_lerp_endpoints.txt", "ok");
602    }
603
604    // 7. clamp brings out-of-range values into [-1,1]
605    #[test]
606    fn test_clamp() {
607        let p = PadPoint::new(2.5, -3.0, 0.5).clamp();
608        assert!((p.pleasure - 1.0).abs() < 1e-6);
609        assert!((p.arousal + 1.0).abs() < 1e-6);
610        assert!((p.dominance - 0.5).abs() < 1e-6);
611        write_tmp("test_clamp.txt", "ok");
612    }
613
614    // 8. EmotionSpace::default_space has 8 anchors
615    #[test]
616    fn test_default_space_count() {
617        let space = EmotionSpace::default_space();
618        assert_eq!(space.anchor_count(), 8);
619        write_tmp("test_default_space_count.txt", "ok");
620    }
621
622    // 9. find_anchor finds by name
623    #[test]
624    fn test_find_anchor() {
625        let space = EmotionSpace::default_space();
626        assert!(space.find_anchor("happy").is_some());
627        assert!(space.find_anchor("nonexistent").is_none());
628        write_tmp("test_find_anchor.txt", "ok");
629    }
630
631    // 10. evaluate at anchor position returns its weights (approximately)
632    #[test]
633    fn test_evaluate_at_anchor() {
634        let space = EmotionSpace::default_space();
635        let happy_pad = PadPoint::happy();
636        let weights = space.evaluate(&happy_pad);
637        // Should have some weights since we are very close to the happy anchor
638        assert!(
639            !weights.is_empty(),
640            "weights should not be empty near happy anchor"
641        );
642        write_tmp("test_evaluate_at_anchor.txt", "ok");
643    }
644
645    // 11. evaluate_rbf returns values in [0, 1]
646    #[test]
647    fn test_evaluate_rbf_range() {
648        let space = EmotionSpace::default_space();
649        let pad = PadPoint::new(0.2, 0.1, -0.1);
650        let weights = space.evaluate_rbf(&pad, 0.5);
651        for (k, v) in &weights {
652            assert!(*v >= 0.0 && *v <= 1.0, "weight for {k} out of range: {v}");
653        }
654        write_tmp("test_evaluate_rbf_range.txt", "ok");
655    }
656
657    // 12. nearest_anchor for happy PAD point
658    #[test]
659    fn test_nearest_anchor() {
660        let space = EmotionSpace::default_space();
661        let near = space.nearest_anchor(&PadPoint::happy());
662        assert!(near.is_some());
663        assert_eq!(near.expect("should succeed").name, "happy");
664        write_tmp("test_nearest_anchor.txt", "ok");
665    }
666
667    // 13. EmotionTransition linear at t=0, t=duration, t=mid
668    #[test]
669    fn test_transition_linear() {
670        let from = PadPoint::neutral();
671        let to = PadPoint::happy();
672        let tr = EmotionTransition::new(from, to, 2.0);
673
674        let at0 = tr.evaluate(0.0);
675        assert!((at0.pleasure - from.pleasure).abs() < 1e-6);
676
677        let at2 = tr.evaluate(2.0);
678        assert!((at2.pleasure - to.pleasure).abs() < 1e-6);
679
680        let at1 = tr.evaluate(1.0);
681        assert!(
682            (at1.pleasure - 0.4).abs() < 1e-5,
683            "mid pleasure: {}",
684            at1.pleasure
685        );
686        write_tmp("test_transition_linear.txt", "ok");
687    }
688
689    // 14. EmotionTransition smooth is different from linear in the interior
690    #[test]
691    fn test_transition_smooth() {
692        let from = PadPoint::neutral();
693        let to = PadPoint::happy();
694        let tr = EmotionTransition::new(from, to, 2.0);
695
696        // At the endpoints, smooth == linear
697        let s0 = tr.evaluate_smooth(0.0);
698        assert!((s0.pleasure - from.pleasure).abs() < 1e-6);
699
700        let s2 = tr.evaluate_smooth(2.0);
701        assert!((s2.pleasure - to.pleasure).abs() < 1e-6);
702
703        // At t=0.25 * duration (t=0.5 s, normalised 0.25), smoothstep != linear
704        let lin = tr.evaluate(0.5);
705        let smooth = tr.evaluate_smooth(0.5);
706        // smoothstep at 0.25 = 0.25^2*(3-2*0.25) = 0.0625*2.5 = 0.15625 < 0.25 (linear)
707        assert!(
708            smooth.pleasure < lin.pleasure,
709            "smooth should lag linear in first half: smooth={}, lin={}",
710            smooth.pleasure,
711            lin.pleasure
712        );
713        write_tmp("test_transition_smooth.txt", "ok");
714    }
715
716    // 15. pad_to_description covers all octants
717    #[test]
718    fn test_pad_to_description() {
719        let desc = pad_to_description(&PadPoint::happy());
720        assert!(desc.contains("happy"), "desc: {desc}");
721
722        let desc2 = pad_to_description(&PadPoint::new(-0.5, -0.5, -0.5));
723        assert!(desc2.contains("unhappy"));
724        write_tmp("test_pad_to_description.txt", "ok");
725    }
726
727    // 16. idw_weight decreases as distance increases
728    #[test]
729    fn test_idw_weight_decreasing() {
730        let w1 = idw_weight(1.0, 2.0);
731        let w2 = idw_weight(2.0, 2.0);
732        let w3 = idw_weight(4.0, 2.0);
733        assert!(w1 > w2, "w1={w1} w2={w2}");
734        assert!(w2 > w3, "w2={w2} w3={w3}");
735        write_tmp("test_idw_weight_decreasing.txt", "ok");
736    }
737
738    // 17. mix_expressions at t=0 returns a, at t=1 returns b
739    #[test]
740    fn test_mix_expressions_endpoints() {
741        let a: HashMap<String, f32> = [("smile", 0.8_f32), ("brow", 0.2_f32)]
742            .iter()
743            .map(|(k, v)| (k.to_string(), *v))
744            .collect();
745        let b: HashMap<String, f32> = [("smile", 0.0_f32), ("brow", 1.0_f32)]
746            .iter()
747            .map(|(k, v)| (k.to_string(), *v))
748            .collect();
749
750        let r0 = mix_expressions(&a, &b, 0.0);
751        assert!((r0["smile"] - 0.8).abs() < 1e-6);
752
753        let r1 = mix_expressions(&a, &b, 1.0);
754        assert!((r1["smile"] - 0.0).abs() < 1e-6);
755        assert!((r1["brow"] - 1.0).abs() < 1e-6);
756        write_tmp("test_mix_expressions_endpoints.txt", "ok");
757    }
758
759    // 18. mix_expressions midpoint
760    #[test]
761    fn test_mix_expressions_midpoint() {
762        let a: HashMap<String, f32> = [("x", 0.0_f32)]
763            .iter()
764            .map(|(k, v)| (k.to_string(), *v))
765            .collect();
766        let b: HashMap<String, f32> = [("x", 1.0_f32)]
767            .iter()
768            .map(|(k, v)| (k.to_string(), *v))
769            .collect();
770        let mid = mix_expressions(&a, &b, 0.5);
771        assert!((mid["x"] - 0.5).abs() < 1e-6);
772        write_tmp("test_mix_expressions_midpoint.txt", "ok");
773    }
774
775    // 19. valence_blend and arousal_blend produce non-empty results
776    #[test]
777    fn test_axis_blends() {
778        let space = EmotionSpace::default_space();
779        let vw = space.valence_blend(0.8);
780        assert!(!vw.is_empty(), "valence_blend should produce weights");
781        let aw = space.arousal_blend(-0.5);
782        assert!(!aw.is_empty(), "arousal_blend should produce weights");
783        write_tmp("test_axis_blends.txt", "ok");
784    }
785}