Skip to content

Commit 51aa6f6

Browse files
authored
Merge pull request #2 from hanamorix/week-2-emotion-core
feat: Week 2 — brain/emotion package (7 modules)
2 parents bf1f3a0 + 767e064 commit 51aa6f6

17 files changed

Lines changed: 4117 additions & 0 deletions

brain/emotion/__init__.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""The emotional core — organising principle of companion-emergence.
2+
3+
Seven sub-modules, each with a single responsibility:
4+
- vocabulary: typed emotion taxonomy + persona extension registry
5+
- state: current emotional state (dict + residue queue + dominant)
6+
- decay: per-emotion temporal decay curves
7+
- arousal: 7-tier body-coupled arousal spectrum
8+
- blend: co-occurrence detection for emergent emotional blends
9+
- influence: state → biasing hints for provider abstraction
10+
- expression: state → face/voice parameters for NellFace
11+
12+
See spec Section 5 for design rationale.
13+
"""
14+
15+
from brain.emotion.vocabulary import Emotion, by_category, get, list_all, register
16+
17+
__all__ = ["Emotion", "get", "list_all", "by_category", "register"]

brain/emotion/arousal.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""7-tier arousal spectrum.
2+
3+
Spec: 7 tiers from dormant through edge. Computed from the current emotional
4+
state + body temperature. Grief and shame suppress arousal. Love alone
5+
doesn't progress past warmed. Desire + tenderness reaches upward.
6+
7+
Design per spec Section 5.2 (arousal sub-module) and Section 5.5 (body-emotion
8+
coupling).
9+
10+
Week 2 scope: forward direction only (state + body → tier). Reverse coupling
11+
(tier → body state updates) is Week 4 when engines land.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
from types import MappingProxyType
17+
18+
from brain.emotion.state import EmotionalState
19+
20+
# The 7 tiers, as named constants (module-level ints for fast comparison).
21+
TIER_DORMANT: int = 0 # no arousal signal at all
22+
TIER_CASUAL: int = 1 # everyday warmth, unfocused
23+
TIER_WARMED: int = 2 # affection present, no pursuit
24+
TIER_REACHING: int = 3 # wanting acknowledged, initiating
25+
TIER_CHARGED: int = 4 # mutual, active
26+
TIER_HELD: int = 5 # peaked and restrained — deliberate pause
27+
TIER_EDGE: int = 6 # at the threshold, no restraint
28+
29+
# Emotions that feed into arousal calculation, with their contribution weights.
30+
# MappingProxyType (read-only dict view) prevents accidental runtime mutation
31+
# from callers. love=0.15 so at max intensity (10) raw=1.5, keeping pure love
32+
# inside WARMED per the module docstring's semantic contract.
33+
_AROUSAL_EMOTIONS: MappingProxyType[str, float] = MappingProxyType(
34+
{
35+
"arousal": 1.0,
36+
"desire": 0.7,
37+
"tenderness": 0.2,
38+
"love": 0.15,
39+
}
40+
)
41+
42+
# Emotions that suppress arousal.
43+
_SUPPRESSORS: MappingProxyType[str, float] = MappingProxyType(
44+
{
45+
"grief": 0.9,
46+
"shame": 0.7,
47+
"fear": 0.5,
48+
}
49+
)
50+
51+
52+
def compute_tier(state: EmotionalState, body_temperature: int) -> int:
53+
"""Return the arousal tier for the given emotional + bodily state.
54+
55+
Args:
56+
state: Current EmotionalState.
57+
body_temperature: Relative body temperature (range roughly -5..+10;
58+
neutral=0). Higher values amplify arousal signal.
59+
60+
Returns:
61+
An integer tier constant (TIER_DORMANT through TIER_EDGE).
62+
"""
63+
# 1. Compute raw arousal score from arousal-adjacent emotions.
64+
raw = 0.0
65+
for name, weight in _AROUSAL_EMOTIONS.items():
66+
intensity = state.emotions.get(name, 0.0)
67+
raw += intensity * weight
68+
69+
# 2. Short-circuit if nothing is feeding arousal — body temp alone doesn't
70+
# create it.
71+
if raw <= 0.0:
72+
return TIER_DORMANT
73+
74+
# 3. Suppressors reduce raw signal proportionally.
75+
suppression = 0.0
76+
for name, weight in _SUPPRESSORS.items():
77+
intensity = state.emotions.get(name, 0.0)
78+
suppression += intensity * weight
79+
# Cap suppression so strong grief can't push below 0.
80+
raw = max(0.0, raw - suppression)
81+
82+
# If suppression fully negated the arousal signal, return DORMANT rather
83+
# than leaking into CASUAL via the <0.5 threshold below — "desire
84+
# crushed by grief" semantically matches DORMANT, not "everyday warmth".
85+
if raw == 0.0:
86+
return TIER_DORMANT
87+
88+
# 4. Body temperature shift — each degree above neutral adds 0.3 to raw.
89+
raw += max(0, body_temperature) * 0.3
90+
91+
# 5. Map the continuous raw score into 7 discrete tiers.
92+
# Thresholds are seed values — tunable as lived experience accrues.
93+
if raw < 0.5:
94+
return TIER_CASUAL
95+
if raw < 2.0:
96+
return TIER_WARMED
97+
if raw < 5.0:
98+
return TIER_REACHING
99+
if raw < 8.0:
100+
return TIER_CHARGED
101+
if raw < 11.0:
102+
return TIER_HELD
103+
return TIER_EDGE

brain/emotion/blend.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
"""Emergent blend detection.
2+
3+
Observes emotional states over time. When two or more emotions co-occur at
4+
high intensity repeatedly, the detector records them as a named blend.
5+
Names can be assigned later (once the shape is recognised).
6+
7+
Design per spec Section 5.2 (blend sub-module). Threshold tunable — current
8+
defaults: intensity ≥5 each, ≥5 co-occurrences to register.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from collections.abc import Iterable
14+
from dataclasses import dataclass, field
15+
from itertools import combinations
16+
from typing import Any
17+
18+
from brain.emotion.state import EmotionalState
19+
20+
21+
@dataclass
22+
class DetectedBlend:
23+
"""A repeatedly-observed co-occurrence of high-intensity emotions.
24+
25+
Attributes:
26+
components: Tuple of emotion names (sorted alphabetically for stable hashing).
27+
count: How many times this combination has been observed above threshold.
28+
name: Optional human-readable label, assigned via BlendDetector.name_blend().
29+
"""
30+
31+
components: tuple[str, ...]
32+
count: int
33+
name: str | None = None
34+
35+
36+
@dataclass
37+
class BlendDetector:
38+
"""Tracks emotional co-occurrences to surface emergent patterns.
39+
40+
Attributes:
41+
intensity_threshold: Minimum per-emotion intensity to count an observation.
42+
detection_threshold: Minimum observations to register the pattern.
43+
_observations: Internal count map (components tuple → count).
44+
_names: Internal name map (components tuple → name).
45+
"""
46+
47+
intensity_threshold: float = 5.0
48+
detection_threshold: int = 5
49+
# Private state populated by observe() / name_blend() / from_dict().
50+
# init=False keeps the public constructor surface minimal — callers
51+
# shouldn't be able to inject arbitrary counts via kwargs.
52+
_observations: dict[tuple[str, ...], int] = field(default_factory=dict, init=False)
53+
_names: dict[tuple[str, ...], str | None] = field(default_factory=dict, init=False)
54+
55+
def observe(self, state: EmotionalState) -> None:
56+
"""Record the high-intensity emotion combinations from the given state."""
57+
high = tuple(
58+
sorted(
59+
name
60+
for name, intensity in state.emotions.items()
61+
if intensity >= self.intensity_threshold
62+
)
63+
)
64+
if len(high) < 2:
65+
return
66+
67+
# Track every pair and every triple. Cap subset size at 3 — with
68+
# typically ≤6 high-intensity emotions at once, C(6,3)=20 but
69+
# C(6,4)=15, so 4-way tracking is affordable, but 3-way is where
70+
# the meaningful emergent patterns ("building_love", "creative_feral")
71+
# actually live. Higher orders are diluted. Bump if that assumption
72+
# breaks as real data accrues.
73+
for size in (2, 3):
74+
if size > len(high):
75+
break
76+
for combo in combinations(high, size):
77+
self._observations[combo] = self._observations.get(combo, 0) + 1
78+
79+
def detected(self) -> list[DetectedBlend]:
80+
"""Return every combination that has crossed the detection threshold."""
81+
result = []
82+
for components, count in self._observations.items():
83+
if count >= self.detection_threshold:
84+
result.append(
85+
DetectedBlend(
86+
components=components,
87+
count=count,
88+
name=self._names.get(components),
89+
)
90+
)
91+
return result
92+
93+
def name_blend(self, components: Iterable[str], name: str) -> None:
94+
"""Assign a human-readable name to a previously-detected blend.
95+
96+
Raises:
97+
KeyError: if the given components haven't been detected yet.
98+
"""
99+
key = tuple(sorted(components))
100+
if key not in self._observations or self._observations[key] < self.detection_threshold:
101+
raise KeyError(f"Blend {key!r} has not been detected yet")
102+
self._names[key] = name
103+
104+
def to_dict(self) -> dict[str, Any]:
105+
"""Serialise to a plain-dict form suitable for JSON."""
106+
return {
107+
"intensity_threshold": self.intensity_threshold,
108+
"detection_threshold": self.detection_threshold,
109+
"observations": [
110+
{"components": list(k), "count": v} for k, v in self._observations.items()
111+
],
112+
"names": [{"components": list(k), "name": v} for k, v in self._names.items()],
113+
}
114+
115+
@classmethod
116+
def from_dict(cls, data: dict[str, Any]) -> BlendDetector:
117+
"""Restore from a dict produced by to_dict."""
118+
detector = cls(
119+
intensity_threshold=float(data.get("intensity_threshold", 5.0)),
120+
detection_threshold=int(data.get("detection_threshold", 5)),
121+
)
122+
# Sort component lists into canonical tuple order on the way in —
123+
# observe() and name_blend() both operate on sorted keys, so any
124+
# caller-supplied JSON with unsorted components would otherwise
125+
# land at a non-matching key and cause spurious KeyErrors later.
126+
for entry in data.get("observations", []):
127+
key = tuple(sorted(entry["components"]))
128+
detector._observations[key] = int(entry["count"])
129+
for entry in data.get("names", []):
130+
name = entry.get("name")
131+
if name is None:
132+
continue
133+
key = tuple(sorted(entry["components"]))
134+
detector._names[key] = name
135+
return detector

