Skip to main content

khive_pack_brain/
lib.rs

1pub mod event;
2pub mod fold;
3pub mod state;
4pub mod tunable;
5
6use std::sync::Mutex;
7
8use async_trait::async_trait;
9use chrono::Utc;
10use serde::Deserialize;
11use serde_json::{json, Value};
12
13use khive_fold::{Fold, FoldContext};
14use khive_runtime::pack::PackRuntime;
15use khive_runtime::{
16    DispatchHook, EventView, KhiveRuntime, NamespaceToken, RuntimeError, VerbRegistry,
17};
18use khive_storage::event::{Event, EventFilter};
19use khive_storage::types::PageRequest;
20use khive_types::{HandlerDef, Pack, VerbCategory, Visibility};
21
22use crate::fold::BalancedRecallFold;
23use crate::state::{BrainState, ProfileBinding, ProfileLifecycle, ProfileRecord};
24
25const ENTITY_CACHE_CAPACITY: usize = 10_000;
26
27// ── Handler table ─────────────────────────────────────────────────────────────
28
29/// Brain pack verb surface per ADR-032 §11.
30///
31/// Visibility::Verb  = exposed on the MCP `request` tool.
32/// Visibility::Subhandler = internal / operator-only.
33///
34/// ADR-025: illocutionary classification applied.
35static BRAIN_HANDLERS: &[HandlerDef] = &[
36    // ── Assertive (read) verbs ────────────────────────────────────────────
37    HandlerDef {
38        name: "brain.state",
39        description: "Return current BrainState snapshot for inspection",
40        visibility: Visibility::Subhandler,
41        category: VerbCategory::Assertive,
42    },
43    HandlerDef {
44        name: "brain.config",
45        description: "Return projected config for a named pack parameter",
46        visibility: Visibility::Subhandler,
47        category: VerbCategory::Assertive,
48    },
49    HandlerDef {
50        name: "brain.events",
51        description: "List recent brain-relevant events for debugging",
52        visibility: Visibility::Subhandler,
53        category: VerbCategory::Assertive,
54    },
55    HandlerDef {
56        name: "brain.profiles",
57        description: "List profiles, optionally filtered by lifecycle",
58        visibility: Visibility::Verb,
59        category: VerbCategory::Assertive,
60    },
61    HandlerDef {
62        name: "brain.profile",
63        description: "Profile metadata, latest snapshot, current state summary",
64        visibility: Visibility::Verb,
65        category: VerbCategory::Assertive,
66    },
67    HandlerDef {
68        name: "brain.resolve",
69        description: "Show which profile would serve a caller context",
70        visibility: Visibility::Verb,
71        category: VerbCategory::Assertive,
72    },
73    // ── Commissive (write state) verbs ────────────────────────────────────
74    HandlerDef {
75        name: "brain.activate",
76        description: "Move a profile to Active (start live update loop)",
77        visibility: Visibility::Verb,
78        category: VerbCategory::Commissive,
79    },
80    HandlerDef {
81        name: "brain.deactivate",
82        description: "Move a profile to Inactive (stop live updates, retain state)",
83        visibility: Visibility::Verb,
84        category: VerbCategory::Commissive,
85    },
86    HandlerDef {
87        name: "brain.archive",
88        description: "Move a profile to Archived (read-only, audit-retained)",
89        visibility: Visibility::Verb,
90        category: VerbCategory::Declaration,
91    },
92    HandlerDef {
93        name: "brain.reset",
94        description: "Reset posteriors to priors (preserves event history)",
95        visibility: Visibility::Verb,
96        category: VerbCategory::Declaration,
97    },
98    HandlerDef {
99        name: "brain.feedback",
100        description: "Emit a FeedbackExplicit event into the shared log",
101        visibility: Visibility::Verb,
102        category: VerbCategory::Commissive,
103    },
104    // ── Declaration verbs ─────────────────────────────────────────────────
105    HandlerDef {
106        name: "brain.bind",
107        description: "Write a row in the profile resolution table",
108        visibility: Visibility::Verb,
109        category: VerbCategory::Declaration,
110    },
111    HandlerDef {
112        name: "brain.unbind",
113        description: "Remove rows from the profile resolution table",
114        visibility: Visibility::Verb,
115        category: VerbCategory::Declaration,
116    },
117    // ── Legacy / internal ─────────────────────────────────────────────────
118    HandlerDef {
119        name: "brain.emit",
120        description: "Manually emit a feedback event (deprecated; use brain.feedback)",
121        visibility: Visibility::Subhandler,
122        category: VerbCategory::Commissive,
123    },
124];
125
126// ── BrainPack ─────────────────────────────────────────────────────────────────
127
128/// Brain pack — profile-oriented auto-tuning (ADR-032).
129///
130/// `BrainState` holds the profile registry. `BalancedRecallFold` drives the
131/// v1 default profile. The old scalar `BrainState` design is superseded; see
132/// ADR-032 §1 and the migration notes in `state.rs`.
133pub struct BrainPack {
134    runtime: KhiveRuntime,
135    /// Profile registry + active balanced-recall state.
136    state: Mutex<BrainState>,
137    /// Fold for the built-in `balanced-recall-v1` profile.
138    fold: BalancedRecallFold,
139}
140
141impl Pack for BrainPack {
142    const NAME: &'static str = "brain";
143    const NOTE_KINDS: &'static [&'static str] = &[];
144    const ENTITY_KINDS: &'static [&'static str] = &[];
145    const HANDLERS: &'static [HandlerDef] = BRAIN_HANDLERS;
146    const REQUIRES: &'static [&'static str] = &["kg"];
147}
148
149impl BrainPack {
150    pub fn new(runtime: KhiveRuntime) -> Self {
151        let fold = BalancedRecallFold::new(ENTITY_CACHE_CAPACITY);
152        let state = BrainState::new(ENTITY_CACHE_CAPACITY);
153        Self {
154            runtime,
155            state: Mutex::new(state),
156            fold,
157        }
158    }
159
160    /// Public snapshot of the current `BrainState`.
161    pub fn snapshot(&self) -> crate::state::BrainStateSnapshot {
162        self.state.lock().unwrap().to_snapshot()
163    }
164
165    // ── brain.state ───────────────────────────────────────────────────────
166
167    async fn handle_state(&self, _params: Value) -> Result<Value, RuntimeError> {
168        let state = self.state.lock().unwrap();
169        let snapshot = state.to_snapshot();
170        serde_json::to_value(&snapshot).map_err(|e| RuntimeError::InvalidInput(e.to_string()))
171    }
172
173    // ── brain.config ──────────────────────────────────────────────────────
174
175    async fn handle_config(&self, params: Value) -> Result<Value, RuntimeError> {
176        #[derive(Deserialize)]
177        struct ConfigParams {
178            parameter: Option<String>,
179        }
180        let p: ConfigParams = serde_json::from_value(params)
181            .map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
182
183        let state = self.state.lock().unwrap();
184        let br = &state.balanced_recall;
185
186        let param_map = [
187            ("recall::relevance_weight", &br.relevance),
188            ("recall::importance_weight", &br.importance),
189            ("recall::temporal_weight", &br.temporal),
190        ];
191
192        match p.parameter {
193            Some(key) => {
194                let posterior = param_map
195                    .iter()
196                    .find(|(k, _)| *k == key)
197                    .map(|(_, p)| *p)
198                    .ok_or_else(|| {
199                        RuntimeError::NotFound(format!(
200                            "parameter {key:?}; valid: {}",
201                            param_map
202                                .iter()
203                                .map(|(k, _)| *k)
204                                .collect::<Vec<_>>()
205                                .join(", ")
206                        ))
207                    })?;
208                Ok(json!({
209                    "parameter": key,
210                    "mean": posterior.mean(),
211                    "variance": posterior.variance(),
212                    "ess": posterior.effective_sample_size(),
213                    "alpha": posterior.alpha,
214                    "beta": posterior.beta,
215                }))
216            }
217            None => {
218                let configs: serde_json::Map<String, Value> = param_map
219                    .iter()
220                    .map(|(k, p)| {
221                        (
222                            (*k).to_owned(),
223                            json!({
224                                "mean": p.mean(),
225                                "variance": p.variance(),
226                                "ess": p.effective_sample_size(),
227                            }),
228                        )
229                    })
230                    .collect();
231                Ok(Value::Object(configs))
232            }
233        }
234    }
235
236    // ── brain.events ──────────────────────────────────────────────────────
237
238    async fn handle_events(
239        &self,
240        token: &NamespaceToken,
241        params: Value,
242    ) -> Result<Value, RuntimeError> {
243        #[derive(Deserialize)]
244        struct EventsParams {
245            limit: Option<u32>,
246        }
247        let p: EventsParams = serde_json::from_value(params)
248            .map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
249
250        let limit = p.limit.unwrap_or(20).min(100);
251        let ns = token.namespace().as_str().to_string();
252
253        let store = self.runtime.events(token)?;
254        let filter = EventFilter {
255            verbs: vec![
256                "recall".into(),
257                "search".into(),
258                "brain.feedback".into(),
259                "brain.emit".into(), // retained for backward-compat queries
260                "get".into(),
261                "remember".into(),
262            ],
263            ..EventFilter::default()
264        };
265        let _ = ns;
266        let page = store
267            .query_events(filter, PageRequest { offset: 0, limit })
268            .await
269            .map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
270
271        let events: Vec<Value> = page
272            .items
273            .iter()
274            .map(|e| {
275                json!({
276                    "id": e.id.to_string(),
277                    "verb": e.verb,
278                    "outcome": e.outcome,
279                    "target_id": e.target_id.map(|t| t.to_string()),
280                    "duration_us": e.duration_us,
281                    "created_at": e.created_at,
282                    "payload": e.payload,
283                })
284            })
285            .collect();
286
287        Ok(json!({
288            "count": events.len(),
289            "events": events,
290        }))
291    }
292
293    // ── brain.profiles ────────────────────────────────────────────────────
294
295    async fn handle_profiles(&self, params: Value) -> Result<Value, RuntimeError> {
296        #[derive(Deserialize)]
297        struct ProfilesParams {
298            lifecycle: Option<String>,
299        }
300        let p: ProfilesParams = serde_json::from_value(params)
301            .map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
302
303        let state = self.state.lock().unwrap();
304        let filter_lc: Option<ProfileLifecycle> = p
305            .lifecycle
306            .as_deref()
307            .map(|s| serde_json::from_value(Value::String(s.to_owned())))
308            .transpose()
309            .map_err(|e| RuntimeError::InvalidInput(format!("invalid lifecycle: {e}")))?;
310
311        let profiles: Vec<&ProfileRecord> = state
312            .profiles
313            .values()
314            .filter(|r| filter_lc.as_ref().is_none_or(|lc| &r.lifecycle == lc))
315            .collect();
316
317        let items: Vec<Value> = profiles
318            .iter()
319            .map(|r| {
320                json!({
321                    "id": r.id,
322                    "description": r.description,
323                    "consumer_kind": r.consumer_kind,
324                    "state_class": r.state_class,
325                    "lifecycle": r.lifecycle,
326                    "total_events": r.total_events,
327                    "exploration_epoch": r.exploration_epoch,
328                    "created_at": r.created_at,
329                })
330            })
331            .collect();
332
333        Ok(json!({ "count": items.len(), "profiles": items }))
334    }
335
336    // ── brain.profile ─────────────────────────────────────────────────────
337
338    async fn handle_profile(&self, params: Value) -> Result<Value, RuntimeError> {
339        #[derive(Deserialize)]
340        struct ProfileParams {
341            id: String,
342        }
343        let p: ProfileParams = serde_json::from_value(params)
344            .map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
345
346        let state = self.state.lock().unwrap();
347        let record = state
348            .profiles
349            .get(&p.id)
350            .ok_or_else(|| RuntimeError::NotFound(format!("profile {:?}", p.id)))?;
351
352        Ok(json!({
353            "id": record.id,
354            "description": record.description,
355            "consumer_kind": record.consumer_kind,
356            "state_class": record.state_class,
357            "lifecycle": record.lifecycle,
358            "total_events": record.total_events,
359            "exploration_epoch": record.exploration_epoch,
360            "created_at": record.created_at,
361            "state_snapshot": record.state_snapshot,
362        }))
363    }
364
365    // ── brain.resolve ─────────────────────────────────────────────────────
366
367    async fn handle_resolve(&self, params: Value) -> Result<Value, RuntimeError> {
368        #[derive(Deserialize)]
369        struct ResolveParams {
370            actor: Option<String>,
371            namespace: Option<String>,
372            consumer_kind: String,
373        }
374        let p: ResolveParams = serde_json::from_value(params)
375            .map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
376
377        let state = self.state.lock().unwrap();
378        match state.resolve(p.actor.as_deref(), p.namespace.as_deref(), &p.consumer_kind) {
379            Some(record) => Ok(json!({
380                "resolved_profile_id": record.id,
381                "lifecycle": record.lifecycle,
382                "consumer_kind": record.consumer_kind,
383            })),
384            None => Err(RuntimeError::NotFound(format!(
385                "no profile resolved for consumer_kind={:?}",
386                p.consumer_kind
387            ))),
388        }
389    }
390
391    // ── brain.activate / deactivate / archive ─────────────────────────────
392
393    async fn handle_activate(&self, params: Value) -> Result<Value, RuntimeError> {
394        self.set_lifecycle(params, ProfileLifecycle::Active).await
395    }
396
397    async fn handle_deactivate(&self, params: Value) -> Result<Value, RuntimeError> {
398        self.set_lifecycle(params, ProfileLifecycle::Inactive).await
399    }
400
401    async fn handle_archive(&self, params: Value) -> Result<Value, RuntimeError> {
402        self.set_lifecycle(params, ProfileLifecycle::Archived).await
403    }
404
405    async fn set_lifecycle(
406        &self,
407        params: Value,
408        lifecycle: ProfileLifecycle,
409    ) -> Result<Value, RuntimeError> {
410        #[derive(Deserialize)]
411        struct LifecycleParams {
412            profile_id: String,
413        }
414        let p: LifecycleParams = serde_json::from_value(params)
415            .map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
416
417        let mut state = self.state.lock().unwrap();
418        let record = state
419            .profiles
420            .get_mut(&p.profile_id)
421            .ok_or_else(|| RuntimeError::NotFound(format!("profile {:?}", p.profile_id)))?;
422
423        record.lifecycle = lifecycle.clone();
424        Ok(json!({
425            "profile_id": p.profile_id,
426            "lifecycle": lifecycle,
427        }))
428    }
429
430    // ── brain.reset ───────────────────────────────────────────────────────
431
432    async fn handle_reset(&self, _params: Value) -> Result<Value, RuntimeError> {
433        let mut state = self.state.lock().unwrap();
434        state.reset_posteriors();
435        Ok(json!({
436            "reset": true,
437            "exploration_epoch": state.balanced_recall.exploration_epoch,
438        }))
439    }
440
441    // ── brain.feedback ────────────────────────────────────────────────────
442
443    async fn handle_feedback(
444        &self,
445        token: &NamespaceToken,
446        params: Value,
447    ) -> Result<Value, RuntimeError> {
448        #[derive(Deserialize)]
449        struct FeedbackParams {
450            target_id: String,
451            signal: String,
452            served_by_profile_id: Option<String>,
453        }
454        let p: FeedbackParams = serde_json::from_value(params)
455            .map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
456
457        let target: uuid::Uuid = p
458            .target_id
459            .parse()
460            .map_err(|e| RuntimeError::InvalidInput(format!("invalid target_id: {e}")))?;
461
462        let signal = match p.signal.as_str() {
463            "useful" => "useful",
464            "not_useful" => "not_useful",
465            "wrong" => "wrong",
466            other => {
467                return Err(RuntimeError::InvalidInput(format!(
468                    "unknown signal {other:?}; valid: useful | not_useful | wrong"
469                )))
470            }
471        };
472
473        let mut data = json!({"signal": signal});
474        if let Some(ref profile_id) = p.served_by_profile_id {
475            data["served_by_profile_id"] = json!(profile_id);
476        }
477
478        let event = Event::new(
479            token.namespace().as_str().to_string(),
480            "brain.feedback",
481            khive_types::EventKind::FeedbackExplicit,
482            khive_types::SubstrateKind::Event,
483            "brain",
484        )
485        .with_target(target)
486        .with_payload(data);
487
488        let store = self.runtime.events(token)?;
489        store
490            .append_event(event.clone())
491            .await
492            .map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
493
494        // Update balanced-recall profile state from this event
495        let ctx = FoldContext::new();
496        let mut state = self.state.lock().unwrap();
497        let current_recall = std::mem::replace(
498            &mut state.balanced_recall,
499            crate::state::BalancedRecallState::new(0),
500        );
501        let updated = self.fold.reduce(current_recall, &event, &ctx);
502        state.balanced_recall = updated;
503
504        // Sync profile record metadata — collect values first to avoid borrow conflict.
505        let total_ev = state.balanced_recall.total_events;
506        let snap_val = serde_json::to_value(state.balanced_recall.to_snapshot()).ok();
507        if let Some(record) = state.profiles.get_mut("balanced-recall-v1") {
508            record.total_events = total_ev;
509            record.state_snapshot = snap_val;
510        }
511
512        Ok(json!({
513            "emitted": true,
514            "event_id": event.id.to_string(),
515            "verb": "brain.feedback",
516            "signal": signal,
517            "target_id": target.to_string(),
518        }))
519    }
520
521    // ── brain.emit (deprecated) ───────────────────────────────────────────
522
523    /// Deprecated: use `brain.feedback`. Kept for backward-compat; routes to
524    /// `handle_feedback` with the same parameters.
525    async fn handle_emit(
526        &self,
527        token: &NamespaceToken,
528        params: Value,
529    ) -> Result<Value, RuntimeError> {
530        self.handle_feedback(token, params).await
531    }
532
533    // ── brain.bind ────────────────────────────────────────────────────────
534
535    async fn handle_bind(&self, params: Value) -> Result<Value, RuntimeError> {
536        #[derive(Deserialize)]
537        struct BindParams {
538            profile_id: String,
539            actor: Option<String>,
540            namespace: Option<String>,
541            consumer_kind: Option<String>,
542            priority: Option<i32>,
543        }
544        let p: BindParams = serde_json::from_value(params)
545            .map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
546
547        let mut state = self.state.lock().unwrap();
548
549        // Verify the profile exists
550        if !state.profiles.contains_key(&p.profile_id) {
551            return Err(RuntimeError::NotFound(format!(
552                "profile {:?}",
553                p.profile_id
554            )));
555        }
556
557        let actor = p.actor.unwrap_or_else(|| "*".into());
558        let namespace = p.namespace.unwrap_or_else(|| "*".into());
559        let consumer_kind = p.consumer_kind.unwrap_or_else(|| "*".into());
560
561        // Validate that '*' is not used as a real value (ADR-032 §10 wildcard sentinel)
562        for (field, val) in [
563            ("actor", &actor),
564            ("namespace", &namespace),
565            ("consumer_kind", &consumer_kind),
566        ] {
567            if val.as_str() != "*" && val.contains('*') {
568                return Err(RuntimeError::InvalidInput(format!(
569                    "{field}: '*' is reserved as the wildcard sentinel and cannot appear inside a real value"
570                )));
571            }
572        }
573
574        // Remove any existing binding for the same (actor, namespace, consumer_kind)
575        state.bindings.retain(|b| {
576            !(b.actor == actor && b.namespace == namespace && b.consumer_kind == consumer_kind)
577        });
578
579        state.bindings.push(ProfileBinding {
580            actor: actor.clone(),
581            namespace: namespace.clone(),
582            consumer_kind: consumer_kind.clone(),
583            profile_id: p.profile_id.clone(),
584            priority: p.priority.unwrap_or(0),
585            created_at: Utc::now(),
586        });
587
588        Ok(json!({
589            "bound": true,
590            "profile_id": p.profile_id,
591            "actor": actor,
592            "namespace": namespace,
593            "consumer_kind": consumer_kind,
594        }))
595    }
596
597    // ── brain.unbind ──────────────────────────────────────────────────────
598
599    async fn handle_unbind(&self, params: Value) -> Result<Value, RuntimeError> {
600        #[derive(Deserialize)]
601        struct UnbindParams {
602            profile_id: Option<String>,
603            actor: Option<String>,
604            namespace: Option<String>,
605            consumer_kind: Option<String>,
606        }
607        let p: UnbindParams = serde_json::from_value(params)
608            .map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
609
610        let mut state = self.state.lock().unwrap();
611        let before = state.bindings.len();
612
613        state.bindings.retain(|b| {
614            let pid_match = p.profile_id.as_ref().is_none_or(|id| &b.profile_id == id);
615            let actor_match = p.actor.as_ref().is_none_or(|a| &b.actor == a);
616            let ns_match = p.namespace.as_ref().is_none_or(|n| &b.namespace == n);
617            let kind_match = p
618                .consumer_kind
619                .as_ref()
620                .is_none_or(|k| &b.consumer_kind == k);
621            // Retain if this binding does NOT match ALL of the provided filters.
622            // A filter that is absent (None) matches everything — only bindings
623            // satisfying every supplied criterion are removed.
624            !(pid_match && actor_match && ns_match && kind_match)
625        });
626
627        let removed = before - state.bindings.len();
628        Ok(json!({ "unbound": removed }))
629    }
630}
631
632// ── Inventory self-registration ───────────────────────────────────────────────
633
634struct BrainPackFactory;
635
636impl khive_runtime::PackFactory for BrainPackFactory {
637    fn name(&self) -> &'static str {
638        "brain"
639    }
640
641    fn requires(&self) -> &'static [&'static str] {
642        &["kg"]
643    }
644
645    fn create(&self, runtime: KhiveRuntime) -> Box<dyn PackRuntime> {
646        Box::new(BrainPack::new(runtime))
647    }
648}
649
650inventory::submit! { khive_runtime::PackRegistration(&BrainPackFactory) }
651
652// ── PackRuntime impl ──────────────────────────────────────────────────────────
653
654#[async_trait]
655impl PackRuntime for BrainPack {
656    fn name(&self) -> &str {
657        <BrainPack as Pack>::NAME
658    }
659
660    fn note_kinds(&self) -> &'static [&'static str] {
661        <BrainPack as Pack>::NOTE_KINDS
662    }
663
664    fn entity_kinds(&self) -> &'static [&'static str] {
665        <BrainPack as Pack>::ENTITY_KINDS
666    }
667
668    fn handlers(&self) -> &'static [HandlerDef] {
669        BRAIN_HANDLERS
670    }
671
672    fn requires(&self) -> &'static [&'static str] {
673        <BrainPack as Pack>::REQUIRES
674    }
675
676    async fn dispatch(
677        &self,
678        verb: &str,
679        params: Value,
680        _registry: &VerbRegistry,
681        token: &NamespaceToken,
682    ) -> Result<Value, RuntimeError> {
683        match verb {
684            // Assertive
685            "brain.state" => self.handle_state(params).await,
686            "brain.config" => self.handle_config(params).await,
687            "brain.events" => self.handle_events(token, params).await,
688            "brain.profiles" => self.handle_profiles(params).await,
689            "brain.profile" => self.handle_profile(params).await,
690            "brain.resolve" => self.handle_resolve(params).await,
691            // Commissive
692            "brain.activate" => self.handle_activate(params).await,
693            "brain.deactivate" => self.handle_deactivate(params).await,
694            "brain.archive" => self.handle_archive(params).await,
695            "brain.reset" => self.handle_reset(params).await,
696            "brain.feedback" => self.handle_feedback(token, params).await,
697            // Declaration
698            "brain.bind" => self.handle_bind(params).await,
699            "brain.unbind" => self.handle_unbind(params).await,
700            // Legacy
701            "brain.emit" => self.handle_emit(token, params).await,
702            _ => Err(RuntimeError::InvalidInput(format!(
703                "brain pack does not handle verb {verb:?}"
704            ))),
705        }
706    }
707}
708
709// ── DispatchHook impl ─────────────────────────────────────────────────────────
710
711/// `BrainPack` as a post-dispatch hook.
712///
713/// When registered via `VerbRegistryBuilder::with_dispatch_hook`, every
714/// successful verb dispatch calls `on_dispatch` with a synthesized `Event`.
715/// The event is fed into `BalancedRecallFold::reduce`, updating the brain's
716/// posteriors in real time — no polling required.
717#[async_trait]
718impl DispatchHook for BrainPack {
719    async fn on_dispatch(&self, view: &EventView) {
720        let ctx = FoldContext::new();
721        let mut state = self.state.lock().unwrap();
722        let current = std::mem::replace(
723            &mut state.balanced_recall,
724            crate::state::BalancedRecallState::new(0),
725        );
726        let updated = self.fold.reduce(current, &view.event, &ctx);
727        state.balanced_recall = updated;
728    }
729}
730
731// ── Tests ─────────────────────────────────────────────────────────────────────
732
733#[cfg(test)]
734mod tests {
735    use super::*;
736    use khive_runtime::{Namespace, VerbRegistryBuilder};
737    use serde_json::json;
738
739    fn make_pack() -> (BrainPack, KhiveRuntime) {
740        let rt = KhiveRuntime::memory().expect("in-memory runtime");
741        let pack = BrainPack::new(rt.clone());
742        (pack, rt)
743    }
744
745    fn empty_registry() -> VerbRegistry {
746        VerbRegistryBuilder::new()
747            .build()
748            .expect("empty registry builds successfully")
749    }
750
751    #[tokio::test]
752    async fn dispatch_unknown_verb_returns_invalid_input() {
753        let (pack, rt) = make_pack();
754        let registry = empty_registry();
755        let err = pack
756            .dispatch(
757                "brain.unknown",
758                json!({}),
759                &registry,
760                &rt.authorize(Namespace::local()),
761            )
762            .await
763            .unwrap_err();
764        if let RuntimeError::InvalidInput(msg) = &err {
765            assert!(
766                msg.contains("brain.unknown"),
767                "expected verb name in error: {msg}"
768            );
769        } else {
770            panic!("expected InvalidInput, got {err:?}");
771        }
772    }
773
774    #[tokio::test]
775    async fn dispatch_reset_returns_true_and_increments_epoch() {
776        let (pack, rt) = make_pack();
777        let registry = empty_registry();
778        let result = pack
779            .dispatch(
780                "brain.reset",
781                json!({}),
782                &registry,
783                &rt.authorize(Namespace::local()),
784            )
785            .await
786            .unwrap();
787        assert_eq!(result["reset"], json!(true));
788        assert_eq!(result["exploration_epoch"], json!(1u64));
789    }
790
791    #[tokio::test]
792    async fn dispatch_feedback_invalid_signal_returns_invalid_input() {
793        let (pack, rt) = make_pack();
794        let registry = empty_registry();
795        let target = "00000000-0000-0000-0000-000000000001";
796        let err = pack
797            .dispatch(
798                "brain.feedback",
799                json!({"target_id": target, "signal": "bad_signal"}),
800                &registry,
801                &rt.authorize(Namespace::local()),
802            )
803            .await
804            .unwrap_err();
805        if let RuntimeError::InvalidInput(msg) = &err {
806            assert!(
807                msg.contains("bad_signal"),
808                "expected signal name in error: {msg}"
809            );
810            assert!(
811                msg.contains("valid"),
812                "expected hint about valid values: {msg}"
813            );
814        } else {
815            panic!("expected InvalidInput, got {err:?}");
816        }
817    }
818
819    #[tokio::test]
820    async fn dispatch_state_returns_snapshot_fields() {
821        let (pack, rt) = make_pack();
822        let registry = empty_registry();
823        let result = pack
824            .dispatch(
825                "brain.state",
826                json!({}),
827                &registry,
828                &rt.authorize(Namespace::local()),
829            )
830            .await
831            .unwrap();
832        assert!(result.get("profiles").is_some(), "missing profiles");
833        assert!(
834            result.get("balanced_recall").is_some(),
835            "missing balanced_recall"
836        );
837        assert!(result.get("bindings").is_some(), "missing bindings");
838    }
839
840    #[tokio::test]
841    async fn dispatch_profiles_returns_default_profile() {
842        let (pack, rt) = make_pack();
843        let registry = empty_registry();
844        let result = pack
845            .dispatch(
846                "brain.profiles",
847                json!({}),
848                &registry,
849                &rt.authorize(Namespace::local()),
850            )
851            .await
852            .unwrap();
853        let profiles = result["profiles"].as_array().unwrap();
854        assert!(!profiles.is_empty(), "expected at least one profile");
855        assert_eq!(profiles[0]["id"], json!("balanced-recall-v1"));
856    }
857
858    #[tokio::test]
859    async fn dispatch_profiles_filtered_by_lifecycle() {
860        let (pack, rt) = make_pack();
861        let registry = empty_registry();
862        let result = pack
863            .dispatch(
864                "brain.profiles",
865                json!({"lifecycle": "active"}),
866                &registry,
867                &rt.authorize(Namespace::local()),
868            )
869            .await
870            .unwrap();
871        let profiles = result["profiles"].as_array().unwrap();
872        for p in profiles {
873            assert_eq!(p["lifecycle"], json!("active"));
874        }
875    }
876
877    #[tokio::test]
878    async fn dispatch_profile_returns_profile_details() {
879        let (pack, rt) = make_pack();
880        let registry = empty_registry();
881        let result = pack
882            .dispatch(
883                "brain.profile",
884                json!({"id": "balanced-recall-v1"}),
885                &registry,
886                &rt.authorize(Namespace::local()),
887            )
888            .await
889            .unwrap();
890        assert_eq!(result["id"], json!("balanced-recall-v1"));
891        assert_eq!(result["state_class"], json!("Bayesian"));
892        assert_eq!(result["consumer_kind"], json!("recall"));
893    }
894
895    #[tokio::test]
896    async fn dispatch_profile_not_found_returns_not_found() {
897        let (pack, rt) = make_pack();
898        let registry = empty_registry();
899        let err = pack
900            .dispatch(
901                "brain.profile",
902                json!({"id": "nonexistent"}),
903                &registry,
904                &rt.authorize(Namespace::local()),
905            )
906            .await
907            .unwrap_err();
908        assert!(matches!(err, RuntimeError::NotFound(_)));
909    }
910
911    #[tokio::test]
912    async fn dispatch_resolve_returns_default_profile_for_recall() {
913        let (pack, rt) = make_pack();
914        let registry = empty_registry();
915        let result = pack
916            .dispatch(
917                "brain.resolve",
918                json!({"consumer_kind": "recall"}),
919                &registry,
920                &rt.authorize(Namespace::local()),
921            )
922            .await
923            .unwrap();
924        assert_eq!(result["resolved_profile_id"], json!("balanced-recall-v1"));
925    }
926
927    #[tokio::test]
928    async fn dispatch_activate_and_deactivate_profile() {
929        let (pack, rt) = make_pack();
930        let registry = empty_registry();
931        let token = rt.authorize(Namespace::local());
932
933        // Deactivate the default profile
934        let result = pack
935            .dispatch(
936                "brain.deactivate",
937                json!({"profile_id": "balanced-recall-v1"}),
938                &registry,
939                &token,
940            )
941            .await
942            .unwrap();
943        assert_eq!(result["lifecycle"], json!("inactive"));
944
945        // Verify via brain.profile
946        let state = pack
947            .dispatch(
948                "brain.profile",
949                json!({"id": "balanced-recall-v1"}),
950                &registry,
951                &token,
952            )
953            .await
954            .unwrap();
955        assert_eq!(state["lifecycle"], json!("inactive"));
956
957        // Reactivate
958        let result = pack
959            .dispatch(
960                "brain.activate",
961                json!({"profile_id": "balanced-recall-v1"}),
962                &registry,
963                &token,
964            )
965            .await
966            .unwrap();
967        assert_eq!(result["lifecycle"], json!("active"));
968    }
969
970    #[tokio::test]
971    async fn dispatch_archive_profile() {
972        let (pack, rt) = make_pack();
973        let registry = empty_registry();
974        let result = pack
975            .dispatch(
976                "brain.archive",
977                json!({"profile_id": "balanced-recall-v1"}),
978                &registry,
979                &rt.authorize(Namespace::local()),
980            )
981            .await
982            .unwrap();
983        assert_eq!(result["lifecycle"], json!("archived"));
984    }
985
986    #[tokio::test]
987    async fn dispatch_activate_nonexistent_profile_returns_not_found() {
988        let (pack, rt) = make_pack();
989        let registry = empty_registry();
990        let err = pack
991            .dispatch(
992                "brain.activate",
993                json!({"profile_id": "ghost-profile"}),
994                &registry,
995                &rt.authorize(Namespace::local()),
996            )
997            .await
998            .unwrap_err();
999        assert!(matches!(err, RuntimeError::NotFound(_)));
1000    }
1001
1002    #[tokio::test]
1003    async fn dispatch_bind_and_resolve_explicit_binding() {
1004        let (pack, rt) = make_pack();
1005        let registry = empty_registry();
1006        let token = rt.authorize(Namespace::local());
1007
1008        // Bind balanced-recall-v1 for actor "agent-x"
1009        let result = pack
1010            .dispatch(
1011                "brain.bind",
1012                json!({
1013                    "profile_id": "balanced-recall-v1",
1014                    "actor": "agent-x",
1015                    "consumer_kind": "recall"
1016                }),
1017                &registry,
1018                &token,
1019            )
1020            .await
1021            .unwrap();
1022        assert_eq!(result["bound"], json!(true));
1023        assert_eq!(result["actor"], json!("agent-x"));
1024
1025        // Resolve — should return the explicitly bound profile
1026        let resolved = pack
1027            .dispatch(
1028                "brain.resolve",
1029                json!({"actor": "agent-x", "consumer_kind": "recall"}),
1030                &registry,
1031                &token,
1032            )
1033            .await
1034            .unwrap();
1035        assert_eq!(resolved["resolved_profile_id"], json!("balanced-recall-v1"));
1036    }
1037
1038    #[tokio::test]
1039    async fn dispatch_bind_nonexistent_profile_returns_not_found() {
1040        let (pack, rt) = make_pack();
1041        let registry = empty_registry();
1042        let err = pack
1043            .dispatch(
1044                "brain.bind",
1045                json!({"profile_id": "ghost", "consumer_kind": "recall"}),
1046                &registry,
1047                &rt.authorize(Namespace::local()),
1048            )
1049            .await
1050            .unwrap_err();
1051        assert!(matches!(err, RuntimeError::NotFound(_)));
1052    }
1053
1054    #[tokio::test]
1055    async fn dispatch_unbind_removes_binding() {
1056        let (pack, rt) = make_pack();
1057        let registry = empty_registry();
1058        let token = rt.authorize(Namespace::local());
1059
1060        // Add a binding
1061        pack.dispatch(
1062            "brain.bind",
1063            json!({"profile_id": "balanced-recall-v1", "actor": "agent-y", "consumer_kind": "recall"}),
1064            &registry,
1065            &token,
1066        )
1067        .await
1068        .unwrap();
1069
1070        // Remove it
1071        let result = pack
1072            .dispatch(
1073                "brain.unbind",
1074                json!({"actor": "agent-y"}),
1075                &registry,
1076                &token,
1077            )
1078            .await
1079            .unwrap();
1080        assert_eq!(result["unbound"], json!(1u64));
1081    }
1082
1083    // Regression test for MAJ-002: unbind with multiple filters must use AND semantics,
1084    // removing only the binding that satisfies ALL supplied criteria.
1085    #[tokio::test]
1086    async fn dispatch_unbind_uses_and_not_or() {
1087        let (pack, rt) = make_pack();
1088        let registry = empty_registry();
1089        let token = rt.authorize(Namespace::local());
1090
1091        // binding 1: ns=A, profile=P1 (the one we want to remove)
1092        pack.dispatch(
1093            "brain.bind",
1094            json!({"profile_id": "balanced-recall-v1", "namespace": "ns-a", "consumer_kind": "recall"}),
1095            &registry,
1096            &token,
1097        )
1098        .await
1099        .unwrap();
1100
1101        // binding 2: ns=B, profile=P1 (must survive)
1102        pack.dispatch(
1103            "brain.bind",
1104            json!({"profile_id": "balanced-recall-v1", "namespace": "ns-b", "consumer_kind": "recall"}),
1105            &registry,
1106            &token,
1107        )
1108        .await
1109        .unwrap();
1110
1111        // Unbind using both filters: only binding-1 should be removed
1112        let result = pack
1113            .dispatch(
1114                "brain.unbind",
1115                json!({"namespace": "ns-a", "profile_id": "balanced-recall-v1"}),
1116                &registry,
1117                &token,
1118            )
1119            .await
1120            .unwrap();
1121        assert_eq!(
1122            result["unbound"],
1123            json!(1u64),
1124            "should remove exactly one binding"
1125        );
1126
1127        // binding-2 (ns-b) must still exist
1128        let state = pack.state.lock().unwrap();
1129        let remaining: Vec<_> = state
1130            .bindings
1131            .iter()
1132            .filter(|b| b.namespace == "ns-b")
1133            .collect();
1134        assert_eq!(remaining.len(), 1, "ns-b binding must survive the unbind");
1135    }
1136
1137    #[tokio::test]
1138    async fn dispatch_config_all_parameters() {
1139        let (pack, rt) = make_pack();
1140        let registry = empty_registry();
1141        let result = pack
1142            .dispatch(
1143                "brain.config",
1144                json!({}),
1145                &registry,
1146                &rt.authorize(Namespace::local()),
1147            )
1148            .await
1149            .unwrap();
1150        let obj = result.as_object().unwrap();
1151        assert!(obj.contains_key("recall::relevance_weight"));
1152        assert!(obj.contains_key("recall::importance_weight"));
1153        assert!(obj.contains_key("recall::temporal_weight"));
1154    }
1155
1156    #[tokio::test]
1157    async fn dispatch_config_single_parameter() {
1158        let (pack, rt) = make_pack();
1159        let registry = empty_registry();
1160        let result = pack
1161            .dispatch(
1162                "brain.config",
1163                json!({"parameter": "recall::relevance_weight"}),
1164                &registry,
1165                &rt.authorize(Namespace::local()),
1166            )
1167            .await
1168            .unwrap();
1169        assert_eq!(result["parameter"], json!("recall::relevance_weight"));
1170        // Prior is Beta(7,3): mean = 0.7
1171        let mean = result["mean"].as_f64().unwrap();
1172        assert!((mean - 0.7).abs() < 1e-6);
1173    }
1174}