Skip to main content

eros_engine_core/
ghost.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! Ghost decision: should the agent stay silent on this turn?
3//!
4//! Score formula and protection rules are deterministic — no LLM call.
5
6use 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
20/// Pure score: (1-intrigue)*0.4 + (1-patience)*0.4 + tension*0.2
21pub fn score(a: &Affinity) -> f64 {
22    (1.0 - a.intrigue) * 0.4 + (1.0 - a.patience) * 0.4 + a.tension * 0.2
23}
24
25/// True when a ghost is permitted by the HARD-SAFETY protections only
26/// (message-count floor, anti-streak, cooldown). The score-threshold layer is
27/// intentionally excluded — the LLM PDE decides ghost-worthiness, while these
28/// vetoes always hold. `ghost_streak` is read from `a`; `message_count` /
29/// `hours_since_last_ghost` from `s` (the same sources `decide` uses).
30pub 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
43/// Decide whether to ghost: hard-safety protections (via `ghost_permitted`),
44/// then the score threshold — 0.85 once this session has ghosted at all, else
45/// 0.65. The raised bar does NOT decay: the branch only asks whether
46/// `hours_since_last_ghost` is `Some`, and `last_ghost_at` is set-only (never
47/// cleared), so one ghost raises the bar for the rest of that session.
48pub 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); // would normally ghost
98        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        // ghost_score = (1-0.1)*0.4 + (1-0.1)*0.4 + 0.5*0.2 = 0.36 + 0.36 + 0.1 = 0.82
128        // base threshold 0.65 → ghost
129        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        // ghost_score = (1-0.5)*0.4 + (1-0.5)*0.4 + 0.0*0.2 = 0.4
140        // base 0.65 → would NOT ghost; post-ghost 0.85 → would NOT ghost
141        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        // ghost_score = (1-0.05)*0.4 + (1-0.05)*0.4 + 0.0*0.2 = 0.76
152        // base 0.65 → would ghost; post-ghost 0.85 → would NOT ghost (0.76 < 0.85)
153        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        // Same affinity as `ghost_when_score_above_threshold_post_protection`
164        // (score 0.82, ghosts at the 0.65 base threshold), but this session has
165        // ghosted before — a month ago. 0.82 < 0.85, so it still must not ghost.
166        //
167        // Pins the property the docs state: the branch tests only
168        // `hours_since_last_ghost.is_some()`, with no upper cutoff, and
169        // `last_ghost_at` is never cleared — so the bar stays raised for the
170        // life of the session. A decay window added here would silently make
171        // ghost-mechanics.md wrong.
172        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); // ghost_streak read from &Affinity
206        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}