Skip to main content

eros_engine_core/
scope.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! Per-request injection scope flags (issue #40). These gate prompt
3//! *injection* only — post-process writes (insight extraction, memory writes,
4//! six-axis affinity eval) are unaffected.
5
6use crate::affinity::Affinity;
7use serde::{Deserialize, Serialize};
8
9/// How much of the user-global structured profile ("基础画像") to inject.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum InsightMode {
12    Off,
13    /// Drop the intimate fields: love_values / emotional_needs / interests.
14    Neutral,
15    Full,
16}
17
18/// Caller-supplied memory injection scope. Default narrows today's behavior
19/// (the #40 mitigation).
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
21#[serde(rename_all = "snake_case")]
22pub enum MemoryScope {
23    Full,
24    #[default]
25    NeutralAndRelationship,
26    RelationshipOnly,
27    NeutralOnly,
28    InsightsOnly,
29    None,
30}
31
32impl MemoryScope {
33    /// Resolve to `(insight mode, inject global memory X, inject relationship memory Y)`.
34    pub fn resolve(self) -> (InsightMode, bool, bool) {
35        match self {
36            MemoryScope::Full => (InsightMode::Full, true, true),
37            MemoryScope::NeutralAndRelationship => (InsightMode::Neutral, true, true),
38            MemoryScope::RelationshipOnly => (InsightMode::Off, false, true),
39            MemoryScope::NeutralOnly => (InsightMode::Neutral, false, false),
40            MemoryScope::InsightsOnly => (InsightMode::Full, false, false),
41            MemoryScope::None => (InsightMode::Off, false, false),
42        }
43    }
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum AffinityAxis {
49    Warmth,
50    Trust,
51    Intrigue,
52    Intimacy,
53    Patience,
54    Tension,
55}
56
57/// Resolved set of affinity axes to inject. Default = `bond`.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59pub struct AffinityScope {
60    pub warmth: bool,
61    pub trust: bool,
62    pub intrigue: bool,
63    pub intimacy: bool,
64    pub patience: bool,
65    pub tension: bool,
66}
67
68impl Default for AffinityScope {
69    fn default() -> Self {
70        Self::bond()
71    }
72}
73
74impl AffinityScope {
75    pub fn none() -> Self {
76        Self {
77            warmth: false,
78            trust: false,
79            intrigue: false,
80            intimacy: false,
81            patience: false,
82            tension: false,
83        }
84    }
85    pub fn full() -> Self {
86        Self {
87            warmth: true,
88            trust: true,
89            intrigue: true,
90            intimacy: true,
91            patience: true,
92            tension: true,
93        }
94    }
95    /// 朋友感: warmth + intimacy + tension.
96    pub fn bond() -> Self {
97        Self {
98            warmth: true,
99            intimacy: true,
100            tension: true,
101            trust: false,
102            intrigue: false,
103            patience: false,
104        }
105    }
106    /// 暧昧感: trust + intrigue + patience.
107    pub fn chemistry() -> Self {
108        Self {
109            trust: true,
110            intrigue: true,
111            patience: true,
112            warmth: false,
113            intimacy: false,
114            tension: false,
115        }
116    }
117    pub fn from_axes(axes: &[AffinityAxis]) -> Self {
118        let mut s = Self::none();
119        for a in axes {
120            match a {
121                AffinityAxis::Warmth => s.warmth = true,
122                AffinityAxis::Trust => s.trust = true,
123                AffinityAxis::Intrigue => s.intrigue = true,
124                AffinityAxis::Intimacy => s.intimacy = true,
125                AffinityAxis::Patience => s.patience = true,
126                AffinityAxis::Tension => s.tension = true,
127            }
128        }
129        s
130    }
131    pub fn contains(self, axis: AffinityAxis) -> bool {
132        match axis {
133            AffinityAxis::Warmth => self.warmth,
134            AffinityAxis::Trust => self.trust,
135            AffinityAxis::Intrigue => self.intrigue,
136            AffinityAxis::Intimacy => self.intimacy,
137            AffinityAxis::Patience => self.patience,
138            AffinityAxis::Tension => self.tension,
139        }
140    }
141    pub fn is_empty(self) -> bool {
142        !(self.warmth
143            || self.trust
144            || self.intrigue
145            || self.intimacy
146            || self.patience
147            || self.tension)
148    }
149
150    /// Number of axes that are active (0..=6). Used for observability tracing.
151    pub fn active_count(self) -> usize {
152        [
153            self.warmth,
154            self.trust,
155            self.intrigue,
156            self.intimacy,
157            self.patience,
158            self.tension,
159        ]
160        .into_iter()
161        .filter(|b| *b)
162        .count()
163    }
164
165    /// Composite length score per the #40 spec. `None` when no axis is in scope
166    /// (caller falls back to the strictest tier, matching `affinity = None`).
167    pub fn length_score(self, a: &Affinity) -> Option<f64> {
168        let warm01 = clamp01((a.warmth + 1.0) / 2.0);
169        let bond = clamp01((warm01 + a.intimacy + a.tension) / 3.0);
170        let chemistry = clamp01((a.trust + a.intrigue + a.patience) / 3.0);
171        let bond_active = self.warmth || self.intimacy || self.tension;
172        let chem_active = self.trust || self.intrigue || self.patience;
173        match (bond_active, chem_active) {
174            (true, true) => Some((bond + chemistry) / 2.0),
175            (true, false) => Some(bond),
176            (false, true) => Some(chemistry),
177            (false, false) => None,
178        }
179    }
180}
181
182fn clamp01(x: f64) -> f64 {
183    x.clamp(0.0, 1.0)
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use chrono::Utc;
190    use uuid::Uuid;
191
192    fn affinity(
193        warmth: f64,
194        trust: f64,
195        intrigue: f64,
196        intimacy: f64,
197        patience: f64,
198        tension: f64,
199    ) -> Affinity {
200        let now = Utc::now();
201        Affinity {
202            id: Uuid::new_v4(),
203            session_id: Uuid::new_v4(),
204            user_id: Uuid::new_v4(),
205            instance_id: Uuid::new_v4(),
206            warmth,
207            trust,
208            intrigue,
209            intimacy,
210            patience,
211            tension,
212            ghost_streak: 0,
213            last_ghost_at: None,
214            total_ghosts: 0,
215            relationship_label: None,
216            created_at: now,
217            updated_at: now,
218        }
219    }
220
221    #[test]
222    fn memory_scope_resolution_table() {
223        use InsightMode::*;
224        assert_eq!(MemoryScope::Full.resolve(), (Full, true, true));
225        assert_eq!(
226            MemoryScope::NeutralAndRelationship.resolve(),
227            (Neutral, true, true)
228        );
229        assert_eq!(MemoryScope::RelationshipOnly.resolve(), (Off, false, true));
230        assert_eq!(MemoryScope::NeutralOnly.resolve(), (Neutral, false, false));
231        assert_eq!(MemoryScope::InsightsOnly.resolve(), (Full, false, false));
232        assert_eq!(MemoryScope::None.resolve(), (Off, false, false));
233    }
234
235    #[test]
236    fn memory_scope_default_is_neutral_and_relationship() {
237        assert_eq!(MemoryScope::default(), MemoryScope::NeutralAndRelationship);
238    }
239
240    #[test]
241    fn memory_scope_serde_snake_case() {
242        let s: MemoryScope = serde_json::from_str("\"relationship_only\"").unwrap();
243        assert_eq!(s, MemoryScope::RelationshipOnly);
244        // multi-word default variant round-trips
245        let n: MemoryScope = serde_json::from_str("\"neutral_and_relationship\"").unwrap();
246        assert_eq!(n, MemoryScope::NeutralAndRelationship);
247        assert_eq!(
248            serde_json::to_string(&MemoryScope::NeutralAndRelationship).unwrap(),
249            "\"neutral_and_relationship\""
250        );
251        assert!(serde_json::from_str::<MemoryScope>("\"bogus\"").is_err());
252    }
253
254    #[test]
255    fn affinity_scope_contains_matches_fields() {
256        let s = AffinityScope::bond();
257        assert!(s.contains(AffinityAxis::Warmth));
258        assert!(s.contains(AffinityAxis::Intimacy));
259        assert!(s.contains(AffinityAxis::Tension));
260        assert!(!s.contains(AffinityAxis::Trust));
261        assert!(!s.contains(AffinityAxis::Intrigue));
262        assert!(!s.contains(AffinityAxis::Patience));
263    }
264
265    #[test]
266    fn affinity_scope_default_is_bond() {
267        let d = AffinityScope::default();
268        assert_eq!(d, AffinityScope::bond());
269        assert!(d.warmth && d.intimacy && d.tension);
270        assert!(!d.trust && !d.intrigue && !d.patience);
271    }
272
273    #[test]
274    fn affinity_scope_chemistry_and_full() {
275        let c = AffinityScope::chemistry();
276        assert!(c.trust && c.intrigue && c.patience);
277        assert!(!c.warmth && !c.intimacy && !c.tension);
278        let f = AffinityScope::full();
279        assert!(!f.is_empty());
280        assert!(f.warmth && f.trust && f.intrigue && f.intimacy && f.patience && f.tension);
281    }
282
283    #[test]
284    fn affinity_scope_from_axes_and_empty() {
285        let s = AffinityScope::from_axes(&[AffinityAxis::Warmth, AffinityAxis::Trust]);
286        assert!(s.warmth && s.trust);
287        assert!(!s.intrigue && !s.intimacy && !s.patience && !s.tension);
288        assert!(AffinityScope::from_axes(&[]).is_empty());
289        assert!(AffinityScope::none().is_empty());
290    }
291
292    #[test]
293    fn affinity_scope_active_count() {
294        assert_eq!(AffinityScope::none().active_count(), 0);
295        assert_eq!(AffinityScope::bond().active_count(), 3);
296        assert_eq!(AffinityScope::full().active_count(), 6);
297        let one = AffinityScope::from_axes(&[AffinityAxis::Warmth]);
298        assert_eq!(one.active_count(), 1);
299    }
300
301    #[test]
302    fn affinity_axis_serde_snake_case() {
303        let a: AffinityAxis = serde_json::from_str("\"warmth\"").unwrap();
304        assert_eq!(a, AffinityAxis::Warmth);
305        assert!(serde_json::from_str::<AffinityAxis>("\"warm\"").is_err());
306    }
307
308    #[test]
309    fn length_score_named_cases() {
310        // warmth=0 → warm01=0.5; intimacy=0.5; tension=0.5 → bond=0.5
311        // trust=0.9; intrigue=0.9; patience=0.9 → chemistry=0.9
312        let a = affinity(0.0, 0.9, 0.9, 0.5, 0.9, 0.5);
313        let bond = AffinityScope::bond().length_score(&a).unwrap();
314        let chem = AffinityScope::chemistry().length_score(&a).unwrap();
315        let full = AffinityScope::full().length_score(&a).unwrap();
316        assert!((bond - 0.5).abs() < 1e-9);
317        assert!((chem - 0.9).abs() < 1e-9);
318        assert!((full - 0.7).abs() < 1e-9); // (0.5 + 0.9) / 2
319        assert_eq!(AffinityScope::none().length_score(&a), None);
320    }
321
322    #[test]
323    fn length_score_array_activates_both_triads() {
324        let a = affinity(0.0, 0.9, 0.9, 0.5, 0.9, 0.5);
325        // warmth ∈ bond, trust ∈ chemistry → both active → avg
326        let s = AffinityScope::from_axes(&[AffinityAxis::Warmth, AffinityAxis::Trust]);
327        assert!((s.length_score(&a).unwrap() - 0.7).abs() < 1e-9);
328    }
329}