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/// Decide whether to ghost, with 4 protection layers:
26///   1. message_count < 10        → never ghost (relationship still nascent)
27///   2. ghost_streak >= 2         → don't ghost twice in a row
28///   3. last ghost < 1h ago       → cooldown
29///   4. recent ghost (any time)   → threshold rises to 0.85; otherwise 0.65
30pub 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); // would normally ghost
84        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        // ghost_score = (1-0.1)*0.4 + (1-0.1)*0.4 + 0.5*0.2 = 0.36 + 0.36 + 0.1 = 0.82
114        // base threshold 0.65 → ghost
115        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        // ghost_score = (1-0.5)*0.4 + (1-0.5)*0.4 + 0.0*0.2 = 0.4
126        // base 0.65 → would NOT ghost; post-ghost 0.85 → would NOT ghost
127        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        // ghost_score = (1-0.05)*0.4 + (1-0.05)*0.4 + 0.0*0.2 = 0.76
138        // base 0.65 → would ghost; post-ghost 0.85 → would NOT ghost (0.76 < 0.85)
139        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}