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    /// Any axis of the `bond()` half (warmth / intimacy / tension) active.
132    /// The voice relationship line injects at half granularity — these two
133    /// helpers are its flattening rule.
134    pub fn any_bond_axis(&self) -> bool {
135        self.warmth || self.intimacy || self.tension
136    }
137    /// Any axis of the `chemistry()` half (trust / intrigue / patience) active.
138    pub fn any_chemistry_axis(&self) -> bool {
139        self.trust || self.intrigue || self.patience
140    }
141    pub fn contains(self, axis: AffinityAxis) -> bool {
142        match axis {
143            AffinityAxis::Warmth => self.warmth,
144            AffinityAxis::Trust => self.trust,
145            AffinityAxis::Intrigue => self.intrigue,
146            AffinityAxis::Intimacy => self.intimacy,
147            AffinityAxis::Patience => self.patience,
148            AffinityAxis::Tension => self.tension,
149        }
150    }
151    pub fn is_empty(self) -> bool {
152        !(self.warmth
153            || self.trust
154            || self.intrigue
155            || self.intimacy
156            || self.patience
157            || self.tension)
158    }
159
160    /// Number of axes that are active (0..=6). Used for observability tracing.
161    pub fn active_count(self) -> usize {
162        [
163            self.warmth,
164            self.trust,
165            self.intrigue,
166            self.intimacy,
167            self.patience,
168            self.tension,
169        ]
170        .into_iter()
171        .filter(|b| *b)
172        .count()
173    }
174
175    /// Composite length score per the #40 spec. `None` when no axis is in scope
176    /// (caller falls back to the strictest tier, matching `affinity = None`).
177    pub fn length_score(self, a: &Affinity) -> Option<f64> {
178        // warmth is 0..1 as of 4.0; the old (w+1)/2 shift would inflate this
179        // half by 0.25.
180        let warm01 = clamp01(a.warmth);
181        let bond = clamp01((warm01 + a.intimacy + a.tension) / 3.0);
182        let chemistry = clamp01((a.trust + a.intrigue + a.patience) / 3.0);
183        let bond_active = self.warmth || self.intimacy || self.tension;
184        let chem_active = self.trust || self.intrigue || self.patience;
185        match (bond_active, chem_active) {
186            (true, true) => Some((bond + chemistry) / 2.0),
187            (true, false) => Some(bond),
188            (false, true) => Some(chemistry),
189            (false, false) => None,
190        }
191    }
192}
193
194fn clamp01(x: f64) -> f64 {
195    x.clamp(0.0, 1.0)
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use chrono::Utc;
202    use uuid::Uuid;
203
204    fn affinity(
205        warmth: f64,
206        trust: f64,
207        intrigue: f64,
208        intimacy: f64,
209        patience: f64,
210        tension: f64,
211    ) -> Affinity {
212        let now = Utc::now();
213        Affinity {
214            id: Uuid::new_v4(),
215            session_id: Uuid::new_v4(),
216            user_id: Uuid::new_v4(),
217            instance_id: Uuid::new_v4(),
218            warmth,
219            trust,
220            intrigue,
221            intimacy,
222            patience,
223            tension,
224            warmth_grade: 2,
225            patience_grade: 2,
226            ghost_streak: 0,
227            last_ghost_at: None,
228            total_ghosts: 0,
229            relationship_label: None,
230            created_at: now,
231            updated_at: now,
232        }
233    }
234
235    #[test]
236    fn memory_scope_resolution_table() {
237        use InsightMode::*;
238        assert_eq!(MemoryScope::Full.resolve(), (Full, true, true));
239        assert_eq!(
240            MemoryScope::NeutralAndRelationship.resolve(),
241            (Neutral, true, true)
242        );
243        assert_eq!(MemoryScope::RelationshipOnly.resolve(), (Off, false, true));
244        assert_eq!(MemoryScope::NeutralOnly.resolve(), (Neutral, false, false));
245        assert_eq!(MemoryScope::InsightsOnly.resolve(), (Full, false, false));
246        assert_eq!(MemoryScope::None.resolve(), (Off, false, false));
247    }
248
249    #[test]
250    fn memory_scope_default_is_neutral_and_relationship() {
251        assert_eq!(MemoryScope::default(), MemoryScope::NeutralAndRelationship);
252    }
253
254    #[test]
255    fn memory_scope_serde_snake_case() {
256        let s: MemoryScope = serde_json::from_str("\"relationship_only\"").unwrap();
257        assert_eq!(s, MemoryScope::RelationshipOnly);
258        // multi-word default variant round-trips
259        let n: MemoryScope = serde_json::from_str("\"neutral_and_relationship\"").unwrap();
260        assert_eq!(n, MemoryScope::NeutralAndRelationship);
261        assert_eq!(
262            serde_json::to_string(&MemoryScope::NeutralAndRelationship).unwrap(),
263            "\"neutral_and_relationship\""
264        );
265        assert!(serde_json::from_str::<MemoryScope>("\"bogus\"").is_err());
266    }
267
268    #[test]
269    fn affinity_scope_contains_matches_fields() {
270        let s = AffinityScope::bond();
271        assert!(s.contains(AffinityAxis::Warmth));
272        assert!(s.contains(AffinityAxis::Intimacy));
273        assert!(s.contains(AffinityAxis::Tension));
274        assert!(!s.contains(AffinityAxis::Trust));
275        assert!(!s.contains(AffinityAxis::Intrigue));
276        assert!(!s.contains(AffinityAxis::Patience));
277    }
278
279    #[test]
280    fn affinity_scope_default_is_bond() {
281        let d = AffinityScope::default();
282        assert_eq!(d, AffinityScope::bond());
283        assert!(d.warmth && d.intimacy && d.tension);
284        assert!(!d.trust && !d.intrigue && !d.patience);
285    }
286
287    #[test]
288    fn affinity_scope_chemistry_and_full() {
289        let c = AffinityScope::chemistry();
290        assert!(c.trust && c.intrigue && c.patience);
291        assert!(!c.warmth && !c.intimacy && !c.tension);
292        let f = AffinityScope::full();
293        assert!(!f.is_empty());
294        assert!(f.warmth && f.trust && f.intrigue && f.intimacy && f.patience && f.tension);
295    }
296
297    #[test]
298    fn affinity_scope_from_axes_and_empty() {
299        let s = AffinityScope::from_axes(&[AffinityAxis::Warmth, AffinityAxis::Trust]);
300        assert!(s.warmth && s.trust);
301        assert!(!s.intrigue && !s.intimacy && !s.patience && !s.tension);
302        assert!(AffinityScope::from_axes(&[]).is_empty());
303        assert!(AffinityScope::none().is_empty());
304    }
305
306    #[test]
307    fn affinity_scope_active_count() {
308        assert_eq!(AffinityScope::none().active_count(), 0);
309        assert_eq!(AffinityScope::bond().active_count(), 3);
310        assert_eq!(AffinityScope::full().active_count(), 6);
311        let one = AffinityScope::from_axes(&[AffinityAxis::Warmth]);
312        assert_eq!(one.active_count(), 1);
313    }
314
315    #[test]
316    fn affinity_axis_serde_snake_case() {
317        let a: AffinityAxis = serde_json::from_str("\"warmth\"").unwrap();
318        assert_eq!(a, AffinityAxis::Warmth);
319        assert!(serde_json::from_str::<AffinityAxis>("\"warm\"").is_err());
320    }
321
322    #[test]
323    fn length_score_named_cases() {
324        // warmth=0.5 (0..1 as of 4.0) → warm01=0.5; intimacy=0.5; tension=0.5 → bond=0.5
325        // trust=0.9; intrigue=0.9; patience=0.9 → chemistry=0.9
326        let a = affinity(0.5, 0.9, 0.9, 0.5, 0.9, 0.5);
327        let bond = AffinityScope::bond().length_score(&a).unwrap();
328        let chem = AffinityScope::chemistry().length_score(&a).unwrap();
329        let full = AffinityScope::full().length_score(&a).unwrap();
330        assert!((bond - 0.5).abs() < 1e-9);
331        assert!((chem - 0.9).abs() < 1e-9);
332        assert!((full - 0.7).abs() < 1e-9); // (0.5 + 0.9) / 2
333        assert_eq!(AffinityScope::none().length_score(&a), None);
334    }
335
336    #[test]
337    fn length_score_array_activates_both_triads() {
338        let a = affinity(0.5, 0.9, 0.9, 0.5, 0.9, 0.5);
339        // warmth ∈ bond, trust ∈ chemistry → both active → avg
340        let s = AffinityScope::from_axes(&[AffinityAxis::Warmth, AffinityAxis::Trust]);
341        assert!((s.length_score(&a).unwrap() - 0.7).abs() < 1e-9);
342    }
343
344    #[test]
345    fn affinity_scope_half_detection() {
346        assert!(AffinityScope::bond().any_bond_axis());
347        assert!(!AffinityScope::bond().any_chemistry_axis());
348        assert!(AffinityScope::chemistry().any_chemistry_axis());
349        assert!(!AffinityScope::chemistry().any_bond_axis());
350        assert!(AffinityScope::full().any_bond_axis());
351        assert!(AffinityScope::full().any_chemistry_axis());
352        assert!(!AffinityScope::none().any_bond_axis());
353        assert!(!AffinityScope::none().any_chemistry_axis());
354        // A single axis activates exactly its half.
355        let w = AffinityScope::from_axes(&[AffinityAxis::Warmth]);
356        assert!(w.any_bond_axis() && !w.any_chemistry_axis());
357        let t = AffinityScope::from_axes(&[AffinityAxis::Trust]);
358        assert!(t.any_chemistry_axis() && !t.any_bond_axis());
359    }
360}