Skip to main content

eros_engine_core/
affinity.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
7pub struct Affinity {
8    pub id: Uuid,
9    pub session_id: Uuid,
10    pub user_id: Uuid,
11    pub instance_id: Uuid,
12    pub warmth: f64,   // -1.0 ..= 1.0
13    pub trust: f64,    //  0.0 ..= 1.0
14    pub intrigue: f64, //  0.0 ..= 1.0
15    pub intimacy: f64, //  0.0 ..= 1.0
16    pub patience: f64, //  0.0 ..= 1.0
17    pub tension: f64,  //  0.0 ..= 1.0
18    pub ghost_streak: i32,
19    pub last_ghost_at: Option<DateTime<Utc>>,
20    pub total_ghosts: i32,
21    pub relationship_label: Option<RelationshipLabel>,
22    pub created_at: DateTime<Utc>,
23    pub updated_at: DateTime<Utc>,
24}
25
26#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
27#[serde(rename_all = "snake_case")]
28pub enum RelationshipLabel {
29    Stranger,
30    Romantic,
31    Friend,
32    Frenemy,
33    SlowBurn,
34}
35
36#[derive(Debug, Clone, Default, Serialize, Deserialize)]
37pub struct AffinityDeltas {
38    pub warmth: f64,
39    pub trust: f64,
40    pub intrigue: f64,
41    pub intimacy: f64,
42    pub patience: f64,
43    pub tension: f64,
44}
45
46impl Affinity {
47    /// Apply LLM-evaluated deltas with EMA smoothing.
48    /// `ema_inertia ∈ [0, 1]` — 0 means full update, 0.8 is standard.
49    pub fn apply_deltas(&mut self, d: &AffinityDeltas, ema_inertia: f64) {
50        let blend = 1.0 - ema_inertia;
51        self.warmth = clamp(self.warmth + blend * d.warmth, -1.0, 1.0);
52        self.trust = clamp(self.trust + blend * d.trust, 0.0, 1.0);
53        self.intrigue = clamp(self.intrigue + blend * d.intrigue, 0.0, 1.0);
54        self.intimacy = clamp(self.intimacy + blend * d.intimacy, 0.0, 1.0);
55        self.patience = clamp(self.patience + blend * d.patience, 0.0, 1.0);
56        self.tension = clamp(self.tension + blend * d.tension, 0.0, 1.0);
57        self.updated_at = Utc::now();
58    }
59
60    pub fn apply_time_decay(&mut self) {
61        let days = (Utc::now() - self.updated_at).num_minutes() as f64 / (60.0 * 24.0);
62        if days <= 0.0 {
63            return;
64        }
65        self.intrigue = clamp(self.intrigue - 0.01 * days, 0.0, 1.0);
66        self.patience = clamp(self.patience + 0.005 * days, 0.0, 1.0);
67        self.tension = clamp(self.tension - 0.005 * days, 0.0, 1.0);
68    }
69
70    pub fn infer_label(&self) -> Option<RelationshipLabel> {
71        // Priority: romantic > friend > frenemy > slow_burn > stranger
72        if self.warmth >= 0.7 && self.tension >= 0.3 && self.intimacy >= 0.4 {
73            return Some(RelationshipLabel::Romantic);
74        }
75        if self.warmth >= 0.7 && self.trust >= 0.6 && self.tension < 0.2 {
76            return Some(RelationshipLabel::Friend);
77        }
78        if self.warmth < 0.4 && self.tension >= 0.6 && self.intrigue >= 0.5 {
79            return Some(RelationshipLabel::Frenemy);
80        }
81        if self.intrigue >= 0.6 && self.tension >= 0.4 && self.intimacy < 0.4 {
82            return Some(RelationshipLabel::SlowBurn);
83        }
84        Some(RelationshipLabel::Stranger)
85    }
86}
87
88fn clamp(v: f64, lo: f64, hi: f64) -> f64 {
89    if v < lo {
90        lo
91    } else if v > hi {
92        hi
93    } else {
94        v
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    fn fresh() -> Affinity {
103        let now = Utc::now();
104        Affinity {
105            id: Uuid::new_v4(),
106            session_id: Uuid::new_v4(),
107            user_id: Uuid::new_v4(),
108            instance_id: Uuid::new_v4(),
109            warmth: 0.3,
110            trust: 0.2,
111            intrigue: 0.5,
112            intimacy: 0.0,
113            patience: 0.5,
114            tension: 0.1,
115            ghost_streak: 0,
116            last_ghost_at: None,
117            total_ghosts: 0,
118            relationship_label: None,
119            created_at: now,
120            updated_at: now,
121        }
122    }
123
124    #[test]
125    fn apply_deltas_clamps_to_valid_ranges() {
126        let mut a = fresh();
127        a.apply_deltas(
128            &AffinityDeltas {
129                warmth: 5.0, // would push past 1.0
130                trust: -2.0, // would push below 0.0
131                intrigue: 0.1,
132                intimacy: 0.0,
133                patience: 0.0,
134                tension: 0.0,
135            },
136            /*ema_inertia*/ 0.0,
137        ); // no smoothing → direct apply
138        assert_eq!(a.warmth, 1.0, "warmth clamps to 1.0 (max)");
139        assert_eq!(a.trust, 0.0, "trust clamps to 0.0 (min)");
140        assert!((a.intrigue - 0.6).abs() < 1e-9);
141    }
142
143    #[test]
144    fn warmth_can_go_negative_others_cannot() {
145        let mut a = fresh();
146        a.apply_deltas(
147            &AffinityDeltas {
148                warmth: -2.0, // -1.0 floor
149                trust: 0.0,
150                intrigue: 0.0,
151                intimacy: 0.0,
152                patience: 0.0,
153                tension: 0.0,
154            },
155            0.0,
156        );
157        assert_eq!(a.warmth, -1.0);
158    }
159
160    #[test]
161    fn ema_smoothing_applies_inertia() {
162        // EMA with inertia=0.8: blended = (1.0 - 0.8) * delta = 0.2 * delta
163        let mut a = fresh();
164        let before = a.warmth;
165        a.apply_deltas(
166            &AffinityDeltas {
167                warmth: 0.5,
168                trust: 0.0,
169                intrigue: 0.0,
170                intimacy: 0.0,
171                patience: 0.0,
172                tension: 0.0,
173            },
174            0.8,
175        );
176        assert!((a.warmth - (before + 0.5 * 0.2)).abs() < 1e-9);
177    }
178
179    #[test]
180    fn time_decay_reduces_intrigue_recovers_patience_softens_tension() {
181        let mut a = fresh();
182        a.intrigue = 0.5;
183        a.patience = 0.5;
184        a.tension = 0.5;
185        a.warmth = 0.7;
186        a.trust = 0.6;
187        a.intimacy = 0.4;
188        a.updated_at = Utc::now() - chrono::Duration::days(10);
189
190        a.apply_time_decay();
191
192        // 10 days * -0.01/day = -0.1
193        assert!((a.intrigue - 0.4).abs() < 1e-9);
194        // 10 days * +0.005/day = +0.05
195        assert!((a.patience - 0.55).abs() < 1e-9);
196        // 10 days * -0.005/day = -0.05
197        assert!((a.tension - 0.45).abs() < 1e-9);
198        // unchanged
199        assert_eq!(a.warmth, 0.7);
200        assert_eq!(a.trust, 0.6);
201        assert_eq!(a.intimacy, 0.4);
202    }
203
204    #[test]
205    fn time_decay_clamps_at_floors_and_ceilings() {
206        let mut a = fresh();
207        a.intrigue = 0.05;
208        a.patience = 0.95;
209        a.tension = 0.02;
210        a.updated_at = Utc::now() - chrono::Duration::days(100);
211
212        a.apply_time_decay();
213
214        assert_eq!(a.intrigue, 0.0);
215        assert_eq!(a.patience, 1.0);
216        assert_eq!(a.tension, 0.0);
217    }
218
219    #[test]
220    fn infer_label_romantic_when_warm_intimate_and_tense() {
221        let mut a = fresh();
222        a.warmth = 0.8;
223        a.tension = 0.4;
224        a.intimacy = 0.5;
225        assert_eq!(a.infer_label(), Some(RelationshipLabel::Romantic));
226    }
227
228    #[test]
229    fn infer_label_friend_when_warm_trusted_low_tension() {
230        let mut a = fresh();
231        a.warmth = 0.75;
232        a.trust = 0.7;
233        a.tension = 0.1;
234        assert_eq!(a.infer_label(), Some(RelationshipLabel::Friend));
235    }
236
237    #[test]
238    fn infer_label_frenemy_when_cold_tense_intrigued() {
239        let mut a = fresh();
240        a.warmth = 0.3;
241        a.tension = 0.7;
242        a.intrigue = 0.6;
243        assert_eq!(a.infer_label(), Some(RelationshipLabel::Frenemy));
244    }
245
246    #[test]
247    fn infer_label_slow_burn_when_intrigued_tense_not_yet_intimate() {
248        let mut a = fresh();
249        a.intrigue = 0.7;
250        a.tension = 0.5;
251        a.intimacy = 0.2;
252        assert_eq!(a.infer_label(), Some(RelationshipLabel::SlowBurn));
253    }
254
255    #[test]
256    fn infer_label_stranger_when_no_thresholds_met() {
257        let a = fresh();
258        assert_eq!(a.infer_label(), Some(RelationshipLabel::Stranger));
259    }
260}