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 apply_deltas_combined_then_gains_and_clamps() {
181 let mut a = fresh(); 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 assert!((a.intrigue - 0.4).abs() < 1e-9);
209 assert!((a.patience - 0.55).abs() < 1e-9);
211 assert!((a.tension - 0.45).abs() < 1e-9);
213 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}