Skip to content

Commit 33768ee

Browse files
enriquephlclaude
andauthored
feat: per-request memory_scope & affinity_scope flags (#40) (#41)
* docs(spec): per-request memory_scope & affinity_scope flags (#40) Caller-driven interim for the cross-companion bleed in #40: two optional per-request flags gate prompt injection only (post-process writes unchanged). memory_scope maps to insight-mode + global/relationship memory switches; affinity_scope resolves to an axis set gating attitude/value injection and a bond/chemistry composite length rule. Defaults (neutral_and_relationship + bond) intentionally narrow today's injection. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(core): add memory_scope/affinity_scope value types (#40) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(core): clippy manual_clamp + rustfmt + add AffinityScope::contains (#40) Addresses code-quality review of Task 1: use f64::clamp, satisfy rustfmt, and add the spec-named contains() accessor with coverage. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(chat): plumb memory_scope/affinity_scope through request → event (#40) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(chat): document BondAndChemistry=full alias + cover all named scopes (#40) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(chat): human_insights-sourced 基础画像 renderer with neutral mode (#40) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(chat): Off guard on renderer + byte-identical parity & empty-row tests (#40) Addresses code-quality review of Task 3: defensive Off→empty in the renderer, a parity test pinning Full == insights_to_bullets, and an unknown-user empty test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(prompt): axis-gated affinity injection + composite length rule (#40) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(prompt): assert attitude-block gating + clarify length threshold reuse (#40) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(chat): gate memory injection by memory_scope + recall short-circuit (#40) Reply path now reads 基础画像 from human_insights and gates global/relationship memory + insight mode by the resolved memory_scope; recall skips embedding + searches when a layer is off. Gift path unchanged. Event field extraction in build_reply_request consolidated into one destructure. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(chat): log x_on/y_on on recall completion + test/comment polish (#40) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(store): backfill human_insights from companion_insights (#40) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(store): guard backfill array projections against non-array JSONB (#40) A legacy companion_insights row with a non-array interests/personality_traits/ deal_breakers (e.g. an early LLM hallucination) would make jsonb_array_elements_text abort the whole backfill. Guard each with jsonb_typeof = 'array'. Tests now run the canonical migration via include_str! (no SQL drift) and cover the malformed-field path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(api): document memory_scope/affinity_scope, regen OpenAPI, add scope tracing (#40) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(chat): log default scopes at debug, non-default at info (#40) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(spec): correct default-scope protection description (#40) The neutral_and_relationship default keeps global memory recall (X) on; it only drops the intimate insight fields. relationship_only is what stops global bleed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c973edd commit 33768ee

15 files changed

Lines changed: 1651 additions & 105 deletions

File tree

crates/eros-engine-core/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ pub mod affinity;
55
pub mod ghost;
66
pub mod pde;
77
pub mod persona;
8+
pub mod scope;
89
pub mod types;

crates/eros-engine-core/src/pde.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,8 @@ mod tests {
209209
prompt_traits: Vec::new(),
210210
audit: None,
211211
tier: None,
212+
memory_scope: Default::default(),
213+
affinity_scope: Default::default(),
212214
}
213215
}
214216

Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
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+
6+
use crate::affinity::Affinity;
7+
use serde::{Deserialize, Serialize};
8+
9+
/// How much of the user-global structured profile ("基础画像") to inject.
10+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11+
pub 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")]
22+
pub enum MemoryScope {
23+
Full,
24+
#[default]
25+
NeutralAndRelationship,
26+
RelationshipOnly,
27+
NeutralOnly,
28+
InsightsOnly,
29+
None,
30+
}
31+
32+
impl 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")]
48+
pub 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)]
59+
pub 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+
68+
impl Default for AffinityScope {
69+
fn default() -> Self {
70+
Self::bond()
71+
}
72+
}
73+
74+
impl 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+
182+
fn clamp01(x: f64) -> f64 {
183+
x.clamp(0.0, 1.0)
184+
}
185+
186+
#[cfg(test)]
187+
mod 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+
}

crates/eros-engine-core/src/types.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use uuid::Uuid;
66

77
use crate::affinity::{Affinity, AffinityDeltas};
88
use crate::persona::CompanionPersona;
9+
use crate::scope::{AffinityScope, MemoryScope};
910

1011
/// A caller-supplied system-prompt fragment. The engine treats `text` as
1112
/// opaque — it is inserted verbatim under the `【附加指引】` section of
@@ -54,6 +55,14 @@ pub enum Event {
5455
/// `model_config.resolve` to pick the per-tier model + allow_traits.
5556
#[serde(default)]
5657
tier: Option<String>,
58+
/// Optional caller-supplied memory injection scope (#40). Defaults to
59+
/// `neutral_and_relationship` when absent.
60+
#[serde(default)]
61+
memory_scope: MemoryScope,
62+
/// Optional caller-supplied affinity-axis injection scope (#40).
63+
/// Defaults to `bond` when absent.
64+
#[serde(default)]
65+
affinity_scope: AffinityScope,
5766
},
5867
Gift {
5968
gift_id: Uuid,
@@ -195,6 +204,26 @@ mod tests {
195204
}
196205
}
197206

207+
#[test]
208+
fn user_message_defaults_scopes_when_absent() {
209+
let raw = r#"{"UserMessage":{"content":"hi","message_id":"00000000-0000-0000-0000-000000000001"}}"#;
210+
let ev: Event = serde_json::from_str(raw).unwrap();
211+
match ev {
212+
Event::UserMessage {
213+
memory_scope,
214+
affinity_scope,
215+
..
216+
} => {
217+
assert_eq!(
218+
memory_scope,
219+
crate::scope::MemoryScope::NeutralAndRelationship
220+
);
221+
assert_eq!(affinity_scope, crate::scope::AffinityScope::bond());
222+
}
223+
_ => panic!("expected UserMessage"),
224+
}
225+
}
226+
198227
#[test]
199228
fn chat_response_defaults_audit_fields_to_none() {
200229
let r = ChatResponse {

0 commit comments

Comments
 (0)