Skip to main content

khive_pack_brain/
event.rs

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/// Feedback signal values for the `brain.feedback` verb (ADR-032 §3).
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
13#[serde(rename_all = "snake_case")]
14pub enum FeedbackSignal {
15    Useful,
16    NotUseful,
17    Wrong,
18}
19
20/// Semantic event taxonomy for brain fold updates (issue #268).
21///
22/// Captures the *kind* of feedback event so that the fold can apply
23/// different update magnitudes to posteriors. Explicit signals carry
24/// stronger evidence than implicit ones; corrections are strongest of all.
25///
26/// Update magnitude guidelines (applied by `FeedbackEventKind::update_weight`):
27///   - `Correction`        → 2.0× (strongest — user actively corrected output)
28///   - `ExplicitPositive`  → 1.5× (user explicitly marked as good)
29///   - `ExplicitNegative`  → 1.5× (user explicitly marked as bad)
30///   - `ImplicitPositive`  → 0.5× (user expanded / interacted — weaker signal)
31///   - `ImplicitNegative`  → 0.5× (user skipped / ignored — weaker signal)
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
33#[serde(rename_all = "snake_case")]
34pub enum FeedbackEventKind {
35    /// User explicitly rated a result as good (e.g., clicked "thumbs up").
36    ExplicitPositive,
37    /// User explicitly rated a result as bad (e.g., clicked "thumbs down").
38    ExplicitNegative,
39    /// User implicitly signalled satisfaction (e.g., expanded a section, dwell time).
40    ImplicitPositive,
41    /// User implicitly signalled dissatisfaction (e.g., skipped result, quick dismiss).
42    ImplicitNegative,
43    /// User corrected the output — strongest signal; overrides relevance posterior.
44    Correction,
45}
46
47impl FeedbackEventKind {
48    /// Magnitude multiplier for posterior updates.
49    ///
50    /// The fold multiplies the standard Beta update step (+1 to α or β) by this
51    /// weight to produce fractional updates. Explicit evidence counts more than
52    /// implicit; corrections count most (2×).
53    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    /// Whether this event kind represents a positive signal.
62    pub fn is_positive(&self) -> bool {
63        matches!(
64            self,
65            FeedbackEventKind::ExplicitPositive | FeedbackEventKind::ImplicitPositive
66        )
67    }
68
69    /// Parse from the `signal` string in a `brain.feedback` event payload.
70    ///
71    /// Accepts the semantic event kind names. Falls back to `None` when the
72    /// string is not a recognised `FeedbackEventKind` (callers can then try
73    /// parsing as the legacy `FeedbackSignal` enum).
74    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/// Interpreted brain signal extracted from a raw Event (ADR-032 §4).
87///
88/// `interpret()` is the single mapping layer from the shared event log to
89/// brain-internal signals. No parallel event enum is needed; the Event
90/// substrate IS the source of truth.
91#[derive(Debug)]
92pub enum BrainSignal {
93    /// A recall verb succeeded — positive signal for the recalled entity.
94    RecallHit { target_id: Uuid, latency_us: i64 },
95    /// A recall verb returned no results — miss signal for tuning.
96    RecallMiss,
97    /// A search verb completed.
98    SearchCompleted { latency_us: i64 },
99    /// Explicit feedback on a specific entity, emitted by `brain.feedback`.
100    Feedback {
101        target_id: Uuid,
102        signal: FeedbackSignal,
103        /// Profile that served the event being rated, if known.
104        served_by_profile_id: Option<String>,
105        section_signals: Option<HashMap<SectionType, FeedbackSignal>>,
106    },
107    /// Semantic feedback with event kind (issue #268).
108    ///
109    /// Produced when the `signal` field in a `brain.feedback` event is one of
110    /// the `FeedbackEventKind` names (`explicit_positive`, `correction`, etc.).
111    /// The fold uses `event_kind.update_weight()` to scale the posterior update.
112    SemanticFeedback {
113        target_id: Uuid,
114        event_kind: FeedbackEventKind,
115        served_by_profile_id: Option<String>,
116    },
117    /// Any other note-substrate access (get, list on notes).
118    NoteAccessed { target_id: Uuid },
119    /// Event is not relevant to the brain.
120    Irrelevant,
121}
122
123/// Extract a brain signal from a raw storage Event (ADR-032 §4).
124///
125/// `brain.emit` is no longer handled here — it was renamed to `brain.feedback`
126/// per ADR-032 §11 (`brain.feedback` is the `FeedbackExplicit` event emitter).
127/// Any `brain.emit` event that predates this ADR is treated as Irrelevant so
128/// that old event log entries do not cause spurious feedback updates.
129///
130/// To add a new signal source: add one match arm to this function. That is
131/// the entire extension surface (ADR-032 §4).
132pub 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 is the ADR-032 §11 verb for FeedbackExplicit events.
148        // (brain.emit predates this ADR; treated as Irrelevant for old replays.)
149        "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            // Issue #268: try semantic event kind names first, then fall back to
169            // legacy FeedbackSignal (useful / not_useful / wrong).
170            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
200/// Extract (entity_id, positive_signal) for per-entity posterior updates.
201pub 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
219/// Is this signal positive for the global recall parameter?
220pub 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        // brain.emit predates ADR-032; old log entries must not trigger feedback.
320        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    // ── FeedbackEventKind unit tests (MAJ-001 coverage) ──────────────────────
449
450    #[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}