Skip to content

Commit c5ade6b

Browse files
cdeustclaude
andcommitted
feat(verif): VADER -> user_mood EMA hook in remember (closes MOOD_CONGRUENT signal gap)
Without this hook the user_mood table (added in b4b23e7) sat at 0.0 forever and MOOD_CONGRUENT_RERANK no-oped despite end-to-end wiring. The hook reuses the existing VADER analyzer in write_gate.tag_memory_emotions (Wang & Bhatt 2024) and EMA-updates user_mood with alpha=0.3 (engineering default, pending calibration). Ablation guard added for symmetry. Source: Bower (1981) "Mood and Memory" Am. Psychologist 36(2). VADER compound from Hutto & Gilbert (ICWSM 2014). EMA structural form follows prior Cortex thermodynamic conventions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ca7f9d4 commit c5ade6b

5 files changed

Lines changed: 430 additions & 0 deletions

File tree

mcp_server/handlers/remember.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
evaluate_gate,
1818
insert_and_post_process,
1919
try_curation,
20+
update_user_mood_ema,
2021
)
2122
from mcp_server.handlers.remember_response import build_merge_response
2223
from mcp_server.infrastructure import wiki_store
@@ -330,6 +331,9 @@ async def _handler_impl(args: dict[str, Any] | None = None) -> dict[str, Any]:
330331
content, embedding, force, store, emb_engine, tags, mod["heat"]
331332
)
332333
if action == "merge":
334+
# Mood signal still updates on merge — the user authored the content,
335+
# whether we keep it as a new row or fold it into an existing one.
336+
update_user_mood_ema(content, source, store)
333337
return build_merge_response(mid, domain, mod, gate)
334338

