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 (rises to 0.85 after a recent ghost, else 0.65).
45pub fn decide(a: &Affinity, s: GhostSignals) -> GhostDecision {
46    if !ghost_permitted(a, s) {
47        return GhostDecision::Reply;
48    }
49    let threshold = if s.hours_since_last_ghost.is_some() {
50        0.85
51    } else {
52        0.65
53    };
54    if score(a) > threshold {
55        GhostDecision::Ghost
56    } else {
57        GhostDecision::Reply
58    }
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64    use crate::affinity::Affinity;
65    use chrono::Utc;
66    use uuid::Uuid;
67
68    fn aff(intrigue: f64, patience: f64, tension: f64, ghost_streak: i32) -> Affinity {
69        let now = Utc::now();
70        Affinity {
71            id: Uuid::new_v4(),
72            session_id: Uuid::new_v4(),
73            user_id: Uuid::new_v4(),
74            instance_id: Uuid::new_v4(),
75            warmth: 0.3,
76            trust: 0.2,
77            intrigue,
78            intimacy: 0.0,
79            patience,
80            tension,
81            ghost_streak,
82            last_ghost_at: None,
83            total_ghosts: 0,
84            relationship_label: None,
85            created_at: now,
86            updated_at: now,
87        }
88    }
89
90    #[test]
91    fn never_ghost_when_message_count_below_10() {
92        let a = aff(0.0, 0.0, 1.0, 0); // would normally ghost
93        let s = GhostSignals {
94            message_count: 5,
95            hours_since_last_ghost: None,
96        };
97        assert_eq!(decide(&a, s), GhostDecision::Reply);
98    }
99
100    #[test]
101    fn never_ghost_two_in_a_row() {
102        let a = aff(0.0, 0.0, 1.0, 2);
103        let s = GhostSignals {
104            message_count: 50,
105            hours_since_last_ghost: Some(0.5),
106        };
107        assert_eq!(decide(&a, s), GhostDecision::Reply);
108    }
109
110    #[test]
111    fn cooldown_blocks_ghost_within_one_hour() {
112        let a = aff(0.0, 0.0, 1.0, 1);
113        let s = GhostSignals {
114            message_count: 50,
115            hours_since_last_ghost: Some(0.5),
116        };
117        assert_eq!(decide(&a, s), GhostDecision::Reply);
118    }
119
120    #[test]
121    fn ghost_when_score_above_threshold_post_protection() {
122        // ghost_score = (1-0.1)*0.4 + (1-0.1)*0.4 + 0.5*0.2 = 0.36 + 0.36 + 0.1 = 0.82
123        // base threshold 0.65 → ghost
124        let a = aff(0.1, 0.1, 0.5, 0);
125        let s = GhostSignals {
126            message_count: 50,
127            hours_since_last_ghost: None,
128        };
129        assert_eq!(decide(&a, s), GhostDecision::Ghost);
130    }
131
132    #[test]
133    fn raised_threshold_after_recent_ghost_blocks_mid_score() {
134        // ghost_score = (1-0.5)*0.4 + (1-0.5)*0.4 + 0.0*0.2 = 0.4
135        // base 0.65 → would NOT ghost; post-ghost 0.85 → would NOT ghost
136        let a = aff(0.5, 0.5, 0.0, 1);
137        let s = GhostSignals {
138            message_count: 50,
139            hours_since_last_ghost: Some(2.0),
140        };
141        assert_eq!(decide(&a, s), GhostDecision::Reply);
142    }
143
144    #[test]
145    fn high_score_blocked_by_post_ghost_higher_threshold() {
146        // ghost_score = (1-0.05)*0.4 + (1-0.05)*0.4 + 0.0*0.2 = 0.76
147        // base 0.65 → would ghost; post-ghost 0.85 → would NOT ghost (0.76 < 0.85)
148        let a = aff(0.05, 0.05, 0.0, 1);
149        let s = GhostSignals {
150            message_count: 50,
151            hours_since_last_ghost: Some(2.0),
152        };
153        assert_eq!(decide(&a, s), GhostDecision::Reply);
154    }
155
156    #[test]
157    fn ghost_score_formula() {
158        let a = aff(0.4, 0.6, 0.5, 0);
159        let expected = (1.0 - 0.4) * 0.4 + (1.0 - 0.6) * 0.4 + 0.5 * 0.2;
160        assert!((score(&a) - expected).abs() < 1e-9);
161    }
162
163    #[test]
164    fn ghost_permitted_false_when_message_count_below_10() {
165        let a = aff(0.1, 0.1, 0.5, 0);
166        let s = GhostSignals {
167            message_count: 5,
168            hours_since_last_ghost: None,
169        };
170        assert!(!ghost_permitted(&a, s));
171    }
172
173    #[test]
174    fn ghost_permitted_false_on_streak() {
175        let a = aff(0.1, 0.1, 0.5, 2); // ghost_streak read from &Affinity
176        let s = GhostSignals {
177            message_count: 50,
178            hours_since_last_ghost: Some(5.0),
179        };
180        assert!(!ghost_permitted(&a, s));
181    }
182
183    #[test]
184    fn ghost_permitted_false_within_cooldown() {
185        let a = aff(0.1, 0.1, 0.5, 0);
186        let s = GhostSignals {
187            message_count: 50,
188            hours_since_last_ghost: Some(0.5),
189        };
190        assert!(!ghost_permitted(&a, s));
191    }
192
193    #[test]
194    fn ghost_permitted_true_when_clear() {
195        let a = aff(0.1, 0.1, 0.5, 0);
196        let s = GhostSignals {
197            message_count: 50,
198            hours_since_last_ghost: Some(5.0),
199        };
200        assert!(ghost_permitted(&a, s));
201    }
202}