use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use khive_storage::event::Event;
use khive_types::EventOutcome;
use crate::state::SectionType;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum FeedbackSignal {
Useful,
NotUseful,
Wrong,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum FeedbackEventKind {
ExplicitPositive,
ExplicitNegative,
ImplicitPositive,
ImplicitNegative,
Correction,
}
impl FeedbackEventKind {
pub fn update_weight(&self) -> f64 {
match self {
FeedbackEventKind::Correction => 2.0,
FeedbackEventKind::ExplicitPositive | FeedbackEventKind::ExplicitNegative => 1.5,
FeedbackEventKind::ImplicitPositive | FeedbackEventKind::ImplicitNegative => 0.5,
}
}
pub fn is_positive(&self) -> bool {
matches!(
self,
FeedbackEventKind::ExplicitPositive | FeedbackEventKind::ImplicitPositive
)
}
pub fn from_signal_str(s: &str) -> Option<Self> {
match s {
"explicit_positive" => Some(FeedbackEventKind::ExplicitPositive),
"explicit_negative" => Some(FeedbackEventKind::ExplicitNegative),
"implicit_positive" => Some(FeedbackEventKind::ImplicitPositive),
"implicit_negative" => Some(FeedbackEventKind::ImplicitNegative),
"correction" => Some(FeedbackEventKind::Correction),
_ => None,
}
}
}
#[derive(Debug)]
pub enum BrainSignal {
RecallHit { target_id: Uuid, latency_us: i64 },
RecallMiss,
SearchCompleted { latency_us: i64 },
Feedback {
target_id: Uuid,
signal: FeedbackSignal,
served_by_profile_id: Option<String>,
section_signals: Option<HashMap<SectionType, FeedbackSignal>>,
},
SemanticFeedback {
target_id: Uuid,
event_kind: FeedbackEventKind,
served_by_profile_id: Option<String>,
},
NoteAccessed { target_id: Uuid },
Irrelevant,
}
pub fn interpret(event: &Event) -> BrainSignal {
match event.verb.as_str() {
"recall" => match event.outcome {
EventOutcome::Success => match event.target_id {
Some(tid) => BrainSignal::RecallHit {
target_id: tid,
latency_us: event.duration_us,
},
None => BrainSignal::RecallMiss,
},
_ => BrainSignal::RecallMiss,
},
"search" => BrainSignal::SearchCompleted {
latency_us: event.duration_us,
},
"brain.feedback" => {
let target = match event.target_id {
Some(t) => t,
None => return BrainSignal::Irrelevant,
};
let signal_str = event
.payload
.get("signal")
.and_then(|s| s.as_str())
.unwrap_or("");
let served_by = event
.payload
.get("served_by_profile_id")
.and_then(|v| v.as_str())
.map(|s| s.to_owned());
let section_signals = event.payload.get("section_signals").and_then(|v| {
serde_json::from_value::<HashMap<SectionType, FeedbackSignal>>(v.clone()).ok()
});
if let Some(event_kind) = FeedbackEventKind::from_signal_str(signal_str) {
BrainSignal::SemanticFeedback {
target_id: target,
event_kind,
served_by_profile_id: served_by,
}
} else {
let signal = serde_json::from_value::<FeedbackSignal>(serde_json::Value::String(
signal_str.to_owned(),
))
.ok();
match signal {
Some(s) => BrainSignal::Feedback {
target_id: target,
signal: s,
served_by_profile_id: served_by,
section_signals,
},
None => BrainSignal::Irrelevant,
}
}
}
"get" | "remember" => match event.target_id {
Some(tid) => BrainSignal::NoteAccessed { target_id: tid },
None => BrainSignal::Irrelevant,
},
_ => BrainSignal::Irrelevant,
}
}
pub fn entity_signal(signal: &BrainSignal) -> Option<(Uuid, bool)> {
match signal {
BrainSignal::RecallHit { target_id, .. } => Some((*target_id, true)),
BrainSignal::NoteAccessed { target_id } => Some((*target_id, true)),
BrainSignal::Feedback {
target_id, signal, ..
} => Some((*target_id, matches!(signal, FeedbackSignal::Useful))),
BrainSignal::SemanticFeedback {
target_id,
event_kind,
..
} => Some((*target_id, event_kind.is_positive())),
BrainSignal::RecallMiss | BrainSignal::SearchCompleted { .. } | BrainSignal::Irrelevant => {
None
}
}
}
pub fn is_recall_positive(signal: &BrainSignal) -> Option<bool> {
match signal {
BrainSignal::RecallHit { .. } => Some(true),
BrainSignal::RecallMiss => Some(false),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use khive_types::{EventKind, SubstrateKind};
fn make_event(verb: &str, outcome: EventOutcome, target: Option<Uuid>) -> Event {
let mut e = Event::new("test", verb, EventKind::Audit, SubstrateKind::Note, "brain");
e.outcome = outcome;
e.target_id = target;
e
}
#[test]
fn recall_success_with_target_is_hit() {
let id = Uuid::new_v4();
let e = make_event("recall", EventOutcome::Success, Some(id));
match interpret(&e) {
BrainSignal::RecallHit { target_id, .. } => assert_eq!(target_id, id),
other => panic!("expected RecallHit, got {other:?}"),
}
}
#[test]
fn recall_success_without_target_is_miss() {
let e = make_event("recall", EventOutcome::Success, None);
assert!(matches!(interpret(&e), BrainSignal::RecallMiss));
}
#[test]
fn recall_error_is_miss() {
let e = make_event("recall", EventOutcome::Error, Some(Uuid::new_v4()));
assert!(matches!(interpret(&e), BrainSignal::RecallMiss));
}
#[test]
fn search_is_completed() {
let e = make_event("search", EventOutcome::Success, None);
assert!(matches!(interpret(&e), BrainSignal::SearchCompleted { .. }));
}
#[test]
fn brain_feedback_with_useful_signal() {
let id = Uuid::new_v4();
let mut e = make_event("brain.feedback", EventOutcome::Success, Some(id));
e.payload = serde_json::json!({"signal": "useful"});
match interpret(&e) {
BrainSignal::Feedback {
target_id,
signal,
served_by_profile_id,
..
} => {
assert_eq!(target_id, id);
assert_eq!(signal, FeedbackSignal::Useful);
assert!(served_by_profile_id.is_none());
}
other => panic!("expected Feedback, got {other:?}"),
}
}
#[test]
fn brain_feedback_with_served_by_profile_id() {
let id = Uuid::new_v4();
let mut e = make_event("brain.feedback", EventOutcome::Success, Some(id));
e.payload = serde_json::json!({
"signal": "not_useful",
"served_by_profile_id": "balanced-recall-v1"
});
match interpret(&e) {
BrainSignal::Feedback {
target_id,
signal,
served_by_profile_id,
..
} => {
assert_eq!(target_id, id);
assert_eq!(signal, FeedbackSignal::NotUseful);
assert_eq!(served_by_profile_id.as_deref(), Some("balanced-recall-v1"));
}
other => panic!("expected Feedback, got {other:?}"),
}
}
#[test]
fn brain_feedback_without_target_is_irrelevant() {
let e = make_event("brain.feedback", EventOutcome::Success, None);
assert!(matches!(interpret(&e), BrainSignal::Irrelevant));
}
#[test]
fn brain_emit_legacy_is_irrelevant() {
let id = Uuid::new_v4();
let mut e = make_event("brain.emit", EventOutcome::Success, Some(id));
e.payload = serde_json::json!({"signal": "useful"});
assert!(matches!(interpret(&e), BrainSignal::Irrelevant));
}
#[test]
fn unknown_verb_is_irrelevant() {
let e = make_event("link", EventOutcome::Success, Some(Uuid::new_v4()));
assert!(matches!(interpret(&e), BrainSignal::Irrelevant));
}
#[test]
fn entity_signal_for_hit() {
let id = Uuid::new_v4();
let sig = BrainSignal::RecallHit {
target_id: id,
latency_us: 100,
};
assert_eq!(entity_signal(&sig), Some((id, true)));
}
#[test]
fn entity_signal_for_miss() {
assert_eq!(entity_signal(&BrainSignal::RecallMiss), None);
}
#[test]
fn recall_positive_classification() {
let hit = BrainSignal::RecallHit {
target_id: Uuid::new_v4(),
latency_us: 0,
};
assert_eq!(is_recall_positive(&hit), Some(true));
assert_eq!(is_recall_positive(&BrainSignal::RecallMiss), Some(false));
assert_eq!(
is_recall_positive(&BrainSignal::SearchCompleted { latency_us: 0 }),
None
);
}
#[test]
fn feedback_not_useful_is_negative_entity_signal() {
let id = Uuid::new_v4();
let sig = BrainSignal::Feedback {
target_id: id,
signal: FeedbackSignal::NotUseful,
served_by_profile_id: None,
section_signals: None,
};
assert_eq!(entity_signal(&sig), Some((id, false)));
}
#[test]
fn feedback_wrong_is_negative_entity_signal() {
let id = Uuid::new_v4();
let sig = BrainSignal::Feedback {
target_id: id,
signal: FeedbackSignal::Wrong,
served_by_profile_id: None,
section_signals: None,
};
assert_eq!(entity_signal(&sig), Some((id, false)));
}
#[test]
fn brain_feedback_invalid_signal_data_is_irrelevant() {
let id = Uuid::new_v4();
let mut e = make_event("brain.feedback", EventOutcome::Success, Some(id));
e.payload = serde_json::json!({"signal": "bad_value"});
assert!(matches!(interpret(&e), BrainSignal::Irrelevant));
}
#[test]
fn note_accessed_via_get_verb_is_positive_entity_signal() {
let id = Uuid::new_v4();
let e = make_event("get", EventOutcome::Success, Some(id));
match interpret(&e) {
BrainSignal::NoteAccessed { target_id } => {
assert_eq!(target_id, id);
assert_eq!(
entity_signal(&BrainSignal::NoteAccessed { target_id }),
Some((id, true))
);
}
other => panic!("expected NoteAccessed, got {other:?}"),
}
}
#[test]
fn note_accessed_via_remember_verb_is_positive_entity_signal() {
let id = Uuid::new_v4();
let e = make_event("remember", EventOutcome::Success, Some(id));
match interpret(&e) {
BrainSignal::NoteAccessed { target_id } => {
assert_eq!(target_id, id);
}
other => panic!("expected NoteAccessed, got {other:?}"),
}
}
#[test]
fn feedback_with_section_signals() {
use crate::state::SectionType;
let id = Uuid::new_v4();
let mut e = make_event("brain.feedback", EventOutcome::Success, Some(id));
e.payload = serde_json::json!({
"signal": "useful",
"section_signals": {
"overview": "useful",
"formalism": "not_useful",
"examples": "wrong"
}
});
match interpret(&e) {
BrainSignal::Feedback {
section_signals, ..
} => {
let ss = section_signals.expect("section_signals should be parsed");
assert_eq!(ss.len(), 3);
assert_eq!(ss[&SectionType::Overview], FeedbackSignal::Useful);
assert_eq!(ss[&SectionType::Formalism], FeedbackSignal::NotUseful);
assert_eq!(ss[&SectionType::Examples], FeedbackSignal::Wrong);
}
other => panic!("expected Feedback, got {other:?}"),
}
}
#[test]
fn feedback_event_kind_from_signal_str_all_variants() {
assert_eq!(
FeedbackEventKind::from_signal_str("explicit_positive"),
Some(FeedbackEventKind::ExplicitPositive)
);
assert_eq!(
FeedbackEventKind::from_signal_str("explicit_negative"),
Some(FeedbackEventKind::ExplicitNegative)
);
assert_eq!(
FeedbackEventKind::from_signal_str("implicit_positive"),
Some(FeedbackEventKind::ImplicitPositive)
);
assert_eq!(
FeedbackEventKind::from_signal_str("implicit_negative"),
Some(FeedbackEventKind::ImplicitNegative)
);
assert_eq!(
FeedbackEventKind::from_signal_str("correction"),
Some(FeedbackEventKind::Correction)
);
}
#[test]
fn feedback_event_kind_from_signal_str_unknown_returns_none() {
assert_eq!(FeedbackEventKind::from_signal_str("useful"), None);
assert_eq!(FeedbackEventKind::from_signal_str("not_useful"), None);
assert_eq!(FeedbackEventKind::from_signal_str(""), None);
assert_eq!(FeedbackEventKind::from_signal_str("ExplicitPositive"), None);
}
#[test]
fn feedback_event_kind_update_weight_values() {
assert!((FeedbackEventKind::Correction.update_weight() - 2.0).abs() < 1e-12);
assert!((FeedbackEventKind::ExplicitPositive.update_weight() - 1.5).abs() < 1e-12);
assert!((FeedbackEventKind::ExplicitNegative.update_weight() - 1.5).abs() < 1e-12);
assert!((FeedbackEventKind::ImplicitPositive.update_weight() - 0.5).abs() < 1e-12);
assert!((FeedbackEventKind::ImplicitNegative.update_weight() - 0.5).abs() < 1e-12);
}
#[test]
fn feedback_event_kind_is_positive_classification() {
assert!(FeedbackEventKind::ExplicitPositive.is_positive());
assert!(FeedbackEventKind::ImplicitPositive.is_positive());
assert!(!FeedbackEventKind::ExplicitNegative.is_positive());
assert!(!FeedbackEventKind::ImplicitNegative.is_positive());
assert!(!FeedbackEventKind::Correction.is_positive());
}
#[test]
fn brain_feedback_semantic_explicit_positive_produces_semantic_signal() {
let id = Uuid::new_v4();
let mut e = make_event("brain.feedback", EventOutcome::Success, Some(id));
e.payload = serde_json::json!({"signal": "explicit_positive"});
match interpret(&e) {
BrainSignal::SemanticFeedback {
target_id,
event_kind,
served_by_profile_id,
} => {
assert_eq!(target_id, id);
assert_eq!(event_kind, FeedbackEventKind::ExplicitPositive);
assert!(served_by_profile_id.is_none());
}
other => panic!("expected SemanticFeedback, got {other:?}"),
}
}
#[test]
fn feedback_without_section_signals_is_none() {
let id = Uuid::new_v4();
let mut e = make_event("brain.feedback", EventOutcome::Success, Some(id));
e.payload = serde_json::json!({"signal": "useful"});
match interpret(&e) {
BrainSignal::Feedback {
section_signals, ..
} => {
assert!(section_signals.is_none());
}
other => panic!("expected Feedback, got {other:?}"),
}
}
#[test]
fn brain_feedback_semantic_correction_produces_semantic_signal() {
let id = Uuid::new_v4();
let mut e = make_event("brain.feedback", EventOutcome::Success, Some(id));
e.payload = serde_json::json!({"signal": "correction"});
match interpret(&e) {
BrainSignal::SemanticFeedback {
target_id,
event_kind,
..
} => {
assert_eq!(target_id, id);
assert_eq!(event_kind, FeedbackEventKind::Correction);
}
other => panic!("expected SemanticFeedback, got {other:?}"),
}
}
#[test]
fn semantic_feedback_entity_signal_positive_for_explicit_positive() {
let id = Uuid::new_v4();
let sig = BrainSignal::SemanticFeedback {
target_id: id,
event_kind: FeedbackEventKind::ExplicitPositive,
served_by_profile_id: None,
};
assert_eq!(entity_signal(&sig), Some((id, true)));
}
#[test]
fn semantic_feedback_entity_signal_negative_for_implicit_negative() {
let id = Uuid::new_v4();
let sig = BrainSignal::SemanticFeedback {
target_id: id,
event_kind: FeedbackEventKind::ImplicitNegative,
served_by_profile_id: None,
};
assert_eq!(entity_signal(&sig), Some((id, false)));
}
#[test]
fn semantic_feedback_entity_signal_negative_for_correction() {
let id = Uuid::new_v4();
let sig = BrainSignal::SemanticFeedback {
target_id: id,
event_kind: FeedbackEventKind::Correction,
served_by_profile_id: None,
};
assert_eq!(entity_signal(&sig), Some((id, false)));
}
}