|
| 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 | +} |
0 commit comments