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    /// Legacy 5-name relationship label (back-compat), derived purely from the
71    /// two line scores — replaces the old multi-axis `infer_label` heuristic.
72    /// New consumers should read `bond_label`/`chemistry_label`. `frenemy` is
73    /// retired from emission (kept in the enum for parse compat).
74    pub fn legacy_relationship_label(&self) -> RelationshipLabel {
75        let bond = self.bond_score();
76        let chem = self.chemistry_score();
77        if tier_index(bond) == 1 && tier_index(chem) == 1 {
78            return RelationshipLabel::Stranger;
79        }
80        if chem > bond {
81            if tier_index(chem) >= 3 {
82                RelationshipLabel::Romantic
83            } else {
84                RelationshipLabel::SlowBurn
85            }
86        } else {
87            RelationshipLabel::Friend
88        }
89    }
90}
91
92fn clamp(v: f64, lo: f64, hi: f64) -> f64 {
93    if v < lo {
94        lo
95    } else if v > hi {
96        hi
97    } else {
98        v
99    }
100}
101
102// ─── Bond / Chemistry lines (read-layer folds of the 6 axes) ────────
103//
104// Two composites folded from the unchanged 6-axis base. `warmth` is shared into
105// both and FLOORED at 0 (a neutral/cold session contributes nothing, so a fresh
106// session sits near 0). `patience` is rule-owned and excluded.
107//
108// Mirrored by the `bond`/`chemistry` GENERATED columns in store migration 0029
109// (warmth floored via GREATEST(warmth,0)). Keep the formula in sync.
110
111/// Tier upper bounds on a line's 0..1 score. Widening by design: easy early, a
112/// grind near the top. Tier 1 = [0, T1), 2 = [T1, T2), 3 = [T2, T3),
113/// 4 = [T3, T4), 5 = [T4, 1]. Tunable.
114const TIER1_HI: f64 = 0.15;
115const TIER2_HI: f64 = 0.35;
116const TIER3_HI: f64 = 0.62;
117const TIER4_HI: f64 = 0.9;
118
119/// 1..=5 tier index for a 0..1 line score.
120fn tier_index(score: f64) -> u8 {
121    if score < TIER1_HI {
122        1
123    } else if score < TIER2_HI {
124        2
125    } else if score < TIER3_HI {
126        3
127    } else if score < TIER4_HI {
128        4
129    } else {
130        5
131    }
132}
133
134/// Map a 0..1 line score to a 0..1 bar fill. Bands are NOT even: tiers 1–4 fill
135/// 25% / 25% / 25% / 20% and tier 5 fills the top 5% (`[0.95, 1.0]`). The apex band
136/// is deliberately narrow so the ceiling reads as rare, but wide enough that the bar
137/// still moves across tier 5's 0.10 raw span (avoids lv4→lv5 damping). Linear within
138/// each band; higher tiers span more raw score, so the bar fills fast early and crawls
139/// near the top. Tunable alongside the thresholds.
140pub fn bar(score: f64) -> f64 {
141    let (lo, hi, band_lo, band_hi) = match tier_index(score) {
142        1 => (0.0, TIER1_HI, 0.0, 0.25),
143        2 => (TIER1_HI, TIER2_HI, 0.25, 0.50),
144        3 => (TIER2_HI, TIER3_HI, 0.50, 0.75),
145        4 => (TIER3_HI, TIER4_HI, 0.75, 0.95),
146        _ => (TIER4_HI, 1.0, 0.95, 1.0),
147    };
148    let within = ((score - lo) / (hi - lo)).clamp(0.0, 1.0);
149    (band_lo + within * (band_hi - band_lo)).clamp(0.0, 1.0)
150}
151
152/// Friendship-line tier (pure function of `bond_score`). Serialised snake_case
153/// key is the frontend's lookup; Chinese display lives in the frontend.
154#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
155#[serde(rename_all = "snake_case")]
156pub enum BondLabel {
157    Acquaintance,
158    Friend,
159    CloseFriend,
160    Confidant,
161    Soulmate,
162}
163
164impl BondLabel {
165    pub fn as_key(self) -> &'static str {
166        match self {
167            BondLabel::Acquaintance => "acquaintance",
168            BondLabel::Friend => "friend",
169            BondLabel::CloseFriend => "close_friend",
170            BondLabel::Confidant => "confidant",
171            BondLabel::Soulmate => "soulmate",
172        }
173    }
174}
175
176/// Romance-line tier (pure function of `chemistry_score`).
177#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
178#[serde(rename_all = "snake_case")]
179pub enum ChemistryLabel {
180    Spark,
181    Flirtation,
182    Crush,
183    Lover,
184    Beloved,
185}
186
187impl ChemistryLabel {
188    pub fn as_key(self) -> &'static str {
189        match self {
190            ChemistryLabel::Spark => "spark",
191            ChemistryLabel::Flirtation => "flirtation",
192            ChemistryLabel::Crush => "crush",
193            ChemistryLabel::Lover => "lover",
194            ChemistryLabel::Beloved => "beloved",
195        }
196    }
197}
198
199/// One line's tier transition this turn, as serialised keys.
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201pub struct LabelTransition {
202    pub from: String,
203    pub to: String,
204}
205
206/// Per-turn tier transition across the two lines. Serde skips `None` fields, so
207/// a JSON object only carries the line(s) that actually moved.
208#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
209pub struct TurnLabelChanges {
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub bond: Option<LabelTransition>,
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub chemistry: Option<LabelTransition>,
214}
215
216impl TurnLabelChanges {
217    pub fn is_empty(&self) -> bool {
218        self.bond.is_none() && self.chemistry.is_none()
219    }
220}
221
222/// Tier transition over a delta-only span (before = post-decay/pre-delta,
223/// after = post-delta). `None` when neither line crossed a tier.
224pub fn diff_labels(before: &Affinity, after: &Affinity) -> Option<TurnLabelChanges> {
225    let bond = (before.bond_label() != after.bond_label()).then(|| LabelTransition {
226        from: before.bond_label().as_key().to_string(),
227        to: after.bond_label().as_key().to_string(),
228    });
229    let chemistry =
230        (before.chemistry_label() != after.chemistry_label()).then(|| LabelTransition {
231            from: before.chemistry_label().as_key().to_string(),
232            to: after.chemistry_label().as_key().to_string(),
233        });
234    let changes = TurnLabelChanges { bond, chemistry };
235    (!changes.is_empty()).then_some(changes)
236}
237
238impl Affinity {
239    /// 0..1 friendship composite. warmth floored at 0; mirrors the `bond`
240    /// generated column in migration 0029.
241    pub fn bond_score(&self) -> f64 {
242        let warm_pos = self.warmth.max(0.0);
243        clamp((warm_pos + self.trust + self.intrigue) / 3.0, 0.0, 1.0)
244    }
245
246    /// 0..1 romance composite. warmth floored at 0; mirrors the `chemistry`
247    /// generated column in migration 0029.
248    pub fn chemistry_score(&self) -> f64 {
249        let warm_pos = self.warmth.max(0.0);
250        clamp((warm_pos + self.intimacy + self.tension) / 3.0, 0.0, 1.0)
251    }
252
253    /// Friendship-line tier label.
254    pub fn bond_label(&self) -> BondLabel {
255        match tier_index(self.bond_score()) {
256            1 => BondLabel::Acquaintance,
257            2 => BondLabel::Friend,
258            3 => BondLabel::CloseFriend,
259            4 => BondLabel::Confidant,
260            _ => BondLabel::Soulmate,
261        }
262    }
263
264    /// Romance-line tier label.
265    pub fn chemistry_label(&self) -> ChemistryLabel {
266        match tier_index(self.chemistry_score()) {
267            1 => ChemistryLabel::Spark,
268            2 => ChemistryLabel::Flirtation,
269            3 => ChemistryLabel::Crush,
270            4 => ChemistryLabel::Lover,
271            _ => ChemistryLabel::Beloved,
272        }
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    fn fresh() -> Affinity {
281        let now = Utc::now();
282        Affinity {
283            id: Uuid::new_v4(),
284            session_id: Uuid::new_v4(),
285            user_id: Uuid::new_v4(),
286            instance_id: Uuid::new_v4(),
287            warmth: 0.3,
288            trust: 0.2,
289            intrigue: 0.5,
290            intimacy: 0.0,
291            patience: 0.5,
292            tension: 0.1,
293            ghost_streak: 0,
294            last_ghost_at: None,
295            total_ghosts: 0,
296            relationship_label: None,
297            created_at: now,
298            updated_at: now,
299        }
300    }
301
302    #[test]
303    fn apply_deltas_clamps_to_valid_ranges() {
304        let mut a = fresh();
305        a.apply_deltas(
306            &AffinityDeltas {
307                warmth: 5.0, // would push past 1.0
308                trust: -2.0, // would push below 0.0
309                intrigue: 0.1,
310                intimacy: 0.0,
311                patience: 0.0,
312                tension: 0.0,
313            },
314            /*ema_inertia*/ 0.0,
315        ); // no smoothing → direct apply
316        assert_eq!(a.warmth, 1.0, "warmth clamps to 1.0 (max)");
317        assert_eq!(a.trust, 0.0, "trust clamps to 0.0 (min)");
318        assert!((a.intrigue - 0.6).abs() < 1e-9);
319    }
320
321    #[test]
322    fn warmth_can_go_negative_others_cannot() {
323        let mut a = fresh();
324        a.apply_deltas(
325            &AffinityDeltas {
326                warmth: -2.0, // -1.0 floor
327                trust: 0.0,
328                intrigue: 0.0,
329                intimacy: 0.0,
330                patience: 0.0,
331                tension: 0.0,
332            },
333            0.0,
334        );
335        assert_eq!(a.warmth, -1.0);
336    }
337
338    #[test]
339    fn ema_smoothing_applies_inertia() {
340        // EMA with inertia=0.8: blended = (1.0 - 0.8) * delta = 0.2 * delta
341        let mut a = fresh();
342        let before = a.warmth;
343        a.apply_deltas(
344            &AffinityDeltas {
345                warmth: 0.5,
346                trust: 0.0,
347                intrigue: 0.0,
348                intimacy: 0.0,
349                patience: 0.0,
350                tension: 0.0,
351            },
352            0.8,
353        );
354        assert!((a.warmth - (before + 0.5 * 0.2)).abs() < 1e-9);
355    }
356
357    #[test]
358    fn apply_deltas_combined_then_gains_and_clamps() {
359        // A pre-summed (rule + llm) delta on a hot axis at v1 pacing
360        // (ema_inertia 0.5 → gain 0.5): 0.3 + 0.5 * 0.15 = 0.375.
361        let mut a = fresh(); // warmth 0.3
362        a.apply_deltas(
363            &AffinityDeltas {
364                warmth: 0.15,
365                ..Default::default()
366            },
367            0.5,
368        );
369        assert!((a.warmth - 0.375).abs() < 1e-9);
370    }
371
372    #[test]
373    fn time_decay_reduces_intrigue_recovers_patience_softens_tension() {
374        let mut a = fresh();
375        a.intrigue = 0.5;
376        a.patience = 0.5;
377        a.tension = 0.5;
378        a.warmth = 0.7;
379        a.trust = 0.6;
380        a.intimacy = 0.4;
381        a.updated_at = Utc::now() - chrono::Duration::days(10);
382
383        a.apply_time_decay();
384
385        // 10 days * -0.01/day = -0.1
386        assert!((a.intrigue - 0.4).abs() < 1e-9);
387        // 10 days * +0.005/day = +0.05
388        assert!((a.patience - 0.55).abs() < 1e-9);
389        // 10 days * -0.005/day = -0.05
390        assert!((a.tension - 0.45).abs() < 1e-9);
391        // unchanged
392        assert_eq!(a.warmth, 0.7);
393        assert_eq!(a.trust, 0.6);
394        assert_eq!(a.intimacy, 0.4);
395    }
396
397    #[test]
398    fn time_decay_clamps_at_floors_and_ceilings() {
399        let mut a = fresh();
400        a.intrigue = 0.05;
401        a.patience = 0.95;
402        a.tension = 0.02;
403        a.updated_at = Utc::now() - chrono::Duration::days(100);
404
405        a.apply_time_decay();
406
407        assert_eq!(a.intrigue, 0.0);
408        assert_eq!(a.patience, 1.0);
409        assert_eq!(a.tension, 0.0);
410    }
411
412    #[test]
413    fn bond_chemistry_scores_fold_axes_with_warmth_floored() {
414        let mut a = fresh();
415        a.warmth = 0.2;
416        a.trust = 0.4;
417        a.intrigue = 0.6;
418        a.intimacy = 0.1;
419        a.tension = 0.3;
420        // bond = (0.2 + 0.4 + 0.6)/3 = 0.4
421        assert!((a.bond_score() - 0.4).abs() < 1e-9);
422        // chemistry = (0.2 + 0.1 + 0.3)/3 = 0.2
423        assert!((a.chemistry_score() - 0.2).abs() < 1e-9);
424        // negative warmth floors to 0 in the composite
425        a.warmth = -1.0;
426        a.trust = 0.0;
427        a.intrigue = 0.0;
428        assert!((a.bond_score()).abs() < 1e-9);
429    }
430
431    #[test]
432    fn tier_index_boundaries() {
433        assert_eq!(tier_index(0.0), 1);
434        assert_eq!(tier_index(0.149), 1);
435        assert_eq!(tier_index(0.15), 2);
436        assert_eq!(tier_index(0.349), 2);
437        assert_eq!(tier_index(0.35), 3);
438        assert_eq!(tier_index(0.619), 3);
439        assert_eq!(tier_index(0.62), 4);
440        assert_eq!(tier_index(0.899), 4);
441        assert_eq!(tier_index(0.9), 5);
442        assert_eq!(tier_index(1.0), 5);
443    }
444
445    #[test]
446    fn bar_maps_tiers_to_bands() {
447        // Tier lower edges land on their band's lower edge.
448        assert!((bar(0.0)).abs() < 1e-9);
449        assert!((bar(0.15) - 0.25).abs() < 1e-9);
450        assert!((bar(0.35) - 0.50).abs() < 1e-9);
451        assert!((bar(0.62) - 0.75).abs() < 1e-9);
452        assert!((bar(0.9) - 0.95).abs() < 1e-9); // tier 5 lower edge
453        assert!((bar(1.0) - 1.0).abs() < 1e-9);
454        // midpoint of tier 1 [0,0.15) → 0.075 → half of the 0..0.25 band
455        assert!((bar(0.075) - 0.125).abs() < 1e-9);
456        // tier 4 midpoint 0.76 → 0.75 + 0.5*(0.95-0.75) = 0.85 (inside [0.75,0.95))
457        assert!((bar(0.76) - 0.85).abs() < 1e-9);
458        // tier 5 midpoint 0.95 → 0.95 + 0.5*(1.0-0.95) = 0.975
459        assert!((bar(0.95) - 0.975).abs() < 1e-9);
460    }
461
462    #[test]
463    fn labels_map_from_scores() {
464        let mut a = fresh();
465        a.warmth = 0.0;
466        a.trust = 0.0;
467        a.intrigue = 0.0;
468        assert_eq!(a.bond_label(), BondLabel::Acquaintance); // bond 0
469        a.trust = 0.6;
470        a.intrigue = 0.6; // bond = 0.4 → tier 3
471        assert_eq!(a.bond_label(), BondLabel::CloseFriend);
472        a.warmth = 0.0;
473        a.intimacy = 0.0;
474        a.tension = 0.0;
475        assert_eq!(a.chemistry_label(), ChemistryLabel::Spark); // chem 0
476        a.intimacy = 1.0;
477        a.tension = 1.0; // chem = 0.667 → tier 4
478        assert_eq!(a.chemistry_label(), ChemistryLabel::Lover);
479        // tier 5 apex
480        a.warmth = 1.0;
481        a.trust = 1.0;
482        a.intrigue = 1.0; // bond = 1.0 → tier 5
483        assert_eq!(a.bond_label(), BondLabel::Soulmate);
484        a.intimacy = 1.0;
485        a.tension = 1.0; // chem = 1.0 → tier 5
486        assert_eq!(a.chemistry_label(), ChemistryLabel::Beloved);
487        assert_eq!(BondLabel::Soulmate.as_key(), "soulmate");
488        assert_eq!(ChemistryLabel::Beloved.as_key(), "beloved");
489    }
490
491    #[test]
492    fn legacy_label_stranger_when_both_tier1() {
493        let mut a = fresh();
494        a.warmth = 0.0;
495        a.trust = 0.0;
496        a.intrigue = 0.0;
497        a.intimacy = 0.0;
498        a.tension = 0.0;
499        assert_eq!(a.legacy_relationship_label(), RelationshipLabel::Stranger);
500    }
501
502    #[test]
503    fn legacy_label_friend_when_bond_leads() {
504        let mut a = fresh();
505        // bond = (0.3+0.6+0.6)/3 = 0.5 ; chem = (0.3+0+0)/3 = 0.1
506        a.warmth = 0.3;
507        a.trust = 0.6;
508        a.intrigue = 0.6;
509        a.intimacy = 0.0;
510        a.tension = 0.0;
511        assert_eq!(a.legacy_relationship_label(), RelationshipLabel::Friend);
512    }
513
514    #[test]
515    fn legacy_label_romantic_when_chemistry_high() {
516        let mut a = fresh();
517        // chem = (0.3+0.9+0.9)/3 = 0.7 (tier4) ; bond = 0.1
518        a.warmth = 0.3;
519        a.intimacy = 0.9;
520        a.tension = 0.9;
521        a.trust = 0.0;
522        a.intrigue = 0.0;
523        assert_eq!(a.legacy_relationship_label(), RelationshipLabel::Romantic);
524    }
525
526    #[test]
527    fn legacy_label_slow_burn_when_chemistry_leads_but_mid() {
528        let mut a = fresh();
529        // chem = (0.3+0.3+0.2)/3 ≈ 0.267 (tier2) ; bond = 0.1 (tier1)
530        a.warmth = 0.3;
531        a.intimacy = 0.3;
532        a.tension = 0.2;
533        a.trust = 0.0;
534        a.intrigue = 0.0;
535        assert_eq!(a.legacy_relationship_label(), RelationshipLabel::SlowBurn);
536    }
537
538    #[test]
539    fn diff_labels_none_when_no_tier_change() {
540        let a = fresh();
541        let b = a.clone();
542        assert!(diff_labels(&a, &b).is_none());
543    }
544
545    #[test]
546    fn diff_labels_reports_single_line_change() {
547        let mut before = fresh();
548        before.warmth = 0.0;
549        before.trust = 0.0;
550        before.intrigue = 0.0;
551        before.intimacy = 0.0;
552        before.tension = 0.0; // bond + chem both tier 1
553        let mut after = before.clone();
554        after.trust = 0.9;
555        after.intrigue = 0.9; // bond = 0.6 → tier 3 (close_friend)
556        let d = diff_labels(&before, &after).unwrap();
557        let bond = d.bond.unwrap();
558        assert_eq!(bond.from, "acquaintance");
559        assert_eq!(bond.to, "close_friend");
560        assert!(d.chemistry.is_none());
561    }
562
563    #[test]
564    fn diff_labels_reports_both_lines() {
565        let mut before = fresh();
566        before.warmth = 0.0;
567        before.trust = 0.0;
568        before.intrigue = 0.0;
569        before.intimacy = 0.0;
570        before.tension = 0.0;
571        let mut after = before.clone();
572        after.trust = 0.9;
573        after.intrigue = 0.9; // bond → close_friend
574        after.intimacy = 0.9;
575        after.tension = 0.9; // chem = 0.6 → tier 3 (crush)
576        let d = diff_labels(&before, &after).unwrap();
577        assert_eq!(d.bond.unwrap().to, "close_friend");
578        assert_eq!(d.chemistry.unwrap().to, "crush");
579    }
580}