eros_engine_core/
ghost.rs1use crate::affinity::Affinity;
7
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub struct GhostSignals {
10 pub message_count: i64,
11 pub hours_since_last_ghost: Option<f64>,
12}
13
14#[derive(Debug, Clone, Copy, PartialEq)]
15pub enum GhostDecision {
16 Ghost,
17 Reply,
18}
19
20pub fn score(a: &Affinity) -> f64 {
22 (1.0 - a.intrigue) * 0.4 + (1.0 - a.patience) * 0.4 + a.tension * 0.2
23}
24
25pub fn decide(a: &Affinity, s: GhostSignals) -> GhostDecision {
31 if s.message_count < 10 {
32 return GhostDecision::Reply;
33 }
34 if a.ghost_streak >= 2 {
35 return GhostDecision::Reply;
36 }
37 if matches!(s.hours_since_last_ghost, Some(h) if h < 1.0) {
38 return GhostDecision::Reply;
39 }
40 let threshold = if s.hours_since_last_ghost.is_some() {
41 0.85
42 } else {
43 0.65
44 };
45 if score(a) > threshold {
46 GhostDecision::Ghost
47 } else {
48 GhostDecision::Reply
49 }
50}
51
52#[cfg(test)]
53mod tests {
54 use super::*;
55 use crate::affinity::Affinity;
56 use chrono::Utc;
57 use uuid::Uuid;
58
59 fn aff(intrigue: f64, patience: f64, tension: f64, ghost_streak: i32) -> Affinity {
60 let now = Utc::now();
61 Affinity {
62 id: Uuid::new_v4(),
63 session_id: Uuid::new_v4(),
64 user_id: Uuid::new_v4(),
65 instance_id: Uuid::new_v4(),
66 warmth: 0.3,
67 trust: 0.2,
68 intrigue,
69 intimacy: 0.0,
70 patience,
71 tension,
72 ghost_streak,
73 last_ghost_at: None,
74 total_ghosts: 0,
75 relationship_label: None,
76 created_at: now,
77 updated_at: now,
78 }
79 }
80
81 #[test]
82 fn never_ghost_when_message_count_below_10() {
83 let a = aff(0.0, 0.0, 1.0, 0); let s = GhostSignals {
85 message_count: 5,
86 hours_since_last_ghost: None,
87 };
88 assert_eq!(decide(&a, s), GhostDecision::Reply);
89 }
90
91 #[test]
92 fn never_ghost_two_in_a_row() {
93 let a = aff(0.0, 0.0, 1.0, 2);
94 let s = GhostSignals {
95 message_count: 50,
96 hours_since_last_ghost: Some(0.5),
97 };
98 assert_eq!(decide(&a, s), GhostDecision::Reply);
99 }
100
101 #[test]
102 fn cooldown_blocks_ghost_within_one_hour() {
103 let a = aff(0.0, 0.0, 1.0, 1);
104 let s = GhostSignals {
105 message_count: 50,
106 hours_since_last_ghost: Some(0.5),
107 };
108 assert_eq!(decide(&a, s), GhostDecision::Reply);
109 }
110
111 #[test]
112 fn ghost_when_score_above_threshold_post_protection() {
113 let a = aff(0.1, 0.1, 0.5, 0);
116 let s = GhostSignals {
117 message_count: 50,
118 hours_since_last_ghost: None,
119 };
120 assert_eq!(decide(&a, s), GhostDecision::Ghost);
121 }
122
123 #[test]
124 fn raised_threshold_after_recent_ghost_blocks_mid_score() {
125 let a = aff(0.5, 0.5, 0.0, 1);
128 let s = GhostSignals {
129 message_count: 50,
130 hours_since_last_ghost: Some(2.0),
131 };
132 assert_eq!(decide(&a, s), GhostDecision::Reply);
133 }
134
135 #[test]
136 fn high_score_blocked_by_post_ghost_higher_threshold() {
137 let a = aff(0.05, 0.05, 0.0, 1);
140 let s = GhostSignals {
141 message_count: 50,
142 hours_since_last_ghost: Some(2.0),
143 };
144 assert_eq!(decide(&a, s), GhostDecision::Reply);
145 }
146
147 #[test]
148 fn ghost_score_formula() {
149 let a = aff(0.4, 0.6, 0.5, 0);
150 let expected = (1.0 - 0.4) * 0.4 + (1.0 - 0.6) * 0.4 + 0.5 * 0.2;
151 assert!((score(&a) - expected).abs() < 1e-9);
152 }
153}