Skip to main content

khive_pack_brain/
state.rs

1use std::collections::{HashMap, VecDeque};
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7// ── BetaPosterior ─────────────────────────────────────────────────────────────
8
9/// Beta-Binomial posterior for a single parameter.
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
11pub struct BetaPosterior {
12    pub alpha: f64,
13    pub beta: f64,
14}
15
16impl BetaPosterior {
17    pub fn new(alpha: f64, beta: f64) -> Self {
18        Self { alpha, beta }
19    }
20
21    pub fn mean(&self) -> f64 {
22        self.alpha / (self.alpha + self.beta)
23    }
24
25    pub fn variance(&self) -> f64 {
26        let n = self.alpha + self.beta;
27        (self.alpha * self.beta) / (n * n * (n + 1.0))
28    }
29
30    pub fn effective_sample_size(&self) -> f64 {
31        self.alpha + self.beta
32    }
33
34    pub fn update_success(&mut self) {
35        self.alpha += 1.0;
36    }
37
38    pub fn update_failure(&mut self) {
39        self.beta += 1.0;
40    }
41
42    /// Combine evidence from two independent observers sharing the same prior.
43    ///
44    /// merged = Beta(a₁ + a₂ − a_prior, b₁ + b₂ − b_prior)
45    pub fn merge(&self, other: &BetaPosterior, prior: &BetaPosterior) -> BetaPosterior {
46        BetaPosterior {
47            alpha: self.alpha + other.alpha - prior.alpha,
48            beta: self.beta + other.beta - prior.beta,
49        }
50    }
51}
52
53impl Default for BetaPosterior {
54    fn default() -> Self {
55        Self::new(1.0, 1.0)
56    }
57}
58
59// ── EntityPosteriors ──────────────────────────────────────────────────────────
60
61/// Bounded LRU map for per-entity posteriors.
62/// Uses a VecDeque to track insertion order; evicts oldest on insert when full.
63pub struct EntityPosteriors {
64    map: HashMap<Uuid, BetaPosterior>,
65    order: VecDeque<Uuid>,
66    capacity: usize,
67}
68
69impl EntityPosteriors {
70    pub fn new(capacity: usize) -> Self {
71        Self {
72            map: HashMap::with_capacity(capacity),
73            order: VecDeque::with_capacity(capacity),
74            capacity,
75        }
76    }
77
78    pub fn get_or_insert(
79        &mut self,
80        id: Uuid,
81        default: impl FnOnce() -> BetaPosterior,
82    ) -> &mut BetaPosterior {
83        if !self.map.contains_key(&id) {
84            if self.map.len() >= self.capacity {
85                if let Some(evicted) = self.order.pop_front() {
86                    self.map.remove(&evicted);
87                }
88            }
89            self.map.insert(id, default());
90            self.order.push_back(id);
91        }
92        self.map.get_mut(&id).unwrap()
93    }
94
95    pub fn get(&self, id: &Uuid) -> Option<&BetaPosterior> {
96        self.map.get(id)
97    }
98
99    pub fn len(&self) -> usize {
100        self.map.len()
101    }
102
103    pub fn is_empty(&self) -> bool {
104        self.map.is_empty()
105    }
106
107    pub fn clear(&mut self) {
108        self.map.clear();
109        self.order.clear();
110    }
111
112    pub fn to_snapshot(&self) -> HashMap<Uuid, BetaPosterior> {
113        self.map.clone()
114    }
115
116    pub fn from_snapshot(snapshot: HashMap<Uuid, BetaPosterior>, capacity: usize) -> Self {
117        let mut ep = Self::new(capacity);
118        for (id, posterior) in snapshot {
119            ep.map.insert(id, posterior);
120            ep.order.push_back(id);
121        }
122        ep
123    }
124}
125
126// ── BalancedRecallState ───────────────────────────────────────────────────────
127
128/// State for the `BalancedRecallProfile` — the v1 default profile.
129///
130/// Migrated from the predecessor scalar `BrainState` design (ADR-032 §5a).
131/// Three-parameter Beta posteriors with informative priors + per-entity LRU.
132pub struct BalancedRecallState {
133    /// relevance_weight — prior Beta(7,3): warm-starts expecting 70% success
134    pub relevance: BetaPosterior,
135    /// importance_weight — prior Beta(2,8)
136    pub importance: BetaPosterior,
137    /// temporal_weight — prior Beta(1,9)
138    pub temporal: BetaPosterior,
139    /// Per-entity posteriors, bounded LRU (10K default)
140    pub entity_posteriors: EntityPosteriors,
141    /// Total events processed by this profile
142    pub total_events: u64,
143    /// Incremented each time posteriors are reset to priors
144    pub exploration_epoch: u64,
145}
146
147impl BalancedRecallState {
148    pub fn new(entity_capacity: usize) -> Self {
149        Self {
150            relevance: BetaPosterior::new(7.0, 3.0),
151            importance: BetaPosterior::new(2.0, 8.0),
152            temporal: BetaPosterior::new(1.0, 9.0),
153            entity_posteriors: EntityPosteriors::new(entity_capacity),
154            total_events: 0,
155            exploration_epoch: 0,
156        }
157    }
158
159    pub fn reset_posteriors(&mut self) {
160        self.relevance = BetaPosterior::new(7.0, 3.0);
161        self.importance = BetaPosterior::new(2.0, 8.0);
162        self.temporal = BetaPosterior::new(1.0, 9.0);
163        self.entity_posteriors.clear();
164        self.exploration_epoch += 1;
165    }
166
167    pub fn to_snapshot(&self) -> BalancedRecallSnapshot {
168        BalancedRecallSnapshot {
169            relevance: self.relevance.clone(),
170            importance: self.importance.clone(),
171            temporal: self.temporal.clone(),
172            entity_posteriors: self.entity_posteriors.to_snapshot(),
173            total_events: self.total_events,
174            exploration_epoch: self.exploration_epoch,
175        }
176    }
177
178    pub fn from_snapshot(snapshot: BalancedRecallSnapshot, entity_capacity: usize) -> Self {
179        Self {
180            relevance: snapshot.relevance,
181            importance: snapshot.importance,
182            temporal: snapshot.temporal,
183            entity_posteriors: EntityPosteriors::from_snapshot(
184                snapshot.entity_posteriors,
185                entity_capacity,
186            ),
187            total_events: snapshot.total_events,
188            exploration_epoch: snapshot.exploration_epoch,
189        }
190    }
191}
192
193/// Serializable snapshot of `BalancedRecallState`.
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct BalancedRecallSnapshot {
196    pub relevance: BetaPosterior,
197    pub importance: BetaPosterior,
198    pub temporal: BetaPosterior,
199    pub entity_posteriors: HashMap<Uuid, BetaPosterior>,
200    pub total_events: u64,
201    pub exploration_epoch: u64,
202}
203
204// ── ProfileLifecycle ──────────────────────────────────────────────────────────
205
206/// Lifecycle states for a registered profile (ADR-032 §10).
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208#[serde(rename_all = "snake_case")]
209pub enum ProfileLifecycle {
210    /// Profile code and metadata exist; not yet registered with brain.
211    Defined,
212    /// Brain knows about it; backtest-eligible. Not yet in live update loop.
213    Registered,
214    /// Live update loop running; snapshots persist.
215    Active,
216    /// Registered but no live updates. State retained; read-only.
217    Inactive,
218    /// Live updates stopped; snapshots and event log retained for audit.
219    Archived,
220}
221
222// ── ProfileRecord ─────────────────────────────────────────────────────────────
223
224/// Profile metadata stored in the registry (ADR-032 §2).
225#[derive(Debug, Clone, Serialize, Deserialize)]
226pub struct ProfileRecord {
227    pub id: String,
228    pub description: String,
229    pub consumer_kind: String,
230    pub state_class: String,
231    pub lifecycle: ProfileLifecycle,
232    pub created_at: DateTime<Utc>,
233    /// Serialized state snapshot (opaque bytes to brain core)
234    pub state_snapshot: Option<serde_json::Value>,
235    pub total_events: u64,
236    pub exploration_epoch: u64,
237}
238
239impl ProfileRecord {
240    pub fn new_balanced_recall(entity_capacity: usize) -> Self {
241        let state = BalancedRecallState::new(entity_capacity);
242        let snapshot = state.to_snapshot();
243        Self {
244            id: "balanced-recall-v1".into(),
245            description: "Default recall profile: three-scalar Beta posteriors (ADR-032 §5a)"
246                .into(),
247            consumer_kind: "recall".into(),
248            state_class: "Bayesian".into(),
249            lifecycle: ProfileLifecycle::Active,
250            created_at: Utc::now(),
251            state_snapshot: serde_json::to_value(snapshot).ok(),
252            total_events: 0,
253            exploration_epoch: 0,
254        }
255    }
256}
257
258// ── ProfileBinding ────────────────────────────────────────────────────────────
259
260/// One row in the profile binding table (ADR-032 §10).
261///
262/// Resolution uses longest-match wins; `*` is the wildcard sentinel.
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct ProfileBinding {
265    pub actor: String,
266    pub namespace: String,
267    pub consumer_kind: String,
268    pub profile_id: String,
269    pub priority: i32,
270    pub created_at: DateTime<Utc>,
271}
272
273// ── BrainState (profile registry) ────────────────────────────────────────────
274
275/// Runtime brain state — profile registry + active state per profile.
276///
277/// ADR-032 §1: BrainState holds profile registry and lifecycle metadata.
278/// Posteriors live inside each profile's own state, opaque to brain.
279pub struct BrainState {
280    /// Registered profiles indexed by profile_id.
281    pub profiles: HashMap<String, ProfileRecord>,
282    /// In-memory BalancedRecallState for the active default profile.
283    pub balanced_recall: BalancedRecallState,
284    /// Profile binding table — maps (actor, namespace, consumer_kind) → profile_id.
285    pub bindings: Vec<ProfileBinding>,
286}
287
288impl BrainState {
289    pub fn new(entity_capacity: usize) -> Self {
290        let mut profiles = HashMap::new();
291        let record = ProfileRecord::new_balanced_recall(entity_capacity);
292        profiles.insert(record.id.clone(), record);
293        Self {
294            profiles,
295            balanced_recall: BalancedRecallState::new(entity_capacity),
296            bindings: Vec::new(),
297        }
298    }
299
300    pub fn to_snapshot(&self) -> BrainStateSnapshot {
301        BrainStateSnapshot {
302            profiles: self.profiles.clone(),
303            balanced_recall: self.balanced_recall.to_snapshot(),
304            bindings: self.bindings.clone(),
305        }
306    }
307
308    pub fn from_snapshot(snapshot: BrainStateSnapshot, entity_capacity: usize) -> Self {
309        Self {
310            profiles: snapshot.profiles,
311            balanced_recall: BalancedRecallState::from_snapshot(
312                snapshot.balanced_recall,
313                entity_capacity,
314            ),
315            bindings: snapshot.bindings,
316        }
317    }
318
319    /// Reset the balanced-recall profile posteriors to priors.
320    pub fn reset_posteriors(&mut self) {
321        self.balanced_recall.reset_posteriors();
322        if let Some(record) = self.profiles.get_mut("balanced-recall-v1") {
323            record.exploration_epoch = self.balanced_recall.exploration_epoch;
324            record.state_snapshot = serde_json::to_value(self.balanced_recall.to_snapshot()).ok();
325        }
326    }
327
328    /// Resolve a profile_id for the given caller context (ADR-032 §10).
329    ///
330    /// Longest-match wins: actor + namespace + consumer_kind beats actor + consumer_kind
331    /// beats namespace + consumer_kind beats consumer_kind alone. Returns the
332    /// `balanced-recall-v1` default when no explicit binding matches.
333    pub fn resolve(
334        &self,
335        actor: Option<&str>,
336        namespace: Option<&str>,
337        consumer_kind: &str,
338    ) -> Option<&ProfileRecord> {
339        let actor_val = actor.unwrap_or("*");
340        let namespace_val = namespace.unwrap_or("*");
341
342        let best = self
343            .bindings
344            .iter()
345            .filter(|b| {
346                (b.actor == "*" || b.actor == actor_val)
347                    && (b.namespace == "*" || b.namespace == namespace_val)
348                    && (b.consumer_kind == "*" || b.consumer_kind == consumer_kind)
349            })
350            .max_by_key(|b| {
351                let actor_score = if b.actor != "*" { 4 } else { 0 };
352                let ns_score = if b.namespace != "*" { 2 } else { 0 };
353                let kind_score = if b.consumer_kind != "*" { 1 } else { 0 };
354                (
355                    actor_score + ns_score + kind_score,
356                    b.priority,
357                    -(b.created_at.timestamp()),
358                )
359            });
360
361        if let Some(binding) = best {
362            return self.profiles.get(&binding.profile_id);
363        }
364
365        // No explicit binding — return the named default profile if it exists and is
366        // usable, otherwise fall through to any active profile for the consumer_kind.
367        // ADR-032 §10: "balanced-recall-v1" is the v1 system-default for recall.
368        if let Some(default) = self.profiles.get("balanced-recall-v1") {
369            if default.lifecycle == ProfileLifecycle::Active
370                && (default.consumer_kind == consumer_kind
371                    || consumer_kind == "*"
372                    || default.consumer_kind == "*")
373            {
374                return Some(default);
375            }
376        }
377
378        // Generic fallback: first active profile matching consumer_kind.
379        self.profiles
380            .values()
381            .find(|p| p.consumer_kind == consumer_kind && p.lifecycle == ProfileLifecycle::Active)
382    }
383}
384
385/// Serializable snapshot of the full brain state.
386#[derive(Debug, Clone, Serialize, Deserialize)]
387pub struct BrainStateSnapshot {
388    pub profiles: HashMap<String, ProfileRecord>,
389    pub balanced_recall: BalancedRecallSnapshot,
390    pub bindings: Vec<ProfileBinding>,
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    #[test]
398    fn beta_posterior_mean() {
399        let p = BetaPosterior::new(7.0, 3.0);
400        assert!((p.mean() - 0.7).abs() < 1e-12);
401    }
402
403    #[test]
404    fn beta_posterior_variance() {
405        let p = BetaPosterior::new(7.0, 3.0);
406        let expected = 21.0 / 1100.0;
407        assert!((p.variance() - expected).abs() < 1e-12);
408    }
409
410    #[test]
411    fn beta_posterior_ess() {
412        let p = BetaPosterior::new(7.0, 3.0);
413        assert!((p.effective_sample_size() - 10.0).abs() < 1e-12);
414    }
415
416    #[test]
417    fn beta_posterior_update() {
418        let mut p = BetaPosterior::new(1.0, 1.0);
419        p.update_success();
420        p.update_success();
421        p.update_failure();
422        assert!((p.alpha - 3.0).abs() < 1e-12);
423        assert!((p.beta - 2.0).abs() < 1e-12);
424        assert!((p.mean() - 0.6).abs() < 1e-12);
425    }
426
427    #[test]
428    fn beta_posterior_merge() {
429        let prior = BetaPosterior::new(2.0, 8.0);
430        let a = BetaPosterior::new(5.0, 9.0); // prior + 3 success, 1 failure
431        let b = BetaPosterior::new(4.0, 10.0); // prior + 2 success, 2 failure
432        let merged = a.merge(&b, &prior);
433        // merged = (5+4-2, 9+10-8) = (7, 11)
434        assert!((merged.alpha - 7.0).abs() < 1e-12);
435        assert!((merged.beta - 11.0).abs() < 1e-12);
436    }
437
438    #[test]
439    fn entity_posteriors_eviction() {
440        let mut ep = EntityPosteriors::new(3);
441        let ids: Vec<Uuid> = (0..5).map(|_| Uuid::new_v4()).collect();
442        for id in &ids {
443            ep.get_or_insert(*id, BetaPosterior::default);
444        }
445        assert_eq!(ep.len(), 3);
446        assert!(ep.get(&ids[0]).is_none());
447        assert!(ep.get(&ids[1]).is_none());
448        assert!(ep.get(&ids[2]).is_some());
449        assert!(ep.get(&ids[3]).is_some());
450        assert!(ep.get(&ids[4]).is_some());
451    }
452
453    #[test]
454    fn entity_posteriors_get_or_insert_existing() {
455        let mut ep = EntityPosteriors::new(10);
456        let id = Uuid::new_v4();
457        ep.get_or_insert(id, BetaPosterior::default)
458            .update_success();
459        let p = ep.get_or_insert(id, BetaPosterior::default);
460        assert!((p.alpha - 2.0).abs() < 1e-12);
461    }
462
463    #[test]
464    fn balanced_recall_state_snapshot_roundtrip() {
465        let mut state = BalancedRecallState::new(100);
466        state.relevance.update_success();
467        state.total_events = 42;
468        let id = Uuid::new_v4();
469        state
470            .entity_posteriors
471            .get_or_insert(id, BetaPosterior::default)
472            .update_success();
473
474        let snapshot = state.to_snapshot();
475        let json = serde_json::to_string(&snapshot).unwrap();
476        let back: BalancedRecallSnapshot = serde_json::from_str(&json).unwrap();
477        assert_eq!(back.total_events, 42);
478        assert!((back.relevance.alpha - 8.0).abs() < 1e-12);
479        assert!(back.entity_posteriors.contains_key(&id));
480    }
481
482    #[test]
483    fn balanced_recall_state_reset_preserves_epoch_increment() {
484        let mut state = BalancedRecallState::new(10);
485        state.total_events = 100;
486        state.reset_posteriors();
487        assert_eq!(state.total_events, 100);
488        assert_eq!(state.exploration_epoch, 1);
489        assert!((state.relevance.alpha - 7.0).abs() < 1e-12);
490        assert!((state.relevance.beta - 3.0).abs() < 1e-12);
491    }
492
493    #[test]
494    fn brain_state_has_balanced_recall_profile_by_default() {
495        let state = BrainState::new(100);
496        assert!(state.profiles.contains_key("balanced-recall-v1"));
497        let record = &state.profiles["balanced-recall-v1"];
498        assert_eq!(record.lifecycle, ProfileLifecycle::Active);
499        assert_eq!(record.consumer_kind, "recall");
500        assert_eq!(record.state_class, "Bayesian");
501    }
502
503    #[test]
504    fn brain_state_reset_posteriors_updates_record() {
505        let mut state = BrainState::new(10);
506        state.balanced_recall.relevance.update_success();
507        state.balanced_recall.total_events = 50;
508        state.reset_posteriors();
509        assert_eq!(state.balanced_recall.exploration_epoch, 1);
510        let record = &state.profiles["balanced-recall-v1"];
511        assert_eq!(record.exploration_epoch, 1);
512    }
513
514    #[test]
515    fn brain_state_resolve_falls_back_to_default() {
516        let state = BrainState::new(100);
517        let resolved = state.resolve(None, None, "recall");
518        assert!(resolved.is_some());
519        assert_eq!(resolved.unwrap().id, "balanced-recall-v1");
520    }
521
522    #[test]
523    fn brain_state_resolve_uses_explicit_binding() {
524        let mut state = BrainState::new(100);
525        // Add a second profile
526        let mut alt = ProfileRecord::new_balanced_recall(100);
527        alt.id = "alt-profile".into();
528        state.profiles.insert("alt-profile".into(), alt);
529
530        // Bind alt-profile for actor "agent-1"
531        state.bindings.push(ProfileBinding {
532            actor: "agent-1".into(),
533            namespace: "*".into(),
534            consumer_kind: "recall".into(),
535            profile_id: "alt-profile".into(),
536            priority: 0,
537            created_at: Utc::now(),
538        });
539
540        let resolved = state.resolve(Some("agent-1"), None, "recall");
541        assert!(resolved.is_some());
542        assert_eq!(resolved.unwrap().id, "alt-profile");
543
544        // Different actor falls back to default
545        let resolved_other = state.resolve(Some("agent-2"), None, "recall");
546        assert_eq!(resolved_other.unwrap().id, "balanced-recall-v1");
547    }
548
549    // Regression test for MAJ-005: an archived default profile must NOT be returned
550    // by resolve (ADR-032 §10: "Archived … NOT resolvable for live recall").
551    #[test]
552    fn brain_state_resolve_skips_archived_default() {
553        let mut state = BrainState::new(100);
554
555        // Archive the built-in default
556        state
557            .profiles
558            .get_mut("balanced-recall-v1")
559            .expect("default profile always exists")
560            .lifecycle = ProfileLifecycle::Archived;
561
562        // No explicit binding → must not return the archived default
563        let resolved = state.resolve(None, None, "recall");
564        assert!(
565            resolved.is_none(),
566            "archived default profile must not be returned by resolve"
567        );
568    }
569
570    #[test]
571    fn entity_posteriors_from_snapshot_rebuilds_map() {
572        let id1 = Uuid::new_v4();
573        let id2 = Uuid::new_v4();
574        let mut snapshot = HashMap::new();
575        snapshot.insert(id1, BetaPosterior::new(3.0, 2.0));
576        snapshot.insert(id2, BetaPosterior::new(5.0, 1.0));
577
578        let ep = EntityPosteriors::from_snapshot(snapshot, 100);
579        assert_eq!(ep.len(), 2);
580        let p1 = ep.get(&id1).unwrap();
581        assert!((p1.alpha - 3.0).abs() < 1e-12);
582        let p2 = ep.get(&id2).unwrap();
583        assert!((p2.alpha - 5.0).abs() < 1e-12);
584    }
585
586    #[test]
587    fn brain_state_snapshot_roundtrip() {
588        let mut state = BrainState::new(100);
589        state.balanced_recall.relevance.update_success();
590        state.balanced_recall.total_events = 55;
591        state.balanced_recall.exploration_epoch = 2;
592        let id = Uuid::new_v4();
593        state
594            .balanced_recall
595            .entity_posteriors
596            .get_or_insert(id, || BetaPosterior::new(4.0, 6.0))
597            .update_success();
598
599        let snap1 = state.to_snapshot();
600        let restored = BrainState::from_snapshot(snap1, 100);
601        let snap2 = restored.to_snapshot();
602
603        assert_eq!(snap2.balanced_recall.total_events, 55);
604        assert_eq!(snap2.balanced_recall.exploration_epoch, 2);
605        assert!((snap2.balanced_recall.relevance.alpha - 8.0).abs() < 1e-12);
606        let ep = snap2.balanced_recall.entity_posteriors.get(&id).unwrap();
607        assert!((ep.alpha - 5.0).abs() < 1e-12);
608        assert!((ep.beta - 6.0).abs() < 1e-12);
609    }
610
611    #[test]
612    fn profile_lifecycle_serde_roundtrip() {
613        let lc = ProfileLifecycle::Active;
614        let json = serde_json::to_string(&lc).unwrap();
615        let back: ProfileLifecycle = serde_json::from_str(&json).unwrap();
616        assert_eq!(back, ProfileLifecycle::Active);
617    }
618
619    #[test]
620    fn beta_posterior_default_has_uniform_prior() {
621        let p = BetaPosterior::default();
622        assert!((p.alpha - 1.0).abs() < 1e-12);
623        assert!((p.beta - 1.0).abs() < 1e-12);
624        assert!((p.mean() - 0.5).abs() < 1e-12);
625    }
626}