1use serde::{Deserialize, Serialize};
2use uuid::Uuid;
3
4use khive_storage::event::Event;
5use khive_types::EventOutcome;
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
9#[serde(rename_all = "snake_case")]
10pub enum FeedbackSignal {
11 Useful,
12 NotUseful,
13 Wrong,
14}
15
16#[derive(Debug)]
22pub enum BrainSignal {
23 RecallHit { target_id: Uuid, latency_us: i64 },
25 RecallMiss,
27 SearchCompleted { latency_us: i64 },
29 Feedback {
31 target_id: Uuid,
32 signal: FeedbackSignal,
33 served_by_profile_id: Option<String>,
35 },
36 NoteAccessed { target_id: Uuid },
38 Irrelevant,
40}
41
42pub 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" => {
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
99pub 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
113pub 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 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}