1use 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, pub trust: f64, pub intrigue: f64, pub intimacy: f64, pub patience: f64, pub tension: f64, 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 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 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, trust: -2.0, intrigue: 0.1,
132 intimacy: 0.0,
133 patience: 0.0,
134 tension: 0.0,
135 },
136 0.0,
137 ); 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, 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 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 assert!((a.intrigue - 0.4).abs() < 1e-9);
194 assert!((a.patience - 0.55).abs() < 1e-9);
196 assert!((a.tension - 0.45).abs() < 1e-9);
198 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}