1use 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 ghost_permitted(a: &Affinity, s: GhostSignals) -> bool {
31 if s.message_count < 10 {
32 return false;
33 }
34 if a.ghost_streak >= 2 {
35 return false;
36 }
37 if matches!(s.hours_since_last_ghost, Some(h) if h < 1.0) {
38 return false;
39 }
40 true
41}
42
43pub fn decide(a: &Affinity, s: GhostSignals) -> GhostDecision {
49 if !ghost_permitted(a, s) {
50 return GhostDecision::Reply;
51 }
52 let threshold = if s.hours_since_last_ghost.is_some() {
53 0.85
54 } else {
55 0.65
56 };
57 if score(a) > threshold {
58 GhostDecision::Ghost
59 } else {
60 GhostDecision::Reply
61 }
62}
63
64#[cfg(test)]
65mod tests {
66 use super::*;
67 use crate::affinity::Affinity;
68 use chrono::Utc;
69 use uuid::Uuid;
70
71 fn aff(intrigue: f64, patience: f64, tension: f64, ghost_streak: i32) -> Affinity {
72 let now = Utc::now();
73 Affinity {
74 id: Uuid::new_v4(),
75 session_id: Uuid::new_v4(),
76 user_id: Uuid::new_v4(),
77 instance_id: Uuid::new_v4(),
78 warmth: 0.3,
79 trust: 0.2,
80 intrigue,
81 intimacy: 0.0,
82 patience,
83 tension,
84 warmth_grade: 2,
85 patience_grade: 2,
86 ghost_streak,
87 last_ghost_at: None,
88 total_ghosts: 0,
89 relationship_label: None,
90 created_at: now,
91 updated_at: now,
92 }
93 }
94
95 #[test]
96 fn never_ghost_when_message_count_below_10() {
97 let a = aff(0.0, 0.0, 1.0, 0); let s = GhostSignals {
99 message_count: 5,
100 hours_since_last_ghost: None,
101 };
102 assert_eq!(decide(&a, s), GhostDecision::Reply);
103 }
104
105 #[test]
106 fn never_ghost_two_in_a_row() {
107 let a = aff(0.0, 0.0, 1.0, 2);
108 let s = GhostSignals {
109 message_count: 50,
110 hours_since_last_ghost: Some(0.5),
111 };
112 assert_eq!(decide(&a, s), GhostDecision::Reply);
113 }
114
115 #[test]
116 fn cooldown_blocks_ghost_within_one_hour() {
117 let a = aff(0.0, 0.0, 1.0, 1);
118 let s = GhostSignals {
119 message_count: 50,
120 hours_since_last_ghost: Some(0.5),
121 };
122 assert_eq!(decide(&a, s), GhostDecision::Reply);
123 }
124
125 #[test]
126 fn ghost_when_score_above_threshold_post_protection() {
127 let a = aff(0.1, 0.1, 0.5, 0);
130 let s = GhostSignals {
131 message_count: 50,
132 hours_since_last_ghost: None,
133 };
134 assert_eq!(decide(&a, s), GhostDecision::Ghost);
135 }
136
137 #[test]
138 fn raised_threshold_after_prior_ghost_blocks_mid_score() {
139 let a = aff(0.5, 0.5, 0.0, 1);
142 let s = GhostSignals {
143 message_count: 50,
144 hours_since_last_ghost: Some(2.0),
145 };
146 assert_eq!(decide(&a, s), GhostDecision::Reply);
147 }
148
149 #[test]
150 fn high_score_blocked_by_post_ghost_higher_threshold() {
151 let a = aff(0.05, 0.05, 0.0, 1);
154 let s = GhostSignals {
155 message_count: 50,
156 hours_since_last_ghost: Some(2.0),
157 };
158 assert_eq!(decide(&a, s), GhostDecision::Reply);
159 }
160
161 #[test]
162 fn raised_threshold_never_decays_with_time() {
163 let a = aff(0.1, 0.1, 0.5, 0);
173 for hours in [2.0, 24.0, 720.0] {
174 let s = GhostSignals {
175 message_count: 50,
176 hours_since_last_ghost: Some(hours),
177 };
178 assert_eq!(
179 decide(&a, s),
180 GhostDecision::Reply,
181 "score 0.82 must stay below the raised 0.85 bar at {hours}h"
182 );
183 }
184 }
185
186 #[test]
187 fn ghost_score_formula() {
188 let a = aff(0.4, 0.6, 0.5, 0);
189 let expected = (1.0 - 0.4) * 0.4 + (1.0 - 0.6) * 0.4 + 0.5 * 0.2;
190 assert!((score(&a) - expected).abs() < 1e-9);
191 }
192
193 #[test]
194 fn ghost_permitted_false_when_message_count_below_10() {
195 let a = aff(0.1, 0.1, 0.5, 0);
196 let s = GhostSignals {
197 message_count: 5,
198 hours_since_last_ghost: None,
199 };
200 assert!(!ghost_permitted(&a, s));
201 }
202
203 #[test]
204 fn ghost_permitted_false_on_streak() {
205 let a = aff(0.1, 0.1, 0.5, 2); let s = GhostSignals {
207 message_count: 50,
208 hours_since_last_ghost: Some(5.0),
209 };
210 assert!(!ghost_permitted(&a, s));
211 }
212
213 #[test]
214 fn ghost_permitted_false_within_cooldown() {
215 let a = aff(0.1, 0.1, 0.5, 0);
216 let s = GhostSignals {
217 message_count: 50,
218 hours_since_last_ghost: Some(0.5),
219 };
220 assert!(!ghost_permitted(&a, s));
221 }
222
223 #[test]
224 fn ghost_permitted_true_when_clear() {
225 let a = aff(0.1, 0.1, 0.5, 0);
226 let s = GhostSignals {
227 message_count: 50,
228 hours_since_last_ghost: Some(5.0),
229 };
230 assert!(ghost_permitted(&a, s));
231 }
232}