Skip to main content

ccf_core/
accumulator.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//! Per-context coherence accumulators and the full coherence field.
13//!
14//! # Patent Claims 2–7, 13
15//!
16//! - [`CoherenceAccumulator`]: per-context trust state with earned floor and asymmetric decay (Claims 2–5).
17//! - [`CoherenceField`]: context-keyed accumulator map with asymmetric min-gate (Claims 6–7, 13).
18//!
19//! # Invariants
20//!
21//! - **CCF-001**: `effective_coherence` uses asymmetric gate:
22//!   - Unfamiliar contexts (ctx < 0.3): `min(instant, ctx)` — earn trust first.
23//!   - Familiar contexts (ctx >= 0.3): `0.3 * instant + 0.7 * ctx` — history buffers noise.
24//! - **CCF-002**: All accumulator values bounded [0.0, 1.0].
25//! - **CCF-003**: Personality modulates deltas, not structure.
26//! - **I-DIST-001**: no_std compatible; uses `hashbrown::HashMap` (no `std` dependency).
27//! - **I-DIST-005**: Zero unsafe code.
28
29use hashbrown::HashMap;
30
31use crate::phase::Personality;
32use crate::vocabulary::{ContextKey, SensorVocabulary};
33
34// ─── Coherence Accumulator ──────────────────────────────────────────────────
35
36/// Per-context coherence accumulator. Grows through repeated positive
37/// interaction, decays with disuse, drops on negative events.
38///
39/// Interaction history builds a protected floor so that earned trust
40/// cannot be erased by transient negative events.
41///
42/// Patent Claims 2–5.
43#[derive(Clone, Debug)]
44#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
45pub struct CoherenceAccumulator {
46    /// Accumulated coherence for this context [0.0, 1.0].
47    pub value: f32,
48    /// Total positive interactions recorded in this context.
49    pub interaction_count: u32,
50    /// Tick of the most recent interaction (positive or negative).
51    pub last_interaction_tick: u64,
52}
53
54impl CoherenceAccumulator {
55    /// Construct a fresh accumulator starting at zero coherence.
56    pub fn new() -> Self {
57        Self {
58            value: 0.0,
59            interaction_count: 0,
60            last_interaction_tick: 0,
61        }
62    }
63
64    /// Cold-start constructor: initialise value from personality `curiosity_drive`.
65    ///
66    /// `curiosity`: personality curiosity_drive in [0.0, 1.0].
67    /// Baseline = 0.15 × curiosity (max 0.15 for curiosity = 1.0).
68    pub fn new_with_baseline(curiosity: f32) -> Self {
69        Self {
70            value: (0.15 * curiosity).clamp(0.0, 1.0),
71            interaction_count: 0,
72            last_interaction_tick: 0,
73        }
74    }
75
76    /// The minimum coherence that interaction history protects against decay or negative events.
77    ///
78    /// Asymptotically approaches 0.5 with repeated interactions — never fully
79    /// immune, but increasingly resilient.
80    ///
81    /// ```text
82    /// floor = 0.5 × (1 − 1 / (1 + count / 20))
83    ///   count =  0 → floor ≈ 0.00
84    ///   count = 20 → floor ≈ 0.25
85    ///   count = 100 → floor ≈ 0.42
86    ///   limit  → 0.50
87    /// ```
88    pub fn earned_floor(&self) -> f32 {
89        0.5 * (1.0 - 1.0 / (1.0 + self.interaction_count as f32 / 20.0))
90    }
91
92    /// Record a positive interaction. Coherence grows asymptotically toward 1.0.
93    ///
94    /// - `recovery_speed`: personality parameter [0.0, 1.0] — higher = faster growth.
95    /// - `tick`: current tick for freshness tracking.
96    /// - `alone`: `true` if presence is Absent — doubles delta for faster bootstrap.
97    pub fn positive_interaction(&mut self, recovery_speed: f32, tick: u64, alone: bool) {
98        let mut delta = 0.02 * (0.5 + recovery_speed) * (1.0 - self.value);
99        if alone {
100            delta *= 2.0; // alone contexts bootstrap faster
101        }
102        self.value = (self.value + delta).min(1.0);
103        self.interaction_count = self.interaction_count.saturating_add(1);
104        self.last_interaction_tick = tick;
105    }
106
107    /// Record a negative interaction (startle, collision, high tension).
108    ///
109    /// The drop is floored at `earned_floor()` so that accumulated trust
110    /// cannot be fully erased by a single negative event.
111    ///
112    /// - `startle_sensitivity`: personality parameter [0.0, 1.0] — higher = bigger drop.
113    /// - `tick`: current tick.
114    pub fn negative_interaction(&mut self, startle_sensitivity: f32, tick: u64) {
115        let floor = self.earned_floor();
116        let delta = 0.05 * (0.5 + startle_sensitivity);
117        self.value = (self.value - delta).max(floor);
118        self.last_interaction_tick = tick;
119    }
120
121    /// Apply time-based decay. Call once per elapsed period.
122    ///
123    /// Coherence decays toward `earned_floor()`, not toward zero.
124    /// More interactions = higher floor = harder to lose earned trust.
125    pub fn decay(&mut self, elapsed_ticks: u64) {
126        let floor = self.earned_floor();
127        if self.value > floor {
128            let decay_rate = 0.0001 * elapsed_ticks as f32;
129            self.value = (self.value - decay_rate).max(floor);
130        }
131    }
132}
133
134impl Default for CoherenceAccumulator {
135    fn default() -> Self {
136        Self::new()
137    }
138}
139
140// ─── Coherence Field ────────────────────────────────────────────────────────
141
142/// Maximum number of tracked contexts. Oldest entry is evicted when full.
143const MAX_CONTEXTS: usize = 64;
144
145/// The coherence field: a map of context → [`CoherenceAccumulator`].
146///
147/// Generic over any sensor vocabulary `V` implementing [`SensorVocabulary<N>`].
148/// Maintains at most [`MAX_CONTEXTS`] entries with LRU eviction.
149///
150/// Patent Claims 6–7, 13.
151pub struct CoherenceField<V: SensorVocabulary<N>, const N: usize> {
152    /// Context-keyed accumulators.
153    accumulators: HashMap<ContextKey<V, N>, CoherenceAccumulator>,
154    /// Personality baseline for new contexts (0.15 × curiosity_drive).
155    personality_baseline: f32,
156    /// Fallback coherence used as floor for unseen contexts in degraded mode.
157    fallback_coherence: Option<f32>,
158}
159
160impl<V: SensorVocabulary<N>, const N: usize> CoherenceField<V, N> {
161    /// Construct a fresh field with no accumulated coherence.
162    pub fn new() -> Self {
163        Self {
164            accumulators: HashMap::new(),
165            personality_baseline: 0.0,
166            fallback_coherence: None,
167        }
168    }
169
170    // ── CCF-001: asymmetric min-gate ───────────────────────────────────────
171
172    /// Compute effective coherence using the asymmetric gate (CCF-001).
173    ///
174    /// - **Unfamiliar** (ctx < 0.3): `min(instant, ctx)` — earn trust first.
175    /// - **Familiar** (ctx ≥ 0.3): `0.3 × instant + 0.7 × ctx` — history buffers noise.
176    pub fn effective_coherence(&self, instant: f32, key: &ContextKey<V, N>) -> f32 {
177        let ctx = self.context_coherence(key);
178        if ctx < 0.3 {
179            if instant < ctx { instant } else { ctx }
180        } else {
181            (0.3 * instant + 0.7 * ctx).clamp(0.0, 1.0)
182        }
183    }
184
185    // ── Interaction API (CCF-003: Personality modulates deltas, not structure) ─
186
187    /// Record a positive interaction for a context, modulated by `personality`.
188    ///
189    /// Creates the accumulator at the personality baseline if the context is unseen.
190    pub fn positive_interaction(
191        &mut self,
192        key: &ContextKey<V, N>,
193        personality: &Personality,
194        tick: u64,
195        alone: bool,
196    ) {
197        self.get_or_create(key)
198            .positive_interaction(personality.recovery_speed, tick, alone);
199    }
200
201    /// Record a negative interaction for a context, modulated by `personality`.
202    ///
203    /// Creates the accumulator at the personality baseline if the context is unseen.
204    pub fn negative_interaction(
205        &mut self,
206        key: &ContextKey<V, N>,
207        personality: &Personality,
208        tick: u64,
209    ) {
210        self.get_or_create(key)
211            .negative_interaction(personality.startle_sensitivity, tick);
212    }
213
214    // ── Read accessors ─────────────────────────────────────────────────────
215
216    /// Get the accumulated coherence for a context.
217    ///
218    /// Returns the accumulator value if seen, or the fallback / 0.0 for unseen contexts.
219    pub fn context_coherence(&self, key: &ContextKey<V, N>) -> f32 {
220        self.accumulators.get(key).map_or_else(
221            || self.fallback_coherence.unwrap_or(0.0),
222            |a| a.value,
223        )
224    }
225
226    /// Number of positive interactions recorded for a context (0 if unseen).
227    pub fn context_interaction_count(&self, key: &ContextKey<V, N>) -> u32 {
228        self.accumulators.get(key).map_or(0, |a| a.interaction_count)
229    }
230
231    // ── Decay ──────────────────────────────────────────────────────────────
232
233    /// Apply time-based decay to all accumulators.
234    pub fn decay_all(&mut self, elapsed_ticks: u64) {
235        for acc in self.accumulators.values_mut() {
236            acc.decay(elapsed_ticks);
237        }
238    }
239
240    // ── Collection helpers ─────────────────────────────────────────────────
241
242    /// Number of tracked contexts.
243    pub fn context_count(&self) -> usize {
244        self.accumulators.len()
245    }
246
247    /// Iterate over all (context key, accumulator) pairs.
248    pub fn iter(&self) -> impl Iterator<Item = (&ContextKey<V, N>, &CoherenceAccumulator)> {
249        self.accumulators.iter()
250    }
251
252    /// All tracked contexts with their coherence value and interaction count,
253    /// sorted by interaction count descending.
254    ///
255    /// Returns `Vec<(key, coherence_value, interaction_count)>`.
256    /// Available only when the `std` feature is enabled (requires heap allocation).
257    #[cfg(feature = "std")]
258    pub fn all_entries(&self) -> std::vec::Vec<(ContextKey<V, N>, f32, u32)> {
259        let mut entries: std::vec::Vec<(ContextKey<V, N>, f32, u32)> = self
260            .accumulators
261            .iter()
262            .map(|(k, acc)| (k.clone(), acc.value, acc.interaction_count))
263            .collect();
264        entries.sort_by(|a, b| b.2.cmp(&a.2));
265        entries
266    }
267
268    // ── Degraded-mode fallback ─────────────────────────────────────────────
269
270    /// Set the fallback coherence returned for unseen contexts in degraded mode.
271    ///
272    /// Pass `None` to clear the fallback (unseen contexts revert to 0.0).
273    pub fn set_fallback(&mut self, value: Option<f32>) {
274        self.fallback_coherence = value;
275    }
276
277    // ── Internal helpers ───────────────────────────────────────────────────
278
279    /// Get or create the accumulator for `key`, initialising at the personality baseline.
280    ///
281    /// Evicts the oldest entry when the field is at [`MAX_CONTEXTS`] capacity.
282    pub fn get_or_create(&mut self, key: &ContextKey<V, N>) -> &mut CoherenceAccumulator {
283        if !self.accumulators.contains_key(key) {
284            if self.accumulators.len() >= MAX_CONTEXTS {
285                self.evict_oldest();
286            }
287            let curiosity = if self.personality_baseline > 0.0 {
288                (self.personality_baseline / 0.15).clamp(0.0, 1.0)
289            } else {
290                0.0
291            };
292            self.accumulators
293                .insert(key.clone(), CoherenceAccumulator::new_with_baseline(curiosity));
294        }
295        self.accumulators.get_mut(key).unwrap()
296    }
297
298    fn evict_oldest(&mut self) {
299        if let Some(oldest_key) = self
300            .accumulators
301            .iter()
302            .min_by_key(|(_, acc)| acc.last_interaction_tick)
303            .map(|(k, _)| k.clone())
304        {
305            self.accumulators.remove(&oldest_key);
306        }
307    }
308}
309
310impl<V: SensorVocabulary<N>, const N: usize> Default for CoherenceField<V, N> {
311    fn default() -> Self {
312        Self::new()
313    }
314}
315
316impl<V: SensorVocabulary<N>, const N: usize> core::fmt::Debug for CoherenceField<V, N> {
317    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
318        f.debug_struct("CoherenceField")
319            .field("context_count", &self.accumulators.len())
320            .field("personality_baseline", &self.personality_baseline)
321            .field("fallback_coherence", &self.fallback_coherence)
322            .finish()
323    }
324}
325
326// ─── Tests ──────────────────────────────────────────────────────────────────
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use crate::mbot::{
332        BrightnessBand, MbotSensors, MotionContext, NoiseBand, Orientation,
333        PresenceSignature, TimePeriod,
334    };
335    use crate::vocabulary::ContextKey;
336
337    // ── Helpers ──────────────────────────────────────────────────────────
338
339    fn make_key(
340        brightness: BrightnessBand,
341        noise: NoiseBand,
342        presence: PresenceSignature,
343    ) -> ContextKey<MbotSensors, 6> {
344        ContextKey::new(MbotSensors {
345            brightness,
346            noise,
347            presence,
348            motion: MotionContext::Static,
349            orientation: Orientation::Upright,
350            time_period: TimePeriod::Day,
351        })
352    }
353
354    fn bright_quiet_static() -> ContextKey<MbotSensors, 6> {
355        make_key(BrightnessBand::Bright, NoiseBand::Quiet, PresenceSignature::Absent)
356    }
357
358    fn dark_loud_close() -> ContextKey<MbotSensors, 6> {
359        make_key(BrightnessBand::Dark, NoiseBand::Loud, PresenceSignature::Close)
360    }
361
362    fn neutral_personality() -> Personality {
363        Personality {
364            curiosity_drive: 0.5,
365            startle_sensitivity: 0.5,
366            recovery_speed: 0.5,
367        }
368    }
369
370    // ── CoherenceAccumulator tests ────────────────────────────────────────
371
372    #[test]
373    fn test_accumulator_positive_growth() {
374        let mut acc = CoherenceAccumulator::new();
375        assert_eq!(acc.value, 0.0);
376
377        for i in 0..50 {
378            acc.positive_interaction(0.5, i, false);
379        }
380        assert!(acc.value > 0.3, "value={}", acc.value);
381        assert!(acc.value < 1.0);
382        assert_eq!(acc.interaction_count, 50);
383    }
384
385    #[test]
386    fn test_accumulator_asymptotic_growth() {
387        let mut acc = CoherenceAccumulator::new();
388        for i in 0..500 {
389            acc.positive_interaction(0.5, i, false);
390        }
391        let high_value = acc.value;
392        for i in 500..510 {
393            acc.positive_interaction(0.5, i, false);
394        }
395        let delta = acc.value - high_value;
396        assert!(delta < 0.01, "delta should be small at high values: {}", delta);
397    }
398
399    #[test]
400    fn test_accumulator_personality_modulation() {
401        let mut fast = CoherenceAccumulator::new();
402        let mut slow = CoherenceAccumulator::new();
403
404        for i in 0..20 {
405            fast.positive_interaction(0.9, i, false);
406            slow.positive_interaction(0.1, i, false);
407        }
408        assert!(
409            fast.value > slow.value,
410            "fast={} should be > slow={}",
411            fast.value,
412            slow.value
413        );
414    }
415
416    #[test]
417    fn test_accumulator_negative_interaction() {
418        let mut acc = CoherenceAccumulator::new();
419        for i in 0..30 {
420            acc.positive_interaction(0.5, i, false);
421        }
422        let before = acc.value;
423        acc.negative_interaction(0.5, 31);
424        assert!(acc.value < before);
425    }
426
427    #[test]
428    fn test_accumulator_earned_floor() {
429        let mut acc = CoherenceAccumulator::new();
430        for i in 0..100 {
431            acc.positive_interaction(0.5, i, false);
432        }
433        let before = acc.value;
434        for i in 100..200 {
435            acc.negative_interaction(1.0, i);
436        }
437        // Floor at 100 interactions ≈ 0.42
438        assert!(
439            acc.value > 0.3,
440            "value={} should be above earned floor",
441            acc.value
442        );
443        assert!(acc.value < before);
444    }
445
446    #[test]
447    fn test_accumulator_decay_toward_floor() {
448        let mut acc = CoherenceAccumulator::new();
449        for i in 0..50 {
450            acc.positive_interaction(0.5, i, false);
451        }
452        let before = acc.value;
453        acc.decay(1000);
454        assert!(acc.value < before);
455        let floor = 0.5 * (1.0 - 1.0 / (1.0 + 50.0 / 20.0));
456        assert!(
457            acc.value >= floor,
458            "value={} should be >= floor={}",
459            acc.value,
460            floor
461        );
462    }
463
464    #[test]
465    fn test_cold_start_baseline() {
466        let acc = CoherenceAccumulator::new_with_baseline(1.0);
467        assert!((acc.value - 0.15).abs() < 0.001, "value={}", acc.value);
468
469        let acc = CoherenceAccumulator::new_with_baseline(0.2);
470        assert!((acc.value - 0.03).abs() < 0.001, "value={}", acc.value);
471
472        let acc = CoherenceAccumulator::new_with_baseline(0.0);
473        assert_eq!(acc.value, 0.0);
474    }
475
476    #[test]
477    fn test_alone_boost() {
478        let mut alone_acc = CoherenceAccumulator::new();
479        let mut social_acc = CoherenceAccumulator::new();
480
481        for i in 0..20 {
482            alone_acc.positive_interaction(0.5, i, true);
483            social_acc.positive_interaction(0.5, i, false);
484        }
485
486        assert!(
487            alone_acc.value > social_acc.value,
488            "alone={} should be > social={}",
489            alone_acc.value,
490            social_acc.value
491        );
492    }
493
494    #[test]
495    fn test_accumulator_value_bounded() {
496        // CCF-002: values always in [0.0, 1.0]
497        let acc = CoherenceAccumulator::new_with_baseline(2.0); // out-of-range curiosity
498        assert!(
499            acc.value >= 0.0 && acc.value <= 1.0,
500            "value={}",
501            acc.value
502        );
503
504        let mut acc = CoherenceAccumulator::new_with_baseline(0.5);
505        for i in 0..1000 {
506            acc.positive_interaction(1.0, i, true);
507        }
508        assert!(acc.value <= 1.0, "value={}", acc.value);
509    }
510
511    // ── CoherenceField tests ──────────────────────────────────────────────
512
513    #[test]
514    fn test_coherence_field_effective_coherence_unfamiliar() {
515        let mut field: CoherenceField<MbotSensors, 6> = CoherenceField::new();
516        let key = bright_quiet_static();
517
518        // New context: ctx = 0.0 → CCF-001 unfamiliar: min(0.8, 0.0) = 0.0
519        let eff = field.effective_coherence(0.8, &key);
520        assert_eq!(eff, 0.0);
521
522        // Build a little coherence (stay under 0.3 threshold)
523        {
524            let acc = field.get_or_create(&key);
525            for i in 0..10 {
526                acc.positive_interaction(0.5, i, false);
527            }
528        }
529        let ctx_coh = field.context_coherence(&key);
530        assert!(ctx_coh > 0.0);
531        assert!(
532            ctx_coh < 0.3,
533            "ctx_coh={} should be < 0.3 for unfamiliar test",
534            ctx_coh
535        );
536
537        // Unfamiliar: min(0.8, ctx) = ctx
538        let eff = field.effective_coherence(0.8, &key);
539        assert!((eff - ctx_coh).abs() < 0.001);
540
541        // Unfamiliar: min(0.05, ctx) = 0.05 when instant < ctx
542        let eff = field.effective_coherence(0.05, &key);
543        assert!((eff - 0.05).abs() < 0.001);
544    }
545
546    #[test]
547    fn test_coherence_field_effective_coherence_familiar() {
548        let mut field: CoherenceField<MbotSensors, 6> = CoherenceField::new();
549        let key = bright_quiet_static();
550
551        // Build up enough coherence to cross 0.3 threshold
552        {
553            let acc = field.get_or_create(&key);
554            for i in 0..80 {
555                acc.positive_interaction(0.5, i, false);
556            }
557        }
558        let ctx_coh = field.context_coherence(&key);
559        assert!(
560            ctx_coh >= 0.3,
561            "ctx_coh={} should be >= 0.3 for familiar test",
562            ctx_coh
563        );
564
565        // Familiar: 0.3 * instant + 0.7 * ctx
566        let eff = field.effective_coherence(0.8, &key);
567        let expected = 0.3 * 0.8 + 0.7 * ctx_coh;
568        assert!(
569            (eff - expected).abs() < 0.001,
570            "eff={} expected={}",
571            eff,
572            expected
573        );
574
575        // Familiar context should buffer against a low instant value
576        let eff_low = field.effective_coherence(0.1, &key);
577        let expected_low = 0.3 * 0.1 + 0.7 * ctx_coh;
578        assert!(
579            (eff_low - expected_low).abs() < 0.001,
580            "eff_low={} expected_low={}",
581            eff_low,
582            expected_low
583        );
584        assert!(
585            eff_low > 0.1,
586            "familiar context should buffer: eff_low={}",
587            eff_low
588        );
589    }
590
591    #[test]
592    fn test_coherence_field_independent_contexts() {
593        let mut field: CoherenceField<MbotSensors, 6> = CoherenceField::new();
594        let key_a = bright_quiet_static();
595        let key_b = dark_loud_close();
596
597        {
598            let acc = field.get_or_create(&key_a);
599            for i in 0..50 {
600                acc.positive_interaction(0.5, i, false);
601            }
602        }
603
604        assert!(field.context_coherence(&key_a) > 0.3);
605        assert_eq!(field.context_coherence(&key_b), 0.0);
606    }
607
608    #[test]
609    fn test_coherence_field_interaction_via_personality() {
610        let mut field: CoherenceField<MbotSensors, 6> = CoherenceField::new();
611        let key = bright_quiet_static();
612        let p = neutral_personality();
613
614        for tick in 0..30 {
615            field.positive_interaction(&key, &p, tick, false);
616        }
617        assert!(field.context_coherence(&key) > 0.0);
618
619        let before = field.context_coherence(&key);
620        field.negative_interaction(&key, &p, 30);
621        assert!(field.context_coherence(&key) < before);
622    }
623
624    #[test]
625    fn test_coherence_field_eviction() {
626        let mut field: CoherenceField<MbotSensors, 6> = CoherenceField::new();
627        // Fill beyond MAX_CONTEXTS using two alternating distinct keys.
628        // We only have a small vocabulary space, so we manipulate tick to force eviction.
629        // First insert MAX_CONTEXTS entries, then insert one more.
630        for i in 0..=MAX_CONTEXTS {
631            let presence = if i % 2 == 0 {
632                PresenceSignature::Absent
633            } else {
634                PresenceSignature::Close
635            };
636            let noise = if i % 3 == 0 {
637                NoiseBand::Quiet
638            } else if i % 3 == 1 {
639                NoiseBand::Moderate
640            } else {
641                NoiseBand::Loud
642            };
643            let brightness = if i % 4 < 2 {
644                BrightnessBand::Bright
645            } else {
646                BrightnessBand::Dark
647            };
648            let key = make_key(brightness, noise, presence);
649            let acc = field.get_or_create(&key);
650            acc.last_interaction_tick = i as u64;
651        }
652        assert!(field.context_count() <= MAX_CONTEXTS);
653    }
654
655    #[test]
656    fn test_coherence_field_decay_all() {
657        let mut field: CoherenceField<MbotSensors, 6> = CoherenceField::new();
658        let key = bright_quiet_static();
659
660        {
661            let acc = field.get_or_create(&key);
662            for i in 0..50 {
663                acc.positive_interaction(0.5, i, false);
664            }
665        }
666        let before = field.context_coherence(&key);
667        field.decay_all(1000);
668        assert!(
669            field.context_coherence(&key) < before,
670            "coherence should decay"
671        );
672    }
673
674    #[test]
675    fn test_coherence_field_fallback() {
676        let mut field: CoherenceField<MbotSensors, 6> = CoherenceField::new();
677        let key = bright_quiet_static();
678
679        // Without fallback, unseen context = 0.0
680        assert_eq!(field.context_coherence(&key), 0.0);
681
682        // With fallback, unseen context = fallback value
683        field.set_fallback(Some(0.4));
684        assert!((field.context_coherence(&key) - 0.4).abs() < 0.001);
685
686        // Seen context still uses its actual value
687        {
688            let acc = field.get_or_create(&key);
689            acc.value = 0.6;
690        }
691        assert!((field.context_coherence(&key) - 0.6).abs() < 0.001);
692
693        // Clear fallback — seen context still works
694        field.set_fallback(None);
695        assert!((field.context_coherence(&key) - 0.6).abs() < 0.001);
696    }
697
698    #[test]
699    fn test_asymmetric_gate_noise_resilience() {
700        let mut field: CoherenceField<MbotSensors, 6> = CoherenceField::new();
701        let key = bright_quiet_static();
702
703        {
704            let acc = field.get_or_create(&key);
705            for i in 0..100 {
706                acc.positive_interaction(0.5, i, false);
707            }
708        }
709        let ctx_coh = field.context_coherence(&key);
710        assert!(ctx_coh >= 0.3, "should be familiar");
711
712        // Simulate a light flicker: instant drops to 0.2
713        let eff = field.effective_coherence(0.2, &key);
714        // Familiar blend: 0.3*0.2 + 0.7*ctx > 0.2
715        assert!(eff > 0.2, "familiar context should buffer noise: eff={}", eff);
716    }
717
718    #[test]
719    fn test_asymmetric_gate_unfamiliar_strict() {
720        let mut field: CoherenceField<MbotSensors, 6> = CoherenceField::new();
721        let key = bright_quiet_static();
722
723        {
724            let acc = field.get_or_create(&key);
725            for i in 0..5 {
726                acc.positive_interaction(0.5, i, false);
727            }
728        }
729        let ctx_coh = field.context_coherence(&key);
730        assert!(ctx_coh < 0.3);
731
732        // High instant doesn't help: min(0.9, ctx) = ctx
733        let eff = field.effective_coherence(0.9, &key);
734        assert!(
735            (eff - ctx_coh).abs() < 0.001,
736            "unfamiliar gate should cap at ctx: eff={} ctx={}",
737            eff,
738            ctx_coh
739        );
740    }
741
742    #[test]
743    fn test_context_interaction_count() {
744        let mut field: CoherenceField<MbotSensors, 6> = CoherenceField::new();
745        let key = bright_quiet_static();
746        let p = neutral_personality();
747
748        assert_eq!(field.context_interaction_count(&key), 0);
749        for tick in 0..5 {
750            field.positive_interaction(&key, &p, tick, false);
751        }
752        assert_eq!(field.context_interaction_count(&key), 5);
753    }
754
755    #[test]
756    fn test_iter_and_context_count() {
757        let mut field: CoherenceField<MbotSensors, 6> = CoherenceField::new();
758        let key_a = bright_quiet_static();
759        let key_b = dark_loud_close();
760        let p = neutral_personality();
761
762        field.positive_interaction(&key_a, &p, 0, false);
763        field.positive_interaction(&key_b, &p, 1, false);
764
765        assert_eq!(field.context_count(), 2);
766        let count = field.iter().count();
767        assert_eq!(count, 2);
768    }
769}