Skip to main content

khive_pack_brain/
event.rs

1use serde::{Deserialize, Serialize};
2use uuid::Uuid;
3
4use khive_storage::event::Event;
5use khive_types::EventOutcome;
6
7/// Feedback signal values for the `brain.feedback` verb (ADR-032 §3).
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
9#[serde(rename_all = "snake_case")]
10pub enum FeedbackSignal {
11    Useful,
12    NotUseful,
13    Wrong,
14}
15
16/// Interpreted brain signal extracted from a raw Event (ADR-032 §4).
17///
18/// `interpret()` is the single mapping layer from the shared event log to
19/// brain-internal signals. No parallel event enum is needed; the Event
20/// substrate IS the source of truth.
21#[derive(Debug)]
22pub enum BrainSignal {
23    /// A recall verb succeeded — positive signal for the recalled entity.
24    RecallHit { target_id: Uuid, latency_us: i64 },
25    /// A recall verb returned no results — miss signal for tuning.
26    RecallMiss,
27    /// A search verb completed.
28    SearchCompleted { latency_us: i64 },
29    /// Explicit feedback on a specific entity, emitted by `brain.feedback`.
30    Feedback {
31        target_id: Uuid,
32        signal: FeedbackSignal,
33        /// Profile that served the event being rated, if known.
34        served_by_profile_id: Option<String>,
35    },
36    /// Any other note-substrate access (get, list on notes).
37    NoteAccessed { target_id: Uuid },
38    /// Event is not relevant to the brain.
39    Irrelevant,
40}
41
42/// Extract a brain signal from a raw storage Event (ADR-032 §4).
43///
44/// `brain.emit` is no longer handled here — it was renamed to `brain.feedback`
45/// per ADR-032 §11 (`brain.feedback` is the `FeedbackExplicit` event emitter).
46/// Any `brain.emit` event that predates this ADR is treated as Irrelevant so
47/// that old event log entries do not cause spurious feedback updates.
48///
49/// To add a new signal source: add one match arm to this function. That is
50/// the entire extension surface (ADR-032 §4).
51pub fn interpret(event: &Event) -> BrainSignal {
52    match event.verb.as_str() {
53        "recall" => match event.outcome {
54            EventOutcome::Success => match event.target_id {
55                Some(tid) => BrainSignal::RecallHit {
56                    target_id: tid,
57                    latency_us: event.duration_us,
58                },
59                None => BrainSignal::RecallMiss,
60            },
61            _ => BrainSignal::RecallMiss,
62        },
63        "search" => BrainSignal::SearchCompleted {
64            latency_us: event.duration_us,
65        },
66        // brain.feedback is the ADR-032 §11 verb for FeedbackExplicit events.
67        // (brain.emit predates this ADR; treated as Irrelevant for old replays.)
68        "brain.feedback" => {
69            let target = match event.target_id {
70                Some(t) => t,
71                None => return BrainSignal::Irrelevant,
72            };
73            let signal = event
74                .payload
75                .get("signal")
76                .and_then(|s| serde_json::from_value::<FeedbackSignal>(s.clone()).ok());
77            let served_by = event
78                .payload
79                .get("served_by_profile_id")
80                .and_then(|v| v.as_str())
81                .map(|s| s.to_owned());
82            match signal {
83                Some(s) => BrainSignal::Feedback {
84                    target_id: target,
85                    signal: s,
86                    served_by_profile_id: served_by,
87                },
88                None => BrainSignal::Irrelevant,
89            }
90        }
91        "get" | "remember" => match event.target_id {
92            Some(tid) => BrainSignal::NoteAccessed { target_id: tid },
93            None => BrainSignal::Irrelevant,
94        },
95        _ => BrainSignal::Irrelevant,
96    }
97}
98
99/// Extract (entity_id, positive_signal) for per-entity posterior updates.
100pub fn entity_signal(signal: &BrainSignal) -> Option<(Uuid, bool)> {
101    match signal {
102        BrainSignal::RecallHit { target_id, .. } => Some((*target_id, true)),
103        BrainSignal::NoteAccessed { target_id } => Some((*target_id, true)),
104        BrainSignal::Feedback {
105            target_id, signal, ..
106        } => Some((*target_id, matches!(signal, FeedbackSignal::Useful))),
107        BrainSignal::RecallMiss | BrainSignal::SearchCompleted { .. } | BrainSignal::Irrelevant => {
108            None
109        }
110    }
111}
112
113/// Is this signal positive for the global recall parameter?
114pub fn is_recall_positive(signal: &BrainSignal) -> Option<bool> {
115    match signal {
116        BrainSignal::RecallHit { .. } => Some(true),
117        BrainSignal::RecallMiss => Some(false),
118        _ => None,
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use khive_types::{EventKind, SubstrateKind};
126
127    fn make_event(verb: &str, outcome: EventOutcome, target: Option<Uuid>) -> Event {
128        let mut e = Event::new("test", verb, EventKind::Audit, SubstrateKind::Note, "brain");
129        e.outcome = outcome;
130        e.target_id = target;
131        e
132    }
133
134    #[test]
135    fn recall_success_with_target_is_hit() {
136        let id = Uuid::new_v4();
137        let e = make_event("recall", EventOutcome::Success, Some(id));
138        match interpret(&e) {
139            BrainSignal::RecallHit { target_id, .. } => assert_eq!(target_id, id),
140            other => panic!("expected RecallHit, got {other:?}"),
141        }
142    }
143
144    #[test]
145    fn recall_success_without_target_is_miss() {
146        let e = make_event("recall", EventOutcome::Success, None);
147        assert!(matches!(interpret(&e), BrainSignal::RecallMiss));
148    }
149
150    #[test]
151    fn recall_error_is_miss() {
152        let e = make_event("recall", EventOutcome::Error, Some(Uuid::new_v4()));
153        assert!(matches!(interpret(&e), BrainSignal::RecallMiss));
154    }
155
156    #[test]
157    fn search_is_completed() {
158        let e = make_event("search", EventOutcome::Success, None);
159        assert!(matches!(interpret(&e), BrainSignal::SearchCompleted { .. }));
160    }
161
162    #[test]
163    fn brain_feedback_with_useful_signal() {
164        let id = Uuid::new_v4();
165        let mut e = make_event("brain.feedback", EventOutcome::Success, Some(id));
166        e.payload = serde_json::json!({"signal": "useful"});
167        match interpret(&e) {
168            BrainSignal::Feedback {
169                target_id,
170                signal,
171                served_by_profile_id,
172            } => {
173                assert_eq!(target_id, id);
174                assert_eq!(signal, FeedbackSignal::Useful);
175                assert!(served_by_profile_id.is_none());
176            }
177            other => panic!("expected Feedback, got {other:?}"),
178        }
179    }
180
181    #[test]
182    fn brain_feedback_with_served_by_profile_id() {
183        let id = Uuid::new_v4();
184        let mut e = make_event("brain.feedback", EventOutcome::Success, Some(id));
185        e.payload = serde_json::json!({
186            "signal": "not_useful",
187            "served_by_profile_id": "balanced-recall-v1"
188        });
189        match interpret(&e) {
190            BrainSignal::Feedback {
191                target_id,
192                signal,
193                served_by_profile_id,
194            } => {
195                assert_eq!(target_id, id);
196                assert_eq!(signal, FeedbackSignal::NotUseful);
197                assert_eq!(served_by_profile_id.as_deref(), Some("balanced-recall-v1"));
198            }
199            other => panic!("expected Feedback, got {other:?}"),
200        }
201    }
202
203    #[test]
204    fn brain_feedback_without_target_is_irrelevant() {
205        let e = make_event("brain.feedback", EventOutcome::Success, None);
206        assert!(matches!(interpret(&e), BrainSignal::Irrelevant));
207    }
208
209    #[test]
210    fn brain_emit_legacy_is_irrelevant() {
211        // brain.emit predates ADR-032; old log entries must not trigger feedback.
212        let id = Uuid::new_v4();
213        let mut e = make_event("brain.emit", EventOutcome::Success, Some(id));
214        e.payload = serde_json::json!({"signal": "useful"});
215        assert!(matches!(interpret(&e), BrainSignal::Irrelevant));
216    }
217
218    #[test]
219    fn unknown_verb_is_irrelevant() {
220        let e = make_event("link", EventOutcome::Success, Some(Uuid::new_v4()));
221        assert!(matches!(interpret(&e), BrainSignal::Irrelevant));
222    }
223
224    #[test]
225    fn entity_signal_for_hit() {
226        let id = Uuid::new_v4();
227        let sig = BrainSignal::RecallHit {
228            target_id: id,
229            latency_us: 100,
230        };
231        assert_eq!(entity_signal(&sig), Some((id, true)));
232    }
233
234    #[test]
235    fn entity_signal_for_miss() {
236        assert_eq!(entity_signal(&BrainSignal::RecallMiss), None);
237    }
238
239    #[test]
240    fn recall_positive_classification() {
241        let hit = BrainSignal::RecallHit {
242            target_id: Uuid::new_v4(),
243            latency_us: 0,
244        };
245        assert_eq!(is_recall_positive(&hit), Some(true));
246        assert_eq!(is_recall_positive(&BrainSignal::RecallMiss), Some(false));
247        assert_eq!(
248            is_recall_positive(&BrainSignal::SearchCompleted { latency_us: 0 }),
249            None
250        );
251    }
252
253    #[test]
254    fn feedback_not_useful_is_negative_entity_signal() {
255        let id = Uuid::new_v4();
256        let sig = BrainSignal::Feedback {
257            target_id: id,
258            signal: FeedbackSignal::NotUseful,
259            served_by_profile_id: None,
260        };
261        assert_eq!(entity_signal(&sig), Some((id, false)));
262    }
263
264    #[test]
265    fn feedback_wrong_is_negative_entity_signal() {
266        let id = Uuid::new_v4();
267        let sig = BrainSignal::Feedback {
268            target_id: id,
269            signal: FeedbackSignal::Wrong,
270            served_by_profile_id: None,
271        };
272        assert_eq!(entity_signal(&sig), Some((id, false)));
273    }
274
275    #[test]
276    fn brain_feedback_invalid_signal_data_is_irrelevant() {
277        let id = Uuid::new_v4();
278        let mut e = make_event("brain.feedback", EventOutcome::Success, Some(id));
279        e.payload = serde_json::json!({"signal": "bad_value"});
280        assert!(matches!(interpret(&e), BrainSignal::Irrelevant));
281    }
282
283    #[test]
284    fn note_accessed_via_get_verb_is_positive_entity_signal() {
285        let id = Uuid::new_v4();
286        let e = make_event("get", EventOutcome::Success, Some(id));
287        match interpret(&e) {
288            BrainSignal::NoteAccessed { target_id } => {
289                assert_eq!(target_id, id);
290                assert_eq!(
291                    entity_signal(&BrainSignal::NoteAccessed { target_id }),
292                    Some((id, true))
293                );
294            }
295            other => panic!("expected NoteAccessed, got {other:?}"),
296        }
297    }
298
299    #[test]
300    fn note_accessed_via_remember_verb_is_positive_entity_signal() {
301        let id = Uuid::new_v4();
302        let e = make_event("remember", EventOutcome::Success, Some(id));
303        match interpret(&e) {
304            BrainSignal::NoteAccessed { target_id } => {
305                assert_eq!(target_id, id);
306            }
307            other => panic!("expected NoteAccessed, got {other:?}"),
308        }
309    }
310}