335339
result = insert_and_post_process(
@@ -357,6 +361,14 @@ async def _handler_impl(args: dict[str, Any] | None = None) -> dict[str, Any]:
357361
result["is_global"] = True
358362
result["global_reason"] = global_reason
359363

364+
# MOOD_CONGRUENT_RERANK signal-feed (Bower 1981 mood-congruent recall):
365+
# EMA-update user_mood.valence from VADER compound on user-authored
366+
# content. Non-user sources are ignored to keep the signal faithful
367+
# to the user's affective state. See remember_helpers.update_user_mood_ema
368+
# for the contract and source-discipline notes.
369+
if result.get("stored"):
370+
update_user_mood_ema(content, source, store)
371+
360372
# Promote decision-shaped memories to the authored wiki layer.
361373
#
362374
# Contract (E8, post-Taleb fragility audit):

mcp_server/handlers/remember_helpers.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
write_gate_calibration,
1515
write_post_store,
1616
)
17+
from mcp_server.core.ablation import Mechanism, is_mechanism_disabled
18+
from mcp_server.shared.vader import vader_compound
1719
from mcp_server.core.dual_store_cls import classify_memory
1820
from mcp_server.core.predictive_coding_flat import (
1921
compute_embedding_novelty,
@@ -411,3 +413,69 @@ def insert_and_post_process(
411413
sep,
412414
interf,
413415
)
416+
417+
418+
# ── User-mood EMA hook (Bower 1981 mood-congruent recall, signal side) ──
419+
# Engineering default; calibration pending future work — Bower (1981)
420+
# "Mood and Memory" Am. Psychologist 36(2) prescribes mood-congruent
421+
# recall qualitatively, not the time-constant of mood drift. No published
422+
# psychophysics constant for the EMA decay of self-report mood at the
423+
# session timescale was located (April 2026). Conservative default
424+
# matches the structural form of other Cortex EMAs (write_gate_calibration).
425+
# When a published value is found, replace this constant and cite.
426+
MOOD_EMA_ALPHA: float = 0.3
427+
428+
429+
def update_user_mood_ema(
430+
content: str,
431+
source: str,
432+
store: MemoryStore,
433+
) -> float | None:
434+
"""EMA-update the user's session-level mood from VADER on user content.
435+
436+
Contract:
437+
pre: content is a hardened, non-empty string; source is one of the
438+
remember.py source enum values; store exposes get_user_mood /
439+
set_user_mood (real PgMemoryStore or duck-compatible stub).
440+
post: when source == "user" AND MOOD_CONGRUENT_RERANK is NOT ablated,
441+
user_mood.valence is upserted to
442+
(1 - α) * old + α * vader_compound(content)
443+
with α = MOOD_EMA_ALPHA, old defaulting to 0.0 when the row
444+
is absent. Returns the new valence on update, or None when
445+
skipped (non-user source, ablated, or store missing API).
446+
Never raises — failures are swallowed and reported as None.
447+
448+
Source-discipline notes:
449+
- VADER compound: Hutto & Gilbert, ICWSM 2014.
450+
- Mood-congruent recall: Bower 1981 Am. Psychologist 36(2).
451+
- α = 0.3: engineering default (see module-level comment above).
452+
453+
User-side definition (self-flagged risk addressed):
454+
Only source == "user" updates mood. System-generated memories
455+
(source ∈ {"tool", "consolidation", "import"}) and conversational
456+
transcripts (source == "session", which is mixed agent/user)
457+
do NOT mutate user_mood, because their content does not reflect
458+
the user's affective state at recall time.
459+
460+
Ablation symmetry: when CORTEX_ABLATE_MOOD_CONGRUENT_RERANK=1,
461+
we also skip the write so the table doesn't accumulate signal
462+
that's then ignored downstream (clean ablation deltas).
463+
"""
464+
if source != "user":
465+
return None
466+
if is_mechanism_disabled(Mechanism.MOOD_CONGRUENT_RERANK):
467+
return None
468+
if not hasattr(store, "set_user_mood") or not hasattr(store, "get_user_mood"):
469+
return None
470+
try:
471+
compound = vader_compound(content)
472+
old = store.get_user_mood()
473+
old_valence = 0.0 if old is None else float(old)
474+
new_valence = (1.0 - MOOD_EMA_ALPHA) * old_valence + MOOD_EMA_ALPHA * compound
475+
# Clamp defensively; set_user_mood clamps too, but we want the
476+
# returned value to match what was persisted.
477+
new_valence = max(-1.0, min(1.0, new_valence))
478+
store.set_user_mood(new_valence)
479+
return new_valence
480+
except Exception: # noqa: BLE001 — non-load-bearing; mood is a soft signal
481+
return None

tasks/smoke_user_mood_ema.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""Smoke: VADER → user_mood EMA hook + MOOD_CONGRUENT_RERANK delta.
2+
3+
End-to-end signal path on a stub store (no live PG required):
4+
5+
1. remember() with a positive-valence message → user_mood drifts positive
6+
2. remember() with a negative-valence message → user_mood drifts back
7+
3. mood_congruent_rerank with the EMA-driven mood produces score deltas
8+
vs the same call with user_mood=None
9+
4. CORTEX_ABLATE_MOOD_CONGRUENT_RERANK=1 short-circuits (identity)
10+
11+
Run:
12+
uv run python tasks/smoke_user_mood_ema.py
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import os
18+
19+
from mcp_server.core.recall_pipeline import mood_congruent_rerank
20+
from mcp_server.handlers.remember_helpers import (
21+
MOOD_EMA_ALPHA,
22+
update_user_mood_ema,
23+
)
24+
from mcp_server.shared.vader import vader_compound
25+
26+
27+
class _MoodStore:
28+
def __init__(self) -> None:
29+
self.valence: float | None = None
30+
31+
def get_user_mood(self) -> float | None:
32+
return self.valence
33+
34+
def set_user_mood(self, valence: float, arousal: float = 0.0) -> None:
35+
self.valence = max(-1.0, min(1.0, float(valence)))
36+
37+
38+
def _candidates() -> list[dict]:
39+
"""Five candidates in increasing emotional_valence."""
40+
return [
41+
{
42+
"memory_id": i,
43+
"content": f"mem {i}",
44+
"score": 1.0 / (i + 1),
45+
"heat": 0.5,
46+
"tags": [],
47+
"domain": "smoke",
48+
"created_at": "2026-04-30T00:00:00Z",
49+
"emotional_valence": v,
50+
}
51+
for i, v in enumerate([-0.9, -0.5, 0.0, +0.5, +0.9])
52+
]
53+
54+
55+
def main() -> None:
56+
print(f"MOOD_EMA_ALPHA = {MOOD_EMA_ALPHA}")
57+
store = _MoodStore()
58+
59+
pos_msg = "Wonderful breakthrough! I am thrilled and delighted with this success."
60+
neg_msg = "This is terrible. I hate this awful broken garbage. Frustrated and angry."
61+
62+
print(f"\n[1] vader_compound(positive) = {vader_compound(pos_msg):+.4f}")
63+
print(f" vader_compound(negative) = {vader_compound(neg_msg):+.4f}")
64+
65+
print("\n[2] EMA progression — positive then negative messages:")
66+
print(f" initial mood: {store.valence}")
67+
new = update_user_mood_ema(pos_msg, source="user", store=store)
68+
print(f" after positive (user): {new:+.4f}")
69+
new = update_user_mood_ema(pos_msg, source="user", store=store)
70+
print(f" after positive (user): {new:+.4f}")
71+
new = update_user_mood_ema(neg_msg, source="user", store=store)
72+
print(f" after negative (user): {new:+.4f}")
73+
74+
print("\n[3] Source gating — non-user sources do NOT update mood:")
75+
pre = store.valence
76+
for src in ("tool", "consolidation", "import", "session"):
77+
result = update_user_mood_ema(pos_msg, source=src, store=store)
78+
print(f" source={src:14s} → returned {result}, mood still {store.valence:+.4f}")
79+
assert store.valence == pre, "non-user source must not mutate mood"
80+
81+
print("\n[4] Drive mood strongly positive for rerank delta test:")
82+
for _ in range(5):
83+
update_user_mood_ema(pos_msg, source="user", store=store)
84+
print(f" mood after 5 positive iters: {store.valence:+.4f}")
85+
86+
cands = _candidates()
87+
baseline = mood_congruent_rerank(cands, user_mood=None)
88+
active = mood_congruent_rerank(cands, user_mood=store.valence)
89+
base_scores = {c["memory_id"]: c["score"] for c in baseline}
90+
active_scores = {c["memory_id"]: c["score"] for c in active}
91+
92+
print("\n[5] mood_congruent_rerank deltas (positive mood):")
93+
print(f" {'id':>3} {'valence':>8} {'baseline':>10} {'active':>10} {'Δ':>10}")
94+
for cid in sorted(base_scores):
95+
v = next(c["emotional_valence"] for c in cands if c["memory_id"] == cid)
96+
bs, as_ = base_scores[cid], active_scores[cid]
97+
print(f" {cid:>3} {v:+8.2f} {bs:>10.6f} {as_:>10.6f} {as_-bs:>+10.6f}")
98+
99+
delta_congruent = active_scores[4] - base_scores[4] # high +valence
100+
delta_incongruent = active_scores[0] - base_scores[0] # high -valence
101+
print(
102+
f"\n Δ congruent (id=4, valence +0.9): {delta_congruent:+.6f}"
103+
f"\n Δ incongruent(id=0, valence -0.9): {delta_incongruent:+.6f}"
104+
)
105+
assert delta_congruent > delta_incongruent, "mood-congruent must gain more"
106+
assert base_scores != active_scores, "rerank must produce a non-identity delta"
107+
print(" ✓ mood-congruent candidate gains MORE than incongruent")
108+
print(" ✓ rerank produces score deltas (NOT identity)")
109+
110+
print("\n[6] Ablation symmetry — CORTEX_ABLATE_MOOD_CONGRUENT_RERANK=1:")
111+
abl_store = _MoodStore()
112+
os.environ["CORTEX_ABLATE_MOOD_CONGRUENT_RERANK"] = "1"
113+
try:
114+
result = update_user_mood_ema(pos_msg, source="user", store=abl_store)
115+
assert result is None, "EMA write must be skipped when ablated"
116+
assert abl_store.valence is None, "store must be untouched"
117+
print(f" EMA hook returned {result}, store.valence = {abl_store.valence}")
118+
# And rerank itself short-circuits
119+
active_abl = mood_congruent_rerank(cands, user_mood=+0.7)
120+
assert active_abl == cands, "rerank must be identity when ablated"
121+
print(" ✓ rerank stage returns identity when ablated")
122+
finally:
123+
os.environ.pop("CORTEX_ABLATE_MOOD_CONGRUENT_RERANK", None)
124+
125+
print("\n✓ smoke OK — VADER → user_mood EMA hook is wired and signal-fed.")
126+
127+
128+
if __name__ == "__main__":
129+
main()

tests_py/core/test_pg_recall_pipeline.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,85 @@ def test_mood_congruent_active_promotes_congruent_valence():
450450
assert out_ids.index(0) < out_ids.index(3)
451451

452452

453+
def test_mood_congruent_ema_driven_path_produces_delta():
454+
"""End-to-end signal path: EMA hook → user_mood → mood_congruent_rerank.
455+
456+
The hook in remember_helpers.update_user_mood_ema feeds VADER compound
457+
into user_mood; the rerank stage then consumes user_mood at recall time.
458+
This test exercises the full chain on a stub store and confirms a
459+
non-identity reorder vs the no-mood (None) baseline. Closes the wiring
460+
gap that left MOOD_CONGRUENT_RERANK signal-fed but unwired in production.
461+
"""
462+
from mcp_server.handlers.remember_helpers import update_user_mood_ema
463+
464+
class _Store:
465+
def __init__(self) -> None:
466+
self.valence: float | None = None
467+
468+
def get_user_mood(self) -> float | None:
469+
return self.valence
470+
471+
def set_user_mood(self, valence: float, arousal: float = 0.0) -> None:
472+
self.valence = max(-1.0, min(1.0, float(valence)))
473+
474+
store = _Store()
475+
# Drive several positive user-authored messages through the EMA hook so
476+
# mood drifts strongly positive. Single message at α=0.3 only reaches
477+
# ~0.3 * compound, so we feed 3 to amplify above the rerank threshold.
478+
for _ in range(3):
479+
update_user_mood_ema(
480+
"Wonderful breakthrough! I am thrilled and delighted with this success.",
481+
source="user",
482+
store=store,
483+
)
484+
assert store.valence is not None and store.valence > 0.2
485+
486+
# Candidate set where base score order is INVERSE of valence so that
487+
# an active mood-congruent rerank reshuffles them. Candidate 0 has the
488+
# lowest base score but the most positive valence; positive user mood
489+
# should boost it above candidate 4 (highest base score, negative valence).
490+
# Input order is the relevance rank (_rrf_blend uses positional rank,
491+
# not score). Order: negative-valence first, positive-valence last —
492+
# active positive mood should pull positive candidates upward.
493+
cands = [
494+
{
495+
"memory_id": i,
496+
"content": f"mem {i}",
497+
"score": 1.0 / (i + 1),
498+
"heat": 0.5,
499+
"tags": [],
500+
"domain": "test",
501+
"created_at": "2026-04-30T00:00:00Z",
502+
"emotional_valence": v,
503+
}
504+
for i, v in enumerate([-0.9, -0.5, 0.0, +0.5, +0.9])
505+
]
506+
baseline = mood_congruent_rerank(cands, user_mood=None)
507+
active = mood_congruent_rerank(cands, user_mood=store.valence)
508+
# Baseline is identity (input order); active must produce score deltas
509+
# — even if positional ranks are unchanged, the RRF blend assigns new
510+
# scores reflecting valence-mood congruence. That score signal is what
511+
# downstream stages (FlashRank, per-type pools) consume.
512+
base_scores = {c["memory_id"]: c["score"] for c in baseline}
513+
active_scores = {c["memory_id"]: c["score"] for c in active}
514+
assert base_scores != active_scores, (
515+
"EMA-driven mood must produce score deltas vs no-mood baseline"
516+
)
517+
# The most mood-congruent candidate (id=4, valence +0.9) must gain
518+
# score relative to its no-mood baseline; the most incongruent (id=0,
519+
# valence -0.9) must lose. We compare the active-vs-baseline ratio,
520+
# not absolute order — at default blend_beta=0.15 with positional
521+
# rank dominance (k=60), the relevance order is preserved, but the
522+
# delta direction is observable and is the signal downstream stages
523+
# (FlashRank, per-type pools) consume.
524+
delta_congruent = active_scores[4] - base_scores[4]
525+
delta_incongruent = active_scores[0] - base_scores[0]
526+
assert delta_congruent > delta_incongruent, (
527+
f"mood-congruent candidate must gain MORE than incongruent: "
528+
f"id=4 Δ={delta_congruent:.6f}, id=0 Δ={delta_incongruent:.6f}"
529+
)
530+
531+
453532
# ── RECONSOLIDATION (Nader 2000) ───────────────────────────────────────
454533

455534

0 commit comments

Comments
 (0)