Skip to main content

ccf_core/
phase.rs

1/*
2 * Notice of Provisional Patent Filing:
3 * The methods and algorithms implemented in this file (specifically relating to
4 * Contextual Coherence Fields and relational coherence accumulation) are the
5 * subject of a United States Provisional Patent Application (63/988,438)
6 * filed on February 23, 2026.
7 *
8 * This source code is licensed under the Business Source License 1.1.
9 * See LICENSE and PATENTS.md in the root directory for full details.
10 */
11
12//! Social phase classification and personality modulators.
13//!
14//! # Patent Claims 3, 14–18
15//!
16//! - [`Personality`]: dynamic modulators — curiosity, startle sensitivity, recovery speed (Claim 3).
17//! - [`SocialPhase`]: four-quadrant phase classifier with Schmitt trigger hysteresis (Claims 14–18).
18//! - [`PhaseSpace`]: configurable thresholds for quadrant transitions (Claim 14).
19//!
20//! # Invariants
21//!
22//! - **CCF-003**: Personality modulates deltas, not structure.
23//! - **CCF-004**: Quadrant boundaries use hysteresis (≈0.10 deadband) to prevent oscillation.
24//! - **I-DIST-001**: no_std compatible.
25//! - **I-DIST-005**: Zero unsafe code.
26
27// ─── Personality ────────────────────────────────────────────────────────────
28
29/// Dynamic personality modulators.
30///
31/// These three parameters are bounded in [0.0, 1.0] and modulate the *rate* of
32/// coherence change, not the structural invariants (CCF-003).
33///
34/// Patent Claim 3 (modulators).
35#[derive(Clone, Debug, PartialEq)]
36#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
37pub struct Personality {
38    /// Drive to explore new contexts. Scales the cold-start baseline and
39    /// the rate of positive coherence accumulation.
40    ///
41    /// Range [0.0, 1.0]. Default 0.5.
42    pub curiosity_drive: f32,
43    /// Sensitivity to startling or aversive events. Scales the magnitude of
44    /// negative-interaction drops.
45    ///
46    /// Range [0.0, 1.0]. Default 0.5.
47    pub startle_sensitivity: f32,
48    /// Speed of coherence recovery after disruption. Scales the delta applied
49    /// by positive interactions.
50    ///
51    /// Range [0.0, 1.0]. Default 0.5.
52    pub recovery_speed: f32,
53}
54
55impl Personality {
56    /// Construct the default mid-range personality (all parameters at 0.5).
57    pub fn new() -> Self {
58        Self {
59            curiosity_drive: 0.5,
60            startle_sensitivity: 0.5,
61            recovery_speed: 0.5,
62        }
63    }
64
65    /// Scale a base coherence gain delta by this personality's `recovery_speed`.
66    ///
67    /// Returns `base * (0.5 + recovery_speed)`, clamped to [0.0, 2.0 * base].
68    pub fn modulate_coherence_gain(&self, base: f32) -> f32 {
69        base * (0.5 + self.recovery_speed)
70    }
71
72    /// Scale a base startle drop by this personality's `startle_sensitivity`.
73    ///
74    /// Returns `base * (0.5 + startle_sensitivity)`, clamped to [0.0, 2.0 * base].
75    pub fn modulate_startle_drop(&self, base: f32) -> f32 {
76        base * (0.5 + self.startle_sensitivity)
77    }
78}
79
80impl Default for Personality {
81    fn default() -> Self {
82        Self::new()
83    }
84}
85
86// ─── PhaseSpace (configurable Schmitt trigger thresholds) ────────────────────
87
88/// Configurable thresholds for [`SocialPhase`] transitions.
89///
90/// Uses hysteresis (Schmitt trigger): the *enter* threshold is higher than the
91/// *exit* threshold so the robot does not oscillate at phase boundaries (CCF-004).
92///
93/// Default thresholds:
94/// - Coherence high: enter ≥ 0.65, exit ≥ 0.55 (10-point deadband).
95/// - Tension high: enter ≥ 0.45, exit ≥ 0.35 (10-point deadband).
96///
97/// Patent Claim 14.
98#[derive(Clone, Debug)]
99#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
100pub struct PhaseSpace {
101    /// Coherence threshold to *enter* the high-coherence quadrants (QuietlyBeloved, ProtectiveGuardian).
102    pub coherence_high_enter: f32,
103    /// Coherence threshold to *stay in* the high-coherence quadrants (exit when below).
104    pub coherence_high_exit: f32,
105    /// Tension threshold to *enter* the high-tension quadrants (StartledRetreat, ProtectiveGuardian).
106    pub tension_high_enter: f32,
107    /// Tension threshold to *stay in* the high-tension quadrants (exit when below).
108    pub tension_high_exit: f32,
109}
110
111impl PhaseSpace {
112    /// Construct the standard PhaseSpace with default thresholds.
113    pub fn new() -> Self {
114        Self::default()
115    }
116}
117
118impl Default for PhaseSpace {
119    fn default() -> Self {
120        Self {
121            coherence_high_enter: 0.65,
122            coherence_high_exit: 0.55,
123            tension_high_enter: 0.45,
124            tension_high_exit: 0.35,
125        }
126    }
127}
128
129// ─── SocialPhase ─────────────────────────────────────────────────────────────
130
131/// Behavioral phase from the 2D (coherence × tension) space.
132///
133/// The four quadrants of the phase plane:
134///
135/// ```text
136///              │ Low tension        │ High tension
137/// ─────────────┼────────────────────┼──────────────────────
138/// Low coherence│ ShyObserver        │ StartledRetreat
139/// High coherence│ QuietlyBeloved    │ ProtectiveGuardian
140/// ```
141///
142/// Transitions use hysteresis (CCF-004) to prevent oscillation at boundaries.
143///
144/// Patent Claims 14–18.
145#[derive(Clone, Copy, Debug, PartialEq, Eq)]
146#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
147pub enum SocialPhase {
148    /// Low coherence, low tension: minimal expression, cautious observation.
149    ShyObserver,
150    /// Low coherence, high tension: protective reflex with additional withdrawal.
151    StartledRetreat,
152    /// High coherence, low tension: full expressive range — "small flourishes".
153    QuietlyBeloved,
154    /// High coherence, high tension: protective but with relational context.
155    ProtectiveGuardian,
156}
157
158impl SocialPhase {
159    /// Determine the current social phase using Schmitt trigger hysteresis (CCF-004).
160    ///
161    /// - `effective_coherence`: output of `CoherenceField::effective_coherence()` in [0.0, 1.0].
162    /// - `tension`: current tension from homeostasis in [0.0, 1.0].
163    /// - `prev`: the phase from the previous tick (enables hysteresis).
164    /// - `ps`: configurable thresholds for quadrant transitions.
165    pub fn classify(
166        effective_coherence: f32,
167        tension: f32,
168        prev: SocialPhase,
169        ps: &PhaseSpace,
170    ) -> SocialPhase {
171        let high_coherence = match prev {
172            SocialPhase::QuietlyBeloved | SocialPhase::ProtectiveGuardian => {
173                effective_coherence >= ps.coherence_high_exit
174            }
175            _ => effective_coherence >= ps.coherence_high_enter,
176        };
177
178        let high_tension = match prev {
179            SocialPhase::StartledRetreat | SocialPhase::ProtectiveGuardian => {
180                tension >= ps.tension_high_exit
181            }
182            _ => tension >= ps.tension_high_enter,
183        };
184
185        match (high_coherence, high_tension) {
186            (false, false) => SocialPhase::ShyObserver,
187            (false, true) => SocialPhase::StartledRetreat,
188            (true, false) => SocialPhase::QuietlyBeloved,
189            (true, true) => SocialPhase::ProtectiveGuardian,
190        }
191    }
192
193    /// Scale factor for expressive output in this phase [0.0, 1.0].
194    ///
195    /// Delegates to [`permeability`] with representative mid-range values
196    /// (coherence = 0.5, tension = 0.3) for backward-compatible ordering.
197    /// New code should call [`permeability`] directly for full control.
198    pub fn expression_scale(&self) -> f32 {
199        permeability(0.5, 0.3, *self)
200    }
201
202    /// LED color tint for this phase (overlaid on reflex mode color).
203    pub fn led_tint(&self) -> [u8; 3] {
204        match self {
205            SocialPhase::ShyObserver => [40, 40, 80],          // Muted blue-grey
206            SocialPhase::StartledRetreat => [80, 20, 20],      // Dark red
207            SocialPhase::QuietlyBeloved => [60, 120, 200],     // Warm blue
208            SocialPhase::ProtectiveGuardian => [200, 100, 0],  // Amber
209        }
210    }
211}
212
213// ─── Output Permeability ─────────────────────────────────────────────────────
214
215/// Compute output permeability — how much personality expression passes through.
216///
217/// The quadrant determines qualitative behavior; the position within the quadrant
218/// determines intensity. This scalar scales all output channels (motor speed,
219/// LED intensity, sound probability, narration depth).
220///
221/// # Ranges per quadrant
222///
223/// | Quadrant | Range | Formula |
224/// |---|---|---|
225/// | ShyObserver | [0.0, 0.3] | `effective_coherence × 0.3` |
226/// | StartledRetreat | 0.1 fixed | reflexive, not expressive |
227/// | QuietlyBeloved | [0.5, 1.0] | `0.5 + effective_coherence × 0.5` |
228/// | ProtectiveGuardian | [0.4, 0.6] | `0.4 + effective_coherence × 0.2` |
229pub fn permeability(effective_coherence: f32, _tension: f32, quadrant: SocialPhase) -> f32 {
230    match quadrant {
231        SocialPhase::ShyObserver => effective_coherence * 0.3,
232        SocialPhase::StartledRetreat => 0.1,
233        SocialPhase::QuietlyBeloved => 0.5 + effective_coherence * 0.5,
234        SocialPhase::ProtectiveGuardian => 0.4 + effective_coherence * 0.2,
235    }
236}
237
238/// Narration depth levels gated by output permeability.
239///
240/// Determines how much reflection the robot performs based on the current
241/// permeability. Lower permeability means less narration overhead.
242#[derive(Clone, Copy, Debug, PartialEq, Eq)]
243#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
244pub enum NarrationDepth {
245    /// permeability < 0.2: No reflection.
246    None,
247    /// permeability 0.2–0.4: Factual observations only.
248    Minimal,
249    /// permeability 0.4–0.6: Contextual awareness.
250    Brief,
251    /// permeability 0.6–0.8: Personality-colored narration.
252    Full,
253    /// permeability > 0.8: Phenomenological reflection.
254    Deep,
255}
256
257impl NarrationDepth {
258    /// Map a permeability scalar to a narration depth level.
259    pub fn from_permeability(p: f32) -> Self {
260        if p < 0.2 {
261            NarrationDepth::None
262        } else if p < 0.4 {
263            NarrationDepth::Minimal
264        } else if p < 0.6 {
265            NarrationDepth::Brief
266        } else if p < 0.8 {
267            NarrationDepth::Full
268        } else {
269            NarrationDepth::Deep
270        }
271    }
272}
273
274// ─── Tests ──────────────────────────────────────────────────────────────────
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    // ── Personality tests ─────────────────────────────────────────────────
281
282    #[test]
283    fn test_personality_new_mid_range() {
284        let p = Personality::new();
285        assert!((p.curiosity_drive - 0.5).abs() < f32::EPSILON);
286        assert!((p.startle_sensitivity - 0.5).abs() < f32::EPSILON);
287        assert!((p.recovery_speed - 0.5).abs() < f32::EPSILON);
288    }
289
290    #[test]
291    fn test_personality_modulate_coherence_gain() {
292        let p = Personality { curiosity_drive: 0.5, startle_sensitivity: 0.5, recovery_speed: 0.9 };
293        // base * (0.5 + 0.9) = base * 1.4
294        let result = p.modulate_coherence_gain(0.02);
295        assert!((result - 0.02 * 1.4).abs() < f32::EPSILON, "got {}", result);
296    }
297
298    #[test]
299    fn test_personality_modulate_startle_drop() {
300        let p = Personality { curiosity_drive: 0.5, startle_sensitivity: 0.1, recovery_speed: 0.5 };
301        // base * (0.5 + 0.1) = base * 0.6
302        let result = p.modulate_startle_drop(0.05);
303        assert!((result - 0.05 * 0.6).abs() < f32::EPSILON, "got {}", result);
304    }
305
306    // ── PhaseSpace tests ──────────────────────────────────────────────────
307
308    #[test]
309    fn test_phase_space_default_values() {
310        let ps = PhaseSpace::default();
311        assert!((ps.coherence_high_enter - 0.65).abs() < f32::EPSILON);
312        assert!((ps.coherence_high_exit - 0.55).abs() < f32::EPSILON);
313        assert!((ps.tension_high_enter - 0.45).abs() < f32::EPSILON);
314        assert!((ps.tension_high_exit - 0.35).abs() < f32::EPSILON);
315    }
316
317    // ── SocialPhase classification tests ──────────────────────────────────
318
319    #[test]
320    fn test_social_phase_shy_observer() {
321        let ps = PhaseSpace::default();
322        let phase = SocialPhase::classify(0.1, 0.1, SocialPhase::ShyObserver, &ps);
323        assert_eq!(phase, SocialPhase::ShyObserver);
324    }
325
326    #[test]
327    fn test_social_phase_quietly_beloved() {
328        let ps = PhaseSpace::default();
329        let phase = SocialPhase::classify(0.8, 0.1, SocialPhase::ShyObserver, &ps);
330        assert_eq!(phase, SocialPhase::QuietlyBeloved);
331    }
332
333    #[test]
334    fn test_social_phase_startled_retreat() {
335        let ps = PhaseSpace::default();
336        let phase = SocialPhase::classify(0.1, 0.7, SocialPhase::ShyObserver, &ps);
337        assert_eq!(phase, SocialPhase::StartledRetreat);
338    }
339
340    #[test]
341    fn test_social_phase_protective_guardian() {
342        let ps = PhaseSpace::default();
343        let phase = SocialPhase::classify(0.8, 0.7, SocialPhase::ShyObserver, &ps);
344        assert_eq!(phase, SocialPhase::ProtectiveGuardian);
345    }
346
347    #[test]
348    fn test_social_phase_hysteresis_coherence() {
349        let ps = PhaseSpace::default();
350
351        // Enter QuietlyBeloved above enter threshold
352        let phase = SocialPhase::classify(0.66, 0.1, SocialPhase::ShyObserver, &ps);
353        assert_eq!(phase, SocialPhase::QuietlyBeloved);
354
355        // Stay in QuietlyBeloved above exit threshold (0.55)
356        let phase = SocialPhase::classify(0.56, 0.1, SocialPhase::QuietlyBeloved, &ps);
357        assert_eq!(phase, SocialPhase::QuietlyBeloved);
358
359        // Exit QuietlyBeloved below exit threshold
360        let phase = SocialPhase::classify(0.54, 0.1, SocialPhase::QuietlyBeloved, &ps);
361        assert_eq!(phase, SocialPhase::ShyObserver);
362    }
363
364    #[test]
365    fn test_social_phase_hysteresis_tension() {
366        let ps = PhaseSpace::default();
367
368        // Enter StartledRetreat above enter threshold (0.45)
369        let phase = SocialPhase::classify(0.1, 0.46, SocialPhase::ShyObserver, &ps);
370        assert_eq!(phase, SocialPhase::StartledRetreat);
371
372        // Stay in StartledRetreat above exit threshold (0.35)
373        let phase = SocialPhase::classify(0.1, 0.36, SocialPhase::StartledRetreat, &ps);
374        assert_eq!(phase, SocialPhase::StartledRetreat);
375
376        // Exit StartledRetreat below exit threshold
377        let phase = SocialPhase::classify(0.1, 0.34, SocialPhase::StartledRetreat, &ps);
378        assert_eq!(phase, SocialPhase::ShyObserver);
379    }
380
381    #[test]
382    fn test_custom_thresholds_stricter() {
383        let strict = PhaseSpace {
384            coherence_high_enter: 0.80,
385            coherence_high_exit: 0.70,
386            ..PhaseSpace::default()
387        };
388
389        // 0.70 coherence: enough for default QB, but not strict
390        let phase = SocialPhase::classify(0.70, 0.1, SocialPhase::ShyObserver, &strict);
391        assert_eq!(
392            phase,
393            SocialPhase::ShyObserver,
394            "coherence 0.70 should NOT enter QB with strict threshold 0.80"
395        );
396
397        // 0.85 coherence: above strict threshold
398        let phase = SocialPhase::classify(0.85, 0.1, SocialPhase::ShyObserver, &strict);
399        assert_eq!(
400            phase,
401            SocialPhase::QuietlyBeloved,
402            "coherence 0.85 should enter QB with strict threshold 0.80"
403        );
404
405        // Hysteresis: stay in QB at 0.75 (above strict exit 0.70)
406        let phase = SocialPhase::classify(0.75, 0.1, SocialPhase::QuietlyBeloved, &strict);
407        assert_eq!(
408            phase,
409            SocialPhase::QuietlyBeloved,
410            "coherence 0.75 should stay in QB (above exit 0.70)"
411        );
412
413        // Drop below strict exit: leave QB
414        let phase = SocialPhase::classify(0.65, 0.1, SocialPhase::QuietlyBeloved, &strict);
415        assert_eq!(
416            phase,
417            SocialPhase::ShyObserver,
418            "coherence 0.65 should exit QB (below exit 0.70)"
419        );
420    }
421
422    #[test]
423    fn test_custom_thresholds_looser() {
424        let loose = PhaseSpace {
425            coherence_high_enter: 0.40,
426            coherence_high_exit: 0.30,
427            ..PhaseSpace::default()
428        };
429
430        // 0.42 coherence: not enough for default (0.65), but enough for loose
431        let phase = SocialPhase::classify(0.42, 0.1, SocialPhase::ShyObserver, &loose);
432        assert_eq!(
433            phase,
434            SocialPhase::QuietlyBeloved,
435            "coherence 0.42 should enter QB with loose threshold 0.40"
436        );
437
438        // With default thresholds, stays ShyObserver
439        let ps = PhaseSpace::default();
440        let phase = SocialPhase::classify(0.42, 0.1, SocialPhase::ShyObserver, &ps);
441        assert_eq!(
442            phase,
443            SocialPhase::ShyObserver,
444            "coherence 0.42 should NOT enter QB with default threshold 0.65"
445        );
446    }
447
448    #[test]
449    fn test_full_quadrant_sweep_with_default_thresholds() {
450        let ps = PhaseSpace::default();
451
452        let cases: &[(f32, f32, SocialPhase, SocialPhase)] = &[
453            (0.1, 0.1, SocialPhase::ShyObserver, SocialPhase::ShyObserver),
454            (0.8, 0.1, SocialPhase::ShyObserver, SocialPhase::QuietlyBeloved),
455            (0.1, 0.7, SocialPhase::ShyObserver, SocialPhase::StartledRetreat),
456            (0.8, 0.7, SocialPhase::ShyObserver, SocialPhase::ProtectiveGuardian),
457            // Hysteresis: stay in QB above exit
458            (0.56, 0.1, SocialPhase::QuietlyBeloved, SocialPhase::QuietlyBeloved),
459            // Hysteresis: exit QB below exit
460            (0.54, 0.1, SocialPhase::QuietlyBeloved, SocialPhase::ShyObserver),
461            // Hysteresis: stay in SR above tension exit
462            (0.1, 0.36, SocialPhase::StartledRetreat, SocialPhase::StartledRetreat),
463            // Hysteresis: exit SR below tension exit
464            (0.1, 0.34, SocialPhase::StartledRetreat, SocialPhase::ShyObserver),
465        ];
466
467        for &(coh, ten, prev, expected) in cases {
468            let result = SocialPhase::classify(coh, ten, prev, &ps);
469            assert_eq!(
470                result, expected,
471                "coh={} ten={} prev={:?}: got {:?}, expected {:?}",
472                coh, ten, prev, result, expected
473            );
474        }
475    }
476
477    #[test]
478    fn test_expression_scale_ordering() {
479        assert!(
480            SocialPhase::QuietlyBeloved.expression_scale()
481                > SocialPhase::ProtectiveGuardian.expression_scale()
482        );
483        assert!(
484            SocialPhase::ProtectiveGuardian.expression_scale()
485                > SocialPhase::ShyObserver.expression_scale()
486        );
487        assert!(
488            SocialPhase::ShyObserver.expression_scale()
489                > SocialPhase::StartledRetreat.expression_scale()
490        );
491    }
492
493    #[test]
494    fn test_led_tint_distinct() {
495        let so = SocialPhase::ShyObserver.led_tint();
496        let sr = SocialPhase::StartledRetreat.led_tint();
497        let qb = SocialPhase::QuietlyBeloved.led_tint();
498        let pg = SocialPhase::ProtectiveGuardian.led_tint();
499        // All four tints must be distinct
500        assert_ne!(so, sr);
501        assert_ne!(so, qb);
502        assert_ne!(so, pg);
503        assert_ne!(sr, qb);
504        assert_ne!(sr, pg);
505        assert_ne!(qb, pg);
506    }
507
508    // ── Permeability tests ────────────────────────────────────────────────
509
510    #[test]
511    fn test_permeability_shy_observer_range() {
512        let p_zero = permeability(0.0, 0.3, SocialPhase::ShyObserver);
513        assert!((p_zero - 0.0).abs() < f32::EPSILON, "got {}", p_zero);
514
515        let p_max = permeability(1.0, 0.3, SocialPhase::ShyObserver);
516        assert!((p_max - 0.3).abs() < f32::EPSILON, "got {}", p_max);
517
518        let p_mid = permeability(0.5, 0.3, SocialPhase::ShyObserver);
519        assert!((p_mid - 0.15).abs() < f32::EPSILON, "got {}", p_mid);
520    }
521
522    #[test]
523    fn test_permeability_startled_retreat_fixed() {
524        for coh in &[0.0_f32, 0.25, 0.5, 0.75, 1.0] {
525            for ten in &[0.0_f32, 0.5, 1.0] {
526                let p = permeability(*coh, *ten, SocialPhase::StartledRetreat);
527                assert!(
528                    (p - 0.1).abs() < f32::EPSILON,
529                    "SR should always be 0.1, got {} at coh={} ten={}",
530                    p,
531                    coh,
532                    ten
533                );
534            }
535        }
536    }
537
538    #[test]
539    fn test_permeability_quietly_beloved_range() {
540        let p_zero = permeability(0.0, 0.3, SocialPhase::QuietlyBeloved);
541        assert!((p_zero - 0.5).abs() < f32::EPSILON, "got {}", p_zero);
542
543        let p_max = permeability(1.0, 0.3, SocialPhase::QuietlyBeloved);
544        assert!((p_max - 1.0).abs() < f32::EPSILON, "got {}", p_max);
545
546        let p_mid = permeability(0.5, 0.3, SocialPhase::QuietlyBeloved);
547        assert!((p_mid - 0.75).abs() < f32::EPSILON, "got {}", p_mid);
548    }
549
550    #[test]
551    fn test_permeability_protective_guardian_range() {
552        let p_zero = permeability(0.0, 0.3, SocialPhase::ProtectiveGuardian);
553        assert!((p_zero - 0.4).abs() < f32::EPSILON, "got {}", p_zero);
554
555        let p_max = permeability(1.0, 0.3, SocialPhase::ProtectiveGuardian);
556        assert!((p_max - 0.6).abs() < f32::EPSILON, "got {}", p_max);
557
558        let p_mid = permeability(0.5, 0.3, SocialPhase::ProtectiveGuardian);
559        assert!((p_mid - 0.5).abs() < f32::EPSILON, "got {}", p_mid);
560    }
561
562    #[test]
563    fn test_permeability_ordering() {
564        let coh = 0.7;
565        let ten = 0.3;
566        let qb = permeability(coh, ten, SocialPhase::QuietlyBeloved);
567        let pg = permeability(coh, ten, SocialPhase::ProtectiveGuardian);
568        let so = permeability(coh, ten, SocialPhase::ShyObserver);
569        let sr = permeability(coh, ten, SocialPhase::StartledRetreat);
570
571        assert!(qb > pg, "QB({}) should be > PG({})", qb, pg);
572        assert!(pg > so, "PG({}) should be > SO({})", pg, so);
573        assert!(so > sr, "SO({}) should be > SR({})", so, sr);
574    }
575
576    #[test]
577    fn test_expression_scale_matches_permeability() {
578        let qb = SocialPhase::QuietlyBeloved.expression_scale();
579        let pg = SocialPhase::ProtectiveGuardian.expression_scale();
580        let so = SocialPhase::ShyObserver.expression_scale();
581        let sr = SocialPhase::StartledRetreat.expression_scale();
582
583        assert!((qb - permeability(0.5, 0.3, SocialPhase::QuietlyBeloved)).abs() < f32::EPSILON);
584        assert!(
585            (pg - permeability(0.5, 0.3, SocialPhase::ProtectiveGuardian)).abs() < f32::EPSILON
586        );
587        assert!((so - permeability(0.5, 0.3, SocialPhase::ShyObserver)).abs() < f32::EPSILON);
588        assert!((sr - permeability(0.5, 0.3, SocialPhase::StartledRetreat)).abs() < f32::EPSILON);
589    }
590
591    // ── NarrationDepth tests ──────────────────────────────────────────────
592
593    #[test]
594    fn test_narration_depth_thresholds() {
595        assert_eq!(NarrationDepth::from_permeability(0.0), NarrationDepth::None);
596        assert_eq!(NarrationDepth::from_permeability(0.19), NarrationDepth::None);
597        assert_eq!(NarrationDepth::from_permeability(0.2), NarrationDepth::Minimal);
598        assert_eq!(NarrationDepth::from_permeability(0.39), NarrationDepth::Minimal);
599        assert_eq!(NarrationDepth::from_permeability(0.4), NarrationDepth::Brief);
600        assert_eq!(NarrationDepth::from_permeability(0.59), NarrationDepth::Brief);
601        assert_eq!(NarrationDepth::from_permeability(0.6), NarrationDepth::Full);
602        assert_eq!(NarrationDepth::from_permeability(0.79), NarrationDepth::Full);
603        assert_eq!(NarrationDepth::from_permeability(0.8), NarrationDepth::Deep);
604        assert_eq!(NarrationDepth::from_permeability(1.0), NarrationDepth::Deep);
605    }
606
607    #[test]
608    fn test_narration_depth_matches_quadrants() {
609        // ShyObserver at max coherence: p=0.3 -> Minimal
610        assert_eq!(
611            NarrationDepth::from_permeability(permeability(1.0, 0.3, SocialPhase::ShyObserver)),
612            NarrationDepth::Minimal
613        );
614        // StartledRetreat: p=0.1 -> None
615        assert_eq!(
616            NarrationDepth::from_permeability(permeability(0.5, 0.5, SocialPhase::StartledRetreat)),
617            NarrationDepth::None
618        );
619        // QuietlyBeloved at max coherence: p=1.0 -> Deep
620        assert_eq!(
621            NarrationDepth::from_permeability(permeability(1.0, 0.1, SocialPhase::QuietlyBeloved)),
622            NarrationDepth::Deep
623        );
624        // ProtectiveGuardian at mid coherence: p=0.5 -> Brief
625        assert_eq!(
626            NarrationDepth::from_permeability(permeability(
627                0.5,
628                0.5,
629                SocialPhase::ProtectiveGuardian
630            )),
631            NarrationDepth::Brief
632        );
633    }
634}