use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::types::Balanced11;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PreferenceEntry {
pub tag: String,
pub valence: Balanced11,
pub exposure_count: u32,
pub last_exposure: DateTime<Utc>,
}
impl PreferenceEntry {
#[must_use]
#[inline]
fn alpha(&self) -> f32 {
1.0 / (1.0 + self.exposure_count as f32)
}
#[inline]
fn update(&mut self, outcome: f32, bias: &PreferenceBias, now: DateTime<Utc>) {
let alpha = self.alpha();
let biased_outcome = if outcome >= 0.0 {
outcome * bias.positive_gain
} else {
outcome * bias.negative_gain
};
self.valence = Balanced11::new(self.valence.get() * (1.0 - alpha) + biased_outcome * alpha);
self.exposure_count = self.exposure_count.saturating_add(1);
self.last_exposure = now;
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct PreferenceBias {
pub positive_gain: f32,
pub negative_gain: f32,
}
impl Default for PreferenceBias {
fn default() -> Self {
Self {
positive_gain: 1.0,
negative_gain: 1.0,
}
}
}
impl PreferenceBias {
#[must_use]
pub fn neutral() -> Self {
Self::default()
}
}
#[cfg(feature = "traits")]
#[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
#[must_use]
pub fn bias_from_personality(profile: &crate::traits::PersonalityProfile) -> PreferenceBias {
use crate::traits::TraitKind;
let warmth = profile.get_trait(TraitKind::Warmth).normalized();
let skepticism = profile.get_trait(TraitKind::Skepticism).normalized();
PreferenceBias {
positive_gain: (1.0 + warmth * 0.3).clamp(0.5, 1.5),
negative_gain: (1.0 + skepticism * 0.3).clamp(0.5, 1.5),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PreferenceStore {
entries: Vec<PreferenceEntry>,
capacity: usize,
pub bias: PreferenceBias,
}
impl PreferenceStore {
#[must_use]
pub fn new(capacity: usize) -> Self {
Self {
entries: Vec::new(),
capacity: capacity.max(1),
bias: PreferenceBias::neutral(),
}
}
#[must_use]
pub fn with_bias(capacity: usize, bias: PreferenceBias) -> Self {
Self {
entries: Vec::new(),
capacity: capacity.max(1),
bias,
}
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
pub fn record_outcome(&mut self, tag: impl Into<String>, outcome: f32, now: DateTime<Utc>) {
let tag = tag.into();
let outcome = outcome.clamp(-1.0, 1.0);
if let Some(entry) = self.entries.iter_mut().find(|e| e.tag == tag) {
entry.update(outcome, &self.bias, now);
return;
}
if self.entries.len() >= self.capacity {
self.evict_weakest();
}
let mut entry = PreferenceEntry {
tag,
valence: Balanced11::ZERO,
exposure_count: 0,
last_exposure: now,
};
entry.update(outcome, &self.bias, now);
self.entries.push(entry);
}
#[must_use]
pub fn preference_for(&self, tag: &str) -> Option<f32> {
self.entries
.iter()
.find(|e| e.tag == tag)
.map(|e| e.valence.get())
}
#[must_use]
pub fn get(&self, tag: &str) -> Option<&PreferenceEntry> {
self.entries.iter().find(|e| e.tag == tag)
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
pub fn decay(&mut self, rate: f32) {
let rate = rate.clamp(0.0, 1.0);
for entry in &mut self.entries {
entry.valence = Balanced11::new(entry.valence.get() * (1.0 - rate));
}
self.entries
.retain(|e| e.valence.get().abs() >= 0.01 || e.exposure_count >= 2);
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
#[must_use]
pub fn top_preferences(&self, n: usize) -> Vec<(&str, f32)> {
let mut positive: Vec<_> = self
.entries
.iter()
.filter(|e| e.valence.get() > 0.0)
.map(|e| (e.tag.as_str(), e.valence.get()))
.collect();
positive.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
positive.truncate(n);
positive
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
#[must_use]
pub fn bottom_preferences(&self, n: usize) -> Vec<(&str, f32)> {
let mut negative: Vec<_> = self
.entries
.iter()
.filter(|e| e.valence.get() < 0.0)
.map(|e| (e.tag.as_str(), e.valence.get()))
.collect();
negative.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
negative.truncate(n);
negative
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
fn evict_weakest(&mut self) {
crate::types::evict_min(&mut self.entries, |e| e.valence.get().abs() as f64);
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
fn now() -> DateTime<Utc> {
Utc::now()
}
#[test]
fn test_new_entry_moves_toward_outcome() {
let mut store = PreferenceStore::new(10);
store.record_outcome("agent_a", 0.8, now());
let v = store.preference_for("agent_a").unwrap();
assert!(
v > 0.0,
"positive outcome should produce positive valence: {v}"
);
}
#[test]
fn test_alpha_decreases_with_exposure() {
let e0 = PreferenceEntry {
tag: "test".into(),
valence: Balanced11::ZERO,
exposure_count: 0,
last_exposure: now(),
};
let e10 = PreferenceEntry {
exposure_count: 10,
..e0.clone()
};
assert!(e0.alpha() > e10.alpha(), "alpha should decrease");
assert!((e0.alpha() - 1.0).abs() < f32::EPSILON); assert!((e10.alpha() - 1.0 / 11.0).abs() < 0.01);
}
#[test]
fn test_repeated_positive_converges() {
let mut store = PreferenceStore::new(10);
for _ in 0..20 {
store.record_outcome("liked", 0.9, now());
}
let v = store.preference_for("liked").unwrap();
assert!(v > 0.7, "20 positive outcomes should converge high: {v}");
}
#[test]
fn test_repeated_negative_converges() {
let mut store = PreferenceStore::new(10);
for _ in 0..20 {
store.record_outcome("disliked", -0.9, now());
}
let v = store.preference_for("disliked").unwrap();
assert!(v < -0.7, "20 negative outcomes should converge low: {v}");
}
#[test]
fn test_mixed_outcomes_near_zero() {
let mut store = PreferenceStore::new(10);
for i in 0..20 {
let outcome = if i % 2 == 0 { 0.5 } else { -0.5 };
store.record_outcome("mixed", outcome, now());
}
let v = store.preference_for("mixed").unwrap();
assert!(v.abs() < 0.3, "mixed outcomes should be near neutral: {v}");
}
#[test]
fn test_early_experience_dominates() {
let mut store = PreferenceStore::new(10);
store.record_outcome("test", 1.0, now()); store.record_outcome("test", -0.5, now()); let v = store.preference_for("test").unwrap();
assert!(v > 0.0, "early strong positive should still dominate: {v}");
}
#[test]
fn test_decay_toward_neutral() {
let mut store = PreferenceStore::new(10);
store.record_outcome("test", 0.8, now());
let before = store.preference_for("test").unwrap();
store.decay(0.3);
let after = store.preference_for("test").unwrap();
assert!(after.abs() < before.abs(), "decay should reduce |valence|");
}
#[test]
fn test_decay_removes_weak() {
let mut store = PreferenceStore::new(10);
store.record_outcome("weak", 0.005, now());
store.decay(0.5);
assert!(
store.preference_for("weak").is_none(),
"weak preference should be removed"
);
}
#[test]
fn test_top_preferences_sorted() {
let mut store = PreferenceStore::new(10);
for _ in 0..10 {
store.record_outcome("best", 0.9, now());
}
for _ in 0..10 {
store.record_outcome("good", 0.5, now());
}
for _ in 0..10 {
store.record_outcome("bad", -0.5, now());
}
let top = store.top_preferences(2);
assert_eq!(top.len(), 2);
assert_eq!(top[0].0, "best");
assert_eq!(top[1].0, "good");
}
#[test]
fn test_bottom_preferences_sorted() {
let mut store = PreferenceStore::new(10);
for _ in 0..10 {
store.record_outcome("worst", -0.9, now());
}
for _ in 0..10 {
store.record_outcome("bad", -0.3, now());
}
let bottom = store.bottom_preferences(2);
assert_eq!(bottom.len(), 2);
assert_eq!(bottom[0].0, "worst");
}
#[test]
fn test_eviction_weakest() {
let mut store = PreferenceStore::new(2);
for _ in 0..10 {
store.record_outcome("strong", 0.9, now());
}
store.record_outcome("weak", 0.1, now());
assert_eq!(store.len(), 2);
for _ in 0..5 {
store.record_outcome("medium", 0.5, now());
}
assert_eq!(store.len(), 2);
assert!(
store.preference_for("weak").is_none(),
"weak should be evicted"
);
assert!(store.preference_for("strong").is_some());
assert!(store.preference_for("medium").is_some());
}
#[test]
fn test_valence_clamped() {
let mut store = PreferenceStore::new(10);
store.bias = PreferenceBias {
positive_gain: 5.0,
negative_gain: 5.0,
};
for _ in 0..50 {
store.record_outcome("extreme", 1.0, now());
}
let v = store.preference_for("extreme").unwrap();
assert!(v <= 1.0, "valence should be clamped: {v}");
}
#[test]
fn test_empty_store() {
let store = PreferenceStore::new(10);
assert!(store.is_empty());
assert_eq!(store.len(), 0);
assert!(store.preference_for("anything").is_none());
assert!(store.top_preferences(5).is_empty());
}
#[test]
fn test_with_bias() {
let bias = PreferenceBias {
positive_gain: 1.5,
negative_gain: 0.5,
};
let store = PreferenceStore::with_bias(10, bias);
assert!((store.bias.positive_gain - 1.5).abs() < f32::EPSILON);
}
#[test]
fn test_get_entry() {
let mut store = PreferenceStore::new(10);
store.record_outcome("test", 0.5, now());
let entry = store.get("test").unwrap();
assert_eq!(entry.exposure_count, 1);
}
#[test]
fn test_serde_store() {
let mut store = PreferenceStore::new(10);
store.record_outcome("test", 0.7, now());
let json = serde_json::to_string(&store).unwrap();
let store2: PreferenceStore = serde_json::from_str(&json).unwrap();
assert_eq!(store2.len(), store.len());
}
#[test]
fn test_serde_bias() {
let b = PreferenceBias {
positive_gain: 1.3,
negative_gain: 0.8,
};
let json = serde_json::to_string(&b).unwrap();
let b2: PreferenceBias = serde_json::from_str(&json).unwrap();
assert!((b2.positive_gain - 1.3).abs() < f32::EPSILON);
}
#[cfg(feature = "traits")]
#[test]
fn test_bias_from_personality_warm() {
let mut p = crate::traits::PersonalityProfile::new("warm");
p.set_trait(
crate::traits::TraitKind::Warmth,
crate::traits::TraitLevel::Highest,
);
let bias = bias_from_personality(&p);
assert!(
bias.positive_gain > 1.0,
"warm should boost positive: {}",
bias.positive_gain
);
}
#[cfg(feature = "traits")]
#[test]
fn test_bias_from_personality_skeptical() {
let mut p = crate::traits::PersonalityProfile::new("skeptic");
p.set_trait(
crate::traits::TraitKind::Skepticism,
crate::traits::TraitLevel::Highest,
);
let bias = bias_from_personality(&p);
assert!(
bias.negative_gain > 1.0,
"skeptic should boost negative: {}",
bias.negative_gain
);
}
#[cfg(feature = "traits")]
#[test]
fn test_bias_warmth_forms_positive_faster() {
let mut warm_store = PreferenceStore::with_bias(
10,
PreferenceBias {
positive_gain: 1.3,
negative_gain: 1.0,
},
);
let mut neutral_store = PreferenceStore::new(10);
for _ in 0..5 {
warm_store.record_outcome("agent", 0.5, now());
neutral_store.record_outcome("agent", 0.5, now());
}
let warm_v = warm_store.preference_for("agent").unwrap();
let neutral_v = neutral_store.preference_for("agent").unwrap();
assert!(
warm_v > neutral_v,
"warm={warm_v} should > neutral={neutral_v}"
);
}
}