Skip to main content

khive_brain_core/
profile.rs

1//! Profile lifecycle, records, bindings, and the BalancedRecall state.
2
3use std::collections::HashMap;
4use std::fmt;
5use std::str::FromStr;
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use uuid::Uuid;
10
11use crate::brain_signal::{entity_signal, is_recall_positive, BrainSignal};
12use crate::posterior::{BetaPosterior, EntityPosteriors};
13use crate::signal::{FeedbackEventKind, FeedbackSignal};
14
15/// Lifecycle states for a registered profile.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum ProfileLifecycle {
19    Defined,
20    Registered,
21    Active,
22    Inactive,
23    Archived,
24}
25
26/// Closed vocabulary of brain profile consumers (ADR-058 amendment, #542).
27/// Adding a new consumer requires adding a variant here — never a bare string
28/// literal at a call site.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum ConsumerKind {
32    Recall,
33    KnowledgeCompose,
34    Rerank,
35}
36
37impl ConsumerKind {
38    /// The exact wire-level `consumer_kind` string for this variant.
39    pub fn as_str(&self) -> &'static str {
40        match self {
41            ConsumerKind::Recall => "recall",
42            ConsumerKind::KnowledgeCompose => "knowledge_compose",
43            ConsumerKind::Rerank => "rerank",
44        }
45    }
46}
47
48impl fmt::Display for ConsumerKind {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        f.write_str(self.as_str())
51    }
52}
53
54impl FromStr for ConsumerKind {
55    type Err = String;
56
57    fn from_str(s: &str) -> Result<Self, Self::Err> {
58        match s {
59            "recall" => Ok(ConsumerKind::Recall),
60            "knowledge_compose" => Ok(ConsumerKind::KnowledgeCompose),
61            "rerank" => Ok(ConsumerKind::Rerank),
62            other => Err(format!("unknown ConsumerKind: {other:?}")),
63        }
64    }
65}
66
67/// Profile metadata stored in the registry.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct ProfileRecord {
70    pub id: String,
71    pub description: String,
72    pub consumer_kind: String,
73    pub state_class: String,
74    pub lifecycle: ProfileLifecycle,
75    pub created_at: DateTime<Utc>,
76    pub state_snapshot: Option<serde_json::Value>,
77    pub total_events: u64,
78    /// Count of `brain.reset` invocations against this profile. Despite the
79    /// name, no exploration schedule exists — nothing else increments it, and
80    /// it stays 0 for profiles that have never been reset. Renaming to
81    /// `reset_epoch` is a versioned output-shape change deferred to the
82    /// posterior-serving lane (ADR-104 follow-up).
83    pub exploration_epoch: u64,
84}
85
86impl ProfileRecord {
87    pub fn new_balanced_recall(entity_capacity: usize) -> Self {
88        let state = BalancedRecallState::new(entity_capacity);
89        let snapshot = state.to_snapshot();
90        Self {
91            id: "balanced-recall-v1".into(),
92            description: "Default recall profile: three-scalar Beta posteriors".into(),
93            consumer_kind: ConsumerKind::Recall.as_str().into(),
94            state_class: "Bayesian".into(),
95            lifecycle: ProfileLifecycle::Active,
96            created_at: Utc::now(),
97            state_snapshot: serde_json::to_value(snapshot).ok(),
98            total_events: 0,
99            exploration_epoch: 0,
100        }
101    }
102}
103
104/// One row in the profile binding table.
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct ProfileBinding {
107    pub actor: String,
108    pub namespace: String,
109    pub consumer_kind: String,
110    pub profile_id: String,
111    pub priority: i32,
112    pub created_at: DateTime<Utc>,
113}
114
115/// Resolve the effective profile for an `(actor, namespace, consumer_kind)`
116/// triple via the brain pack's tier-2 binding table. Returns `None` unless
117/// `brain.resolve` reports `matched_binding=true` — the system-default
118/// fallback profile it returns otherwise must not be mistaken for an explicit
119/// binding, or each pack's tier-3 (global tuning prior) would become
120/// unreachable.
121///
122/// `actor` should be the caller's identity via `NamespaceToken::actor().binding_id()`
123/// so actor-scoped bindings can match; pass `None` for the anonymous caller or
124/// when the call site has no caller identity to thread through (wildcard
125/// `actor="*"` bindings still match in that case). Never pass the anonymous
126/// actor's raw `id` ("local") — it would let an anonymous caller match an
127/// explicit `actor="local"` binding that `None` never can.
128///
129/// Shared by the memory pack (`ConsumerKind::Recall`) and the knowledge pack
130/// (`ConsumerKind::KnowledgeCompose`) — ADR-058 amendment, #542; actor-aware
131/// resolution added by #697.
132pub async fn resolve_consumer_profile(
133    registry: &khive_runtime::VerbRegistry,
134    actor: Option<&str>,
135    namespace: &str,
136    consumer_kind: ConsumerKind,
137) -> Option<String> {
138    let resolve_params = serde_json::json!({
139        "actor": actor,
140        "namespace": namespace,
141        "consumer_kind": consumer_kind.as_str(),
142    });
143    match registry.dispatch("brain.resolve", resolve_params).await {
144        Ok(v) => {
145            let matched_binding = v
146                .get("matched_binding")
147                .and_then(|b| b.as_bool())
148                .unwrap_or(false);
149            if matched_binding {
150                v.get("resolved_profile_id")
151                    .and_then(|id| id.as_str())
152                    .map(str::to_owned)
153            } else {
154                None
155            }
156        }
157        Err(_) => None,
158    }
159}
160
161// ── BalancedRecallState ─────────────────────────────────────────────────────
162
163/// Live Beta-posterior state for the `balanced-recall-v1` profile.
164pub struct BalancedRecallState {
165    pub relevance: BetaPosterior,
166    pub salience: BetaPosterior,
167    pub temporal: BetaPosterior,
168    pub entity_posteriors: EntityPosteriors,
169    pub total_events: u64,
170    pub exploration_epoch: u64,
171}
172
173impl BalancedRecallState {
174    pub fn new(entity_capacity: usize) -> Self {
175        Self {
176            relevance: BetaPosterior::new(7.0, 3.0),
177            salience: BetaPosterior::new(2.0, 8.0),
178            temporal: BetaPosterior::new(1.0, 9.0),
179            entity_posteriors: EntityPosteriors::new(entity_capacity),
180            total_events: 0,
181            exploration_epoch: 0,
182        }
183    }
184
185    pub fn reset_posteriors(&mut self) {
186        self.relevance = BetaPosterior::new(7.0, 3.0);
187        self.salience = BetaPosterior::new(2.0, 8.0);
188        self.temporal = BetaPosterior::new(1.0, 9.0);
189        self.entity_posteriors.clear();
190        self.exploration_epoch += 1;
191    }
192
193    pub fn to_snapshot(&self) -> BalancedRecallSnapshot {
194        BalancedRecallSnapshot {
195            relevance: self.relevance.clone(),
196            salience: self.salience.clone(),
197            temporal: self.temporal.clone(),
198            entity_posteriors: self.entity_posteriors.to_snapshot(),
199            entity_posteriors_version: 1,
200            entity_posterior_order: self.entity_posteriors.order(),
201            total_events: self.total_events,
202            exploration_epoch: self.exploration_epoch,
203        }
204    }
205
206    pub fn from_snapshot(snapshot: BalancedRecallSnapshot, entity_capacity: usize) -> Self {
207        Self {
208            relevance: snapshot.relevance,
209            salience: snapshot.salience,
210            temporal: snapshot.temporal,
211            entity_posteriors: EntityPosteriors::from_snapshot(
212                snapshot.entity_posteriors,
213                snapshot.entity_posterior_order,
214                entity_capacity,
215            ),
216            total_events: snapshot.total_events,
217            exploration_epoch: snapshot.exploration_epoch,
218        }
219    }
220
221    /// Apply a brain signal to update posteriors in place.
222    /// This is the core profile update logic — pure, deterministic, no IO.
223    pub fn apply_signal(&mut self, signal: &BrainSignal) {
224        self.total_events += 1;
225
226        // Global recall-relevance posterior
227        if let Some(positive) = is_recall_positive(signal) {
228            if positive {
229                self.relevance.update_success();
230            } else {
231                self.relevance.update_failure();
232            }
233        }
234
235        // Salience posterior — driven by explicit feedback signal
236        if let BrainSignal::Feedback { signal: ref fb, .. } = signal {
237            match fb {
238                FeedbackSignal::Useful => self.salience.update_success(),
239                FeedbackSignal::NotUseful | FeedbackSignal::Wrong => self.salience.update_failure(),
240            }
241        }
242
243        // Semantic feedback: weighted posterior updates.
244        //
245        // ADR-081 §2: `effective_weight` can be `0.0` when the fold gate clamps
246        // an over-cap implicit event. `update_*_weighted` requires a strictly
247        // positive weight (it asserts), so a zero-weight event still counts
248        // toward `total_events` (above) — it happened and was folded — but
249        // contributes no posterior movement at all, matching "folded at zero
250        // weight" literally rather than passing 0 into the weighted update.
251        if let BrainSignal::SemanticFeedback {
252            event_kind: ref ek,
253            effective_weight,
254            ..
255        } = signal
256        {
257            let w = *effective_weight;
258            if w > 0.0 {
259                if ek.is_positive() {
260                    self.salience.update_success_weighted(w);
261                } else {
262                    self.salience.update_failure_weighted(w);
263                }
264                if *ek == FeedbackEventKind::Correction {
265                    self.relevance.update_failure_weighted(w);
266                }
267            }
268        }
269
270        // Temporal posterior — driven by recall latency
271        const FAST_US: i64 = 50_000;
272        match signal {
273            BrainSignal::RecallHit { latency_us, .. } => {
274                if *latency_us <= FAST_US {
275                    self.temporal.update_success();
276                } else {
277                    self.temporal.update_failure();
278                }
279            }
280            BrainSignal::RecallMiss => self.temporal.update_failure(),
281            _ => {}
282        }
283
284        // Per-entity posterior updates
285        if let BrainSignal::SemanticFeedback {
286            target_id: eid,
287            event_kind: ref ek,
288            effective_weight,
289            ..
290        } = signal
291        {
292            let w = *effective_weight;
293            if w > 0.0 {
294                let posterior = self
295                    .entity_posteriors
296                    .get_or_insert(*eid, || BetaPosterior::new(1.0, 1.0));
297                if ek.is_positive() {
298                    posterior.update_success_weighted(w);
299                } else {
300                    posterior.update_failure_weighted(w);
301                }
302            }
303        } else if let Some((entity_id, positive)) = entity_signal(signal) {
304            let posterior = self
305                .entity_posteriors
306                .get_or_insert(entity_id, || BetaPosterior::new(1.0, 1.0));
307            if positive {
308                posterior.update_success();
309            } else {
310                posterior.update_failure();
311            }
312        }
313    }
314}
315
316#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
317pub struct BalancedRecallSnapshot {
318    pub relevance: BetaPosterior,
319    pub salience: BetaPosterior,
320    pub temporal: BetaPosterior,
321    pub entity_posteriors: HashMap<Uuid, BetaPosterior>,
322    /// Snapshot schema version for `entity_posteriors`/`entity_posterior_order`.
323    /// `0` (the serde default) marks a legacy snapshot with no order metadata;
324    /// `1` marks a snapshot written with `entity_posterior_order` populated.
325    #[serde(default)]
326    pub entity_posteriors_version: u32,
327    /// Eviction order (oldest first) for `entity_posteriors`, as of the write.
328    /// Empty on legacy snapshots — restore falls back to a deterministic
329    /// ascending-`Uuid` compatibility order in that case.
330    #[serde(default)]
331    pub entity_posterior_order: Vec<Uuid>,
332    pub total_events: u64,
333    pub exploration_epoch: u64,
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    /// ADR-058 amendment (#542): each variant's wire string is exact and stable.
341    #[test]
342    fn consumer_kind_as_str_matches_wire_vocabulary() {
343        assert_eq!(ConsumerKind::Recall.as_str(), "recall");
344        assert_eq!(ConsumerKind::KnowledgeCompose.as_str(), "knowledge_compose");
345        assert_eq!(ConsumerKind::Rerank.as_str(), "rerank");
346    }
347
348    /// ADR-058 amendment (#542): `as_str()` and `FromStr` round-trip for every variant.
349    #[test]
350    fn consumer_kind_from_str_round_trips() {
351        for kind in [
352            ConsumerKind::Recall,
353            ConsumerKind::KnowledgeCompose,
354            ConsumerKind::Rerank,
355        ] {
356            assert_eq!(kind.as_str().parse::<ConsumerKind>().unwrap(), kind);
357        }
358    }
359
360    #[test]
361    fn consumer_kind_from_str_rejects_unknown() {
362        assert!("bogus".parse::<ConsumerKind>().is_err());
363    }
364
365    /// ADR-048 Phase-1 gate: profile save/load round-trip must produce identical
366    /// posteriors (snapshot == restored state).
367    #[test]
368    fn adr048_snapshot_roundtrip_equality() {
369        let mut state = BalancedRecallState::new(100);
370        let id = Uuid::new_v4();
371        for _ in 0..50 {
372            state.apply_signal(&crate::brain_signal::BrainSignal::RecallHit {
373                target_id: id,
374                latency_us: 10_000,
375            });
376        }
377        let snap = state.to_snapshot();
378        let restored = BalancedRecallState::from_snapshot(snap.clone(), 100);
379        let restored_snap = restored.to_snapshot();
380
381        // Full structural equality — covers every field including entity_posteriors.
382        // If any field (including a newly-added one) is not preserved by the
383        // round-trip, this assertion will catch it.
384        assert_eq!(
385            snap, restored_snap,
386            "ADR-048 Phase-1: snapshot != restored state"
387        );
388    }
389
390    /// ADR-048 Phase-1 gate: ESS cap convergence must shift the mean by ≥0.3.
391    /// See crates/khive-brain-core/docs/testing-strategy.md#profilersadr048_ess_cap_mean_shift_ge_0_3-adr-048-phase-1-gate
392    #[test]
393    fn adr048_ess_cap_mean_shift_ge_0_3() {
394        let mut state = BalancedRecallState::new(200);
395        let id = Uuid::new_v4();
396
397        for _ in 0..200 {
398            state.apply_signal(&BrainSignal::Feedback {
399                target_id: id,
400                signal: FeedbackSignal::Useful,
401                served_by_profile_id: None,
402                section_signals: None,
403            });
404        }
405        let mean_after_positive = state.salience.mean();
406
407        for _ in 0..200 {
408            state.apply_signal(&BrainSignal::Feedback {
409                target_id: id,
410                signal: FeedbackSignal::NotUseful,
411                served_by_profile_id: None,
412                section_signals: None,
413            });
414        }
415        let mean_after_opposing = state.salience.mean();
416
417        let shift = (mean_after_positive - mean_after_opposing).abs();
418        assert!(
419            shift >= 0.3,
420            "ESS cap convergence: mean shift {shift:.4} < 0.3 (positive={mean_after_positive:.4}, opposing={mean_after_opposing:.4})"
421        );
422    }
423
424    /// BRAINCORE-AUD-001: legacy (version 0) oversized restore is bounded.
425    /// See crates/khive-brain-core/docs/testing-strategy.md#profilerslegacy_entity_posteriors_restore_uses_deterministic_order_and_capacity-braincore-aud-001
426    #[test]
427    fn legacy_entity_posteriors_restore_uses_deterministic_order_and_capacity() {
428        let capacity = 2;
429        let mut ids = vec![
430            Uuid::new_v4(),
431            Uuid::new_v4(),
432            Uuid::new_v4(),
433            Uuid::new_v4(),
434        ];
435        ids.sort();
436
437        let mut entity_posteriors = HashMap::new();
438        for id in &ids {
439            entity_posteriors.insert(*id, BetaPosterior::default());
440        }
441
442        let legacy = BalancedRecallSnapshot {
443            relevance: BetaPosterior::default(),
444            salience: BetaPosterior::default(),
445            temporal: BetaPosterior::default(),
446            entity_posteriors,
447            entity_posteriors_version: 0,
448            entity_posterior_order: Vec::new(),
449            total_events: 0,
450            exploration_epoch: 0,
451        };
452
453        let restored = BalancedRecallState::from_snapshot(legacy, capacity);
454
455        assert_eq!(restored.entity_posteriors.len(), capacity);
456        for id in ids.iter().take(capacity) {
457            assert!(
458                restored.entity_posteriors.get(id).is_some(),
459                "deterministic sort prefix id {id} must be retained"
460            );
461        }
462        for id in ids.iter().skip(capacity) {
463            assert!(
464                restored.entity_posteriors.get(id).is_none(),
465                "id {id} beyond the deterministic sort prefix must be dropped"
466            );
467        }
468    }
469}