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