Skip to content

Commit 5eac54d

Browse files
milkyskiesclaude
andauthored
[#540] Affective ToM: store last-observed mood per known agent (#741)
* [#540] AffectiveToM: store last-observed mood per known agent Adds an AffectiveToM component holding per-target PerceivedMood entries (dominant emotion, mood, stress, confidence, observation tick). Each tick, observers sample the EmotionalState of every visible agent; a slow staggered decay system fades confidence linearly over 24 game hours and prunes entries below MIN_CONFIDENCE. Capacity capped at 32 targets, oldest evicted. Substrate for fortune-of-others appraisal (#542), prosocial behaviors (#71), and other-regarding drives (#736). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * [#540] simplify: filter sentient, derive confidence, change-detect events - Promote dominant_emotion to EmotionalState method (reusable by appraisal). - Filter visible via iter_by_concept(Sentient) using MindGraph — skips non-sentient visibles cleanly, future-proof for animals with emotions. - Drop stored confidence; derive via confidence_at(now) to avoid stale state. - SimEvent only on dominant-emotion change (not per-pair-per-tick). - Trim narrative comments per project rules. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * [#540] fix: skip Entity id 0 in capacity test (Bevy 0.18 rejects it) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: milkyskies <milkyskies> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 04954f5 commit 5eac54d

8 files changed

Lines changed: 512 additions & 0 deletions

File tree

src/agent/events.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,15 @@ pub enum SimEventKind {
469469
belief_count: usize,
470470
},
471471

472+
/// An observer recorded a fresh affective-state observation about
473+
/// another agent — mood / emotion / stress at this tick.
474+
AffectiveToMUpdated {
475+
#[serde(serialize_with = "crate::core::entity_serde::serialize_entity")]
476+
agent: Entity,
477+
#[serde(serialize_with = "crate::core::entity_serde::serialize_entity")]
478+
about: Entity,
479+
},
480+
472481
/// A perishable item's freshness hit zero and its concept transitioned
473482
/// to its rotten variant (e.g. Apple → RottenApple). Fires once per item
474483
/// per spoilage — not every tick while rotten.

src/agent/mind/affective_tom.rs

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
//! Affective theory of mind: per-agent store of last-observed mood / emotion.
2+
//!
3+
//! Reads: VisibleObjects, MindGraph (sentience filter), EmotionalState, TickCount
4+
//! Writes: AffectiveToM, SimEvent
5+
//! Upstream: mind::social_perception
6+
//! Downstream: psyche::appraisal, prosocial behaviors, other-regarding drives
7+
8+
use bevy::prelude::*;
9+
use std::collections::HashMap;
10+
11+
use crate::agent::Agent;
12+
use crate::agent::events::{SimEvent, SimEventKind};
13+
use crate::agent::mind::knowledge::{Concept, MindGraph, Node};
14+
use crate::agent::mind::perception::VisibleObjects;
15+
use crate::agent::psyche::emotions::{EmotionType, EmotionalState};
16+
use crate::core::GameTime;
17+
use crate::core::tick::{TICK_RARE_PERIOD, TickCount};
18+
19+
/// Cap on distinct targets per observer. Matches the first-order ToM
20+
/// per-target cap so memory stays predictable as the social graph grows.
21+
pub const MAX_AFFECTIVE_TARGETS: usize = 32;
22+
23+
/// Game-time over which observation confidence decays linearly to zero.
24+
/// 24 game-hours: by next morning, what an agent saw of someone yesterday
25+
/// is fully faded.
26+
pub const CONFIDENCE_DECAY_TICKS: u64 = 24 * GameTime::TICKS_PER_HOUR;
27+
28+
/// Confidence floor below which an entry is pruned during decay.
29+
pub const MIN_CONFIDENCE: f32 = 0.1;
30+
31+
/// Mood at or below this counts as "distressed" even when no acute
32+
/// emotion is active — captures the drained / numb / stressed-out band.
33+
const DISTRESSED_MOOD: f32 = -0.3;
34+
35+
/// Snapshot of another agent's affective state at a single observation.
36+
/// Confidence is derived from `observed_at` via `confidence_at(now)` —
37+
/// not stored, to avoid stale-by-default state.
38+
#[derive(Debug, Clone, Copy)]
39+
pub struct PerceivedMood {
40+
pub dominant_emotion: Option<EmotionType>,
41+
/// Valence at observation, range -1.0..=1.0.
42+
pub mood: f32,
43+
/// Stress at observation, range 0..=100.
44+
pub stress: f32,
45+
pub observed_at: u64,
46+
}
47+
48+
impl PerceivedMood {
49+
/// Linearly-decayed confidence at `now`, clamped to [0, 1].
50+
pub fn confidence_at(&self, now: u64) -> f32 {
51+
let age = now.saturating_sub(self.observed_at) as f32;
52+
(1.0 - age / CONFIDENCE_DECAY_TICKS as f32).clamp(0.0, 1.0)
53+
}
54+
}
55+
56+
#[derive(Component, Debug, Default, Clone, Reflect)]
57+
#[reflect(Component)]
58+
pub struct AffectiveToM {
59+
#[reflect(ignore)]
60+
beliefs: HashMap<Entity, PerceivedMood>,
61+
}
62+
63+
impl AffectiveToM {
64+
/// Record what `target` looked like emotionally at `tick`. Refreshes
65+
/// the existing entry; on capacity overflow evicts the oldest target.
66+
pub fn record_observation(
67+
&mut self,
68+
target: Entity,
69+
dominant_emotion: Option<EmotionType>,
70+
mood: f32,
71+
stress: f32,
72+
tick: u64,
73+
) {
74+
let entry = PerceivedMood {
75+
dominant_emotion,
76+
mood,
77+
stress,
78+
observed_at: tick,
79+
};
80+
81+
if let Some(existing) = self.beliefs.get_mut(&target) {
82+
*existing = entry;
83+
return;
84+
}
85+
86+
if self.beliefs.len() >= MAX_AFFECTIVE_TARGETS
87+
&& let Some(oldest) = self
88+
.beliefs
89+
.iter()
90+
.min_by_key(|(_, e)| e.observed_at)
91+
.map(|(t, _)| *t)
92+
{
93+
self.beliefs.remove(&oldest);
94+
}
95+
96+
self.beliefs.insert(target, entry);
97+
}
98+
99+
pub fn perceived_mood(&self, target: Entity) -> Option<&PerceivedMood> {
100+
self.beliefs.get(&target)
101+
}
102+
103+
/// True iff the last observation of `target` shows distress: a
104+
/// negative-valence dominant emotion, or — when no acute emotion is
105+
/// active — a mood at or below `DISTRESSED_MOOD`. The mood-only
106+
/// branch catches "drained / quietly miserable", which has no
107+
/// acute-emotion signature.
108+
pub fn has_seen_distressed(&self, target: Entity) -> bool {
109+
let Some(mood) = self.beliefs.get(&target) else {
110+
return false;
111+
};
112+
let by_emotion = matches!(
113+
mood.dominant_emotion,
114+
Some(EmotionType::Sadness | EmotionType::Fear | EmotionType::Anger)
115+
);
116+
by_emotion || mood.mood <= DISTRESSED_MOOD
117+
}
118+
119+
pub fn target_count(&self) -> usize {
120+
self.beliefs.len()
121+
}
122+
123+
/// Drop entries whose confidence at `now` has fallen below
124+
/// `MIN_CONFIDENCE`. Returns the number of evictions.
125+
pub fn decay(&mut self, now: u64) -> usize {
126+
let before = self.beliefs.len();
127+
self.beliefs
128+
.retain(|_, mood| mood.confidence_at(now) >= MIN_CONFIDENCE);
129+
before - self.beliefs.len()
130+
}
131+
}
132+
133+
/// Per-observer system: sample affective state of every visible sentient
134+
/// agent and record it on the observer's `AffectiveToM`. Runs every
135+
/// tick — work per observer is `visible-sentient-count` HashMap ops.
136+
/// SimEvents are gated to dominant-emotion changes so the log isn't
137+
/// flooded with no-ops.
138+
pub fn update_affective_tom(
139+
mut observers: Query<(Entity, &VisibleObjects, &MindGraph, &mut AffectiveToM), With<Agent>>,
140+
targets: Query<&EmotionalState, With<Agent>>,
141+
tick: Res<TickCount>,
142+
mut sim_events: MessageWriter<SimEvent>,
143+
) {
144+
let now = tick.current;
145+
146+
for (observer, visible, mind, mut tom) in observers.iter_mut() {
147+
for visible_entity in
148+
visible.iter_by_concept(|c| mind.has_trait(&Node::Concept(c), Concept::Sentient))
149+
{
150+
if visible_entity == observer {
151+
continue;
152+
}
153+
let Ok(state) = targets.get(visible_entity) else {
154+
continue;
155+
};
156+
157+
// Change-detection: only emit a SimEvent when the dominant
158+
// emotion flips. Mood / stress drift quietly under the live
159+
// sample but don't deserve their own log line.
160+
let new_emotion = state.dominant_emotion();
161+
let prev_emotion = tom
162+
.perceived_mood(visible_entity)
163+
.and_then(|m| m.dominant_emotion);
164+
165+
tom.record_observation(
166+
visible_entity,
167+
new_emotion,
168+
state.current_mood,
169+
state.stress_level,
170+
now,
171+
);
172+
173+
if new_emotion != prev_emotion {
174+
sim_events.write(SimEvent::single(
175+
now,
176+
observer,
177+
SimEventKind::AffectiveToMUpdated {
178+
agent: observer,
179+
about: visible_entity,
180+
},
181+
));
182+
}
183+
}
184+
}
185+
}
186+
187+
/// Slow staggered prune of stale entries. Cadence shares
188+
/// `TICK_RARE_PERIOD` with the update system so each observer's
189+
/// AffectiveToM is touched at most once per period.
190+
pub fn decay_affective_tom(
191+
mut observers: Query<(Entity, &mut AffectiveToM), With<Agent>>,
192+
tick: Res<TickCount>,
193+
) {
194+
for (entity, mut tom) in observers.iter_mut() {
195+
if !tick.should_run(entity, TICK_RARE_PERIOD) {
196+
continue;
197+
}
198+
tom.decay(tick.current);
199+
}
200+
}
201+
202+
#[cfg(test)]
203+
mod tests {
204+
use super::*;
205+
use crate::agent::psyche::emotions::Emotion;
206+
207+
fn test_entity(id: u32) -> Entity {
208+
Entity::from_bits(id as u64)
209+
}
210+
211+
#[test]
212+
fn recording_a_sad_target_stores_sadness_as_dominant_emotion() {
213+
let mut tom = AffectiveToM::default();
214+
let alice = test_entity(1);
215+
let mut state = EmotionalState::default();
216+
state.add_emotion(Emotion::new(EmotionType::Sadness, 0.7));
217+
218+
tom.record_observation(alice, state.dominant_emotion(), -0.4, 30.0, 100);
219+
220+
let mood = tom.perceived_mood(alice).expect("must record observation");
221+
assert_eq!(mood.dominant_emotion, Some(EmotionType::Sadness));
222+
assert!((mood.mood - -0.4).abs() < 1e-6);
223+
assert_eq!(mood.observed_at, 100);
224+
}
225+
226+
#[test]
227+
fn never_observed_target_returns_none() {
228+
let tom = AffectiveToM::default();
229+
assert!(tom.perceived_mood(test_entity(99)).is_none());
230+
assert!(!tom.has_seen_distressed(test_entity(99)));
231+
}
232+
233+
#[test]
234+
fn confidence_decays_linearly_with_age() {
235+
let mut tom = AffectiveToM::default();
236+
let alice = test_entity(1);
237+
tom.record_observation(alice, None, 0.0, 0.0, 0);
238+
239+
let mood = tom.perceived_mood(alice).unwrap();
240+
let halfway = mood.confidence_at(CONFIDENCE_DECAY_TICKS / 2);
241+
assert!(
242+
(halfway - 0.5).abs() < 1e-3,
243+
"halfway through decay window should give ~0.5, got {halfway}"
244+
);
245+
assert!(mood.confidence_at(CONFIDENCE_DECAY_TICKS * 2).abs() < 1e-6);
246+
}
247+
248+
#[test]
249+
fn decay_evicts_entries_below_threshold() {
250+
let mut tom = AffectiveToM::default();
251+
let alice = test_entity(1);
252+
tom.record_observation(alice, None, 0.0, 0.0, 0);
253+
254+
let evicted = tom.decay(CONFIDENCE_DECAY_TICKS + 1);
255+
assert_eq!(evicted, 1);
256+
assert!(tom.perceived_mood(alice).is_none());
257+
}
258+
259+
#[test]
260+
fn capacity_evicts_oldest_target() {
261+
let mut tom = AffectiveToM::default();
262+
// Entity::from_bits(0) is invalid in Bevy 0.18 — start ids at 1.
263+
for i in 1..=MAX_AFFECTIVE_TARGETS {
264+
tom.record_observation(test_entity(i as u32), None, 0.0, 0.0, i as u64);
265+
}
266+
assert_eq!(tom.target_count(), MAX_AFFECTIVE_TARGETS);
267+
268+
let newcomer = test_entity(999);
269+
tom.record_observation(newcomer, None, 0.0, 0.0, 1000);
270+
assert_eq!(tom.target_count(), MAX_AFFECTIVE_TARGETS);
271+
assert!(tom.perceived_mood(test_entity(1)).is_none());
272+
assert!(tom.perceived_mood(newcomer).is_some());
273+
}
274+
275+
#[test]
276+
fn re_observing_same_target_refreshes_in_place() {
277+
let mut tom = AffectiveToM::default();
278+
let alice = test_entity(1);
279+
tom.record_observation(alice, Some(EmotionType::Sadness), -0.5, 40.0, 100);
280+
tom.record_observation(alice, Some(EmotionType::Joy), 0.6, 5.0, 500);
281+
282+
assert_eq!(tom.target_count(), 1);
283+
let mood = tom.perceived_mood(alice).unwrap();
284+
assert_eq!(mood.dominant_emotion, Some(EmotionType::Joy));
285+
assert_eq!(mood.observed_at, 500);
286+
}
287+
288+
#[test]
289+
fn has_seen_distressed_fires_on_negative_dominant_emotion() {
290+
let mut tom = AffectiveToM::default();
291+
let alice = test_entity(1);
292+
tom.record_observation(alice, Some(EmotionType::Sadness), 0.0, 30.0, 0);
293+
assert!(tom.has_seen_distressed(alice));
294+
}
295+
296+
#[test]
297+
fn has_seen_distressed_fires_on_low_mood_without_emotion() {
298+
let mut tom = AffectiveToM::default();
299+
let alice = test_entity(1);
300+
tom.record_observation(alice, None, -0.6, 10.0, 0);
301+
assert!(tom.has_seen_distressed(alice));
302+
}
303+
304+
#[test]
305+
fn has_seen_distressed_quiet_for_neutral_target() {
306+
let mut tom = AffectiveToM::default();
307+
let alice = test_entity(1);
308+
tom.record_observation(alice, Some(EmotionType::Joy), 0.4, 10.0, 0);
309+
assert!(!tom.has_seen_distressed(alice));
310+
}
311+
}

src/agent/mind/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
pub mod affective_tom;
12
pub mod belief_state;
23
pub mod belief_updater;
34
pub mod consolidation;

src/agent/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ impl Plugin for AgentPlugin {
115115
.register_type::<body::needs::Consciousness>()
116116
.register_type::<body::needs::PsychologicalDrives>()
117117
.register_type::<body::needs::SocialDriveOverride>()
118+
.register_type::<mind::affective_tom::AffectiveToM>()
118119
.register_type::<mind::memory::WorkingMemory>()
119120
.register_type::<psyche::emotions::EmotionalState>()
120121
.register_type::<mind::knowledge::MindGraph>()
@@ -209,6 +210,10 @@ impl Plugin for AgentPlugin {
209210
.after(mind::social_perception::perceive_other_agents),
210211
mind::theory_of_mind::update_shared_experience_tom
211212
.after(mind::perception::write_perceptions_to_mind),
213+
mind::affective_tom::update_affective_tom
214+
.after(mind::social_perception::perceive_other_agents),
215+
mind::affective_tom::decay_affective_tom
216+
.after(mind::affective_tom::update_affective_tom),
212217
)
213218
.in_set(crate::core::PerfBucket::Perception)
214219
.in_set(crate::core::PerfSubBucket::PerceptionSocial)

src/agent/psyche/emotions.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,21 @@ impl EmotionalState {
115115
.unwrap_or(0.0)
116116
}
117117

118+
/// Strongest active emotion, or `None` if no emotion is active.
119+
/// Used by affective theory of mind, appraisal, and any other
120+
/// "what mood is this agent in" lookup.
121+
pub fn dominant_emotion(&self) -> Option<EmotionType> {
122+
self.active_emotions
123+
.iter()
124+
.filter(|e| e.intensity > 0.0)
125+
.max_by(|a, b| {
126+
a.intensity
127+
.partial_cmp(&b.intensity)
128+
.unwrap_or(std::cmp::Ordering::Equal)
129+
})
130+
.map(|e| e.emotion_type)
131+
}
132+
118133
/// Advance emotion decay by `dt` seconds. Each emotion's fuel drains at a
119134
/// rate driven by `EmotionConfig`, with intensity tracking fuel directly.
120135
/// Emotions whose fuel falls below the removal threshold are dropped.

src/agent/spawn_human.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ pub struct PersonPerceptionBundle {
7070
pub social_identity: crate::agent::mind::social_identity::SocialIdentity,
7171
pub vision: Vision,
7272
pub visible: VisibleObjects,
73+
pub affective_tom: crate::agent::mind::affective_tom::AffectiveToM,
7374
}
7475

7576
/// Brains, drives, and the rest of the cognitive layer.
@@ -203,6 +204,7 @@ pub fn build_person_logic(
203204
social_identity: crate::agent::mind::social_identity::SocialIdentity::default(),
204205
vision: Vision { range: 100.0 },
205206
visible: VisibleObjects::default(),
207+
affective_tom: crate::agent::mind::affective_tom::AffectiveToM::default(),
206208
};
207209

208210
let brain = PersonBrainBundle {

0 commit comments

Comments
 (0)