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::mbot::{
331        BrightnessBand, MbotSensors, MotionContext, NoiseBand, Orientation,
332        PresenceSignature, TimePeriod,
333    };
334    use crate::vocabulary::ContextKey;
335
336    // ── Helpers ──────────────────────────────────────────────────────────
337
338    fn make_key(
339        brightness: BrightnessBand,
340        noise: NoiseBand,
341        presence: PresenceSignature,
342    ) -> ContextKey<MbotSensors, 6> {
343        ContextKey::new(MbotSensors {
344            brightness,
345            noise,
346            presence,
347            motion: MotionContext::Static,
348            orientation: Orientation::Upright,
349            time_period: TimePeriod::Day,
350        })
351    }
352
353    fn bright_quiet_static() -> ContextKey<MbotSensors, 6> {
354        make_key(BrightnessBand::Bright, NoiseBand::Quiet, PresenceSignature::Absent)
355    }
356
357    fn dark_loud_close() -> ContextKey<MbotSensors, 6> {
358        make_key(BrightnessBand::Dark, NoiseBand::Loud, PresenceSignature::Close)
359    }
360
361    fn neutral_personality() -> Personality {
362        Personality {
363            curiosity_drive: 0.5,
364            startle_sensitivity: 0.5,
365            recovery_speed: 0.5,
366        }
367    }
368
369    // ── CoherenceAccumulator tests ────────────────────────────────────────
370
371    #[test]
372    fn test_accumulator_positive_growth() {
373        let mut acc = CoherenceAccumulator::new();
374        assert_eq!(acc.value, 0.0);
375
376        for i in 0..50 {
377            acc.positive_interaction(0.5, i, false);
378        }
379        assert!(acc.value > 0.3, "value={}", acc.value);
380        assert!(acc.value < 1.0);
381        assert_eq!(acc.interaction_count, 50);
382    }
383
384    #[test]
385    fn test_accumulator_asymptotic_growth() {
386        let mut acc = CoherenceAccumulator::new();
387        for i in 0..500 {
388            acc.positive_interaction(0.5, i, false);
389        }
390        let high_value = acc.value;
391        for i in 500..510 {
392            acc.positive_interaction(0.5, i, false);
393        }
394        let delta = acc.value - high_value;
395        assert!(delta < 0.01, "delta should be small at high values: {}", delta);
396    }
397
398    #[test]
399    fn test_accumulator_personality_modulation() {
400        let mut fast = CoherenceAccumulator::new();
401        let mut slow = CoherenceAccumulator::new();
402
403        for i in 0..20 {
404            fast.positive_interaction(0.9, i, false);
405            slow.positive_interaction(0.1, i, false);
406        }
407        assert!(
408            fast.value > slow.value,
409            "fast={} should be > slow={}",
410            fast.value,
411            slow.value
412        );
413    }
414
415    #[test]
416    fn test_accumulator_negative_interaction() {
417        let mut acc = CoherenceAccumulator::new();
418        for i in 0..30 {
419            acc.positive_interaction(0.5, i, false);
420        }
421        let before = acc.value;
422        acc.negative_interaction(0.5, 31);
423        assert!(acc.value < before);
424    }
425
426    #[test]
427    fn test_accumulator_earned_floor() {
428        let mut acc = CoherenceAccumulator::new();
429        for i in 0..100 {
430            acc.positive_interaction(0.5, i, false);
431        }
432        let before = acc.value;
433        for i in 100..200 {
434            acc.negative_interaction(1.0, i);
435        }
436        // Floor at 100 interactions ≈ 0.42
437        assert!(
438            acc.value > 0.3,
439            "value={} should be above earned floor",
440            acc.value
441        );
442        assert!(acc.value < before);
443    }
444
445    #[test]
446    fn test_accumulator_decay_toward_floor() {
447        let mut acc = CoherenceAccumulator::new();
448        for i in 0..50 {
449            acc.positive_interaction(0.5, i, false);
450        }
451        let before = acc.value;
452        acc.decay(1000);
453        assert!(acc.value < before);
454        let floor = 0.5 * (1.0 - 1.0 / (1.0 + 50.0 / 20.0));
455        assert!(
456            acc.value >= floor,
457            "value={} should be >= floor={}",
458            acc.value,
459            floor
460        );
461    }
462
463    #[test]
464    fn test_cold_start_baseline() {
465        let acc = CoherenceAccumulator::new_with_baseline(1.0);
466        assert!((acc.value - 0.15).abs() < 0.001, "value={}", acc.value);
467
468        let acc = CoherenceAccumulator::new_with_baseline(0.2);
469        assert!((acc.value - 0.03).abs() < 0.001, "value={}", acc.value);
470
471        let acc = CoherenceAccumulator::new_with_baseline(0.0);
472        assert_eq!(acc.value, 0.0);
473    }
474
475    #[test]
476    fn test_alone_boost() {
477        let mut alone_acc = CoherenceAccumulator::new();
478        let mut social_acc = CoherenceAccumulator::new();
479
480        for i in 0..20 {
481            alone_acc.positive_interaction(0.5, i, true);
482            social_acc.positive_interaction(0.5, i, false);
483        }
484
485        assert!(
486            alone_acc.value > social_acc.value,
487            "alone={} should be > social={}",
488            alone_acc.value,
489            social_acc.value
490        );
491    }
492
493    #[test]
494    fn test_accumulator_value_bounded() {
495        // CCF-002: values always in [0.0, 1.0]
496        let acc = CoherenceAccumulator::new_with_baseline(2.0); // out-of-range curiosity
497        assert!(
498            acc.value >= 0.0 && acc.value <= 1.0,
499            "value={}",
500            acc.value
501        );
502
503        let mut acc = CoherenceAccumulator::new_with_baseline(0.5);
504        for i in 0..1000 {
505            acc.positive_interaction(1.0, i, true);
506        }
507        assert!(acc.value <= 1.0, "value={}", acc.value);
508    }
509
510    // ── CoherenceField tests ──────────────────────────────────────────────
511
512    #[test]
513    fn test_coherence_field_effective_coherence_unfamiliar() {
514        let mut field: CoherenceField<MbotSensors, 6> = CoherenceField::new();
515        let key = bright_quiet_static();
516
517        // New context: ctx = 0.0 → CCF-001 unfamiliar: min(0.8, 0.0) = 0.0
518        let eff = field.effective_coherence(0.8, &key);
519        assert_eq!(eff, 0.0);
520
521        // Build a little coherence (stay under 0.3 threshold)
522        {
523            let acc = field.get_or_create(&key);
524            for i in 0..10 {
525                acc.positive_interaction(0.5, i, false);
526            }
527        }
528        let ctx_coh = field.context_coherence(&key);
529        assert!(ctx_coh > 0.0);
530        assert!(
531            ctx_coh < 0.3,
532            "ctx_coh={} should be < 0.3 for unfamiliar test",
533            ctx_coh
534        );
535
536        // Unfamiliar: min(0.8, ctx) = ctx
537        let eff = field.effective_coherence(0.8, &key);
538        assert!((eff - ctx_coh).abs() < 0.001);
539
540        // Unfamiliar: min(0.05, ctx) = 0.05 when instant < ctx
541        let eff = field.effective_coherence(0.05, &key);
542        assert!((eff - 0.05).abs() < 0.001);
543    }
544
545    #[test]
546    fn test_coherence_field_effective_coherence_familiar() {
547        let mut field: CoherenceField<MbotSensors, 6> = CoherenceField::new();
548        let key = bright_quiet_static();
549
550        // Build up enough coherence to cross 0.3 threshold
551        {
552            let acc = field.get_or_create(&key);
553            for i in 0..80 {
554                acc.positive_interaction(0.5, i, false);
555            }
556        }
557        let ctx_coh = field.context_coherence(&key);
558        assert!(
559            ctx_coh >= 0.3,
560            "ctx_coh={} should be >= 0.3 for familiar test",
561            ctx_coh
562        );
563
564        // Familiar: 0.3 * instant + 0.7 * ctx
565        let eff = field.effective_coherence(0.8, &key);
566        let expected = 0.3 * 0.8 + 0.7 * ctx_coh;
567        assert!(
568            (eff - expected).abs() < 0.001,
569            "eff={} expected={}",
570            eff,
571            expected
572        );
573
574        // Familiar context should buffer against a low instant value
575        let eff_low = field.effective_coherence(0.1, &key);
576        let expected_low = 0.3 * 0.1 + 0.7 * ctx_coh;
577        assert!(
578            (eff_low - expected_low).abs() < 0.001,
579            "eff_low={} expected_low={}",
580            eff_low,
581            expected_low
582        );
583        assert!(
584            eff_low > 0.1,
585            "familiar context should buffer: eff_low={}",
586            eff_low
587        );
588    }
589
590    #[test]
591    fn test_coherence_field_independent_contexts() {
592        let mut field: CoherenceField<MbotSensors, 6> = CoherenceField::new();
593        let key_a = bright_quiet_static();
594        let key_b = dark_loud_close();
595
596        {
597            let acc = field.get_or_create(&key_a);
598            for i in 0..50 {
599                acc.positive_interaction(0.5, i, false);
600            }
601        }
602
603        assert!(field.context_coherence(&key_a) > 0.3);
604        assert_eq!(field.context_coherence(&key_b), 0.0);
605    }
606
607    #[test]
608    fn test_coherence_field_interaction_via_personality() {
609        let mut field: CoherenceField<MbotSensors, 6> = CoherenceField::new();
610        let key = bright_quiet_static();
611        let p = neutral_personality();
612
613        for tick in 0..30 {
614            field.positive_interaction(&key, &p, tick, false);
615        }
616        assert!(field.context_coherence(&key) > 0.0);
617
618        let before = field.context_coherence(&key);
619        field.negative_interaction(&key, &p, 30);
620        assert!(field.context_coherence(&key) < before);
621    }
622
623    #[test]
624    fn test_coherence_field_eviction() {
625        let mut field: CoherenceField<MbotSensors, 6> = CoherenceField::new();
626        // Fill beyond MAX_CONTEXTS using two alternating distinct keys.
627        // We only have a small vocabulary space, so we manipulate tick to force eviction.
628        // First insert MAX_CONTEXTS entries, then insert one more.
629        for i in 0..=MAX_CONTEXTS {
630            let presence = if i % 2 == 0 {
631                PresenceSignature::Absent
632            } else {
633                PresenceSignature::Close
634            };
635            let noise = if i % 3 == 0 {
636                NoiseBand::Quiet
637            } else if i % 3 == 1 {
638                NoiseBand::Moderate
639            } else {
640                NoiseBand::Loud
641            };
642            let brightness = if i % 4 < 2 {
643                BrightnessBand::Bright
644            } else {
645                BrightnessBand::Dark
646            };
647            let key = make_key(brightness, noise, presence);
648            let acc = field.get_or_create(&key);
649            acc.last_interaction_tick = i as u64;
650        }
651        assert!(field.context_count() <= MAX_CONTEXTS);
652    }
653
654    #[test]
655    fn test_coherence_field_decay_all() {
656        let mut field: CoherenceField<MbotSensors, 6> = CoherenceField::new();
657        let key = bright_quiet_static();
658
659        {
660            let acc = field.get_or_create(&key);
661            for i in 0..50 {
662                acc.positive_interaction(0.5, i, false);
663            }
664        }
665        let before = field.context_coherence(&key);
666        field.decay_all(1000);
667        assert!(
668            field.context_coherence(&key) < before,
669            "coherence should decay"
670        );
671    }
672
673    #[test]
674    fn test_coherence_field_fallback() {
675        let mut field: CoherenceField<MbotSensors, 6> = CoherenceField::new();
676        let key = bright_quiet_static();
677
678        // Without fallback, unseen context = 0.0
679        assert_eq!(field.context_coherence(&key), 0.0);
680
681        // With fallback, unseen context = fallback value
682        field.set_fallback(Some(0.4));
683        assert!((field.context_coherence(&key) - 0.4).abs() < 0.001);
684
685        // Seen context still uses its actual value
686        {
687            let acc = field.get_or_create(&key);
688            acc.value = 0.6;
689        }
690        assert!((field.context_coherence(&key) - 0.6).abs() < 0.001);
691
692        // Clear fallback — seen context still works
693        field.set_fallback(None);
694        assert!((field.context_coherence(&key) - 0.6).abs() < 0.001);
695    }
696
697    #[test]
698    fn test_asymmetric_gate_noise_resilience() {
699        let mut field: CoherenceField<MbotSensors, 6> = CoherenceField::new();
700        let key = bright_quiet_static();
701
702        {
703            let acc = field.get_or_create(&key);
704            for i in 0..100 {
705                acc.positive_interaction(0.5, i, false);
706            }
707        }
708        let ctx_coh = field.context_coherence(&key);
709        assert!(ctx_coh >= 0.3, "should be familiar");
710
711        // Simulate a light flicker: instant drops to 0.2
712        let eff = field.effective_coherence(0.2, &key);
713        // Familiar blend: 0.3*0.2 + 0.7*ctx > 0.2
714        assert!(eff > 0.2, "familiar context should buffer noise: eff={}", eff);
715    }
716
717    #[test]
718    fn test_asymmetric_gate_unfamiliar_strict() {
719        let mut field: CoherenceField<MbotSensors, 6> = CoherenceField::new();
720        let key = bright_quiet_static();
721
722        {
723            let acc = field.get_or_create(&key);
724            for i in 0..5 {
725                acc.positive_interaction(0.5, i, false);
726            }
727        }
728        let ctx_coh = field.context_coherence(&key);
729        assert!(ctx_coh < 0.3);
730
731        // High instant doesn't help: min(0.9, ctx) = ctx
732        let eff = field.effective_coherence(0.9, &key);
733        assert!(
734            (eff - ctx_coh).abs() < 0.001,
735            "unfamiliar gate should cap at ctx: eff={} ctx={}",
736            eff,
737            ctx_coh
738        );
739    }
740
741    #[test]
742    fn test_context_interaction_count() {
743        let mut field: CoherenceField<MbotSensors, 6> = CoherenceField::new();
744        let key = bright_quiet_static();
745        let p = neutral_personality();
746
747        assert_eq!(field.context_interaction_count(&key), 0);
748        for tick in 0..5 {
749            field.positive_interaction(&key, &p, tick, false);
750        }
751        assert_eq!(field.context_interaction_count(&key), 5);
752    }
753
754    #[test]
755    fn test_iter_and_context_count() {
756        let mut field: CoherenceField<MbotSensors, 6> = CoherenceField::new();
757        let key_a = bright_quiet_static();
758        let key_b = dark_loud_close();
759        let p = neutral_personality();
760
761        field.positive_interaction(&key_a, &p, 0, false);
762        field.positive_interaction(&key_b, &p, 1, false);
763
764        assert_eq!(field.context_count(), 2);
765        let count = field.iter().count();
766        assert_eq!(count, 2);
767    }
768}