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 warmth_grade: i16,
21 pub patience_grade: i16,
23 pub ghost_streak: i32,
24 pub last_ghost_at: Option<DateTime<Utc>>,
25 pub total_ghosts: i32,
26 pub relationship_label: Option<RelationshipLabel>,
27 pub created_at: DateTime<Utc>,
28 pub updated_at: DateTime<Utc>,
29}
30
31#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
32#[serde(rename_all = "snake_case")]
33pub enum RelationshipLabel {
34 Stranger,
35 Romantic,
36 Friend,
37 Frenemy,
38 SlowBurn,
39}
40
41#[derive(Debug, Clone, Default, Serialize, Deserialize)]
42pub struct AffinityDeltas {
43 pub warmth: f64,
44 pub trust: f64,
45 pub intrigue: f64,
46 pub intimacy: f64,
47 pub patience: f64,
48 pub tension: f64,
49}
50
51impl Affinity {
52 pub fn apply_deltas(&mut self, d: &AffinityDeltas) {
56 self.warmth = clamp(self.warmth + d.warmth, 0.0, 1.0);
57 self.trust = clamp(self.trust + d.trust, 0.0, 1.0);
58 self.intrigue = clamp(self.intrigue + d.intrigue, 0.0, 1.0);
59 self.intimacy = clamp(self.intimacy + d.intimacy, 0.0, 1.0);
60 self.patience = clamp(self.patience + d.patience, 0.0, 1.0);
61 self.tension = clamp(self.tension + d.tension, 0.0, 1.0);
62 self.updated_at = Utc::now();
63 }
64
65 pub fn apply_time_decay(&mut self) {
71 let days = (Utc::now() - self.updated_at).num_minutes() as f64 / (60.0 * 24.0);
72 if days <= 0.0 {
73 return;
74 }
75 self.intrigue = clamp(self.intrigue - 0.01 * days, 0.0, 1.0);
76 self.tension = clamp(self.tension - 0.005 * days, 0.0, 1.0);
77 }
78
79 pub fn refresh_endpoints(&mut self, t: &AffinityTuning) {
85 let days = (Utc::now() - self.updated_at).num_minutes() as f64 / (60.0 * 24.0);
86 let decay = endpoint_time_decay(days, t.time_decay_rate, t.time_decay_floor);
87 self.warmth = endpoint_value(
88 self.warmth_grade,
89 self.chemistry_score(),
90 decay,
91 t.floor_ratio,
92 );
93 self.patience =
94 endpoint_value(self.patience_grade, self.bond_score(), decay, t.floor_ratio);
95 }
96
97 pub fn legacy_relationship_label(&self) -> RelationshipLabel {
102 let bond = self.bond_score();
103 let chem = self.chemistry_score();
104 if tier_index(bond) == 1 && tier_index(chem) == 1 {
105 return RelationshipLabel::Stranger;
106 }
107 if chem > bond {
108 if tier_index(chem) >= 3 {
109 RelationshipLabel::Romantic
110 } else {
111 RelationshipLabel::SlowBurn
112 }
113 } else {
114 RelationshipLabel::Friend
115 }
116 }
117}
118
119fn clamp(v: f64, lo: f64, hi: f64) -> f64 {
120 if v < lo {
121 lo
122 } else if v > hi {
123 hi
124 } else {
125 v
126 }
127}
128
129const TIER1_HI: f64 = 0.15;
144const TIER2_HI: f64 = 0.35;
145const TIER3_HI: f64 = 0.62;
146const TIER4_HI: f64 = 0.9;
147
148const INTIMACY_RUNG3_LO: f64 = 0.76;
156
157const _: () = assert!(
158 TIER3_HI < INTIMACY_RUNG3_LO && INTIMACY_RUNG3_LO < TIER4_HI,
159 "the top intimacy rung must open inside tier 4, not at the apex"
160);
161
162const PATIENCE_LO: f64 = 0.35;
169const PATIENCE_HI: f64 = 0.65;
170
171pub const ENDPOINT_BOOST_MAX: f64 = 1.5;
185
186#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
189pub struct EndpointLevelReads {
190 pub warmth: Option<i16>,
191 pub patience: Option<i16>,
192}
193
194fn endpoint_base(level: i16) -> f64 {
196 f64::from(level.clamp(1, 3) - 1) / 3.0
197}
198
199pub fn endpoint_boost(counterpart: f64) -> f64 {
202 let slope = (ENDPOINT_BOOST_MAX - 1.0) / (1.0 - TIER2_HI);
203 1.0 + slope * (counterpart - TIER2_HI)
204}
205
206pub fn endpoint_time_decay(days: f64, rate: f64, floor: f64) -> f64 {
209 (1.0 - rate * days.max(0.0)).max(floor)
210}
211
212pub fn endpoint_value(level: i16, counterpart: f64, decay: f64, floor_ratio: f64) -> f64 {
217 let boosted = endpoint_base(level) * endpoint_boost(counterpart);
218 (boosted.max(floor_ratio * counterpart) * decay).clamp(0.0, 1.0)
219}
220
221fn tier_index(score: f64) -> u8 {
223 if score < TIER1_HI {
224 1
225 } else if score < TIER2_HI {
226 2
227 } else if score < TIER3_HI {
228 3
229 } else if score < TIER4_HI {
230 4
231 } else {
232 5
233 }
234}
235
236#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
239#[serde(rename_all = "snake_case")]
240pub enum BondLabel {
241 Acquaintance,
242 Friend,
243 CloseFriend,
244 Confidant,
245 Soulmate,
246}
247
248impl BondLabel {
249 pub fn as_key(self) -> &'static str {
250 match self {
251 BondLabel::Acquaintance => "acquaintance",
252 BondLabel::Friend => "friend",
253 BondLabel::CloseFriend => "close_friend",
254 BondLabel::Confidant => "confidant",
255 BondLabel::Soulmate => "soulmate",
256 }
257 }
258}
259
260#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
262#[serde(rename_all = "snake_case")]
263pub enum ChemistryLabel {
264 Spark,
265 Flirtation,
266 Crush,
267 Lover,
268 Beloved,
269}
270
271impl ChemistryLabel {
272 pub fn as_key(self) -> &'static str {
273 match self {
274 ChemistryLabel::Spark => "spark",
275 ChemistryLabel::Flirtation => "flirtation",
276 ChemistryLabel::Crush => "crush",
277 ChemistryLabel::Lover => "lover",
278 ChemistryLabel::Beloved => "beloved",
279 }
280 }
281}
282
283#[derive(Debug, Clone, Copy, PartialEq, Eq)]
287pub enum PatienceBand {
288 Low,
289 Mid,
290 High,
291}
292
293#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
295pub struct LabelTransition {
296 pub from: String,
297 pub to: String,
298}
299
300#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
303pub struct TurnLabelChanges {
304 #[serde(default, skip_serializing_if = "Option::is_none")]
305 pub bond: Option<LabelTransition>,
306 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub chemistry: Option<LabelTransition>,
308}
309
310impl TurnLabelChanges {
311 pub fn is_empty(&self) -> bool {
312 self.bond.is_none() && self.chemistry.is_none()
313 }
314}
315
316pub fn diff_labels(before: &Affinity, after: &Affinity) -> Option<TurnLabelChanges> {
319 let bond = (before.bond_label() != after.bond_label()).then(|| LabelTransition {
320 from: before.bond_label().as_key().to_string(),
321 to: after.bond_label().as_key().to_string(),
322 });
323 let chemistry =
324 (before.chemistry_label() != after.chemistry_label()).then(|| LabelTransition {
325 from: before.chemistry_label().as_key().to_string(),
326 to: after.chemistry_label().as_key().to_string(),
327 });
328 let changes = TurnLabelChanges { bond, chemistry };
329 (!changes.is_empty()).then_some(changes)
330}
331
332impl Affinity {
333 pub fn bond_score(&self) -> f64 {
336 clamp((self.trust + self.intrigue) / 2.0, 0.0, 1.0)
337 }
338
339 pub fn chemistry_score(&self) -> f64 {
342 clamp((self.intimacy + self.tension) / 2.0, 0.0, 1.0)
343 }
344
345 pub fn bond_label(&self) -> BondLabel {
347 match tier_index(self.bond_score()) {
348 1 => BondLabel::Acquaintance,
349 2 => BondLabel::Friend,
350 3 => BondLabel::CloseFriend,
351 4 => BondLabel::Confidant,
352 _ => BondLabel::Soulmate,
353 }
354 }
355
356 pub fn chemistry_label(&self) -> ChemistryLabel {
358 match tier_index(self.chemistry_score()) {
359 1 => ChemistryLabel::Spark,
360 2 => ChemistryLabel::Flirtation,
361 3 => ChemistryLabel::Crush,
362 4 => ChemistryLabel::Lover,
363 _ => ChemistryLabel::Beloved,
364 }
365 }
366
367 pub fn intimacy_rung(&self) -> u8 {
378 let s = self.bond_score().max(self.chemistry_score());
379 if tier_index(s) == 1 {
380 1
381 } else if s < INTIMACY_RUNG3_LO {
382 2
383 } else {
384 3
385 }
386 }
387
388 pub fn patience_band(&self) -> PatienceBand {
391 if self.patience < PATIENCE_LO {
392 PatienceBand::Low
393 } else if self.patience < PATIENCE_HI {
394 PatienceBand::Mid
395 } else {
396 PatienceBand::High
397 }
398 }
399}
400
401#[derive(Debug, Clone, PartialEq)]
417pub struct AffinityTuning {
418 pub grade_unit_bond: f64,
423 pub grade_unit_chem: f64,
425 pub neg_factor: f64,
428 pub tier_decay: [f64; 5],
430 pub cross_penalty_ratio: f64,
435 pub cross_penalty_start: f64,
437 pub delta_threshold: f64,
439 pub demo_boost: f64,
442 pub floor_ratio: f64,
446 pub time_decay_rate: f64,
448 pub time_decay_floor: f64,
450}
451
452impl Default for AffinityTuning {
453 fn default() -> Self {
454 Self {
455 grade_unit_bond: 0.0786,
459 grade_unit_chem: 0.0266,
460 neg_factor: 1.5,
461 tier_decay: [1.0, 0.70, 0.45, 0.25, 0.10],
462 cross_penalty_ratio: 5.0 / 6.0,
464 cross_penalty_start: 0.35,
465 delta_threshold: 0.0,
466 demo_boost: 1.4,
467 floor_ratio: 0.2,
468 time_decay_rate: 0.02,
469 time_decay_floor: 0.5,
470 }
471 }
472}
473
474#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
479pub struct AxisGrades {
480 pub trust: i8,
481 pub intrigue: i8,
482 pub intimacy: i8,
483 pub tension: i8,
484}
485
486#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
490pub struct PendingDeltas {
491 #[serde(default)]
492 pub trust: f64,
493 #[serde(default)]
494 pub intrigue: f64,
495 #[serde(default)]
496 pub intimacy: f64,
497 #[serde(default)]
498 pub tension: f64,
499}
500
501impl PendingDeltas {
502 pub fn is_zero(&self) -> bool {
503 self.trust == 0.0 && self.intrigue == 0.0 && self.intimacy == 0.0 && self.tension == 0.0
504 }
505}
506
507#[derive(Debug, Clone, Default)]
514pub struct GradeTurnOutcome {
515 pub raw: AffinityDeltas,
516 pub committed: AffinityDeltas,
517 pub pending: PendingDeltas,
518 pub cross_penalty_assessed: CrossPenaltyAssessed,
528}
529
530#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
532pub struct CrossPenaltyAssessed {
533 pub trust: f64,
534 pub intrigue: f64,
535 pub intimacy: f64,
536 pub tension: f64,
537}
538
539impl CrossPenaltyAssessed {
540 pub fn is_zero(&self) -> bool {
541 self.trust == 0.0 && self.intrigue == 0.0 && self.intimacy == 0.0 && self.tension == 0.0
542 }
543}
544
545pub fn grade_turn(
550 a: &Affinity,
551 grades: &AxisGrades,
552 rule: &AffinityDeltas,
553 pending: &PendingDeltas,
554 boost: f64,
555 t: &AffinityTuning,
556) -> GradeTurnOutcome {
557 let bond = a.bond_score();
559 let chem = a.chemistry_score();
560 let bond_tier = tier_index(bond) as usize;
561 let chem_tier = tier_index(chem) as usize;
562
563 let decay = |tier: usize| t.tier_decay[tier - 1];
564 let penalty = |counterpart: f64, kappa: f64| {
566 let start = t.cross_penalty_start;
567 let ramp = ((counterpart - start).max(0.0) / (1.0 - start)).powi(2);
568 kappa * ramp
569 };
570
571 let raw = |g: i8, rule_d: f64, unit: f64| {
574 let g = f64::from(g.clamp(-4, 4));
575 let judge = if g >= 0.0 {
576 g * unit * boost
577 } else {
578 g * unit * t.neg_factor
579 };
580 judge + rule_d
581 };
582
583 let real = |r: f64, own_tier: usize, counterpart: f64, kappa: f64, g: i8| {
610 let p = penalty(counterpart, kappa) * f64::from(g.saturating_abs().min(4)) / 4.0;
611 (decay(own_tier) * r.max(0.0) + r.min(0.0) - p, p)
612 };
613
614 let gate = |rho: f64, pend: f64| {
616 let acc = pend + rho;
617 if acc.abs() >= t.delta_threshold {
618 (acc, 0.0)
619 } else {
620 (0.0, acc)
621 }
622 };
623
624 let axis = |g: i8, rule_d: f64, own_tier: usize, counterpart: f64, pend: f64, unit: f64| {
625 let r = raw(g, rule_d, unit);
626 let kappa = t.cross_penalty_ratio * unit;
627 let (rho, charged) = real(r, own_tier, counterpart, kappa, g);
628 let (committed, pend) = gate(rho, pend);
629 (r, committed, pend, charged)
630 };
631
632 let (t_raw, t_com, t_pend, t_pen) = axis(
633 grades.trust,
634 rule.trust,
635 bond_tier,
636 chem,
637 pending.trust,
638 t.grade_unit_bond,
639 );
640 let (ig_raw, ig_com, ig_pend, ig_pen) = axis(
641 grades.intrigue,
642 rule.intrigue,
643 bond_tier,
644 chem,
645 pending.intrigue,
646 t.grade_unit_bond,
647 );
648 let (im_raw, im_com, im_pend, im_pen) = axis(
649 grades.intimacy,
650 rule.intimacy,
651 chem_tier,
652 bond,
653 pending.intimacy,
654 t.grade_unit_chem,
655 );
656 let (tn_raw, tn_com, tn_pend, tn_pen) = axis(
657 grades.tension,
658 rule.tension,
659 chem_tier,
660 bond,
661 pending.tension,
662 t.grade_unit_chem,
663 );
664
665 GradeTurnOutcome {
666 raw: AffinityDeltas {
667 warmth: 0.0, trust: t_raw,
669 intrigue: ig_raw,
670 intimacy: im_raw,
671 tension: tn_raw,
672 patience: 0.0,
673 },
674 committed: AffinityDeltas {
675 warmth: 0.0,
676 trust: t_com,
677 intrigue: ig_com,
678 intimacy: im_com,
679 tension: tn_com,
680 patience: 0.0,
681 },
682 pending: PendingDeltas {
683 trust: t_pend,
684 intrigue: ig_pend,
685 intimacy: im_pend,
686 tension: tn_pend,
687 },
688 cross_penalty_assessed: CrossPenaltyAssessed {
689 trust: t_pen,
690 intrigue: ig_pen,
691 intimacy: im_pen,
692 tension: tn_pen,
693 },
694 }
695}
696
697#[cfg(test)]
698mod tests {
699 use super::*;
700
701 fn fresh() -> Affinity {
702 let now = Utc::now();
703 Affinity {
704 id: Uuid::new_v4(),
705 session_id: Uuid::new_v4(),
706 user_id: Uuid::new_v4(),
707 instance_id: Uuid::new_v4(),
708 warmth: 0.3,
709 trust: 0.2,
710 intrigue: 0.5,
711 intimacy: 0.0,
712 patience: 0.5,
713 tension: 0.1,
714 warmth_grade: 2,
715 patience_grade: 2,
716 ghost_streak: 0,
717 last_ghost_at: None,
718 total_ghosts: 0,
719 relationship_label: None,
720 created_at: now,
721 updated_at: now,
722 }
723 }
724
725 #[test]
726 fn apply_deltas_clamps_to_valid_ranges() {
727 let mut a = fresh();
728 a.apply_deltas(&AffinityDeltas {
729 warmth: 5.0, trust: -2.0, intrigue: 0.1,
732 intimacy: 0.0,
733 patience: 0.0,
734 tension: 0.0,
735 });
736 assert_eq!(a.warmth, 1.0, "warmth clamps to 1.0 (max)");
737 assert_eq!(a.trust, 0.0, "trust clamps to 0.0 (min)");
738 assert!((a.intrigue - 0.6).abs() < 1e-9);
739 }
740
741 #[test]
742 fn warmth_floors_at_zero_like_every_axis() {
743 let mut a = fresh();
745 a.apply_deltas(&AffinityDeltas {
746 warmth: -2.0,
747 trust: 0.0,
748 intrigue: 0.0,
749 intimacy: 0.0,
750 patience: 0.0,
751 tension: 0.0,
752 });
753 assert_eq!(a.warmth, 0.0);
754 }
755
756 #[test]
757 fn apply_deltas_is_direct_no_smoothing() {
758 let mut a = fresh(); a.apply_deltas(&AffinityDeltas {
761 warmth: 0.15,
762 ..Default::default()
763 });
764 assert!((a.warmth - 0.45).abs() < 1e-9);
765 }
766
767 #[test]
768 fn time_decay_reduces_intrigue_softens_tension_leaves_the_rest() {
769 let mut a = fresh();
770 a.intrigue = 0.5;
771 a.patience = 0.5;
772 a.tension = 0.5;
773 a.warmth = 0.7;
774 a.trust = 0.6;
775 a.intimacy = 0.4;
776 a.updated_at = Utc::now() - chrono::Duration::days(10);
777
778 a.apply_time_decay();
779
780 assert!((a.intrigue - 0.4).abs() < 1e-9);
782 assert!((a.tension - 0.45).abs() < 1e-9);
784 assert_eq!(a.patience, 0.5);
786 assert_eq!(a.warmth, 0.7);
787 assert_eq!(a.trust, 0.6);
788 assert_eq!(a.intimacy, 0.4);
789 }
790
791 #[test]
792 fn time_decay_clamps_at_floors() {
793 let mut a = fresh();
794 a.intrigue = 0.05;
795 a.tension = 0.02;
796 a.updated_at = Utc::now() - chrono::Duration::days(100);
797
798 a.apply_time_decay();
799
800 assert_eq!(a.intrigue, 0.0);
801 assert_eq!(a.tension, 0.0);
802 }
803
804 #[test]
805 fn tier_index_boundaries() {
806 assert_eq!(tier_index(0.0), 1);
807 assert_eq!(tier_index(0.149), 1);
808 assert_eq!(tier_index(0.15), 2);
809 assert_eq!(tier_index(0.349), 2);
810 assert_eq!(tier_index(0.35), 3);
811 assert_eq!(tier_index(0.619), 3);
812 assert_eq!(tier_index(0.62), 4);
813 assert_eq!(tier_index(0.899), 4);
814 assert_eq!(tier_index(0.9), 5);
815 assert_eq!(tier_index(1.0), 5);
816 }
817
818 #[test]
826 fn intimacy_rung_cuts_against_the_tier_ladder() {
827 let mut a = fresh();
828
829 a.warmth = 0.1;
831 a.trust = 0.0;
832 a.intrigue = 0.0;
833 a.intimacy = 0.0;
834 a.tension = 0.0;
835 assert_eq!(a.bond_label(), BondLabel::Acquaintance);
836 assert_eq!(a.chemistry_label(), ChemistryLabel::Spark);
837 assert_eq!(a.intimacy_rung(), 1);
838
839 a.warmth = 0.7;
841 a.trust = 0.7;
842 a.intrigue = 0.7;
843 assert_eq!(a.intimacy_rung(), 2);
844
845 a.warmth = 0.8;
847 a.trust = 0.8;
848 a.intrigue = 0.8;
849 assert_eq!(a.bond_label(), BondLabel::Confidant);
850 assert_eq!(a.intimacy_rung(), 3);
851 }
852
853 #[test]
855 fn intimacy_rung_takes_the_further_line() {
856 let mut a = fresh();
857 a.warmth = 0.95;
859 a.trust = 0.0;
860 a.intrigue = 0.3;
861 a.intimacy = 0.95;
862 a.tension = 0.95;
863 assert!(a.bond_score() < a.chemistry_score());
864 assert_eq!(a.intimacy_rung(), 3);
865 a.trust = 0.95;
867 a.intrigue = 0.95;
868 a.intimacy = 0.0;
869 a.tension = 0.0;
870 assert!(a.chemistry_score() < a.bond_score());
871 assert_eq!(a.intimacy_rung(), 3);
872 }
873
874 #[test]
876 fn intimacy_rung_of_a_seeded_session_is_one() {
877 let mut a = fresh();
878 a.warmth = 0.1;
879 a.trust = 0.0;
880 a.intrigue = 0.0;
881 a.intimacy = 0.0;
882 a.tension = 0.0;
883 assert!(a.bond_score().max(a.chemistry_score()) < TIER1_HI);
884 assert_eq!(a.intimacy_rung(), 1);
885 }
886
887 #[test]
888 fn patience_band_boundaries() {
889 let mut a = fresh();
890 let mut at = |p: f64| {
891 a.patience = p;
892 a.patience_band()
893 };
894 assert_eq!(at(0.0), PatienceBand::Low);
895 assert_eq!(at(0.349), PatienceBand::Low);
896 assert_eq!(at(0.35), PatienceBand::Mid); assert_eq!(at(0.649), PatienceBand::Mid);
898 assert_eq!(at(0.65), PatienceBand::High); assert_eq!(at(1.0), PatienceBand::High);
900 }
901
902 #[test]
904 fn patience_band_is_independent_of_the_composites() {
905 let mut a = fresh();
906 a.patience = 0.2;
907 a.warmth = 1.0;
908 a.trust = 1.0;
909 a.intrigue = 1.0;
910 a.intimacy = 1.0;
911 a.tension = 1.0;
912 assert_eq!(a.intimacy_rung(), 3);
913 assert_eq!(a.patience_band(), PatienceBand::Low);
914 }
915
916 #[test]
917 fn labels_map_from_scores() {
918 let mut a = fresh();
919 a.warmth = 0.0;
920 a.trust = 0.0;
921 a.intrigue = 0.0;
922 assert_eq!(a.bond_label(), BondLabel::Acquaintance); a.trust = 0.6;
924 a.intrigue = 0.6; assert_eq!(a.bond_label(), BondLabel::CloseFriend);
926 a.warmth = 0.0;
927 a.intimacy = 0.0;
928 a.tension = 0.0;
929 assert_eq!(a.chemistry_label(), ChemistryLabel::Spark); a.intimacy = 0.8;
931 a.tension = 0.6; assert_eq!(a.chemistry_label(), ChemistryLabel::Lover);
933 a.warmth = 1.0;
935 a.trust = 1.0;
936 a.intrigue = 1.0; assert_eq!(a.bond_label(), BondLabel::Soulmate);
938 a.intimacy = 1.0;
939 a.tension = 1.0; assert_eq!(a.chemistry_label(), ChemistryLabel::Beloved);
941 assert_eq!(BondLabel::Soulmate.as_key(), "soulmate");
942 assert_eq!(ChemistryLabel::Beloved.as_key(), "beloved");
943 }
944
945 #[test]
946 fn legacy_label_stranger_when_both_tier1() {
947 let mut a = fresh();
948 a.warmth = 0.0;
949 a.trust = 0.0;
950 a.intrigue = 0.0;
951 a.intimacy = 0.0;
952 a.tension = 0.0;
953 assert_eq!(a.legacy_relationship_label(), RelationshipLabel::Stranger);
954 }
955
956 #[test]
957 fn legacy_label_friend_when_bond_leads() {
958 let mut a = fresh();
959 a.warmth = 0.3;
961 a.trust = 0.6;
962 a.intrigue = 0.6;
963 a.intimacy = 0.0;
964 a.tension = 0.0;
965 assert_eq!(a.legacy_relationship_label(), RelationshipLabel::Friend);
966 }
967
968 #[test]
969 fn legacy_label_romantic_when_chemistry_high() {
970 let mut a = fresh();
971 a.warmth = 0.3;
973 a.intimacy = 0.9;
974 a.tension = 0.9;
975 a.trust = 0.0;
976 a.intrigue = 0.0;
977 assert_eq!(a.legacy_relationship_label(), RelationshipLabel::Romantic);
978 }
979
980 #[test]
981 fn legacy_label_slow_burn_when_chemistry_leads_but_mid() {
982 let mut a = fresh();
983 a.warmth = 0.3;
985 a.intimacy = 0.3;
986 a.tension = 0.2;
987 a.trust = 0.0;
988 a.intrigue = 0.0;
989 assert_eq!(a.legacy_relationship_label(), RelationshipLabel::SlowBurn);
990 }
991
992 #[test]
993 fn diff_labels_none_when_no_tier_change() {
994 let a = fresh();
995 let b = a.clone();
996 assert!(diff_labels(&a, &b).is_none());
997 }
998
999 #[test]
1000 fn diff_labels_reports_single_line_change() {
1001 let mut before = fresh();
1002 before.warmth = 0.0;
1003 before.trust = 0.0;
1004 before.intrigue = 0.0;
1005 before.intimacy = 0.0;
1006 before.tension = 0.0; let mut after = before.clone();
1008 after.trust = 0.6;
1009 after.intrigue = 0.6; let d = diff_labels(&before, &after).unwrap();
1011 let bond = d.bond.unwrap();
1012 assert_eq!(bond.from, "acquaintance");
1013 assert_eq!(bond.to, "close_friend");
1014 assert!(d.chemistry.is_none());
1015 }
1016
1017 fn zeroed() -> Affinity {
1020 let mut a = fresh();
1021 a.warmth = 0.0;
1022 a.trust = 0.0;
1023 a.intrigue = 0.0;
1024 a.intimacy = 0.0;
1025 a.tension = 0.0;
1026 a
1027 }
1028
1029 fn turn(
1030 a: &Affinity,
1031 grades: AxisGrades,
1032 rule: AffinityDeltas,
1033 pending: PendingDeltas,
1034 boost: f64,
1035 t: &AffinityTuning,
1036 ) -> GradeTurnOutcome {
1037 grade_turn(a, &grades, &rule, &pending, boost, t)
1038 }
1039
1040 #[test]
1043 fn positive_decays_by_own_tier_and_pays_counterpart_ramp() {
1044 let t = AffinityTuning::default();
1045 let mut a = zeroed();
1046 a.trust = 0.75;
1047 a.intrigue = 0.75; let o = turn(
1049 &a,
1050 AxisGrades {
1051 trust: 2,
1052 intimacy: 2,
1053 ..Default::default()
1054 },
1055 AffinityDeltas::default(),
1056 PendingDeltas::default(),
1057 1.0,
1058 &t,
1059 );
1060 assert!((o.committed.trust - 2.0 * t.grade_unit_bond * 0.25).abs() < 1e-9);
1062 let p = t.cross_penalty_ratio * t.grade_unit_chem * (0.40f64 / 0.65).powi(2) * 2.0 / 4.0;
1066 assert!((o.committed.intimacy - (2.0 * t.grade_unit_chem - p)).abs() < 1e-9);
1067 assert!((o.cross_penalty_assessed.intimacy - p).abs() < 1e-9);
1068 assert_eq!(o.cross_penalty_assessed.trust, 0.0, "counterpart below y₀");
1069 }
1070
1071 #[test]
1080 fn penalty_scales_with_the_grade_so_the_outcome_cannot_flip_between_grades() {
1081 let mut a = zeroed();
1082 a.intimacy = 1.0;
1083 a.tension = 1.0; let t = AffinityTuning::default();
1085 let at = |g: i8| {
1086 turn(
1087 &a,
1088 AxisGrades {
1089 trust: g,
1090 ..Default::default()
1091 },
1092 AffinityDeltas::default(),
1093 PendingDeltas::default(),
1094 1.0,
1095 &t,
1096 )
1097 };
1098 let unit_net = t.grade_unit_bond * (1.0 - t.cross_penalty_ratio / 4.0);
1101 assert!((at(1).committed.trust - unit_net).abs() < 1e-9);
1102 assert!((at(2).committed.trust - 2.0 * unit_net).abs() < 1e-9);
1103 assert!((at(4).committed.trust - 4.0 * unit_net).abs() < 1e-9);
1104 for g in 1..=4 {
1105 assert!(
1106 at(g).committed.trust > 0.0,
1107 "a positive verdict must not lower the score here (g{g})"
1108 );
1109 }
1110 }
1111
1112 #[test]
1118 fn tier_five_against_a_high_counterpart_still_loses_at_every_grade() {
1119 let t = AffinityTuning::default();
1120 let mut a = zeroed();
1121 a.trust = 1.0;
1122 a.intrigue = 1.0; a.intimacy = 1.0;
1124 a.tension = 1.0; let unit_net = t.grade_unit_chem * (0.10 - t.cross_penalty_ratio / 4.0);
1127 assert!(unit_net < 0.0);
1128 for g in 1..=4 {
1129 let o = turn(
1130 &a,
1131 AxisGrades {
1132 intimacy: g,
1133 ..Default::default()
1134 },
1135 AffinityDeltas::default(),
1136 PendingDeltas::default(),
1137 1.0,
1138 &t,
1139 );
1140 assert!(
1141 (o.committed.intimacy - f64::from(g) * unit_net).abs() < 1e-9,
1142 "g{g}"
1143 );
1144 assert!(o.committed.intimacy < 0.0, "g{g}");
1145 }
1146 }
1147
1148 #[test]
1151 fn negative_skips_decay_and_pays_extra() {
1152 let t = AffinityTuning::default();
1153 let mut a = zeroed();
1154 a.intimacy = 1.0;
1155 a.tension = 1.0; a.trust = 0.9;
1157 a.intrigue = 0.9; let o = turn(
1159 &a,
1160 AxisGrades {
1161 trust: -2,
1162 ..Default::default()
1163 },
1164 AffinityDeltas::default(),
1165 PendingDeltas::default(),
1166 1.0,
1167 &t,
1168 );
1169 let p = t.cross_penalty_ratio * t.grade_unit_bond * 2.0 / 4.0;
1172 let expect = -2.0 * t.grade_unit_bond * t.neg_factor - p;
1173 assert!((o.committed.trust - expect).abs() < 1e-9);
1174 assert!((o.cross_penalty_assessed.trust - p).abs() < 1e-9);
1175 }
1176
1177 #[test]
1180 fn zero_verdict_charges_nothing() {
1181 let mut a = zeroed();
1182 a.warmth = 1.0;
1183 a.trust = 1.0;
1184 a.intrigue = 1.0;
1185 a.intimacy = 1.0;
1186 a.tension = 1.0;
1187 let pending = PendingDeltas {
1188 trust: 0.02,
1189 ..Default::default()
1190 };
1191 let t = AffinityTuning {
1192 delta_threshold: 0.5,
1193 ..Default::default()
1194 };
1195 let o = turn(
1196 &a,
1197 AxisGrades::default(),
1198 AffinityDeltas::default(),
1199 pending,
1200 1.0,
1201 &t,
1202 );
1203 assert_eq!(o.committed.trust, 0.0);
1204 assert_eq!(o.committed.warmth, 0.0);
1205 assert!((o.pending.trust - 0.02).abs() < 1e-9);
1206 }
1207
1208 #[test]
1211 fn rule_only_delta_decays_but_pays_no_penalty() {
1212 let mut a = zeroed();
1213 a.trust = 0.2;
1214 a.intrigue = 0.3; a.intimacy = 1.0;
1216 a.tension = 1.0; let o = turn(
1218 &a,
1219 AxisGrades::default(),
1220 AffinityDeltas {
1221 intrigue: 0.02,
1222 ..Default::default()
1223 },
1224 PendingDeltas::default(),
1225 1.0,
1226 &AffinityTuning::default(),
1227 );
1228 assert!((o.committed.intrigue - 0.7 * 0.02).abs() < 1e-9);
1229 }
1230
1231 #[test]
1234 fn threshold_accumulates_until_it_clears() {
1235 let a = zeroed(); let t = AffinityTuning {
1237 delta_threshold: 0.5,
1238 ..Default::default()
1239 };
1240 let mut pending = PendingDeltas::default();
1241 let mut committed = Vec::new();
1242 for r in [0.1, 0.2, 0.3] {
1243 let o = turn(
1244 &a,
1245 AxisGrades::default(),
1246 AffinityDeltas {
1247 trust: r,
1248 ..Default::default()
1249 },
1250 pending,
1251 1.0,
1252 &t,
1253 );
1254 committed.push(o.committed.trust);
1255 pending = o.pending;
1256 }
1257 assert_eq!(committed[0], 0.0);
1258 assert_eq!(committed[1], 0.0);
1259 assert!((committed[2] - 0.6).abs() < 1e-9);
1260 assert!(pending.is_zero());
1261 }
1262
1263 #[test]
1266 fn threshold_gate_cancels_opposite_signs() {
1267 let a = zeroed();
1268 let t = AffinityTuning {
1269 delta_threshold: 0.5,
1270 ..Default::default()
1271 };
1272 let o = turn(
1273 &a,
1274 AxisGrades::default(),
1275 AffinityDeltas {
1276 trust: -0.1,
1277 ..Default::default()
1278 },
1279 PendingDeltas {
1280 trust: 0.1,
1281 ..Default::default()
1282 },
1283 1.0,
1284 &t,
1285 );
1286 assert_eq!(o.committed.trust, 0.0);
1287 assert_eq!(o.pending.trust, 0.0, "+0.1 pending − 0.1 real cancels out");
1288 }
1289
1290 #[test]
1292 fn demo_boost_is_positive_only() {
1293 let t = AffinityTuning::default();
1294 let a = zeroed();
1295 let o = turn(
1296 &a,
1297 AxisGrades {
1298 trust: 1,
1299 intimacy: -1,
1300 ..Default::default()
1301 },
1302 AffinityDeltas::default(),
1303 PendingDeltas::default(),
1304 1.4,
1305 &t,
1306 );
1307 assert!((o.committed.trust - 1.4 * t.grade_unit_bond).abs() < 1e-9);
1308 assert!((o.committed.intimacy - (-t.grade_unit_chem * t.neg_factor)).abs() < 1e-9);
1309 }
1310
1311 #[test]
1315 fn endpoint_fields_are_inert_in_the_pipeline() {
1316 let a = zeroed();
1317 let o = turn(
1318 &a,
1319 AxisGrades::default(),
1320 AffinityDeltas {
1321 warmth: 0.5,
1322 patience: -0.02,
1323 ..Default::default()
1324 },
1325 PendingDeltas::default(),
1326 1.0,
1327 &AffinityTuning::default(),
1328 );
1329 assert_eq!(o.raw.warmth, 0.0);
1330 assert_eq!(o.committed.warmth, 0.0);
1331 assert_eq!(o.raw.patience, 0.0);
1332 assert_eq!(o.committed.patience, 0.0);
1333 }
1334
1335 #[test]
1338 fn grades_clamp_to_plus_minus_four() {
1339 let t = AffinityTuning::default();
1340 let a = zeroed();
1341 let o = turn(
1342 &a,
1343 AxisGrades {
1344 trust: 9,
1345 intimacy: -9,
1346 ..Default::default()
1347 },
1348 AffinityDeltas::default(),
1349 PendingDeltas::default(),
1350 1.0,
1351 &t,
1352 );
1353 assert!((o.committed.trust - 4.0 * t.grade_unit_bond).abs() < 1e-9);
1354 assert!((o.committed.intimacy - (-4.0 * t.grade_unit_chem * t.neg_factor)).abs() < 1e-9);
1355 }
1356
1357 #[test]
1358 fn diff_labels_reports_both_lines() {
1359 let mut before = fresh();
1360 before.warmth = 0.0;
1361 before.trust = 0.0;
1362 before.intrigue = 0.0;
1363 before.intimacy = 0.0;
1364 before.tension = 0.0;
1365 let mut after = before.clone();
1366 after.trust = 0.6;
1367 after.intrigue = 0.6; after.intimacy = 0.5;
1369 after.tension = 0.5; let d = diff_labels(&before, &after).unwrap();
1371 assert_eq!(d.bond.unwrap().to, "close_friend");
1372 assert_eq!(d.chemistry.unwrap().to, "crush");
1373 }
1374
1375 #[test]
1378 fn endpoint_boost_anchors() {
1379 assert!((endpoint_boost(0.35) - 1.0).abs() < 1e-12);
1381 assert!((endpoint_boost(1.0) - 1.5).abs() < 1e-12);
1382 assert!((endpoint_boost(0.0) - (1.0 - 0.35 * 10.0 / 13.0)).abs() < 1e-12);
1383 }
1384
1385 #[test]
1386 fn endpoint_value_exact_ceiling_and_ranges() {
1387 assert!((endpoint_value(3, 1.0, 1.0, 0.2) - 1.0).abs() < 1e-9);
1389 assert!(
1391 (endpoint_value(2, 0.0, 1.0, 0.2) - (1.0 / 3.0) * (1.0 - 0.35 * 10.0 / 13.0)).abs()
1392 < 1e-9
1393 );
1394 assert!((endpoint_value(2, 1.0, 1.0, 0.2) - 0.5).abs() < 1e-9);
1395 assert!(
1396 (endpoint_value(3, 0.0, 1.0, 0.2) - (2.0 / 3.0) * (1.0 - 0.35 * 10.0 / 13.0)).abs()
1397 < 1e-9
1398 );
1399 }
1400
1401 #[test]
1402 fn endpoint_floor_only_acts_on_level_one() {
1403 assert!((endpoint_value(1, 0.9, 1.0, 0.2) - 0.18).abs() < 1e-9);
1405 assert!((endpoint_value(1, 0.0, 1.0, 0.2) - 0.0).abs() < 1e-9);
1406 for x in [0.0, 0.35, 0.7, 1.0] {
1408 let with_floor = endpoint_value(2, x, 1.0, 0.2);
1409 let without = endpoint_value(2, x, 1.0, 0.0);
1410 assert!(
1411 (with_floor - without).abs() < 1e-12,
1412 "floor must not touch level 2 at x={x}"
1413 );
1414 }
1415 }
1416
1417 #[test]
1418 fn endpoint_level_out_of_range_clamps() {
1419 assert_eq!(
1421 endpoint_value(0, 0.5, 1.0, 0.2),
1422 endpoint_value(1, 0.5, 1.0, 0.2)
1423 );
1424 assert_eq!(
1425 endpoint_value(7, 0.5, 1.0, 0.2),
1426 endpoint_value(3, 0.5, 1.0, 0.2)
1427 );
1428 }
1429
1430 #[test]
1431 fn composites_are_two_axis_means() {
1432 let mut a = fresh();
1433 a.warmth = 1.0; a.trust = 0.4;
1435 a.intrigue = 0.6;
1436 a.intimacy = 0.3;
1437 a.tension = 0.2;
1438 assert!((a.bond_score() - 0.5).abs() < 1e-9);
1439 assert!((a.chemistry_score() - 0.25).abs() < 1e-9);
1440 }
1441
1442 #[test]
1443 fn refresh_endpoints_tsundere_quadrant() {
1444 let t = AffinityTuning::default();
1446 let mut a = fresh();
1447 a.trust = 0.1;
1448 a.intrigue = 0.1; a.intimacy = 0.9;
1450 a.tension = 0.9; a.warmth_grade = 3;
1452 a.patience_grade = 3;
1453 a.updated_at = Utc::now(); a.refresh_endpoints(&t);
1455 assert!((a.warmth - (2.0 / 3.0) * (1.0 + (10.0 / 13.0) * 0.55)).abs() < 1e-6);
1457 assert!((a.patience - (2.0 / 3.0) * (1.0 - (10.0 / 13.0) * 0.25)).abs() < 1e-6);
1459 assert!(a.warmth > a.patience, "tsundere: warm but impatient");
1460 }
1461
1462 #[test]
1463 fn time_decay_no_longer_drifts_patience_up() {
1464 let mut a = fresh();
1465 a.patience = 0.4;
1466 a.updated_at = Utc::now() - chrono::Duration::days(10);
1467 a.apply_time_decay();
1468 assert!(
1469 (a.patience - 0.4).abs() < 1e-12,
1470 "patience drift retired; endpoint decay owns absence now"
1471 );
1472 }
1473
1474 #[test]
1475 fn per_line_units_and_ratio_kappa() {
1476 let t = AffinityTuning::default();
1479 let mut a = fresh();
1480 a.warmth = 0.0;
1481 a.trust = 0.0;
1482 a.intrigue = 0.0;
1483 a.intimacy = 0.0;
1484 a.tension = 0.0;
1485 let g = AxisGrades {
1486 trust: 2,
1487 intrigue: 0,
1488 intimacy: 2,
1489 tension: 0,
1490 };
1491 let out = grade_turn(
1492 &a,
1493 &g,
1494 &AffinityDeltas::default(),
1495 &PendingDeltas::default(),
1496 1.0,
1497 &t,
1498 );
1499 assert!((out.committed.trust - 2.0 * t.grade_unit_bond).abs() < 1e-9);
1500 assert!((out.committed.intimacy - 2.0 * t.grade_unit_chem).abs() < 1e-9);
1501 assert_eq!(out.committed.warmth, 0.0);
1502 assert_eq!(out.committed.patience, 0.0);
1503 }
1504
1505 #[test]
1506 fn break_even_position_is_unit_invariant() {
1507 let mut t1 = AffinityTuning::default();
1511 let mut t2 = AffinityTuning::default();
1512 t1.grade_unit_chem = 0.0266;
1513 t2.grade_unit_chem = 0.10;
1514 let y_star = |t: &AffinityTuning| {
1515 let mut a = fresh();
1516 a.warmth = 0.0;
1517 a.intimacy = 1.0;
1518 a.tension = 0.9; (0..=1000).map(|i| f64::from(i) / 1000.0).find(|&y| {
1520 let mut b = a.clone();
1521 b.trust = y;
1522 b.intrigue = y; let g = AxisGrades {
1524 trust: 0,
1525 intrigue: 0,
1526 intimacy: 1,
1527 tension: 0,
1528 };
1529 let out = grade_turn(
1530 &b,
1531 &g,
1532 &AffinityDeltas::default(),
1533 &PendingDeltas::default(),
1534 1.0,
1535 t,
1536 );
1537 out.committed.intimacy < 0.0
1538 })
1539 };
1540 let y1 = y_star(&t1);
1541 assert!(y1.is_some(), "a break-even must exist at tier 5");
1542 assert_eq!(
1543 y1,
1544 y_star(&t2),
1545 "κ tied to unit ⇒ wall does not move with the unit"
1546 );
1547 }
1548
1549 #[test]
1550 fn endpoint_time_decay_linear_with_floor() {
1551 assert!((endpoint_time_decay(0.0, 0.02, 0.5) - 1.0).abs() < 1e-12);
1552 assert!((endpoint_time_decay(7.0, 0.02, 0.5) - 0.86).abs() < 1e-12);
1553 assert!((endpoint_time_decay(25.0, 0.02, 0.5) - 0.5).abs() < 1e-12);
1554 assert!((endpoint_time_decay(60.0, 0.02, 0.5) - 0.5).abs() < 1e-12);
1555 assert!((endpoint_time_decay(-3.0, 0.02, 0.5) - 1.0).abs() < 1e-12); }
1557}