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