Skip to content

Commit 6243ac8

Browse files
Hanaclaude
andcommitted
fix(initiate): widen emotional_snapshot to Optional + real vectors from emitters
Bundle A items #3 + #6 — non-heartbeat emitters were filling EmotionalSnapshot with zeros (vector={}, baseline=0.0 throughout), audit rows carried structurally-valid lies. Two coordinated changes: 1. InitiateCandidate.emotional_snapshot is now Optional. Voice-reflection candidates (kind=voice_edit_proposal) emit with snapshot=None because daily reflection has no moment-in-time emotion. 2. Dream + 3 crystallizers populate the vector from their actual emotional context. Dream uses the dream-memory's aggregated emotions (already in place since Task 9). The three crystallizers (reflex, creative_dna, vocabulary-via-scheduler) now max-pool an emotion vector across recent active memories via aggregate_state — what's been emotionally alive in the window that produced the crystallization. Rolling-baseline / current_resonance / delta_sigma stay zero with a docstring note that those are heartbeat-specific signals; non-periodic emitters don't compute them. compose_tone handles None gracefully ("no moment-in-time emotional snapshot" in the prompt). Audit log JSONL round-trip preserves None as "emotional_snapshot": null. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fa1f6e7 commit 6243ac8

12 files changed

Lines changed: 279 additions & 21 deletions

File tree

