1#![allow(dead_code)]
11
12use std::collections::HashMap;
13
14#[derive(Debug, Clone, Copy, PartialEq)]
22pub struct PadPoint {
23 pub pleasure: f32,
25 pub arousal: f32,
27 pub dominance: f32,
29}
30
31impl PadPoint {
32 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 pub fn neutral() -> Self {
43 Self::new(0.0, 0.0, 0.0)
44 }
45
46 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 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 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 pub fn happy() -> Self {
78 Self::new(0.8, 0.5, 0.3)
79 }
80
81 pub fn sad() -> Self {
83 Self::new(-0.6, -0.4, -0.4)
84 }
85
86 pub fn angry() -> Self {
88 Self::new(-0.5, 0.8, 0.7)
89 }
90
91 pub fn fearful() -> Self {
93 Self::new(-0.6, 0.7, -0.6)
94 }
95
96 pub fn surprised() -> Self {
98 Self::new(0.1, 0.8, -0.3)
99 }
100
101 pub fn disgusted() -> Self {
103 Self::new(-0.7, 0.3, 0.4)
104 }
105
106 pub fn contemptuous() -> Self {
108 Self::new(-0.1, 0.2, 0.6)
109 }
110}
111
112pub struct EmotionAnchor {
118 pub name: String,
120 pub pad: PadPoint,
122 pub morph_weights: HashMap<String, f32>,
124}
125
126impl EmotionAnchor {
127 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
137pub struct EmotionSpace {
143 anchors: Vec<EmotionAnchor>,
144}
145
146impl EmotionSpace {
147 pub fn new() -> Self {
149 Self {
150 anchors: Vec::new(),
151 }
152 }
153
154 pub fn add_anchor(&mut self, anchor: EmotionAnchor) {
156 self.anchors.push(anchor);
157 }
158
159 pub fn anchor_count(&self) -> usize {
161 self.anchors.len()
162 }
163
164 pub fn find_anchor(&self, name: &str) -> Option<&EmotionAnchor> {
166 self.anchors.iter().find(|a| a.name == name)
167 }
168
169 pub fn default_space() -> Self {
174 let mut space = Self::new();
175
176 space.add_anchor(EmotionAnchor::new(
178 "neutral",
179 PadPoint::neutral(),
180 HashMap::new(),
181 ));
182
183 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 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 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 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 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 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 space.add_anchor(EmotionAnchor::new(
277 "contemptuous",
278 PadPoint::contemptuous(),
279 [
280 ("lip-corner-puller", 0.5), ("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 pub fn evaluate(&self, pad: &PadPoint) -> HashMap<String, f32> {
300 self.evaluate_idw(pad, 2.0)
301 }
302
303 fn evaluate_idw(&self, pad: &PadPoint, power: f32) -> HashMap<String, f32> {
305 if self.anchors.is_empty() {
306 return HashMap::new();
307 }
308
309 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 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 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 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 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
420pub struct EmotionTransition {
426 pub from: PadPoint,
428 pub to: PadPoint,
430 pub duration_seconds: f32,
432}
433
434impl EmotionTransition {
435 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 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 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); self.from.lerp(&self.to, s)
460 }
461}
462
463pub 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
485pub 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
496pub 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#[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 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 let lin = tr.evaluate(0.5);
705 let smooth = tr.evaluate_smooth(0.5);
706 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 #[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 #[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 #[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 #[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 #[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}