1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6use khive_storage::event::Event;
7use khive_types::EventOutcome;
8
9use crate::state::SectionType;
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
13#[serde(rename_all = "snake_case")]
14pub enum FeedbackSignal {
15 Useful,
16 NotUseful,
17 Wrong,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
33#[serde(rename_all = "snake_case")]
34pub enum FeedbackEventKind {
35 ExplicitPositive,
37 ExplicitNegative,
39 ImplicitPositive,
41 ImplicitNegative,
43 Correction,
45}
46
47impl FeedbackEventKind {
48 pub fn update_weight(&self) -> f64 {
54 match self {
55 FeedbackEventKind::Correction => 2.0,
56 FeedbackEventKind::ExplicitPositive | FeedbackEventKind::ExplicitNegative => 1.5,
57 FeedbackEventKind::ImplicitPositive | FeedbackEventKind::ImplicitNegative => 0.5,
58 }
59 }
60
61 pub fn is_positive(&self) -> bool {
63 matches!(
64 self,
65 FeedbackEventKind::ExplicitPositive | FeedbackEventKind::ImplicitPositive
66 )
67 }
68
69 pub fn from_signal_str(s: &str) -> Option<Self> {
75 match s {
76 "explicit_positive" => Some(FeedbackEventKind::ExplicitPositive),
77 "explicit_negative" => Some(FeedbackEventKind::ExplicitNegative),
78 "implicit_positive" => Some(FeedbackEventKind::ImplicitPositive),
79 "implicit_negative" => Some(FeedbackEventKind::ImplicitNegative),
80 "correction" => Some(FeedbackEventKind::Correction),
81 _ => None,
82 }
83 }
84}
85
86#[derive(Debug)]
92pub enum BrainSignal {
93 RecallHit { target_id: Uuid, latency_us: i64 },
95 RecallMiss,
97 SearchCompleted { latency_us: i64 },
99 Feedback {
101 target_id: Uuid,
102 signal: FeedbackSignal,
103 served_by_profile_id: Option<String>,
105 section_signals: Option<HashMap<SectionType, FeedbackSignal>>,
106 },
107 SemanticFeedback {
113 target_id: Uuid,
114 event_kind: FeedbackEventKind,
115 served_by_profile_id: Option<String>,
116 },
117 NoteAccessed { target_id: Uuid },
119 Irrelevant,
121}
122
123pub fn interpret(event: &Event) -> BrainSignal {
133 match event.verb.as_str() {
134 "recall" => match event.outcome {
135 EventOutcome::Success => match event.target_id {
136 Some(tid) => BrainSignal::RecallHit {
137 target_id: tid,
138 latency_us: event.duration_us,
139 },
140 None => BrainSignal::RecallMiss,
141 },
142 _ => BrainSignal::RecallMiss,
143 },
144 "search" => BrainSignal::SearchCompleted {
145 latency_us: event.duration_us,
146 },
147 "brain.feedback" => {
150 let target = match event.target_id {
151 Some(t) => t,
152 None => return BrainSignal::Irrelevant,
153 };
154 let signal_str = event
155 .payload
156 .get("signal")
157 .and_then(|s| s.as_str())
158 .unwrap_or("");
159 let served_by = event
160 .payload
161 .get("served_by_profile_id")
162 .and_then(|v| v.as_str())
163 .map(|s| s.to_owned());
164 let section_signals = event.payload.get("section_signals").and_then(|v| {
165 serde_json::from_value::<HashMap<SectionType, FeedbackSignal>>(v.clone()).ok()
166 });
167
168 if let Some(event_kind) = FeedbackEventKind::from_signal_str(signal_str) {
171 BrainSignal::SemanticFeedback {
172 target_id: target,
173 event_kind,
174 served_by_profile_id: served_by,
175 }
176 } else {
177 let signal = serde_json::from_value::<FeedbackSignal>(serde_json::Value::String(
178 signal_str.to_owned(),
179 ))
180 .ok();
181 match signal {
182 Some(s) => BrainSignal::Feedback {
183 target_id: target,
184 signal: s,
185 served_by_profile_id: served_by,
186 section_signals,
187 },
188 None => BrainSignal::Irrelevant,
189 }
190 }
191 }
192 "get" | "remember" => match event.target_id {
193 Some(tid) => BrainSignal::NoteAccessed { target_id: tid },
194 None => BrainSignal::Irrelevant,
195 },
196 _ => BrainSignal::Irrelevant,
197 }
198}
199
200pub fn entity_signal(signal: &BrainSignal) -> Option<(Uuid, bool)> {
202 match signal {
203 BrainSignal::RecallHit { target_id, .. } => Some((*target_id, true)),
204 BrainSignal::NoteAccessed { target_id } => Some((*target_id, true)),
205 BrainSignal::Feedback {
206 target_id, signal, ..
207 } => Some((*target_id, matches!(signal, FeedbackSignal::Useful))),
208 BrainSignal::SemanticFeedback {
209 target_id,
210 event_kind,
211 ..
212 } => Some((*target_id, event_kind.is_positive())),
213 BrainSignal::RecallMiss | BrainSignal::SearchCompleted { .. } | BrainSignal::Irrelevant => {
214 None
215 }
216 }
217}
218
219pub fn is_recall_positive(signal: &BrainSignal) -> Option<bool> {
221 match signal {
222 BrainSignal::RecallHit { .. } => Some(true),
223 BrainSignal::RecallMiss => Some(false),
224 _ => None,
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231 use khive_types::{EventKind, SubstrateKind};
232
233 fn make_event(verb: &str, outcome: EventOutcome, target: Option<Uuid>) -> Event {
234 let mut e = Event::new("test", verb, EventKind::Audit, SubstrateKind::Note, "brain");
235 e.outcome = outcome;
236 e.target_id = target;
237 e
238 }
239
240 #[test]
241 fn recall_success_with_target_is_hit() {
242 let id = Uuid::new_v4();
243 let e = make_event("recall", EventOutcome::Success, Some(id));
244 match interpret(&e) {
245 BrainSignal::RecallHit { target_id, .. } => assert_eq!(target_id, id),
246 other => panic!("expected RecallHit, got {other:?}"),
247 }
248 }
249
250 #[test]
251 fn recall_success_without_target_is_miss() {
252 let e = make_event("recall", EventOutcome::Success, None);
253 assert!(matches!(interpret(&e), BrainSignal::RecallMiss));
254 }
255
256 #[test]
257 fn recall_error_is_miss() {
258 let e = make_event("recall", EventOutcome::Error, Some(Uuid::new_v4()));
259 assert!(matches!(interpret(&e), BrainSignal::RecallMiss));
260 }
261
262 #[test]
263 fn search_is_completed() {
264 let e = make_event("search", EventOutcome::Success, None);
265 assert!(matches!(interpret(&e), BrainSignal::SearchCompleted { .. }));
266 }
267
268 #[test]
269 fn brain_feedback_with_useful_signal() {
270 let id = Uuid::new_v4();
271 let mut e = make_event("brain.feedback", EventOutcome::Success, Some(id));
272 e.payload = serde_json::json!({"signal": "useful"});
273 match interpret(&e) {
274 BrainSignal::Feedback {
275 target_id,
276 signal,
277 served_by_profile_id,
278 ..
279 } => {
280 assert_eq!(target_id, id);
281 assert_eq!(signal, FeedbackSignal::Useful);
282 assert!(served_by_profile_id.is_none());
283 }
284 other => panic!("expected Feedback, got {other:?}"),
285 }
286 }
287
288 #[test]
289 fn brain_feedback_with_served_by_profile_id() {
290 let id = Uuid::new_v4();
291 let mut e = make_event("brain.feedback", EventOutcome::Success, Some(id));
292 e.payload = serde_json::json!({
293 "signal": "not_useful",
294 "served_by_profile_id": "balanced-recall-v1"
295 });
296 match interpret(&e) {
297 BrainSignal::Feedback {
298 target_id,
299 signal,
300 served_by_profile_id,
301 ..
302 } => {
303 assert_eq!(target_id, id);
304 assert_eq!(signal, FeedbackSignal::NotUseful);
305 assert_eq!(served_by_profile_id.as_deref(), Some("balanced-recall-v1"));
306 }
307 other => panic!("expected Feedback, got {other:?}"),
308 }
309 }
310
311 #[test]
312 fn brain_feedback_without_target_is_irrelevant() {
313 let e = make_event("brain.feedback", EventOutcome::Success, None);
314 assert!(matches!(interpret(&e), BrainSignal::Irrelevant));
315 }
316
317 #[test]
318 fn brain_emit_legacy_is_irrelevant() {
319 let id = Uuid::new_v4();
321 let mut e = make_event("brain.emit", EventOutcome::Success, Some(id));
322 e.payload = serde_json::json!({"signal": "useful"});
323 assert!(matches!(interpret(&e), BrainSignal::Irrelevant));
324 }
325
326 #[test]
327 fn unknown_verb_is_irrelevant() {
328 let e = make_event("link", EventOutcome::Success, Some(Uuid::new_v4()));
329 assert!(matches!(interpret(&e), BrainSignal::Irrelevant));
330 }
331
332 #[test]
333 fn entity_signal_for_hit() {
334 let id = Uuid::new_v4();
335 let sig = BrainSignal::RecallHit {
336 target_id: id,
337 latency_us: 100,
338 };
339 assert_eq!(entity_signal(&sig), Some((id, true)));
340 }
341
342 #[test]
343 fn entity_signal_for_miss() {
344 assert_eq!(entity_signal(&BrainSignal::RecallMiss), None);
345 }
346
347 #[test]
348 fn recall_positive_classification() {
349 let hit = BrainSignal::RecallHit {
350 target_id: Uuid::new_v4(),
351 latency_us: 0,
352 };
353 assert_eq!(is_recall_positive(&hit), Some(true));
354 assert_eq!(is_recall_positive(&BrainSignal::RecallMiss), Some(false));
355 assert_eq!(
356 is_recall_positive(&BrainSignal::SearchCompleted { latency_us: 0 }),
357 None
358 );
359 }
360
361 #[test]
362 fn feedback_not_useful_is_negative_entity_signal() {
363 let id = Uuid::new_v4();
364 let sig = BrainSignal::Feedback {
365 target_id: id,
366 signal: FeedbackSignal::NotUseful,
367 served_by_profile_id: None,
368 section_signals: None,
369 };
370 assert_eq!(entity_signal(&sig), Some((id, false)));
371 }
372
373 #[test]
374 fn feedback_wrong_is_negative_entity_signal() {
375 let id = Uuid::new_v4();
376 let sig = BrainSignal::Feedback {
377 target_id: id,
378 signal: FeedbackSignal::Wrong,
379 served_by_profile_id: None,
380 section_signals: None,
381 };
382 assert_eq!(entity_signal(&sig), Some((id, false)));
383 }
384
385 #[test]
386 fn brain_feedback_invalid_signal_data_is_irrelevant() {
387 let id = Uuid::new_v4();
388 let mut e = make_event("brain.feedback", EventOutcome::Success, Some(id));
389 e.payload = serde_json::json!({"signal": "bad_value"});
390 assert!(matches!(interpret(&e), BrainSignal::Irrelevant));
391 }
392
393 #[test]
394 fn note_accessed_via_get_verb_is_positive_entity_signal() {
395 let id = Uuid::new_v4();
396 let e = make_event("get", EventOutcome::Success, Some(id));
397 match interpret(&e) {
398 BrainSignal::NoteAccessed { target_id } => {
399 assert_eq!(target_id, id);
400 assert_eq!(
401 entity_signal(&BrainSignal::NoteAccessed { target_id }),
402 Some((id, true))
403 );
404 }
405 other => panic!("expected NoteAccessed, got {other:?}"),
406 }
407 }
408
409 #[test]
410 fn note_accessed_via_remember_verb_is_positive_entity_signal() {
411 let id = Uuid::new_v4();
412 let e = make_event("remember", EventOutcome::Success, Some(id));
413 match interpret(&e) {
414 BrainSignal::NoteAccessed { target_id } => {
415 assert_eq!(target_id, id);
416 }
417 other => panic!("expected NoteAccessed, got {other:?}"),
418 }
419 }
420
421 #[test]
422 fn feedback_with_section_signals() {
423 use crate::state::SectionType;
424 let id = Uuid::new_v4();
425 let mut e = make_event("brain.feedback", EventOutcome::Success, Some(id));
426 e.payload = serde_json::json!({
427 "signal": "useful",
428 "section_signals": {
429 "overview": "useful",
430 "formalism": "not_useful",
431 "examples": "wrong"
432 }
433 });
434 match interpret(&e) {
435 BrainSignal::Feedback {
436 section_signals, ..
437 } => {
438 let ss = section_signals.expect("section_signals should be parsed");
439 assert_eq!(ss.len(), 3);
440 assert_eq!(ss[&SectionType::Overview], FeedbackSignal::Useful);
441 assert_eq!(ss[&SectionType::Formalism], FeedbackSignal::NotUseful);
442 assert_eq!(ss[&SectionType::Examples], FeedbackSignal::Wrong);
443 }
444 other => panic!("expected Feedback, got {other:?}"),
445 }
446 }
447
448 #[test]
451 fn feedback_event_kind_from_signal_str_all_variants() {
452 assert_eq!(
453 FeedbackEventKind::from_signal_str("explicit_positive"),
454 Some(FeedbackEventKind::ExplicitPositive)
455 );
456 assert_eq!(
457 FeedbackEventKind::from_signal_str("explicit_negative"),
458 Some(FeedbackEventKind::ExplicitNegative)
459 );
460 assert_eq!(
461 FeedbackEventKind::from_signal_str("implicit_positive"),
462 Some(FeedbackEventKind::ImplicitPositive)
463 );
464 assert_eq!(
465 FeedbackEventKind::from_signal_str("implicit_negative"),
466 Some(FeedbackEventKind::ImplicitNegative)
467 );
468 assert_eq!(
469 FeedbackEventKind::from_signal_str("correction"),
470 Some(FeedbackEventKind::Correction)
471 );
472 }
473
474 #[test]
475 fn feedback_event_kind_from_signal_str_unknown_returns_none() {
476 assert_eq!(FeedbackEventKind::from_signal_str("useful"), None);
477 assert_eq!(FeedbackEventKind::from_signal_str("not_useful"), None);
478 assert_eq!(FeedbackEventKind::from_signal_str(""), None);
479 assert_eq!(FeedbackEventKind::from_signal_str("ExplicitPositive"), None);
480 }
481
482 #[test]
483 fn feedback_event_kind_update_weight_values() {
484 assert!((FeedbackEventKind::Correction.update_weight() - 2.0).abs() < 1e-12);
485 assert!((FeedbackEventKind::ExplicitPositive.update_weight() - 1.5).abs() < 1e-12);
486 assert!((FeedbackEventKind::ExplicitNegative.update_weight() - 1.5).abs() < 1e-12);
487 assert!((FeedbackEventKind::ImplicitPositive.update_weight() - 0.5).abs() < 1e-12);
488 assert!((FeedbackEventKind::ImplicitNegative.update_weight() - 0.5).abs() < 1e-12);
489 }
490
491 #[test]
492 fn feedback_event_kind_is_positive_classification() {
493 assert!(FeedbackEventKind::ExplicitPositive.is_positive());
494 assert!(FeedbackEventKind::ImplicitPositive.is_positive());
495 assert!(!FeedbackEventKind::ExplicitNegative.is_positive());
496 assert!(!FeedbackEventKind::ImplicitNegative.is_positive());
497 assert!(!FeedbackEventKind::Correction.is_positive());
498 }
499
500 #[test]
501 fn brain_feedback_semantic_explicit_positive_produces_semantic_signal() {
502 let id = Uuid::new_v4();
503 let mut e = make_event("brain.feedback", EventOutcome::Success, Some(id));
504 e.payload = serde_json::json!({"signal": "explicit_positive"});
505 match interpret(&e) {
506 BrainSignal::SemanticFeedback {
507 target_id,
508 event_kind,
509 served_by_profile_id,
510 } => {
511 assert_eq!(target_id, id);
512 assert_eq!(event_kind, FeedbackEventKind::ExplicitPositive);
513 assert!(served_by_profile_id.is_none());
514 }
515 other => panic!("expected SemanticFeedback, got {other:?}"),
516 }
517 }
518
519 #[test]
520 fn feedback_without_section_signals_is_none() {
521 let id = Uuid::new_v4();
522 let mut e = make_event("brain.feedback", EventOutcome::Success, Some(id));
523 e.payload = serde_json::json!({"signal": "useful"});
524 match interpret(&e) {
525 BrainSignal::Feedback {
526 section_signals, ..
527 } => {
528 assert!(section_signals.is_none());
529 }
530 other => panic!("expected Feedback, got {other:?}"),
531 }
532 }
533
534 #[test]
535 fn brain_feedback_semantic_correction_produces_semantic_signal() {
536 let id = Uuid::new_v4();
537 let mut e = make_event("brain.feedback", EventOutcome::Success, Some(id));
538 e.payload = serde_json::json!({"signal": "correction"});
539 match interpret(&e) {
540 BrainSignal::SemanticFeedback {
541 target_id,
542 event_kind,
543 ..
544 } => {
545 assert_eq!(target_id, id);
546 assert_eq!(event_kind, FeedbackEventKind::Correction);
547 }
548 other => panic!("expected SemanticFeedback, got {other:?}"),
549 }
550 }
551
552 #[test]
553 fn semantic_feedback_entity_signal_positive_for_explicit_positive() {
554 let id = Uuid::new_v4();
555 let sig = BrainSignal::SemanticFeedback {
556 target_id: id,
557 event_kind: FeedbackEventKind::ExplicitPositive,
558 served_by_profile_id: None,
559 };
560 assert_eq!(entity_signal(&sig), Some((id, true)));
561 }
562
563 #[test]
564 fn semantic_feedback_entity_signal_negative_for_implicit_negative() {
565 let id = Uuid::new_v4();
566 let sig = BrainSignal::SemanticFeedback {
567 target_id: id,
568 event_kind: FeedbackEventKind::ImplicitNegative,
569 served_by_profile_id: None,
570 };
571 assert_eq!(entity_signal(&sig), Some((id, false)));
572 }
573
574 #[test]
575 fn semantic_feedback_entity_signal_negative_for_correction() {
576 let id = Uuid::new_v4();
577 let sig = BrainSignal::SemanticFeedback {
578 target_id: id,
579 event_kind: FeedbackEventKind::Correction,
580 served_by_profile_id: None,
581 };
582 assert_eq!(entity_signal(&sig), Some((id, false)));
583 }
584}