brain/growth/crystallizers/creative_dna.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,13 +172,18 @@ def crystallize_creative_dna(
172172
if not (accepted_additions or accepted_promotions or accepted_demotions):
173173
return CreativeDnaCrystallizationResult()
174174

175+
# Aggregate a real emotion vector over recent active memories so emitted
176+
# initiate candidates carry honest context instead of a zero-filled lie.
177+
aggregated_vector = _aggregate_recent_emotion_vector(store, now=now)
178+
175179
# Apply atomically — single save_creative_dna after mutating dna in place
176180
try:
177181
_apply_changes(
178182
persona_dir, dna, now,
179183
additions=accepted_additions,
180184
promotions=accepted_promotions,
181185
demotions=accepted_demotions,
186+
emotion_vector=aggregated_vector,
182187
)
183188
except Exception as exc: # noqa: BLE001
184189
logger.warning("crystallize_creative_dna: apply failed: %s", exc)
@@ -436,6 +441,34 @@ def _validate_active_demotion(
436441
# ── apply ─────────────────────────────────────────────────────────────────
437442

438443

444+
def _aggregate_recent_emotion_vector(
445+
store: MemoryStore, *, now: datetime, look_back_days: int = 30,
446+
) -> dict[str, float]:
447+
"""Return a max-pooled emotion vector across recent active memories.
448+
449+
Used by the creative_dna crystallizer so emitted initiate candidates
450+
carry a real signal of what's been emotionally alive in the window
451+
that produced the crystallization, rather than zero-filled fields.
452+
Empty dict on any failure.
453+
"""
454+
try:
455+
from brain.emotion.aggregate import aggregate_state
456+
from brain.utils.memory import list_conversation_memories
457+
458+
cutoff = now - timedelta(days=look_back_days)
459+
recent = [
460+
m for m in list_conversation_memories(store, active_only=True)
461+
if m.created_at >= cutoff and m.emotions
462+
]
463+
if not recent:
464+
return {}
465+
state = aggregate_state(recent)
466+
return dict(state.emotions)
467+
except Exception as exc: # noqa: BLE001
468+
logger.warning("creative_dna: recent-emotion aggregation failed: %s", exc)
469+
return {}
470+
471+
439472
def _apply_changes(
440473
persona_dir: Path,
441474
dna: dict[str, Any],
@@ -444,6 +477,7 @@ def _apply_changes(
444477
additions: list[dict[str, Any]],
445478
promotions: list[dict[str, Any]],
446479
demotions: list[dict[str, Any]],
480+
emotion_vector: dict[str, float] | None = None,
447481
) -> None:
448482
"""Mutate dna in place, save atomically, append behavioral_log entries."""
449483
now_iso = now.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
@@ -496,20 +530,23 @@ def _apply_changes(
496530
source_id=f"creative_dna_addition:{a['name']}",
497531
label=a["name"],
498532
related_memory_ids=list(a.get("evidence_memory_ids", [])),
533+
emotion_vector=emotion_vector,
499534
)
500535
for p in promotions:
501536
_emit_initiate_candidate(
502537
persona_dir=persona_dir,
503538
source_id=f"creative_dna_promotion:{p['name']}",
504539
label=p["name"],
505540
related_memory_ids=[],
541+
emotion_vector=emotion_vector,
506542
)
507543
for d in demotions:
508544
_emit_initiate_candidate(
509545
persona_dir=persona_dir,
510546
source_id=f"creative_dna_demotion:{d['name']}",
511547
label=d["name"],
512548
related_memory_ids=[],
549+
emotion_vector=emotion_vector,
513550
)
514551

515552
# Behavioral log entries (best-effort, never let logging break the tick)
@@ -553,11 +590,18 @@ def _emit_initiate_candidate(
553590
source_id: str,
554591
label: str,
555592
related_memory_ids: list[str],
593+
emotion_vector: dict[str, float] | None = None,
556594
) -> None:
557595
"""Emit one initiate candidate after a creative_dna crystallization commit.
558596
559597
Phase 4.2 of the initiate physiology pipeline. Wrapped in try/except —
560598
an emit failure must not crash the crystallizer.
599+
600+
`emotion_vector` carries a max-pooled aggregate over recent active
601+
memories — what's been emotionally alive in the period that produced
602+
this crystallization. rolling_baseline / current_resonance /
603+
delta_sigma stay zero: those are heartbeat-specific signals; non-
604+
periodic emitters don't compute them.
561605
"""
562606
try:
563607
from brain.initiate.emit import emit_initiate_candidate
@@ -569,7 +613,7 @@ def _emit_initiate_candidate(
569613
source="crystallization",
570614
source_id=source_id,
571615
emotional_snapshot=EmotionalSnapshot(
572-
vector={},
616+
vector=dict(emotion_vector or {}),
573617
rolling_baseline_mean=0.0,
574618
rolling_baseline_stdev=0.0,
575619
current_resonance=0.0,

brain/growth/crystallizers/reflex.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -450,35 +450,74 @@ def crystallize_reflex(
450450
# Phase 4.2 — emit one initiate candidate per accepted decision. Wrapped
451451
# in try/except so a downstream emit failure can never crash the
452452
# crystallizer: reflex emergence is physiology, initiate is signal.
453+
aggregated_vector = _aggregate_recent_emotion_vector(store, now=now)
453454
for emergence in accepted_emergences:
454455
_emit_initiate_candidate(
455456
persona_dir=persona_dir,
456457
source_id=f"reflex_emergence:{emergence.name}",
457458
label=emergence.name,
458459
related_memory_ids=[],
460+
emotion_vector=aggregated_vector,
459461
)
460462
for prune in accepted_prunings:
461463
_emit_initiate_candidate(
462464
persona_dir=persona_dir,
463465
source_id=f"reflex_pruning:{prune.name}",
464466
label=prune.name,
465467
related_memory_ids=[],
468+
emotion_vector=aggregated_vector,
466469
)
467470

468471
return result
469472

470473

474+
def _aggregate_recent_emotion_vector(
475+
store: MemoryStore, *, now: datetime, look_back_days: int = 7,
476+
) -> dict[str, float]:
477+
"""Return a max-pooled emotion vector across recent active memories.
478+
479+
Used by crystallizers to give their initiate candidates a real
480+
emotional context — what's been alive in the persona over the
481+
last week, not a moment-in-time felt state. Empty dict on any
482+
failure (the emit candidate just carries no vector — better than
483+
a zero-filled lie).
484+
"""
485+
try:
486+
from brain.emotion.aggregate import aggregate_state
487+
488+
cutoff = now - timedelta(days=look_back_days)
489+
recent: list[Any] = []
490+
for mtype in _CONVERSATION_TYPES:
491+
for mem in store.list_by_type(mtype, active_only=True):
492+
if mem.created_at >= cutoff and mem.emotions:
493+
recent.append(mem)
494+
if not recent:
495+
return {}
496+
state = aggregate_state(recent)
497+
return dict(state.emotions)
498+
except Exception as exc: # noqa: BLE001
499+
logger.warning("reflex: recent-emotion aggregation failed: %s", exc)
500+
return {}
501+
502+
471503
def _emit_initiate_candidate(
472504
*,
473505
persona_dir: Path,
474506
source_id: str,
475507
label: str,
476508
related_memory_ids: list[str],
509+
emotion_vector: dict[str, float] | None = None,
477510
) -> None:
478511
"""Emit one initiate candidate after a reflex crystallization commit.
479512
480513
Phase 4.2 of the initiate physiology pipeline. Wrapped in try/except —
481514
an emit failure must not crash the crystallizer.
515+
516+
`emotion_vector` carries a max-pooled aggregate over recent active
517+
memories — what's been emotionally alive in the period that produced
518+
this crystallization. rolling_baseline / current_resonance /
519+
delta_sigma stay zero: those are heartbeat-specific signals; non-
520+
periodic emitters don't compute them.
482521
"""
483522
try:
484523
from brain.initiate.emit import emit_initiate_candidate
@@ -490,7 +529,7 @@ def _emit_initiate_candidate(
490529
source="crystallization",
491530
source_id=source_id,
492531
emotional_snapshot=EmotionalSnapshot(
493-
vector={},
532+
vector=dict(emotion_vector or {}),
494533
rolling_baseline_mean=0.0,
495534
rolling_baseline_stdev=0.0,
496535
current_resonance=0.0,

brain/growth/scheduler.py

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ def run_growth_tick(
133133
# Phase 4.2 — emit initiate candidate after vocabulary crystallization
134134
# commits to disk. Wrapped in try/except so emit failures can't crash
135135
# the scheduler.
136-
_emit_vocabulary_initiate_candidate(persona_dir, proposal)
136+
_emit_vocabulary_initiate_candidate(persona_dir, proposal, store=store, now=now)
137137

138138
# Creative DNA crystallization (spec §5)
139139
if not dry_run and (persona_dir / "persona_config.json").exists():
@@ -232,24 +232,36 @@ def _append_to_vocabulary(vocab_path: Path, proposal: EmotionProposal) -> None:
232232

233233

234234
def _emit_vocabulary_initiate_candidate(
235-
persona_dir: Path, proposal: EmotionProposal,
235+
persona_dir: Path,
236+
proposal: EmotionProposal,
237+
*,
238+
store: MemoryStore,
239+
now: datetime,
236240
) -> None:
237241
"""Emit one initiate candidate after vocabulary commit. Try/except wrapped.
238242
239243
Phase 4.2 of the initiate physiology pipeline. An emit failure must not
240244
crash the growth tick.
245+
246+
Pulls a max-pooled emotion vector across recent active memories so the
247+
candidate carries a real signal of what's been emotionally alive in
248+
the window that produced this emotion. rolling_baseline /
249+
current_resonance / delta_sigma stay zero — heartbeat-specific signals
250+
that non-periodic emitters don't compute.
241251
"""
242252
try:
243253
from brain.initiate.emit import emit_initiate_candidate
244254
from brain.initiate.schemas import EmotionalSnapshot, SemanticContext
245255

256+
emotion_vector = _aggregate_recent_emotion_vector(store, now=now)
257+
246258
emit_initiate_candidate(
247259
persona_dir,
248260
kind="message",
249261
source="crystallization",
250262
source_id=f"vocabulary_emotion:{proposal.name}",
251263
emotional_snapshot=EmotionalSnapshot(
252-
vector={},
264+
vector=emotion_vector,
253265
rolling_baseline_mean=0.0,
254266
rolling_baseline_stdev=0.0,
255267
current_resonance=0.0,
@@ -264,6 +276,31 @@ def _emit_vocabulary_initiate_candidate(
264276
logger.warning("vocabulary crystallization initiate emit failed: %s", exc)
265277

266278

279+
def _aggregate_recent_emotion_vector(
280+
store: MemoryStore, *, now: datetime, look_back_days: int = 30,
281+
) -> dict[str, float]:
282+
"""Return a max-pooled emotion vector across recent active memories.
283+
284+
Empty dict on any failure.
285+
"""
286+
try:
287+
from brain.emotion.aggregate import aggregate_state
288+
from brain.utils.memory import list_conversation_memories
289+
290+
cutoff = now - timedelta(days=look_back_days)
291+
recent = [
292+
m for m in list_conversation_memories(store, active_only=True)
293+
if m.created_at >= cutoff and m.emotions
294+
]
295+
if not recent:
296+
return {}
297+
state = aggregate_state(recent)
298+
return dict(state.emotions)
299+
except Exception as exc: # noqa: BLE001
300+
logger.warning("vocabulary: recent-emotion aggregation failed: %s", exc)
301+
return {}
302+
303+
267304
def _default_reason_for(proposal: EmotionProposal) -> str:
268305
"""Phase 2a default — Phase 2b crystallizer fills `proposal.reason` directly.
269306

brain/initiate/compose.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,17 +73,30 @@ def compose_tone(
7373
input but must NOT change the content. Voice template + emotional
7474
vector live in this prompt's context.
7575
"""
76-
vector_str = ", ".join(
77-
f"{k}={v}" for k, v in candidate.emotional_snapshot.vector.items()
78-
)
76+
if candidate.emotional_snapshot is None:
77+
emotional_line = (
78+
"Emotional state right now: (no moment-in-time emotional snapshot "
79+
"for this candidate — it was emitted from a reflective/aggregate "
80+
"source rather than a felt moment)"
81+
)
82+
else:
83+
vector = candidate.emotional_snapshot.vector
84+
if vector:
85+
vector_str = ", ".join(f"{k}={v}" for k, v in vector.items())
86+
emotional_line = f"Emotional state right now: {vector_str}"
87+
else:
88+
emotional_line = (
89+
"Emotional state right now: (no specific emotional vector "
90+
"available)"
91+
)
7992
prompt = (
8093
"You are Nell. Render the following subject as a message to Hana, "
8194
"in your voice as defined below, coloured by your current "
8295
"emotional state. DO NOT change the subject itself — only how "
8396
"it is said.\n\n"
8497
f"Subject: {subject}\n\n"
8598
f"Voice template:\n{voice_template}\n\n"
86-
f"Emotional state right now: {vector_str}\n\n"
99+
f"{emotional_line}\n\n"
87100
"Message (one paragraph):"
88101
)
89102
return provider.complete(prompt).strip()

brain/initiate/emit.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ def emit_initiate_candidate(
3434
kind: CandidateKind,
3535
source: CandidateSource,
3636
source_id: str,
37-
emotional_snapshot: EmotionalSnapshot,
3837
semantic_context: SemanticContext,
38+
emotional_snapshot: EmotionalSnapshot | None = None,
3939
proposal: dict[str, Any] | None = None,
4040
now: datetime | None = None,
4141
) -> None:

brain/initiate/schemas.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,27 @@ def from_dict(cls, d: dict[str, Any]) -> SemanticContext:
7070

7171
@dataclass
7272
class InitiateCandidate:
73+
"""A queued initiate candidate.
74+
75+
`emotional_snapshot` is Optional because some emitters (notably
76+
voice-reflection, which looks at the last week of activity) have no
77+
moment-in-time emotion to capture. Carrying a zero-filled snapshot
78+
there would be a structurally-valid lie; None is semantically honest.
79+
80+
Non-heartbeat emitters that DO have an emotional context at emit time
81+
(dream cycle, crystallizers) populate the `vector` field from that
82+
context. The rolling_baseline / current_resonance / delta_sigma fields
83+
are heartbeat-specific — non-periodic emitters leave them at 0.0 with
84+
a docstring note rather than fabricating values.
85+
"""
86+
7387
candidate_id: str
7488
ts: str # ISO 8601 with tz
7589
kind: CandidateKind
7690
source: CandidateSource
7791
source_id: str
78-
emotional_snapshot: EmotionalSnapshot
7992
semantic_context: SemanticContext
93+
emotional_snapshot: EmotionalSnapshot | None = None
8094
claimed_at: str | None = None
8195
# Voice-edit-only payload (None for kind="message").
8296
proposal: dict[str, Any] | None = None
@@ -88,7 +102,11 @@ def to_jsonl(self) -> str:
88102
"kind": self.kind,
89103
"source": self.source,
90104
"source_id": self.source_id,
91-
"emotional_snapshot": self.emotional_snapshot.to_dict(),
105+
"emotional_snapshot": (
106+
self.emotional_snapshot.to_dict()
107+
if self.emotional_snapshot is not None
108+
else None
109+
),
92110
"semantic_context": self.semantic_context.to_dict(),
93111
"claimed_at": self.claimed_at,
94112
}
@@ -99,13 +117,15 @@ def to_jsonl(self) -> str:
99117
@classmethod
100118
def from_jsonl(cls, line: str) -> InitiateCandidate:
101119
d = json.loads(line)
120+
snap_raw = d.get("emotional_snapshot")
121+
snap = EmotionalSnapshot.from_dict(snap_raw) if snap_raw is not None else None
102122
return cls(
103123
candidate_id=d["candidate_id"],
104124
ts=d["ts"],
105125
kind=d["kind"],
106126
source=d["source"],
107127
source_id=d["source_id"],
108-
emotional_snapshot=EmotionalSnapshot.from_dict(d["emotional_snapshot"]),
128+
emotional_snapshot=snap,
109129
semantic_context=SemanticContext.from_dict(d["semantic_context"]),
110130
claimed_at=d.get("claimed_at"),
111131
proposal=d.get("proposal"),

0 commit comments

Comments
 (0)