mindsphere_coach / src /mindsphere /core /emotional_state.py
Mahault
Fix emotion detection: center arousal in backend, fix LLM classifier fallback, add 57 emotion accuracy tests
22521f9
"""
Circumplex Model of Emotion for MindSphere Coach.
Implements the Active Inference formulation from:
Pattisapu, Verbelen, Pitliya, Kiefer & Albarracin (2024)
"Free Energy in a Circumplex Model of Emotion"
Two-dimensional emotional space:
Arousal = H[Q(s|o)] = entropy of posterior beliefs
High entropy β†’ high uncertainty β†’ high arousal (alert, anxious)
Low entropy β†’ high certainty β†’ low arousal (calm, relaxed)
Valence = Utility - Expected Utility = log P(o|C) - E[log P(o|C)]
Positive β†’ "better than expected" outcome (happy, excited)
Negative β†’ "worse than expected" outcome (sad, angry)
The key innovation in MindSphere: the agent PREDICTS the user's emotional
state via ToM, then the LLM generates OBSERVATIONS by classifying user text.
Prediction error drives both emotional belief updates AND ToM learning.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
# =============================================================================
# CORE COMPUTATION β€” from Pattisapu et al. (2024)
# =============================================================================
def compute_belief_entropy(qs: np.ndarray, eps: float = 1e-16) -> float:
"""
Compute entropy of posterior beliefs H[Q(s|o)].
This is the arousal signal in the Circumplex Model:
- High entropy = high uncertainty = high arousal
- Low entropy = high certainty = low arousal
"""
qs_safe = np.clip(qs, eps, 1.0)
qs_safe = qs_safe / qs_safe.sum()
entropy = -np.sum(qs_safe * np.log(qs_safe))
return float(entropy)
def compute_utility(obs_idx: int, C: np.ndarray, eps: float = 1e-16) -> float:
"""
Compute utility of an observation given preferences.
U = log P(o|C) or C[obs] if C is already log-preferences.
"""
c_val = C[obs_idx]
if np.all(C <= 0):
return float(c_val)
else:
return float(np.log(max(c_val, eps)))
def compute_expected_utility(
qs: np.ndarray,
A: np.ndarray,
C: np.ndarray,
eps: float = 1e-16,
) -> float:
"""
Compute expected utility before seeing observation.
EU = E_Q(o|s) [log P(o|C)] = sum_o P(o|qs) * log P(o|C)
"""
obs_dist = A @ qs
obs_dist = np.clip(obs_dist, eps, 1.0)
obs_dist = obs_dist / obs_dist.sum()
if np.all(C <= 0):
log_C = C
else:
log_C = np.log(np.clip(C, eps, 1.0))
eu = np.sum(obs_dist * log_C)
return float(eu)
def compute_valence(
obs_idx: int,
qs_prior: np.ndarray,
A: np.ndarray,
C: np.ndarray,
eps: float = 1e-16,
) -> float:
"""
Compute valence as reward prediction error.
Valence = Utility - Expected Utility
= log P(o|C) - E[log P(o|C)]
Positive valence = "better than expected"
Negative valence = "worse than expected"
"""
u = compute_utility(obs_idx, C, eps)
eu = compute_expected_utility(qs_prior, A, C, eps)
return u - eu
# =============================================================================
# CIRCUMPLEX MAPPING
# =============================================================================
# 8 emotion sectors (45 degrees each)
EMOTION_SECTORS = {
"happy": (337.5, 22.5),
"excited": (22.5, 67.5),
"alert": (67.5, 112.5),
"angry": (112.5, 157.5),
"sad": (157.5, 202.5),
"depressed": (202.5, 247.5),
"calm": (247.5, 292.5),
"relaxed": (292.5, 337.5),
}
@dataclass
class EmotionalState:
"""
Single emotional state in the Circumplex Model.
arousal: H[Q(s|o)] = posterior entropy (uncertainty)
valence: U - EU = reward prediction error
intensity: sqrt(arousal^2 + valence^2)
angle: position on the circumplex (degrees)
"""
arousal: float
valence: float
intensity: float = 0.0
angle: float = 0.0
timestep: int = 0
def __post_init__(self):
self.intensity = float(np.sqrt(self.arousal**2 + self.valence**2))
self.angle = float(np.degrees(np.arctan2(self.arousal, self.valence)))
if self.angle < 0:
self.angle += 360
def emotion_label(self) -> str:
"""Get discrete emotion label based on angle in circumplex."""
angle = self.angle % 360
if 337.5 <= angle or angle < 22.5:
return "happy"
elif 22.5 <= angle < 67.5:
return "excited"
elif 67.5 <= angle < 112.5:
return "alert"
elif 112.5 <= angle < 157.5:
return "angry"
elif 157.5 <= angle < 202.5:
return "sad"
elif 202.5 <= angle < 247.5:
return "depressed"
elif 247.5 <= angle < 292.5:
return "calm"
else:
return "relaxed"
def quadrant(self) -> str:
"""Get quadrant description."""
if self.valence > 0 and self.arousal > 0:
return "high-arousal-positive"
elif self.valence <= 0 and self.arousal > 0:
return "high-arousal-negative"
elif self.valence > 0 and self.arousal <= 0:
return "low-arousal-positive"
else:
return "low-arousal-negative"
def to_dict(self) -> Dict[str, Any]:
"""Serialize to dict."""
return {
"arousal": round(self.arousal, 3),
"valence": round(self.valence, 3),
"intensity": round(self.intensity, 3),
"angle": round(self.angle, 1),
"emotion": self.emotion_label(),
"quadrant": self.quadrant(),
}
# =============================================================================
# OBSERVATION MODEL β€” mapping LLM-classified text to valence/arousal
# =============================================================================
# Observation levels for LLM-classified emotional signals
VALENCE_OBS_LEVELS = ["very_negative", "negative", "neutral", "positive", "very_positive"]
AROUSAL_OBS_LEVELS = ["very_low", "low", "moderate", "high", "very_high"]
# Valence state levels (hidden)
VALENCE_STATES = ["very_negative", "negative", "neutral", "positive", "very_positive"]
# Arousal state levels (hidden)
AROUSAL_STATES = ["very_low", "low", "moderate", "high", "very_high"]
def build_emotion_A_matrix(n_obs: int = 5, n_states: int = 5) -> np.ndarray:
"""
Build observation model A: p(obs | state) for emotional factors.
LLM-classified text is the observation. The hidden state is the
user's actual emotional state. The A-matrix encodes how likely
each LLM classification is given the true state.
Higher diagonal = LLM classification is reliable.
"""
# High reliability on diagonal, with noise for uncertainty
A = np.array([
[0.60, 0.20, 0.05, 0.02, 0.01], # obs=very_negative
[0.25, 0.50, 0.15, 0.05, 0.02], # obs=negative
[0.10, 0.20, 0.55, 0.20, 0.10], # obs=neutral
[0.03, 0.07, 0.15, 0.50, 0.25], # obs=positive
[0.02, 0.03, 0.10, 0.23, 0.62], # obs=very_positive
], dtype=np.float64)
# Normalize columns
for col in range(n_states):
A[:, col] = A[:, col] / A[:, col].sum()
return A
def build_emotion_B_matrix(n_states: int = 5) -> np.ndarray:
"""
Build transition model B: p(state' | state) for emotional factors.
Emotional states have inertia but drift toward neutral over time.
This captures the psychological reality that extreme emotions
don't persist indefinitely without continued stimuli.
"""
# Each state has 70% inertia, 20% drift toward neutral, 10% noise
B = np.zeros((n_states, n_states), dtype=np.float64)
neutral_idx = n_states // 2 # index 2
for s in range(n_states):
B[s, s] = 0.70 # Inertia
# Drift toward neutral
if s < neutral_idx:
B[s + 1, s] = 0.20 # Move toward neutral
elif s > neutral_idx:
B[s - 1, s] = 0.20 # Move toward neutral
else:
B[s, s] += 0.20 # Stay at neutral
# Small noise to all states
B[:, s] += 0.10 / n_states
# Normalize columns
for col in range(n_states):
B[:, col] = B[:, col] / B[:, col].sum()
return B
def build_emotion_D(n_states: int = 5) -> np.ndarray:
"""
Build initial prior D for emotional factors.
Start with a mild assumption of neutral emotional state.
"""
D = np.array([0.05, 0.15, 0.60, 0.15, 0.05], dtype=np.float64)
return D / D.sum()
# =============================================================================
# PREDICT-OBSERVE-UPDATE ENGINE
# =============================================================================
@dataclass
class EmotionalPrediction:
"""
A prediction of the user's emotional state, made BEFORE observing their text.
This is the "predict" half of the predict-observe-update loop.
"""
predicted_valence: float # From ToM + preference models
predicted_arousal: float # From belief entropy
predicted_emotion: str # Circumplex label
confidence: float # How confident we are in the prediction
source: str = "tom" # Where the prediction came from
def to_dict(self) -> Dict[str, Any]:
return {
"predicted_valence": round(self.predicted_valence, 3),
"predicted_arousal": round(self.predicted_arousal, 3),
"predicted_emotion": self.predicted_emotion,
"confidence": round(self.confidence, 3),
"source": self.source,
}
@dataclass
class EmotionalObservation:
"""
An observation of the user's emotional state from LLM classification.
This is the "observe" half of the predict-observe-update loop.
"""
observed_valence_idx: int # 0-4 index into VALENCE_OBS_LEVELS
observed_arousal_idx: int # 0-4 index into AROUSAL_OBS_LEVELS
observed_valence: float # Continuous value [-1, 1]
observed_arousal: float # Continuous value [0, 1]
observed_emotion: str # Circumplex label
raw_classification: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
return {
"observed_valence": round(self.observed_valence, 3),
"observed_arousal": round(self.observed_arousal, 3),
"observed_emotion": self.observed_emotion,
"valence_idx": self.observed_valence_idx,
"arousal_idx": self.observed_arousal_idx,
}
@dataclass
class PredictionError:
"""
Prediction error between predicted and observed emotional state.
This drives learning:
- Large errors β†’ the ToM model is wrong β†’ update particles more
- Small errors β†’ the model is accurate β†’ increase confidence
"""
valence_error: float # observed - predicted
arousal_error: float # observed - predicted
magnitude: float = 0.0 # sqrt(v_err^2 + a_err^2)
surprise: float = 0.0 # - log p(observed | predicted)
def __post_init__(self):
self.magnitude = float(np.sqrt(
self.valence_error**2 + self.arousal_error**2
))
def to_dict(self) -> Dict[str, Any]:
return {
"valence_error": round(self.valence_error, 3),
"arousal_error": round(self.arousal_error, 3),
"magnitude": round(self.magnitude, 3),
"surprise": round(self.surprise, 3),
}
class EmotionEngine:
"""
Circumplex emotion inference engine with predict-observe-update loop.
Architecture:
1. PREDICT: Before observing user text, predict their emotional state
- Arousal from belief entropy (how uncertain is our model?)
- Valence from ToM (how does the user feel about what happened?)
2. OBSERVE: LLM classifies user text into valence/arousal observations
- These become formal POMDP observations
3. UPDATE: Compare prediction vs observation
- Update emotional belief via Bayesian update (A-matrix)
- Use prediction error to soft-update ToM particles
- Large errors β†’ ToM is miscalibrated β†’ increase learning rate
- Small errors β†’ ToM is accurate β†’ increase confidence
4. RECORD: Store emotional trajectory for context
"""
def __init__(self):
# POMDP matrices for emotional factors
self.A_valence = build_emotion_A_matrix()
self.A_arousal = build_emotion_A_matrix()
self.B_valence = build_emotion_B_matrix()
self.B_arousal = build_emotion_B_matrix()
# Beliefs over emotional states
self.belief_valence = build_emotion_D()
self.belief_arousal = build_emotion_D()
# Preferences: agent prefers user in positive, calm state
self.C_valence = np.array([-2.0, -0.5, 0.0, 0.5, 1.0], dtype=np.float64)
self.C_arousal = np.array([0.0, 0.5, 0.0, -0.5, -1.0], dtype=np.float64)
# History
self.predictions: List[EmotionalPrediction] = []
self.observations: List[EmotionalObservation] = []
self.errors: List[PredictionError] = []
self.states: List[EmotionalState] = []
# Normalization scales (from Pattisapu et al.)
self.arousal_scale = 1.6 # log(5) for 5-state factors
self.valence_scale = 3.0
def predict(
self,
belief_entropies: Dict[str, float],
tom_felt_cost: float,
tom_p_accept: float,
reliability: float,
) -> EmotionalPrediction:
"""
PREDICT the user's emotional state before observing their text.
Arousal: derived from the entropy of the agent's beliefs about the user.
If the agent is uncertain about the user β†’ the user is likely in an
uncertain situation β†’ high arousal.
Valence: derived from ToM predictions.
- Low felt_cost + high p_accept β†’ things going well β†’ positive valence
- High felt_cost + low p_accept β†’ things going badly β†’ negative valence
This follows the Active Inference pattern: valence = U - EU.
The "utility" for the user is low felt cost and acceptance.
"""
# Arousal from belief entropy (average across skill factors)
if belief_entropies:
avg_entropy = np.mean(list(belief_entropies.values()))
arousal = avg_entropy / self.arousal_scale
else:
arousal = 0.5
# Valence from ToM predictions
# User's "utility" is inversely related to felt cost
# p_accept is a proxy for how well things are going
valence_raw = (1.0 - tom_felt_cost) * 0.5 + tom_p_accept * 0.5 - 0.5
valence = float(np.tanh(valence_raw / 0.3))
# Scale by reliability β€” less confident predictions are closer to neutral
arousal = arousal * reliability + 0.5 * (1 - reliability)
valence = valence * reliability
# Map to circumplex
state = EmotionalState(arousal=arousal, valence=valence)
prediction = EmotionalPrediction(
predicted_valence=valence,
predicted_arousal=arousal,
predicted_emotion=state.emotion_label(),
confidence=reliability,
)
self.predictions.append(prediction)
return prediction
def observe(
self,
valence_idx: int,
arousal_idx: int,
raw_classification: Optional[Dict] = None,
) -> EmotionalObservation:
"""
OBSERVE the user's emotional state from LLM classification.
The LLM classifies user text into discrete valence (0-4) and
arousal (0-4) levels. These are formal POMDP observations.
"""
# Map indices to continuous values
valence_map = [-0.8, -0.4, 0.0, 0.4, 0.8]
arousal_map = [0.1, 0.3, 0.5, 0.7, 0.9]
v = valence_map[min(valence_idx, 4)]
a_raw = arousal_map[min(arousal_idx, 4)]
# Center arousal for circumplex mapping: [0.1,0.9] β†’ [-0.8,0.8]
# This ensures emotions map to all 4 quadrants, not just the top half.
a_centered = (a_raw - 0.5) * 2
state = EmotionalState(arousal=a_centered, valence=v)
observation = EmotionalObservation(
observed_valence_idx=valence_idx,
observed_arousal_idx=arousal_idx,
observed_valence=v,
observed_arousal=a_raw,
observed_emotion=state.emotion_label(),
raw_classification=raw_classification or {},
)
self.observations.append(observation)
return observation
def update(
self,
prediction: EmotionalPrediction,
observation: EmotionalObservation,
) -> PredictionError:
"""
UPDATE beliefs by comparing prediction against observation.
This is the core of the predict-observe-update loop:
1. Bayesian update of emotional beliefs using A-matrix
2. Compute prediction error for ToM learning
3. Record emotional state
Returns the prediction error, which the caller uses to
soft-update ToM particles.
"""
# --- Bayesian belief update ---
# Valence belief: p(state | obs) ∝ p(obs | state) * p(state)
likelihood_v = self.A_valence[observation.observed_valence_idx, :]
self.belief_valence = likelihood_v * self.belief_valence
bv_sum = self.belief_valence.sum()
if bv_sum > 0:
self.belief_valence /= bv_sum
else:
self.belief_valence = build_emotion_D()
# Arousal belief
likelihood_a = self.A_arousal[observation.observed_arousal_idx, :]
self.belief_arousal = likelihood_a * self.belief_arousal
ba_sum = self.belief_arousal.sum()
if ba_sum > 0:
self.belief_arousal /= ba_sum
else:
self.belief_arousal = build_emotion_D()
# --- Apply transition dynamics (drift toward neutral) ---
self.belief_valence = self.B_valence @ self.belief_valence
self.belief_arousal = self.B_arousal @ self.belief_arousal
# --- Compute prediction error ---
v_error = observation.observed_valence - prediction.predicted_valence
a_error = observation.observed_arousal - prediction.predicted_arousal
# Surprise: how unlikely was this observation given the prediction?
# Use the belief before update to compute this
surprise_v = -np.log(max(likelihood_v.max(), 1e-12))
surprise_a = -np.log(max(likelihood_a.max(), 1e-12))
surprise = float(surprise_v + surprise_a) / 2
error = PredictionError(
valence_error=v_error,
arousal_error=a_error,
surprise=surprise,
)
self.errors.append(error)
# --- Record emotional state ---
# Center arousal for circumplex mapping: raw [0.1,0.9] β†’ centered [-0.8,0.8]
a_centered = (observation.observed_arousal - 0.5) * 2
state = EmotionalState(
arousal=a_centered,
valence=observation.observed_valence,
timestep=len(self.states),
)
self.states.append(state)
return error
def get_current_emotion(self) -> Optional[EmotionalState]:
"""Get the most recent emotional state."""
return self.states[-1] if self.states else None
def get_belief_state(self) -> Dict[str, Any]:
"""Get current emotional beliefs for the POMDP."""
# Most likely valence/arousal states
v_idx = int(np.argmax(self.belief_valence))
a_idx = int(np.argmax(self.belief_arousal))
return {
"valence": {
"belief": self.belief_valence.tolist(),
"most_likely": VALENCE_STATES[v_idx],
"confidence": float(np.max(self.belief_valence)),
"entropy": compute_belief_entropy(self.belief_valence),
},
"arousal": {
"belief": self.belief_arousal.tolist(),
"most_likely": AROUSAL_STATES[a_idx],
"confidence": float(np.max(self.belief_arousal)),
"entropy": compute_belief_entropy(self.belief_arousal),
},
}
def get_emotional_trajectory(self) -> Dict[str, Any]:
"""Get the emotional trajectory summary."""
if not self.states:
return {"states": [], "emotions": [], "errors": []}
return {
"states": [s.to_dict() for s in self.states[-5:]],
"emotions": [s.emotion_label() for s in self.states[-5:]],
"predictions": [p.to_dict() for p in self.predictions[-5:]],
"errors": [e.to_dict() for e in self.errors[-5:]],
"current": self.states[-1].to_dict(),
"avg_prediction_error": float(np.mean(
[e.magnitude for e in self.errors[-10:]]
)) if self.errors else 0.0,
}
def reset(self) -> None:
"""Reset all state."""
self.belief_valence = build_emotion_D()
self.belief_arousal = build_emotion_D()
self.predictions = []
self.observations = []
self.errors = []
self.states = []