1use hashbrown::HashMap;
30
31use crate::phase::Personality;
32use crate::vocabulary::{ContextKey, SensorVocabulary};
33
34#[derive(Clone, Debug)]
44#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
45pub struct CoherenceAccumulator {
46 pub value: f32,
48 pub interaction_count: u32,
50 pub last_interaction_tick: u64,
52}
53
54impl CoherenceAccumulator {
55 pub fn new() -> Self {
57 Self {
58 value: 0.0,
59 interaction_count: 0,
60 last_interaction_tick: 0,
61 }
62 }
63
64 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 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 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; }
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 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 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
140const MAX_CONTEXTS: usize = 64;
144
145pub struct CoherenceField<V: SensorVocabulary<N>, const N: usize> {
152 accumulators: HashMap<ContextKey<V, N>, CoherenceAccumulator>,
154 personality_baseline: f32,
156 fallback_coherence: Option<f32>,
158}
159
160impl<V: SensorVocabulary<N>, const N: usize> CoherenceField<V, N> {
161 pub fn new() -> Self {
163 Self {
164 accumulators: HashMap::new(),
165 personality_baseline: 0.0,
166 fallback_coherence: None,
167 }
168 }
169
170 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 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 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 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 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 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 pub fn context_count(&self) -> usize {
244 self.accumulators.len()
245 }
246
247 pub fn iter(&self) -> impl Iterator<Item = (&ContextKey<V, N>, &CoherenceAccumulator)> {
249 self.accumulators.iter()
250 }
251
252 #[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 pub fn set_fallback(&mut self, value: Option<f32>) {
274 self.fallback_coherence = value;
275 }
276
277 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#[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 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 #[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 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 let acc = CoherenceAccumulator::new_with_baseline(2.0); 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 #[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 let eff = field.effective_coherence(0.8, &key);
520 assert_eq!(eff, 0.0);
521
522 {
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 let eff = field.effective_coherence(0.8, &key);
539 assert!((eff - ctx_coh).abs() < 0.001);
540
541 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 {
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 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 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 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 assert_eq!(field.context_coherence(&key), 0.0);
681
682 field.set_fallback(Some(0.4));
684 assert!((field.context_coherence(&key) - 0.4).abs() < 0.001);
685
686 {
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 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 let eff = field.effective_coherence(0.2, &key);
714 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 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}