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; v1 default is 0.5 (gain 0.5).
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 apply_deltas_combined_then_gains_and_clamps() {
181        // A pre-summed (rule + llm) delta on a hot axis at v1 pacing
182        // (ema_inertia 0.5 → gain 0.5): 0.3 + 0.5 * 0.15 = 0.375.
183        let mut a = fresh(); // warmth 0.3
184        a.apply_deltas(
185            &AffinityDeltas {
186                warmth: 0.15,
187                ..Default::default()
188            },
189            0.5,
190        );
191        assert!((a.warmth - 0.375).abs() < 1e-9);
192    }
193
194    #[test]
195    fn time_decay_reduces_intrigue_recovers_patience_softens_tension() {
196        let mut a = fresh();
197        a.intrigue = 0.5;
198        a.patience = 0.5;
199        a.tension = 0.5;
200        a.warmth = 0.7;
201        a.trust = 0.6;
202        a.intimacy = 0.4;
203        a.updated_at = Utc::now() - chrono::Duration::days(10);
204
205        a.apply_time_decay();
206
207        // 10 days * -0.01/day = -0.1
208        assert!((a.intrigue - 0.4).abs() < 1e-9);
209        // 10 days * +0.005/day = +0.05
210        assert!((a.patience - 0.55).abs() < 1e-9);
211        // 10 days * -0.005/day = -0.05
212        assert!((a.tension - 0.45).abs() < 1e-9);
213        // unchanged
214        assert_eq!(a.warmth, 0.7);
215        assert_eq!(a.trust, 0.6);
216        assert_eq!(a.intimacy, 0.4);
217    }
218
219    #[test]
220    fn time_decay_clamps_at_floors_and_ceilings() {
221        let mut a = fresh();
222        a.intrigue = 0.05;
223        a.patience = 0.95;
224        a.tension = 0.02;
225        a.updated_at = Utc::now() - chrono::Duration::days(100);
226
227        a.apply_time_decay();
228
229        assert_eq!(a.intrigue, 0.0);
230        assert_eq!(a.patience, 1.0);
231        assert_eq!(a.tension, 0.0);
232    }
233
234    #[test]
235    fn infer_label_romantic_when_warm_intimate_and_tense() {
236        let mut a = fresh();
237        a.warmth = 0.8;
238        a.tension = 0.4;
239        a.intimacy = 0.5;
240        assert_eq!(a.infer_label(), Some(RelationshipLabel::Romantic));
241    }
242
243    #[test]
244    fn infer_label_friend_when_warm_trusted_low_tension() {
245        let mut a = fresh();
246        a.warmth = 0.75;
247        a.trust = 0.7;
248        a.tension = 0.1;
249        assert_eq!(a.infer_label(), Some(RelationshipLabel::Friend));
250    }
251
252    #[test]
253    fn infer_label_frenemy_when_cold_tense_intrigued() {
254        let mut a = fresh();
255        a.warmth = 0.3;
256        a.tension = 0.7;
257        a.intrigue = 0.6;
258        assert_eq!(a.infer_label(), Some(RelationshipLabel::Frenemy));
259    }
260
261    #[test]
262    fn infer_label_slow_burn_when_intrigued_tense_not_yet_intimate() {
263        let mut a = fresh();
264        a.intrigue = 0.7;
265        a.tension = 0.5;
266        a.intimacy = 0.2;
267        assert_eq!(a.infer_label(), Some(RelationshipLabel::SlowBurn));
268    }
269
270    #[test]
271    fn infer_label_stranger_when_no_thresholds_met() {
272        let a = fresh();
273        assert_eq!(a.infer_label(), Some(RelationshipLabel::Stranger));
274    }
275}