brain/emotion/decay.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""Temporal decay for emotions.
2+
3+
Each emotion in the vocabulary has a half-life (or None = identity-level,
4+
doesn't decay). apply_decay() walks a state and applies exponential decay
5+
to each emotion based on the elapsed time since it was last touched.
6+
7+
Design per spec Section 10.1 (per-emotion decay curves).
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from brain.emotion.state import EmotionalState
13+
from brain.emotion.vocabulary import get as _get_emotion
14+
15+
# Below this intensity, the emotion is considered noise and removed entirely.
16+
# Prevents residue accumulation from very-old events.
17+
_NOISE_FLOOR: float = 0.01
18+
19+
_SECONDS_PER_DAY: float = 24 * 3600
20+
21+
22+
def apply_decay(state: EmotionalState, elapsed_seconds: float) -> None:
23+
"""Decay every known emotion in the state by its half-life.
24+
25+
Emotions with half_life=None (identity-level) are untouched.
26+
Emotions not in the vocabulary are also untouched (stale-data guard —
27+
see EmotionalState.from_dict's permissive contract).
28+
Emotions decayed below the noise floor are removed.
29+
30+
Non-positive `elapsed_seconds` (0 or negative) is a silent no-op. Callers
31+
that want negative values to raise should validate upstream.
32+
33+
Mutates state in place; recomputes dominant after.
34+
"""
35+
if elapsed_seconds <= 0:
36+
return
37+
38+
to_remove: list[str] = []
39+
elapsed_days = elapsed_seconds / _SECONDS_PER_DAY
40+
41+
for name, intensity in state.emotions.items():
42+
emotion = _get_emotion(name)
43+
if emotion is None:
44+
# Stale or persona-specific emotion no longer registered — leave it.
45+
continue
46+
if emotion.decay_half_life_days is None:
47+
# Identity-level — no decay.
48+
continue
49+
50+
# Exponential decay: new = old * (1/2)^(elapsed / half_life)
51+
ratio = 0.5 ** (elapsed_days / emotion.decay_half_life_days)
52+
new_intensity = intensity * ratio
53+
54+
if new_intensity < _NOISE_FLOOR:
55+
to_remove.append(name)
56+
else:
57+
state.emotions[name] = new_intensity
58+
59+
for name in to_remove:
60+
del state.emotions[name]
61+
62+
state._recompute_dominant()

0 commit comments

Comments
 (0)