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 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
102const TIER1_HI: f64 = 0.15;
115const TIER2_HI: f64 = 0.35;
116const TIER3_HI: f64 = 0.62;
117const TIER4_HI: f64 = 0.9;
118
119fn 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
134pub 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#[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201pub struct LabelTransition {
202 pub from: String,
203 pub to: String,
204}
205
206#[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
222pub 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 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 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 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 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, trust: -2.0, intrigue: 0.1,
310 intimacy: 0.0,
311 patience: 0.0,
312 tension: 0.0,
313 },
314 0.0,
315 ); 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, 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 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 let mut a = fresh(); 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 assert!((a.intrigue - 0.4).abs() < 1e-9);
387 assert!((a.patience - 0.55).abs() < 1e-9);
389 assert!((a.tension - 0.45).abs() < 1e-9);
391 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 assert!((a.bond_score() - 0.4).abs() < 1e-9);
422 assert!((a.chemistry_score() - 0.2).abs() < 1e-9);
424 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 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); assert!((bar(1.0) - 1.0).abs() < 1e-9);
454 assert!((bar(0.075) - 0.125).abs() < 1e-9);
456 assert!((bar(0.76) - 0.85).abs() < 1e-9);
458 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); a.trust = 0.6;
470 a.intrigue = 0.6; 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); a.intimacy = 1.0;
477 a.tension = 1.0; assert_eq!(a.chemistry_label(), ChemistryLabel::Lover);
479 a.warmth = 1.0;
481 a.trust = 1.0;
482 a.intrigue = 1.0; assert_eq!(a.bond_label(), BondLabel::Soulmate);
484 a.intimacy = 1.0;
485 a.tension = 1.0; 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 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 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 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; let mut after = before.clone();
554 after.trust = 0.9;
555 after.intrigue = 0.9; 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; after.intimacy = 0.9;
575 after.tension = 0.9; 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}