1use 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#[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#[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 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#[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 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#[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
115pub 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
161pub 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 pub fn apply_signal(&mut self, signal: &BrainSignal) {
224 self.total_events += 1;
225
226 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 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 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 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 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 #[serde(default)]
326 pub entity_posteriors_version: u32,
327 #[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 #[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 #[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 #[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 assert_eq!(
385 snap, restored_snap,
386 "ADR-048 Phase-1: snapshot != restored state"
387 );
388 }
389
390 #[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 #[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}