use chrono::{DateTime, Duration, Utc};
use rocksdb::{ColumnFamily, ColumnFamilyDescriptor, IteratorMode, Options, WriteBatch, DB};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::Path;
use std::sync::Arc;
use crate::memory::types::{ExperienceType, MemoryId};
pub(crate) const CF_FEEDBACK: &str = "feedback";
const MAX_RECENT_SIGNALS: usize = 20;
const MAX_CONTEXT_FINGERPRINTS: usize = 100;
const OVERLAP_STRONG_THRESHOLD: f32 = 0.4;
const OVERLAP_WEAK_THRESHOLD: f32 = 0.1;
const SEMANTIC_STRONG_THRESHOLD: f32 = 0.6;
const SEMANTIC_WEAK_THRESHOLD: f32 = 0.3;
const SIGNAL_STRONG_MULTIPLIER: f32 = 0.8;
const SIGNAL_WEAK_MULTIPLIER: f32 = 0.3;
const SIGNAL_NO_OVERLAP_PENALTY: f32 = -0.2; const SIGNAL_NEGATIVE_KEYWORD_PENALTY: f32 = -0.5;
const SIGNAL_REPETITION_PENALTY: f32 = -0.4; const SIGNAL_TOPIC_CHANGE_BOOST: f32 = 0.2; const SIGNAL_IGNORED_PENALTY: f32 = -0.2;
const ENTITY_WEIGHT: f32 = 0.4;
const SEMANTIC_WEIGHT: f32 = 0.6;
const TOOL_USAGE_MIN_OVERLAP: f32 = 0.08;
const TOOL_USAGE_STRONG_THRESHOLD: f32 = 0.25;
const TOOL_USAGE_SUCCESS_SIGNAL: f32 = 0.7;
const TOOL_USAGE_FAILURE_SIGNAL: f32 = -0.4;
const TOOL_USAGE_WEIGHT: f32 = 0.35;
const INFO_ATTRIBUTION_MIN: f32 = 0.05;
const INFO_ATTRIBUTION_STRONG: f32 = 0.25;
const INFO_ATTRIBUTION_STRONG_SIGNAL: f32 = 0.85;
const INFO_ATTRIBUTION_WEAK_SIGNAL: f32 = 0.3;
const INFO_ATTRIBUTION_NO_SIGNAL: f32 = -0.15;
const INFO_ATTRIBUTION_WEIGHT: f32 = 0.35;
const ENTITY_WEIGHT_WITH_INFO: f32 = 0.30;
const SEMANTIC_WEIGHT_WITH_INFO: f32 = 0.35;
const STABILITY_INCREMENT: f32 = 0.05;
const STABILITY_DECREMENT_MULTIPLIER: f32 = 0.1;
const TREND_IMPROVING_THRESHOLD: f32 = 0.1;
const TREND_DECLINING_THRESHOLD: f32 = -0.1;
const DECAY_HALF_LIFE_DAYS: f32 = 14.0;
const NEGATIVE_KEYWORDS: &[&str] = &[
"wrong",
"incorrect",
"not correct",
"nope",
"not what i meant",
"that's not right",
"that's wrong",
"i already said",
"i told you",
"i already told",
"already mentioned",
"not helpful",
"not relevant",
"not useful",
"irrelevant",
"useless",
"doesn't help",
"didn't help",
"not related",
"doesn't work",
"didn't work",
"broken",
"still broken",
"that failed",
"forget that",
"ignore that",
"disregard",
"stop suggesting",
"don't show",
];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum SignalTrigger {
EntityOverlap { overlap_ratio: f32 },
SemanticSimilarity { similarity: f32 },
NegativeKeywords { keywords: Vec<String> },
UserRepetition { similarity: f32 },
TopicChange { similarity: f32 },
Ignored { overlap_ratio: f32 },
EntityFlow {
derived_ratio: f32,
novel_ratio: f32,
memory_entities_used: usize,
response_entities_total: usize,
},
ToolUsage {
content_overlap: f32,
tool_name: String,
success: bool,
},
InformationAttribution {
attribution_score: f32,
raw_similarity: f32,
},
TemporalCredit {
turns_aggregated: u32,
raw_total: f32,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolAction {
pub tool_name: String,
#[serde(default)]
pub inputs: HashMap<String, String>,
pub success: bool,
#[serde(default)]
pub output_snippet: Option<String>,
#[serde(default)]
pub reward: Option<f32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignalRecord {
pub timestamp: DateTime<Utc>,
pub value: f32,
pub confidence: f32,
pub trigger: SignalTrigger,
}
impl SignalRecord {
pub fn new(value: f32, confidence: f32, trigger: SignalTrigger) -> Self {
Self {
timestamp: Utc::now(),
value: value.clamp(-1.0, 1.0),
confidence: confidence.clamp(0.0, 1.0),
trigger,
}
}
pub fn from_entity_overlap(overlap_ratio: f32) -> Self {
let (value, confidence) = if overlap_ratio >= OVERLAP_STRONG_THRESHOLD {
(SIGNAL_STRONG_MULTIPLIER * overlap_ratio, 0.9)
} else if overlap_ratio >= OVERLAP_WEAK_THRESHOLD {
(SIGNAL_WEAK_MULTIPLIER * overlap_ratio, 0.6)
} else {
(SIGNAL_NO_OVERLAP_PENALTY, 0.4)
};
Self::new(
value,
confidence,
SignalTrigger::EntityOverlap { overlap_ratio },
)
}
pub fn from_negative_keywords(keywords: Vec<String>) -> Self {
Self::new(
SIGNAL_NEGATIVE_KEYWORD_PENALTY,
0.95, SignalTrigger::NegativeKeywords { keywords },
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Trend {
Improving,
Stable,
Declining,
Insufficient,
}
impl Trend {
pub fn from_signals(signals: &VecDeque<SignalRecord>) -> Self {
if signals.len() < 3 {
return Trend::Insufficient;
}
let n = signals.len() as f32;
let mut sum_x = 0.0;
let mut sum_y = 0.0;
let mut sum_xy = 0.0;
let mut sum_xx = 0.0;
for (i, signal) in signals.iter().enumerate() {
let x = i as f32;
let y = signal.value;
sum_x += x;
sum_y += y;
sum_xy += x * y;
sum_xx += x * x;
}
let denominator = n * sum_xx - sum_x * sum_x;
if denominator.abs() < f32::EPSILON {
return Trend::Stable;
}
let slope = (n * sum_xy - sum_x * sum_y) / denominator;
if slope > TREND_IMPROVING_THRESHOLD {
Trend::Improving
} else if slope < TREND_DECLINING_THRESHOLD {
Trend::Declining
} else {
Trend::Stable
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextFingerprint {
pub entities: Vec<String>,
pub embedding_signature: [f32; 16],
pub timestamp: DateTime<Utc>,
pub was_helpful: bool,
}
impl ContextFingerprint {
pub fn new(entities: Vec<String>, embedding: &[f32], was_helpful: bool) -> Self {
let mut signature = [0.0f32; 16];
if !embedding.is_empty() {
let len = embedding.len();
for (i, sig) in signature.iter_mut().enumerate() {
let idx = (i * len / 16).min(len - 1);
*sig = embedding[idx];
}
}
Self {
entities,
embedding_signature: signature,
timestamp: Utc::now(),
was_helpful,
}
}
pub fn similarity(&self, other: &ContextFingerprint) -> f32 {
let self_set: HashSet<_> = self.entities.iter().collect();
let other_set: HashSet<_> = other.entities.iter().collect();
let intersection = self_set.intersection(&other_set).count() as f32;
let union = self_set.union(&other_set).count() as f32;
let entity_sim = if union > 0.0 {
intersection / union
} else {
0.0
};
let mut dot = 0.0;
let mut norm_a = 0.0;
let mut norm_b = 0.0;
for i in 0..16 {
dot += self.embedding_signature[i] * other.embedding_signature[i];
norm_a += self.embedding_signature[i] * self.embedding_signature[i];
norm_b += other.embedding_signature[i] * other.embedding_signature[i];
}
let embed_sim = if norm_a > 0.0 && norm_b > 0.0 {
dot / (norm_a.sqrt() * norm_b.sqrt())
} else {
0.0
};
entity_sim * 0.6 + embed_sim * 0.4
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeedbackMomentum {
pub memory_id: MemoryId,
pub memory_type: ExperienceType,
pub ema: f32,
pub signal_count: u32,
pub stability: f32,
pub first_signal_at: Option<DateTime<Utc>>,
pub last_signal_at: Option<DateTime<Utc>>,
pub recent_signals: VecDeque<SignalRecord>,
pub helpful_contexts: Vec<ContextFingerprint>,
pub misleading_contexts: Vec<ContextFingerprint>,
}
impl FeedbackMomentum {
pub fn new(memory_id: MemoryId, memory_type: ExperienceType) -> Self {
Self {
memory_id,
memory_type,
ema: 0.0,
signal_count: 0,
stability: 0.5, first_signal_at: None,
last_signal_at: None,
recent_signals: VecDeque::with_capacity(MAX_RECENT_SIGNALS),
helpful_contexts: Vec::new(),
misleading_contexts: Vec::new(),
}
}
pub fn base_inertia(&self) -> f32 {
match self.memory_type {
ExperienceType::Learning => 0.95,
ExperienceType::Decision => 0.90,
ExperienceType::Pattern => 0.85,
ExperienceType::Discovery => 0.75,
ExperienceType::Context => 0.60,
ExperienceType::Task => 0.50,
ExperienceType::Observation => 0.40,
ExperienceType::Conversation => 0.30,
ExperienceType::Error => 0.20,
ExperienceType::CodeEdit => 0.50,
ExperienceType::FileAccess => 0.40,
ExperienceType::Search => 0.35,
ExperienceType::Command => 0.35,
ExperienceType::Intention => 0.60,
}
}
pub fn age_factor(&self) -> f32 {
let age_days = self
.first_signal_at
.map(|first| {
let duration = Utc::now() - first;
duration.num_days() as f32
})
.unwrap_or(0.0);
if age_days < 1.0 {
0.8 } else if age_days < 7.0 {
0.9 } else if age_days < 30.0 {
1.0 } else {
1.1 }
}
pub fn history_factor(&self) -> f32 {
match self.signal_count {
0..=2 => 0.7, 3..=9 => 0.9, 10..=49 => 1.0, _ => 1.1, }
}
pub fn stability_factor(&self) -> f32 {
0.8 + (self.stability * 0.4)
}
pub fn effective_inertia(&self) -> f32 {
let inertia = self.base_inertia()
* self.age_factor()
* self.history_factor()
* self.stability_factor();
inertia.clamp(0.5, 0.99)
}
pub fn recency_weight(&self, signal_time: DateTime<Utc>) -> f32 {
let time_since_last = self
.last_signal_at
.map(|last| signal_time - last)
.unwrap_or_else(Duration::zero);
if time_since_last < Duration::hours(1) {
1.0
} else if time_since_last < Duration::days(1) {
0.9
} else if time_since_last < Duration::days(7) {
0.7
} else {
0.5
}
}
pub fn update(&mut self, signal: SignalRecord) {
let now = signal.timestamp;
if self.first_signal_at.is_none() {
self.first_signal_at = Some(now);
}
let effective_inertia = self.effective_inertia();
let recency = self.recency_weight(now);
let alpha = (1.0 - effective_inertia) * recency * signal.confidence;
let old_ema = self.ema;
self.ema = old_ema * (1.0 - alpha) + signal.value * alpha;
let direction_matches =
(signal.value > 0.0) == (old_ema > 0.0) || old_ema.abs() < f32::EPSILON;
if direction_matches {
self.stability = (self.stability + STABILITY_INCREMENT).min(1.0);
} else {
let contradiction_strength = (signal.value - old_ema).abs();
self.stability =
(self.stability - STABILITY_DECREMENT_MULTIPLIER * contradiction_strength).max(0.0);
}
self.recent_signals.push_back(signal);
if self.recent_signals.len() > MAX_RECENT_SIGNALS {
self.recent_signals.pop_front();
}
self.signal_count += 1;
self.last_signal_at = Some(now);
}
pub fn trend(&self) -> Trend {
Trend::from_signals(&self.recent_signals)
}
pub fn add_context(&mut self, fingerprint: ContextFingerprint) {
let target = if fingerprint.was_helpful {
&mut self.helpful_contexts
} else {
&mut self.misleading_contexts
};
target.push(fingerprint);
if target.len() > MAX_CONTEXT_FINGERPRINTS {
target.remove(0);
}
}
pub fn matches_helpful_pattern(&self, current: &ContextFingerprint) -> Option<f32> {
self.helpful_contexts
.iter()
.map(|fp| fp.similarity(current))
.max_by(|a, b| a.total_cmp(b))
}
pub fn matches_misleading_pattern(&self, current: &ContextFingerprint) -> Option<f32> {
self.misleading_contexts
.iter()
.map(|fp| fp.similarity(current))
.max_by(|a, b| a.total_cmp(b))
}
pub fn ema_with_decay(&self) -> f32 {
let days_since_last = self
.last_signal_at
.map(|last| {
let duration = Utc::now() - last;
duration.num_hours() as f32 / 24.0
})
.unwrap_or(0.0);
if days_since_last < 0.1 {
return self.ema;
}
let decay_factor = 0.5_f32.powf(days_since_last / DECAY_HALF_LIFE_DAYS);
self.ema * decay_factor
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SurfacedMemoryInfo {
pub id: MemoryId,
pub entities: HashSet<String>,
pub content_preview: String,
pub score: f32,
#[serde(default)]
pub embedding: Vec<f32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingFeedback {
pub user_id: String,
pub surfaced_at: DateTime<Utc>,
pub surfaced_memories: Vec<SurfacedMemoryInfo>,
pub context: String,
pub context_embedding: Vec<f32>,
#[serde(default)]
pub tool_actions: Vec<ToolAction>,
}
impl PendingFeedback {
pub fn new(
user_id: String,
context: String,
context_embedding: Vec<f32>,
memories: Vec<SurfacedMemoryInfo>,
) -> Self {
Self {
user_id,
surfaced_at: Utc::now(),
surfaced_memories: memories,
context,
context_embedding,
tool_actions: Vec::new(),
}
}
pub fn is_expired(&self) -> bool {
Utc::now() - self.surfaced_at > Duration::hours(1)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeedbackWindow {
pub user_id: String,
pub turn_counter: u32,
pub entries: VecDeque<WindowEntry>,
pub window_size: usize,
pub created_at: DateTime<Utc>,
pub last_turn_at: DateTime<Utc>,
pub deferred_credits: HashMap<MemoryId, Vec<DeferredCredit>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WindowEntry {
pub turn_number: u32,
pub surfaced_memories: Vec<SurfacedMemoryInfo>,
pub surfaced_at: DateTime<Utc>,
pub context_embedding: Vec<f32>,
pub context_preview: String,
#[serde(default)]
pub tool_actions: Vec<ToolAction>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeferredCredit {
pub raw_signal: f32,
pub confidence: f32,
pub trigger: SignalTrigger,
pub turns_elapsed: u32,
pub discounted_value: f32,
pub computed_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum SessionOutcome {
TaskCompletion {
turns_engaged: u32,
final_similarity: f32,
},
Abandonment {
gap_seconds: i64,
frustration_detected: bool,
},
ReEngagement {
gap_turns: u32,
topic_similarity: f32,
},
NaturalEnd,
}
impl FeedbackWindow {
pub fn new(user_id: String) -> Self {
let now = Utc::now();
Self {
user_id,
turn_counter: 0,
entries: VecDeque::with_capacity(crate::constants::FEEDBACK_WINDOW_SIZE + 1),
window_size: crate::constants::FEEDBACK_WINDOW_SIZE,
created_at: now,
last_turn_at: now,
deferred_credits: HashMap::new(),
}
}
pub fn has_session_gap(&self) -> bool {
let gap = (Utc::now() - self.last_turn_at).num_seconds();
gap > crate::constants::FEEDBACK_SESSION_GAP_SECS
}
pub fn is_expired(&self) -> bool {
(Utc::now() - self.last_turn_at).num_seconds() > 7200
}
pub fn all_memory_ids(&self) -> Vec<MemoryId> {
let mut ids = Vec::new();
for entry in &self.entries {
for mem in &entry.surfaced_memories {
if !ids.contains(&mem.id) {
ids.push(mem.id.clone());
}
}
}
ids
}
pub fn detect_session_outcome(&self) -> Option<SessionOutcome> {
if self.entries.len() < 2 {
return None;
}
let entries: Vec<&WindowEntry> = self.entries.iter().collect();
let len = entries.len();
let mut sustained_turns = 0u32;
for i in 1..len {
if entries[i - 1].context_embedding.is_empty()
|| entries[i].context_embedding.is_empty()
{
sustained_turns = 0;
continue;
}
let sim = cosine_similarity_vecs(
&entries[i - 1].context_embedding,
&entries[i].context_embedding,
);
if sim > 0.5 {
sustained_turns += 1;
} else {
if sustained_turns >= crate::constants::SESSION_COMPLETION_MIN_TURNS && sim < 0.3 {
return Some(SessionOutcome::TaskCompletion {
turns_engaged: sustained_turns,
final_similarity: sim,
});
}
sustained_turns = 0;
}
}
if len >= 4 {
for i in 2..len {
if entries[0].context_embedding.is_empty()
|| entries[i].context_embedding.is_empty()
|| entries[i - 1].context_embedding.is_empty()
{
continue;
}
let sim_to_earlier = cosine_similarity_vecs(
&entries[0].context_embedding,
&entries[i].context_embedding,
);
let sim_to_mid = cosine_similarity_vecs(
&entries[0].context_embedding,
&entries[i - 1].context_embedding,
);
if sim_to_mid < 0.3 && sim_to_earlier > 0.6 {
return Some(SessionOutcome::ReEngagement {
gap_turns: i as u32 - 1,
topic_similarity: sim_to_earlier,
});
}
}
}
None
}
}
fn cosine_similarity_vecs(a: &[f32], b: &[f32]) -> f32 {
if a.len() != b.len() || a.is_empty() {
return 0.0;
}
let mut dot = 0.0f32;
let mut norm_a = 0.0f32;
let mut norm_b = 0.0f32;
for (x, y) in a.iter().zip(b.iter()) {
dot += x * y;
norm_a += x * x;
norm_b += y * y;
}
let denom = norm_a.sqrt() * norm_b.sqrt();
if denom < 1e-10 {
0.0
} else {
(dot / denom).clamp(-1.0, 1.0)
}
}
pub fn extract_entities_simple(text: &str) -> HashSet<String> {
text.to_lowercase()
.split(|c: char| !c.is_alphanumeric() && c != '_')
.filter(|word| word.len() > 2)
.map(|s| s.to_string())
.collect()
}
pub fn calculate_entity_overlap(
memory_entities: &HashSet<String>,
response_entities: &HashSet<String>,
) -> f32 {
if memory_entities.is_empty() {
return 0.0;
}
let intersection = memory_entities.intersection(response_entities).count() as f32;
intersection / memory_entities.len() as f32
}
fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
if a.len() != b.len() || a.is_empty() {
return 0.0;
}
let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm_a == 0.0 || norm_b == 0.0 {
return 0.0;
}
(dot / (norm_a * norm_b)).clamp(-1.0, 1.0)
}
fn compute_information_attribution(
query_emb: &[f32],
memory_emb: &[f32],
response_emb: &[f32],
) -> Option<(f32, f32)> {
if query_emb.is_empty()
|| memory_emb.len() != query_emb.len()
|| response_emb.len() != query_emb.len()
{
return None;
}
let query_dot_query: f32 = query_emb.iter().map(|x| x * x).sum();
if query_dot_query < 1e-10 {
return None; }
let mem_dot_query: f32 = memory_emb.iter().zip(query_emb).map(|(m, q)| m * q).sum();
let resp_dot_query: f32 = response_emb.iter().zip(query_emb).map(|(r, q)| r * q).sum();
let mem_proj_scale = mem_dot_query / query_dot_query;
let resp_proj_scale = resp_dot_query / query_dot_query;
let mem_residual: Vec<f32> = memory_emb
.iter()
.zip(query_emb)
.map(|(m, q)| m - mem_proj_scale * q)
.collect();
let resp_residual: Vec<f32> = response_emb
.iter()
.zip(query_emb)
.map(|(r, q)| r - resp_proj_scale * q)
.collect();
let attribution = cosine_similarity(&mem_residual, &resp_residual).max(0.0);
let raw_similarity = cosine_similarity(memory_emb, response_emb);
Some((attribution, raw_similarity))
}
fn signal_from_semantic_similarity(similarity: f32) -> (f32, f32) {
if similarity >= SEMANTIC_STRONG_THRESHOLD {
(SIGNAL_STRONG_MULTIPLIER * similarity, 0.9)
} else if similarity >= SEMANTIC_WEAK_THRESHOLD {
(SIGNAL_WEAK_MULTIPLIER * similarity, 0.6)
} else {
(SIGNAL_NO_OVERLAP_PENALTY * 0.5, 0.3) }
}
pub fn detect_negative_keywords(text: &str) -> Vec<String> {
let lower = text.to_lowercase();
NEGATIVE_KEYWORDS
.iter()
.filter(|&&kw| lower.contains(kw))
.map(|&s| s.to_string())
.collect()
}
pub fn calculate_entity_flow(
memory_entities: &HashSet<String>,
response_entities: &HashSet<String>,
) -> (f32, f32, usize, usize) {
if response_entities.is_empty() {
return (0.0, 0.0, 0, 0);
}
let derived: HashSet<_> = response_entities
.intersection(memory_entities)
.cloned()
.collect();
let derived_count = derived.len();
let novel_count = response_entities.len() - derived_count;
let derived_ratio = derived_count as f32 / response_entities.len() as f32;
let novel_ratio = novel_count as f32 / response_entities.len() as f32;
(
derived_ratio,
novel_ratio,
derived_count,
response_entities.len(),
)
}
pub fn signal_from_entity_flow(
derived_ratio: f32,
novel_ratio: f32,
memory_entities_used: usize,
response_entities_total: usize,
) -> SignalRecord {
let value = if derived_ratio >= 0.5 {
0.6 + (derived_ratio - 0.5) * 0.4
} else if derived_ratio >= 0.2 {
derived_ratio * 1.5
} else if novel_ratio >= 0.8 {
-0.1
} else {
0.0
};
let confidence = if response_entities_total >= 3 {
0.8 } else {
0.5 };
SignalRecord::new(
value,
confidence,
SignalTrigger::EntityFlow {
derived_ratio,
novel_ratio,
memory_entities_used,
response_entities_total,
},
)
}
pub fn process_implicit_feedback(
pending: &PendingFeedback,
response_text: &str,
user_followup: Option<&str>,
) -> Vec<(MemoryId, SignalRecord)> {
process_implicit_feedback_with_semantics(pending, response_text, user_followup, None)
}
pub fn process_implicit_feedback_with_semantics(
pending: &PendingFeedback,
response_text: &str,
user_followup: Option<&str>,
response_embedding: Option<&[f32]>,
) -> Vec<(MemoryId, SignalRecord)> {
let response_entities = extract_entities_simple(response_text);
let mut signals = Vec::new();
for memory in &pending.surfaced_memories {
let entity_overlap = calculate_entity_overlap(&memory.entities, &response_entities);
let (entity_value, entity_conf) = if entity_overlap >= OVERLAP_STRONG_THRESHOLD {
(SIGNAL_STRONG_MULTIPLIER * entity_overlap, 0.9)
} else if entity_overlap >= OVERLAP_WEAK_THRESHOLD {
(SIGNAL_WEAK_MULTIPLIER * entity_overlap, 0.6)
} else {
(SIGNAL_NO_OVERLAP_PENALTY, 0.4)
};
let (semantic_value, semantic_conf, has_semantic) =
if let Some(resp_emb) = response_embedding {
if !memory.embedding.is_empty() {
let similarity = cosine_similarity(&memory.embedding, resp_emb);
let (val, conf) = signal_from_semantic_similarity(similarity);
(val, conf, true)
} else {
(0.0, 0.0, false)
}
} else {
(0.0, 0.0, false)
};
let (combined_value, combined_confidence, trigger) = if has_semantic {
if let Some((attr_score, raw_sim)) = response_embedding.and_then(|resp_emb| {
compute_information_attribution(
&pending.context_embedding,
&memory.embedding,
resp_emb,
)
}) {
let (info_value, info_conf) = if attr_score >= INFO_ATTRIBUTION_STRONG {
(INFO_ATTRIBUTION_STRONG_SIGNAL * attr_score.min(1.0), 0.9)
} else if attr_score >= INFO_ATTRIBUTION_MIN {
(INFO_ATTRIBUTION_WEAK_SIGNAL * attr_score, 0.65)
} else {
(INFO_ATTRIBUTION_NO_SIGNAL, 0.5)
};
let value = (ENTITY_WEIGHT_WITH_INFO * entity_value)
+ (SEMANTIC_WEIGHT_WITH_INFO * semantic_value)
+ (INFO_ATTRIBUTION_WEIGHT * info_value);
let confidence = (ENTITY_WEIGHT_WITH_INFO * entity_conf)
+ (SEMANTIC_WEIGHT_WITH_INFO * semantic_conf)
+ (INFO_ATTRIBUTION_WEIGHT * info_conf);
(
value,
confidence,
SignalTrigger::InformationAttribution {
attribution_score: attr_score,
raw_similarity: raw_sim,
},
)
} else {
let value = (ENTITY_WEIGHT * entity_value) + (SEMANTIC_WEIGHT * semantic_value);
let confidence = (ENTITY_WEIGHT * entity_conf) + (SEMANTIC_WEIGHT * semantic_conf);
let similarity = response_embedding
.map(|resp_emb| cosine_similarity(&memory.embedding, resp_emb))
.unwrap_or(0.0);
(
value,
confidence,
SignalTrigger::SemanticSimilarity { similarity },
)
}
} else {
(
entity_value,
entity_conf,
SignalTrigger::EntityOverlap {
overlap_ratio: entity_overlap,
},
)
};
let (combined_value, combined_confidence, trigger) =
if let Some((tool_val, tool_conf, tool_name, tool_overlap)) =
compute_tool_usage_signal(memory, &pending.tool_actions)
{
let blended_value =
(TOOL_USAGE_WEIGHT * tool_val) + ((1.0 - TOOL_USAGE_WEIGHT) * combined_value);
let blended_conf = tool_conf.max(combined_confidence);
(
blended_value,
blended_conf,
SignalTrigger::ToolUsage {
content_overlap: tool_overlap,
tool_name,
success: tool_val > 0.0,
},
)
} else {
(combined_value, combined_confidence, trigger)
};
let mut signal = SignalRecord::new(combined_value, combined_confidence, trigger);
if let Some(followup) = user_followup {
let negative = detect_negative_keywords(followup);
if !negative.is_empty() {
signal.value += SIGNAL_NEGATIVE_KEYWORD_PENALTY;
signal.value = signal.value.clamp(-1.0, 1.0);
signal.confidence = 0.95; }
}
signals.push((memory.id.clone(), signal));
}
signals
}
pub fn compute_tool_usage_signal(
memory: &SurfacedMemoryInfo,
tool_actions: &[ToolAction],
) -> Option<(f32, f32, String, f32)> {
if tool_actions.is_empty() {
return None;
}
let memory_tokens: HashSet<&str> = memory
.content_preview
.split(|c: char| !c.is_alphanumeric() && c != '_' && c != '-' && c != '.' && c != '/')
.filter(|w| w.len() >= 3)
.collect();
if memory_tokens.is_empty() {
return None;
}
let mut best_overlap = 0.0f32;
let mut best_tool = String::new();
let mut best_success = false;
let mut best_reward: Option<f32> = None;
for action in tool_actions {
let mut action_text = String::new();
for value in action.inputs.values() {
action_text.push(' ');
action_text.push_str(value);
}
if let Some(ref snippet) = action.output_snippet {
action_text.push(' ');
action_text.push_str(snippet);
}
let action_tokens: HashSet<&str> = action_text
.split(|c: char| !c.is_alphanumeric() && c != '_' && c != '-' && c != '.' && c != '/')
.filter(|w| w.len() >= 3)
.collect();
if action_tokens.is_empty() {
continue;
}
let intersection = memory_tokens.intersection(&action_tokens).count() as f32;
let union = memory_tokens.union(&action_tokens).count() as f32;
let overlap = if union > 0.0 {
intersection / union
} else {
0.0
};
if overlap > best_overlap {
best_overlap = overlap;
best_tool = action.tool_name.clone();
best_success = action.success;
best_reward = action.reward;
}
}
if best_overlap < TOOL_USAGE_MIN_OVERLAP {
return None;
}
let base_value = if let Some(reward) = best_reward {
reward * best_overlap
} else if best_success {
TOOL_USAGE_SUCCESS_SIGNAL * best_overlap
} else {
TOOL_USAGE_FAILURE_SIGNAL * best_overlap
};
let confidence = if best_overlap >= TOOL_USAGE_STRONG_THRESHOLD {
0.9
} else {
0.65
};
Some((base_value, confidence, best_tool, best_overlap))
}
pub fn apply_context_pattern_signals(
signals: &mut [(MemoryId, SignalRecord)],
is_repetition: bool,
is_topic_change: bool,
_context_similarity: f32,
) {
for (memory_id, signal) in signals.iter_mut() {
if is_repetition {
if signal.value < 0.15 {
signal.value += SIGNAL_REPETITION_PENALTY;
signal.value = signal.value.clamp(-1.0, 1.0);
signal.trigger = SignalTrigger::UserRepetition {
similarity: _context_similarity,
};
signal.confidence = 0.85; tracing::debug!(
"Repetition detected for memory {:?}: applied penalty",
memory_id
);
}
} else if is_topic_change {
if signal.value > 0.05 {
signal.value += SIGNAL_TOPIC_CHANGE_BOOST;
signal.value = signal.value.clamp(-1.0, 1.0);
signal.trigger = SignalTrigger::TopicChange {
similarity: _context_similarity,
};
signal.confidence = 0.7; tracing::debug!(
"Topic change detected for memory {:?}: applied boost",
memory_id
);
}
}
if signal.value < -0.05 && signal.value > -0.3 {
signal.value = SIGNAL_IGNORED_PENALTY.min(signal.value);
if !matches!(signal.trigger, SignalTrigger::UserRepetition { .. }) {
signal.trigger = SignalTrigger::Ignored {
overlap_ratio: match &signal.trigger {
SignalTrigger::EntityOverlap { overlap_ratio } => *overlap_ratio,
_ => 0.0,
},
};
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PreviousContext {
pub context: String,
pub embedding: Vec<f32>,
pub timestamp: DateTime<Utc>,
pub surfaced_memory_ids: Vec<MemoryId>,
}
pub struct FeedbackStore {
pub momentum: HashMap<MemoryId, FeedbackMomentum>,
pending: HashMap<String, PendingFeedback>,
windows: HashMap<String, FeedbackWindow>,
previous_context: HashMap<String, PreviousContext>,
db: Option<Arc<DB>>,
dirty: HashSet<MemoryId>,
}
impl std::fmt::Debug for FeedbackStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FeedbackStore")
.field("momentum_count", &self.momentum.len())
.field("pending_count", &self.pending.len())
.field("windows_count", &self.windows.len())
.field("previous_context_count", &self.previous_context.len())
.field("has_db", &self.db.is_some())
.field("dirty_count", &self.dirty.len())
.finish()
}
}
impl Default for FeedbackStore {
fn default() -> Self {
Self {
momentum: HashMap::new(),
pending: HashMap::new(),
windows: HashMap::new(),
previous_context: HashMap::new(),
db: None,
dirty: HashSet::new(),
}
}
}
impl FeedbackStore {
pub fn new() -> Self {
Self::default()
}
fn feedback_cf(&self) -> Option<&ColumnFamily> {
self.db.as_ref().and_then(|db| db.cf_handle(CF_FEEDBACK))
}
pub fn with_shared_db(db: Arc<DB>, base_path: &Path) -> anyhow::Result<Self> {
Self::migrate_from_separate_db(base_path, &db)?;
let cf = db.cf_handle(CF_FEEDBACK).expect("feedback CF must exist");
let mut momentum = HashMap::new();
let iter = db.prefix_iterator_cf(cf, b"momentum:");
for item in iter {
if let Ok((key, value)) = item {
if let Ok(key_str) = std::str::from_utf8(&key) {
if !key_str.starts_with("momentum:") {
break;
}
if let Ok(m) = serde_json::from_slice::<FeedbackMomentum>(&value) {
momentum.insert(m.memory_id.clone(), m);
}
}
}
}
let mut pending = HashMap::new();
let iter = db.prefix_iterator_cf(cf, b"pending:");
for item in iter {
if let Ok((key, value)) = item {
if let Ok(key_str) = std::str::from_utf8(&key) {
if !key_str.starts_with("pending:") {
break;
}
if let Ok(p) = serde_json::from_slice::<PendingFeedback>(&value) {
if !p.is_expired() {
pending.insert(p.user_id.clone(), p);
} else {
let _ = db.delete_cf(cf, key_str.as_bytes());
}
}
}
}
}
let mut previous_context = HashMap::new();
let iter = db.prefix_iterator_cf(cf, b"prev_ctx:");
for item in iter {
if let Ok((key, value)) = item {
if let Ok(key_str) = std::str::from_utf8(&key) {
if !key_str.starts_with("prev_ctx:") {
break;
}
if let Ok(ctx) = serde_json::from_slice::<PreviousContext>(&value) {
let user_id = key_str.strip_prefix("prev_ctx:").unwrap_or("");
previous_context.insert(user_id.to_string(), ctx);
}
}
}
}
let mut windows = HashMap::new();
let iter = db.prefix_iterator_cf(cf, b"window:");
for item in iter {
if let Ok((key, value)) = item {
if let Ok(key_str) = std::str::from_utf8(&key) {
if !key_str.starts_with("window:") {
break;
}
if let Ok(w) = serde_json::from_slice::<FeedbackWindow>(&value) {
if !w.is_expired() {
windows.insert(w.user_id.clone(), w);
} else {
let _ = db.delete_cf(cf, key_str.as_bytes());
}
}
}
}
}
tracing::info!(
"Loaded {} momentum, {} pending, {} windows, {} previous context from shared feedback CF",
momentum.len(),
pending.len(),
windows.len(),
previous_context.len()
);
Ok(Self {
momentum,
pending,
windows,
previous_context,
db: Some(db),
dirty: HashSet::new(),
})
}
fn migrate_from_separate_db(base_path: &Path, db: &DB) -> anyhow::Result<()> {
let old_dir = base_path.join("feedback");
if !old_dir.is_dir() {
return Ok(());
}
let cf = db.cf_handle(CF_FEEDBACK).expect("feedback CF must exist");
let old_opts = Options::default();
match DB::open_for_read_only(&old_opts, &old_dir, false) {
Ok(old_db) => {
let mut batch = WriteBatch::default();
let mut count = 0usize;
for item in old_db.iterator(IteratorMode::Start) {
if let Ok((key, value)) = item {
batch.put_cf(cf, &key, &value);
count += 1;
if count % 10_000 == 0 {
db.write(std::mem::take(&mut batch))?;
}
}
}
if !batch.is_empty() {
db.write(batch)?;
}
drop(old_db);
tracing::info!(" feedback: migrated {count} entries to {CF_FEEDBACK} CF");
let backup = base_path.join("feedback.pre_cf_migration");
if backup.exists() {
let _ = std::fs::remove_dir_all(&backup);
}
if let Err(e) = std::fs::rename(&old_dir, &backup) {
tracing::warn!("Could not rename old feedback dir: {e}");
}
}
Err(e) => tracing::warn!("Could not open old feedback DB for migration: {e}"),
}
Ok(())
}
pub fn with_persistence<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> {
let mut opts = Options::default();
opts.create_if_missing(true);
opts.create_missing_column_families(true);
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
let cfs = vec![
ColumnFamilyDescriptor::new("default", Options::default()),
ColumnFamilyDescriptor::new(CF_FEEDBACK, {
let mut cf_opts = Options::default();
cf_opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
cf_opts
}),
];
let db = DB::open_cf_descriptors(&opts, path.as_ref(), cfs)?;
let db = Arc::new(db);
let cf = db.cf_handle(CF_FEEDBACK).expect("feedback CF must exist");
let mut momentum = HashMap::new();
let iter = db.prefix_iterator_cf(cf, b"momentum:");
for item in iter {
if let Ok((key, value)) = item {
if let Ok(key_str) = std::str::from_utf8(&key) {
if !key_str.starts_with("momentum:") {
break;
}
if let Ok(m) = serde_json::from_slice::<FeedbackMomentum>(&value) {
momentum.insert(m.memory_id.clone(), m);
}
}
}
}
let mut pending = HashMap::new();
let iter = db.prefix_iterator_cf(cf, b"pending:");
for item in iter {
if let Ok((key, value)) = item {
if let Ok(key_str) = std::str::from_utf8(&key) {
if !key_str.starts_with("pending:") {
break;
}
if let Ok(p) = serde_json::from_slice::<PendingFeedback>(&value) {
if !p.is_expired() {
pending.insert(p.user_id.clone(), p);
} else {
let _ = db.delete_cf(cf, key_str.as_bytes());
}
}
}
}
}
let mut previous_context = HashMap::new();
let iter = db.prefix_iterator_cf(cf, b"prev_ctx:");
for item in iter {
if let Ok((key, value)) = item {
if let Ok(key_str) = std::str::from_utf8(&key) {
if !key_str.starts_with("prev_ctx:") {
break;
}
if let Ok(ctx) = serde_json::from_slice::<PreviousContext>(&value) {
let user_id = key_str.strip_prefix("prev_ctx:").unwrap_or("");
previous_context.insert(user_id.to_string(), ctx);
}
}
}
}
tracing::info!(
"Loaded {} momentum, {} pending, {} previous context from feedback CF",
momentum.len(),
pending.len(),
previous_context.len()
);
Ok(Self {
momentum,
pending,
windows: HashMap::new(),
previous_context,
db: Some(db),
dirty: HashSet::new(),
})
}
pub fn get_or_create_momentum(
&mut self,
memory_id: MemoryId,
memory_type: ExperienceType,
) -> &mut FeedbackMomentum {
if !self.momentum.contains_key(&memory_id) {
if let (Some(db), Some(cf)) = (&self.db, self.feedback_cf()) {
let key = format!("momentum:{}", memory_id.0);
if let Ok(Some(data)) = db.get_cf(cf, key.as_bytes()) {
if let Ok(m) = serde_json::from_slice::<FeedbackMomentum>(&data) {
self.momentum.insert(memory_id.clone(), m);
}
}
}
}
self.momentum.entry(memory_id.clone()).or_insert_with(|| {
self.dirty.insert(memory_id.clone());
FeedbackMomentum::new(memory_id, memory_type)
})
}
pub fn get_momentum(&self, memory_id: &MemoryId) -> Option<FeedbackMomentum> {
if let Some(m) = self.momentum.get(memory_id) {
return Some(m.clone());
}
if let (Some(db), Some(cf)) = (&self.db, self.feedback_cf()) {
let key = format!("momentum:{}", memory_id.0);
if let Ok(Some(data)) = db.get_cf(cf, key.as_bytes()) {
if let Ok(m) = serde_json::from_slice::<FeedbackMomentum>(&data) {
return Some(m);
}
}
}
None
}
pub fn mark_dirty(&mut self, memory_id: &MemoryId) {
self.dirty.insert(memory_id.clone());
}
pub fn set_pending(&mut self, pending: PendingFeedback) {
let user_id = pending.user_id.clone();
self.pending.insert(user_id.clone(), pending.clone());
if let (Some(db), Some(cf)) = (&self.db, self.feedback_cf()) {
let key = format!("pending:{}", user_id);
if let Ok(value) = serde_json::to_vec(&pending) {
if let Err(e) = db.put_cf(cf, key.as_bytes(), &value) {
tracing::warn!("Failed to persist pending feedback: {}", e);
}
}
}
}
pub fn take_pending(&mut self, user_id: &str) -> Option<PendingFeedback> {
let result = self.pending.remove(user_id);
if let (Some(db), Some(cf)) = (&self.db, self.feedback_cf()) {
let key = format!("pending:{}", user_id);
let _ = db.delete_cf(cf, key.as_bytes());
}
result
}
pub fn get_pending(&self, user_id: &str) -> Option<&PendingFeedback> {
self.pending.get(user_id)
}
pub fn cleanup_expired(&mut self) {
self.pending.retain(|_, p| !p.is_expired());
let expired_users: Vec<String> = self
.windows
.iter()
.filter(|(_, w)| w.is_expired())
.map(|(k, _)| k.clone())
.collect();
for user_id in &expired_users {
self.flush_window(user_id);
}
}
pub fn get_or_create_window(&mut self, user_id: &str) -> &mut FeedbackWindow {
if let Some(window) = self.windows.get(user_id) {
if window.has_session_gap() {
let stale = self.windows.remove(user_id).unwrap();
self.apply_window_credits(&stale);
if let (Some(db), Some(cf)) = (&self.db, self.feedback_cf()) {
let key = format!("window:{}", user_id);
let _ = db.delete_cf(cf, key.as_bytes());
}
}
}
self.windows
.entry(user_id.to_string())
.or_insert_with(|| FeedbackWindow::new(user_id.to_string()))
}
pub fn push_window_entry(&mut self, user_id: &str, entry: WindowEntry) -> Vec<MemoryId> {
let window = self.get_or_create_window(user_id);
window.turn_counter = entry.turn_number + 1;
window.last_turn_at = entry.surfaced_at;
window.entries.push_back(entry);
let mut evicted_ids = Vec::new();
if window.entries.len() > window.window_size {
if let Some(evicted) = window.entries.pop_front() {
for mem in &evicted.surfaced_memories {
evicted_ids.push(mem.id.clone());
}
}
}
if !evicted_ids.is_empty() {
let mut credits_to_apply: Vec<(MemoryId, Vec<DeferredCredit>)> = Vec::new();
let window = self.windows.get_mut(user_id).unwrap();
for id in &evicted_ids {
if let Some(credits) = window.deferred_credits.remove(id) {
if !credits.is_empty() {
credits_to_apply.push((id.clone(), credits));
}
}
}
for (id, credits) in credits_to_apply {
self.apply_deferred_credit(&id, &credits);
}
}
self.persist_window(user_id);
evicted_ids
}
pub fn accumulate_deferred_credit(
&mut self,
user_id: &str,
memory_id: &MemoryId,
credit: DeferredCredit,
) {
if let Some(window) = self.windows.get_mut(user_id) {
window
.deferred_credits
.entry(memory_id.clone())
.or_default()
.push(credit);
}
}
pub fn snapshot_window_entries(&self, user_id: &str) -> Vec<WindowEntry> {
self.windows
.get(user_id)
.map(|w| w.entries.iter().cloned().collect())
.unwrap_or_default()
}
pub fn window_turn_counter(&self, user_id: &str) -> u32 {
self.windows
.get(user_id)
.map(|w| w.turn_counter)
.unwrap_or(0)
}
pub fn detect_session_outcome(&self, user_id: &str) -> Option<SessionOutcome> {
self.windows
.get(user_id)
.and_then(|w| w.detect_session_outcome())
}
pub fn flush_window(&mut self, user_id: &str) {
if let Some(window) = self.windows.remove(user_id) {
self.apply_window_credits(&window);
if let (Some(db), Some(cf)) = (&self.db, self.feedback_cf()) {
let key = format!("window:{}", user_id);
let _ = db.delete_cf(cf, key.as_bytes());
}
}
}
fn apply_window_credits(&mut self, window: &FeedbackWindow) {
for (memory_id, credits) in &window.deferred_credits {
self.apply_deferred_credit(memory_id, credits);
}
}
fn apply_deferred_credit(&mut self, memory_id: &MemoryId, credits: &[DeferredCredit]) {
if credits.is_empty() {
return;
}
let total: f32 = credits
.iter()
.map(|c| c.discounted_value * c.confidence)
.sum();
let avg_confidence: f32 =
credits.iter().map(|c| c.confidence).sum::<f32>() / credits.len() as f32;
if total.abs() < crate::constants::TEMPORAL_CREDIT_MIN_THRESHOLD {
return;
}
let clamped = total.clamp(-0.5, 0.5);
let signal = SignalRecord::new(
clamped,
(avg_confidence * 0.8).clamp(0.0, 1.0),
SignalTrigger::TemporalCredit {
turns_aggregated: credits.len() as u32,
raw_total: total,
},
);
let momentum = self.get_or_create_momentum(
memory_id.clone(),
crate::memory::types::ExperienceType::Context,
);
momentum.update(signal);
self.dirty.insert(memory_id.clone());
tracing::debug!(
memory_id = %memory_id.0,
credits = credits.len(),
total_discounted = format!("{:.3}", clamped),
"Applied temporal deferred credits to momentum"
);
}
fn persist_window(&self, user_id: &str) {
if let Some(window) = self.windows.get(user_id) {
if let (Some(db), Some(cf)) = (&self.db, self.feedback_cf()) {
let key = format!("window:{}", user_id);
if let Ok(value) = serde_json::to_vec(window) {
if let Err(e) = db.put_cf(cf, key.as_bytes(), &value) {
tracing::warn!("Failed to persist feedback window: {}", e);
}
}
}
}
}
pub fn set_previous_context(
&mut self,
user_id: &str,
context: String,
embedding: Vec<f32>,
surfaced_memory_ids: Vec<MemoryId>,
) {
let prev_ctx = PreviousContext {
context,
embedding,
timestamp: Utc::now(),
surfaced_memory_ids,
};
self.previous_context
.insert(user_id.to_string(), prev_ctx.clone());
if let (Some(db), Some(cf)) = (&self.db, self.feedback_cf()) {
let key = format!("prev_ctx:{}", user_id);
if let Ok(value) = serde_json::to_vec(&prev_ctx) {
if let Err(e) = db.put_cf(cf, key.as_bytes(), &value) {
tracing::warn!("Failed to persist previous context: {}", e);
}
}
}
}
pub fn get_previous_context(&self, user_id: &str) -> Option<&PreviousContext> {
self.previous_context.get(user_id)
}
pub fn detect_context_pattern(
&self,
user_id: &str,
current_embedding: &[f32],
) -> Option<(bool, bool, f32)> {
let prev = self.previous_context.get(user_id)?;
if prev.embedding.is_empty() || current_embedding.is_empty() {
return None;
}
let similarity = cosine_similarity(&prev.embedding, current_embedding);
let is_repetition = similarity > 0.8; let is_topic_change = similarity < 0.3;
Some((is_repetition, is_topic_change, similarity))
}
pub fn flush(&mut self) -> anyhow::Result<usize> {
let Some(ref db) = self.db else {
return Ok(0);
};
let Some(cf) = db.cf_handle(CF_FEEDBACK) else {
return Ok(0);
};
let dirty: Vec<MemoryId> = self.dirty.drain().collect();
let mut flushed = 0;
for memory_id in &dirty {
if let Some(momentum) = self.momentum.get(memory_id) {
let key = format!("momentum:{}", memory_id.0);
let value = serde_json::to_vec(momentum)?;
db.put_cf(cf, key.as_bytes(), &value)?;
flushed += 1;
}
}
for (user_id, pending) in &self.pending {
let key = format!("pending:{}", user_id);
let value = serde_json::to_vec(pending)?;
db.put_cf(cf, key.as_bytes(), &value)?;
}
for (user_id, window) in &self.windows {
let key = format!("window:{}", user_id);
let value = serde_json::to_vec(window)?;
db.put_cf(cf, key.as_bytes(), &value)?;
}
use rocksdb::FlushOptions;
let mut flush_opts = FlushOptions::default();
flush_opts.set_wait(true);
db.flush_cf_opt(cf, &flush_opts)
.map_err(|e| anyhow::anyhow!("Failed to flush feedback CF: {e}"))?;
if flushed > 0 {
tracing::debug!("Flushed {} feedback momentum entries to disk", flushed);
}
Ok(flushed)
}
pub fn database(&self) -> Option<&Arc<DB>> {
self.db.as_ref()
}
pub fn stats(&self) -> FeedbackStoreStats {
FeedbackStoreStats {
total_momentum_entries: self.momentum.len(),
total_pending: self.pending.len(),
avg_ema: if self.momentum.is_empty() {
0.0
} else {
self.momentum
.values()
.map(|m| m.ema_with_decay())
.sum::<f32>()
/ self.momentum.len() as f32
},
avg_stability: if self.momentum.is_empty() {
0.0
} else {
self.momentum.values().map(|m| m.stability).sum::<f32>()
/ self.momentum.len() as f32
},
total_windows: self.windows.len(),
total_deferred_credits: self
.windows
.values()
.map(|w| w.deferred_credits.values().map(|v| v.len()).sum::<usize>())
.sum(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeedbackStoreStats {
pub total_momentum_entries: usize,
pub total_pending: usize,
pub avg_ema: f32,
pub avg_stability: f32,
pub total_windows: usize,
pub total_deferred_credits: usize,
}
#[cfg(test)]
mod tests {
use super::*;
use uuid::Uuid;
#[test]
fn test_signal_from_entity_overlap() {
let signal = SignalRecord::from_entity_overlap(0.7);
assert!(signal.value > 0.5);
assert!(signal.confidence > 0.8);
let signal = SignalRecord::from_entity_overlap(0.3);
assert!(signal.value > 0.0);
assert!(signal.value < 0.5);
let signal = SignalRecord::from_entity_overlap(0.05);
assert!(signal.value < 0.0);
}
#[test]
fn test_momentum_inertia_by_type() {
let learning = FeedbackMomentum::new(MemoryId(Uuid::new_v4()), ExperienceType::Learning);
let conversation =
FeedbackMomentum::new(MemoryId(Uuid::new_v4()), ExperienceType::Conversation);
assert!(learning.base_inertia() > conversation.base_inertia());
assert!(learning.base_inertia() >= 0.9);
assert!(conversation.base_inertia() <= 0.4);
}
#[test]
fn test_momentum_update_with_inertia() {
let mut momentum = FeedbackMomentum::new(
MemoryId(Uuid::new_v4()),
ExperienceType::Learning, );
momentum.update(SignalRecord::new(
1.0,
1.0,
SignalTrigger::EntityOverlap { overlap_ratio: 1.0 },
));
assert!(momentum.ema > 0.0);
assert!(momentum.ema < 0.5);
for _ in 0..20 {
momentum.update(SignalRecord::new(
1.0,
1.0,
SignalTrigger::EntityOverlap { overlap_ratio: 1.0 },
));
}
assert!(momentum.ema > 0.5);
assert!(momentum.stability > 0.7);
}
#[test]
fn test_trend_detection() {
let mut signals = VecDeque::new();
assert_eq!(Trend::from_signals(&signals), Trend::Insufficient);
for i in 0..10 {
signals.push_back(SignalRecord::new(
i as f32 * 0.15, 1.0,
SignalTrigger::TopicChange { similarity: 0.2 },
));
}
assert_eq!(Trend::from_signals(&signals), Trend::Improving);
signals.clear();
for i in (0..10).rev() {
signals.push_back(SignalRecord::new(
i as f32 * 0.15, 1.0,
SignalTrigger::TopicChange { similarity: 0.2 },
));
}
assert_eq!(Trend::from_signals(&signals), Trend::Declining);
}
#[test]
fn test_entity_overlap() {
let memory: HashSet<String> = ["rust", "async", "tokio"]
.iter()
.map(|s| s.to_string())
.collect();
let response: HashSet<String> = ["rust", "tokio", "spawn"]
.iter()
.map(|s| s.to_string())
.collect();
let overlap = calculate_entity_overlap(&memory, &response);
assert!((overlap - 0.666).abs() < 0.01); }
#[test]
fn test_negative_keyword_detection() {
let text = "No, that's not what I meant";
let keywords = detect_negative_keywords(text);
assert!(keywords.contains(&"not what i meant".to_string()));
let text2 = "That's not helpful at all, it's irrelevant";
let keywords2 = detect_negative_keywords(text2);
assert!(keywords2.contains(&"not helpful".to_string()));
assert!(keywords2.contains(&"irrelevant".to_string()));
let text3 = "Please forget that, it doesn't work";
let keywords3 = detect_negative_keywords(text3);
assert!(keywords3.contains(&"forget that".to_string()));
assert!(keywords3.contains(&"doesn't work".to_string()));
let text4 = "Can you help me debug this function?";
let keywords4 = detect_negative_keywords(text4);
assert!(keywords4.is_empty());
}
#[test]
fn test_feedback_store_pending() {
let mut store = FeedbackStore::new();
let user_id = "test-user";
assert!(store.get_pending(user_id).is_none());
let pending = PendingFeedback::new(
user_id.to_string(),
"test context".to_string(),
vec![0.1; 384],
vec![SurfacedMemoryInfo {
id: MemoryId(Uuid::new_v4()),
entities: ["rust", "memory"].iter().map(|s| s.to_string()).collect(),
content_preview: "Test memory".to_string(),
score: 0.8,
embedding: Vec::new(),
}],
);
store.set_pending(pending);
assert!(store.get_pending(user_id).is_some());
assert_eq!(
store.get_pending(user_id).unwrap().surfaced_memories.len(),
1
);
let taken = store.take_pending(user_id);
assert!(taken.is_some());
assert!(store.get_pending(user_id).is_none());
}
#[test]
fn test_feedback_store_momentum() {
let mut store = FeedbackStore::new();
let memory_id = MemoryId(Uuid::new_v4());
let momentum = store.get_or_create_momentum(memory_id.clone(), ExperienceType::Context);
assert_eq!(momentum.signal_count, 0);
assert_eq!(momentum.ema, 0.0);
momentum.update(SignalRecord::new(
0.8,
1.0,
SignalTrigger::EntityOverlap { overlap_ratio: 0.8 },
));
assert!(momentum.ema > 0.0);
assert_eq!(momentum.signal_count, 1);
let momentum2 = store.get_momentum(&memory_id);
assert!(momentum2.is_some());
assert_eq!(momentum2.unwrap().signal_count, 1);
}
#[test]
fn test_process_implicit_feedback_full() {
let memory_id1 = MemoryId(Uuid::new_v4());
let memory_id2 = MemoryId(Uuid::new_v4());
let pending = PendingFeedback::new(
"user1".to_string(),
"How do I use async in Rust?".to_string(),
vec![0.1; 384],
vec![
SurfacedMemoryInfo {
id: memory_id1.clone(),
entities: ["rust", "async", "tokio"]
.iter()
.map(|s| s.to_string())
.collect(),
content_preview: "Rust async with tokio".to_string(),
score: 0.9,
embedding: Vec::new(),
},
SurfacedMemoryInfo {
id: memory_id2.clone(),
entities: ["python", "django"].iter().map(|s| s.to_string()).collect(),
content_preview: "Python Django web".to_string(),
score: 0.3,
embedding: Vec::new(),
},
],
);
let response =
"To use async in Rust, you can use tokio runtime. Here is an example with async await.";
let signals = process_implicit_feedback(&pending, response, None);
assert_eq!(signals.len(), 2);
let (id1, sig1) = &signals[0];
assert_eq!(id1, &memory_id1);
assert!(sig1.value > 0.0);
let (id2, sig2) = &signals[1];
assert_eq!(id2, &memory_id2);
assert!(sig2.value <= 0.0);
}
#[test]
fn test_process_implicit_feedback_with_negative_keywords() {
let memory_id = MemoryId(Uuid::new_v4());
let pending = PendingFeedback::new(
"user1".to_string(),
"How do I use async?".to_string(),
vec![0.1; 384],
vec![SurfacedMemoryInfo {
id: memory_id.clone(),
entities: ["async", "code"].iter().map(|s| s.to_string()).collect(),
content_preview: "Async code".to_string(),
score: 0.9,
embedding: Vec::new(),
}],
);
let response = "Here is the async code pattern";
let signals1 = process_implicit_feedback(&pending, response, None);
let value_without = signals1[0].1.value;
let signals2 = process_implicit_feedback(&pending, response, Some("No, that is wrong!"));
let value_with = signals2[0].1.value;
assert!(value_with < value_without);
}
#[test]
fn test_context_fingerprint_similarity() {
let embedding: Vec<f32> = (0..384).map(|i| (i as f32) * 0.01).collect();
let fp1 = ContextFingerprint::new(
vec!["rust".to_string(), "memory".to_string()],
&embedding,
true,
);
let fp2 = ContextFingerprint::new(
vec!["rust".to_string(), "async".to_string()],
&embedding,
false,
);
let different_embedding: Vec<f32> = (0..384).map(|i| 1.0 - (i as f32) * 0.01).collect();
let fp3 = ContextFingerprint::new(
vec!["python".to_string(), "django".to_string()],
&different_embedding,
true,
);
let sim12 = fp1.similarity(&fp2);
let sim13 = fp1.similarity(&fp3);
assert!(sim12 > sim13);
}
#[test]
fn test_feedback_store_stats() {
let mut store = FeedbackStore::new();
let stats = store.stats();
assert_eq!(stats.total_momentum_entries, 0);
assert_eq!(stats.total_pending, 0);
for i in 0..5 {
let mut momentum =
FeedbackMomentum::new(MemoryId(Uuid::new_v4()), ExperienceType::Context);
momentum.ema = i as f32 * 0.2; store.momentum.insert(momentum.memory_id.clone(), momentum);
}
let stats = store.stats();
assert_eq!(stats.total_momentum_entries, 5);
assert!((stats.avg_ema - 0.4).abs() < 0.01); }
#[test]
fn test_process_feedback_with_semantic_similarity() {
let memory_id1 = MemoryId(Uuid::new_v4());
let memory_id2 = MemoryId(Uuid::new_v4());
let rust_embedding: Vec<f32> = (0..384).map(|i| (i as f32) * 0.01).collect();
let python_embedding: Vec<f32> = (0..384).map(|i| 1.0 - (i as f32) * 0.01).collect();
let pending = PendingFeedback::new(
"user1".to_string(),
"How do I use async in Rust?".to_string(),
vec![0.1; 384],
vec![
SurfacedMemoryInfo {
id: memory_id1.clone(),
entities: ["rust", "async", "tokio"]
.iter()
.map(|s| s.to_string())
.collect(),
content_preview: "Rust async with tokio".to_string(),
score: 0.9,
embedding: rust_embedding.clone(),
},
SurfacedMemoryInfo {
id: memory_id2.clone(),
entities: ["python", "django"].iter().map(|s| s.to_string()).collect(),
content_preview: "Python Django web".to_string(),
score: 0.3,
embedding: python_embedding.clone(),
},
],
);
let response = "Here is how to use async/await in Rust with tokio runtime.";
let response_embedding = rust_embedding;
let signals_entity_only = process_implicit_feedback(&pending, response, None);
let signals_with_semantic = process_implicit_feedback_with_semantics(
&pending,
response,
None,
Some(&response_embedding),
);
let (id1, _sig1_entity) = &signals_entity_only[0];
let (_, sig1_semantic) = &signals_with_semantic[0];
assert_eq!(id1, &memory_id1);
match &sig1_semantic.trigger {
SignalTrigger::InformationAttribution {
attribution_score,
raw_similarity,
} => {
assert!(*raw_similarity > 0.9);
assert!(*attribution_score >= 0.0);
}
SignalTrigger::SemanticSimilarity { similarity } => {
assert!(*similarity > 0.9);
}
_ => panic!("Expected InformationAttribution or SemanticSimilarity trigger"),
}
let (id2, sig2_semantic) = &signals_with_semantic[1];
assert_eq!(id2, &memory_id2);
match &sig2_semantic.trigger {
SignalTrigger::InformationAttribution { raw_similarity, .. } => {
assert!(*raw_similarity < 0.5); }
SignalTrigger::SemanticSimilarity { similarity } => {
assert!(*similarity < 0.5);
}
_ => panic!("Expected InformationAttribution or SemanticSimilarity trigger"),
}
}
#[test]
fn test_cosine_similarity_basic() {
let a = vec![1.0, 0.0, 0.0];
let b = vec![1.0, 0.0, 0.0];
assert!((cosine_similarity(&a, &b) - 1.0).abs() < 0.001);
let c = vec![0.0, 1.0, 0.0];
assert!((cosine_similarity(&a, &c) - 0.0).abs() < 0.001);
let d = vec![-1.0, 0.0, 0.0];
assert!((cosine_similarity(&a, &d) - (-1.0)).abs() < 0.001);
assert!((cosine_similarity(&[], &[]) - 0.0).abs() < 0.001);
}
#[test]
fn test_calculate_entity_flow() {
use std::collections::HashSet;
let memory_entities: HashSet<String> = ["rust", "async", "tokio", "futures"]
.iter()
.map(|s| s.to_string())
.collect();
let response_entities: HashSet<String> = ["rust", "async", "tokio", "runtime"]
.iter()
.map(|s| s.to_string())
.collect();
let (derived_ratio, novel_ratio, derived_count, total) =
calculate_entity_flow(&memory_entities, &response_entities);
assert_eq!(derived_count, 3); assert_eq!(total, 4);
assert!((derived_ratio - 0.75).abs() < 0.01);
assert!((novel_ratio - 0.25).abs() < 0.01);
let response_novel: HashSet<String> = ["python", "django", "flask", "web"]
.iter()
.map(|s| s.to_string())
.collect();
let (derived_ratio2, novel_ratio2, derived_count2, _) =
calculate_entity_flow(&memory_entities, &response_novel);
assert_eq!(derived_count2, 0);
assert!((derived_ratio2 - 0.0).abs() < 0.01);
assert!((novel_ratio2 - 1.0).abs() < 0.01);
let empty: HashSet<String> = HashSet::new();
let (dr, nr, dc, total) = calculate_entity_flow(&memory_entities, &empty);
assert_eq!(dc, 0);
assert_eq!(total, 0);
assert!((dr - 0.0).abs() < 0.01);
assert!((nr - 0.0).abs() < 0.01);
}
#[test]
fn test_signal_from_entity_flow() {
let sig1 = signal_from_entity_flow(0.75, 0.25, 3, 4);
assert!(sig1.value > 0.5); assert!((sig1.confidence - 0.8).abs() < 0.01);
let sig2 = signal_from_entity_flow(0.3, 0.7, 1, 4);
assert!(sig2.value > 0.0 && sig2.value <= 0.5); assert!((sig2.confidence - 0.8).abs() < 0.01);
let sig3 = signal_from_entity_flow(0.1, 0.9, 0, 4);
assert!(sig3.value < 0.0); assert!((sig3.value - (-0.1)).abs() < 0.01);
let sig4 = signal_from_entity_flow(0.5, 0.5, 1, 2);
assert!((sig4.confidence - 0.5).abs() < 0.01);
match sig1.trigger {
SignalTrigger::EntityFlow {
derived_ratio,
novel_ratio,
memory_entities_used,
response_entities_total,
} => {
assert!((derived_ratio - 0.75).abs() < 0.01);
assert!((novel_ratio - 0.25).abs() < 0.01);
assert_eq!(memory_entities_used, 3);
assert_eq!(response_entities_total, 4);
}
_ => panic!("Expected EntityFlow trigger"),
}
}
}