Skip to content

Commit 81e8d90

Browse files
cdeustclaude
andcommitted
feat(verif): wire EMOTIONAL_RETRIEVAL + MOOD_CONGRUENT_RERANK as live read-path stages
Two enum entries (EMOTIONAL_RETRIEVAL, MOOD_CONGRUENT_RERANK) had no production call site. They are now live post-WRRF stages in core/recall_pipeline.py, gated by CORTEX_ABLATE_<NAME>=1, blended via RRF (Cormack 2009). EMOTIONAL_RETRIEVAL: infers query valence via VADER (Hutto & Gilbert 2014); ranks candidates by |stored_valence - q_valence|; RRF-blends with WRRF rank at beta=0.20. Neutral queries (|compound|<0.10) no-op to avoid noise. MOOD_CONGRUENT_RERANK: takes user_mood from store.get_user_mood() if exposed; ranks by congruence with user mood; RRF beta=0.15. Returns identity when no mood signal is available — we do NOT fabricate one. Both blend weights are engineering placeholders pending task #50 (6-knob grid: HOPFIELD x HDC x SA x DENDRITIC x EMOTIONAL_RETRIEVAL x MOOD_CONGRUENT). Source: Bower 1981 "Mood and Memory" Am. Psychologist 36(2) — qualitative claim, no paper-prescribed magnitude. - mcp_server/core/recall_pipeline.py: +2 stages, +constants - mcp_server/core/pg_recall.py: +2 stage calls, +_get_user_mood helper - tests_py/core/test_pg_recall_pipeline.py: +6 tests (21 total, all green) - tasks/smoke_recall_pipeline.py: extended to 6 mechanisms; all produce observable ID/score deltas - tasks/ablation-dormant-mechanisms.md: moved both entries from "future" to "Active read-path" Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bc0ae4f commit 81e8d90

5 files changed

Lines changed: 282 additions & 6 deletions

File tree

mcp_server/core/pg_recall.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,33 @@ def _get_titans() -> TitansMemory:
3333
return _titans
3434

3535

36+
def _get_user_mood(store: Any) -> float | None:
37+
"""Return the user's session-level mood in [-1, +1], or None if absent.
38+
39+
Looks for an explicit ``get_user_mood()`` method on the store. There is
40+
no such method in the current ``PgMemoryStore`` (April 2026), so this
41+
helper returns ``None`` and the MOOD_CONGRUENT_RERANK stage no-ops —
42+
per the zetetic source-discipline rule we do NOT fabricate a mood signal.
43+
When an upstream emotion classifier or manual checkpoint annotation
44+
populates a mood store, expose ``get_user_mood()`` and this helper
45+
will start returning real values without further wiring changes.
46+
"""
47+
if store is None:
48+
return None
49+
if not hasattr(store, "get_user_mood"):
50+
return None
51+
try:
52+
v = store.get_user_mood()
53+
except Exception: # noqa: BLE001 — non-load-bearing; absence is fine
54+
return None
55+
if v is None:
56+
return None
57+
try:
58+
return max(-1.0, min(1.0, float(v)))
59+
except (TypeError, ValueError):
60+
return None
61+
62+
3663
# ── Chronological reranking ─────────────────────────────────────────────
3764
# ChronoRAG (Chen et al., arxiv 2508.18748, 2025): for event ordering
3865
# queries, blend relevance rank with chronological rank via Reciprocal
@@ -257,8 +284,10 @@ def recall(
257284
# constants and citations.
258285
from mcp_server.core.recall_pipeline import (
259286
dendritic_modulate,
287+
emotional_retrieval_rerank,
260288
hdc_rerank,
261289
hopfield_complete,
290+
mood_congruent_rerank,
262291
spreading_activation_expand,
263292
)
264293

@@ -272,6 +301,16 @@ def recall(
272301
candidates = spreading_activation_expand(candidates, query, store)
273302
candidates = dendritic_modulate(candidates, query, store)
274303

304+
# 4e. EMOTIONAL_RETRIEVAL — Bower 1981 mood-congruent recall using
305+
# the QUERY's inferred valence (VADER) against each candidate's stored
306+
# emotional_valence. No-ops on neutral queries.
307+
candidates = emotional_retrieval_rerank(candidates, query)
308+
309+
# 4f. MOOD_CONGRUENT_RERANK — Bower 1981 mood-state-dependent recall
310+
# using the USER's session-level mood. Returns identity when no mood
311+
# signal exists; we do not fabricate one.
312+
candidates = mood_congruent_rerank(candidates, _get_user_mood(store))
313+
275314
# 5. Client-side FlashRank reranking
276315
if rerank and len(candidates) > 1:
277316
ranked_pairs = [(c["memory_id"], c.get("score", 0.0)) for c in candidates]

mcp_server/core/recall_pipeline.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,23 @@
5252
# matches get a +DELTA bump and conflicting branches get -DELTA.
5353
_DENDRITIC_DELTA: float = 0.10
5454

55+
# Emotional / mood-congruent rerank blend weights.
56+
# source: engineering default; calibration in tasks/blend-weight-calibration.md
57+
# (task #50, 6-knob grid HOPFIELD × HDC × SA × DENDRITIC × EMOTIONAL_RETRIEVAL ×
58+
# MOOD_CONGRUENT). Bower (1981) "Mood and Memory," Am. Psychologist 36(2)
59+
# does not prescribe a numeric blend weight; the paper's claim is qualitative
60+
# (mood-congruent recall is faster/more accurate than incongruent), so the
61+
# magnitude is set conservatively below the perception-side stages.
62+
_EMOTIONAL_RETRIEVAL_BETA: float = 0.20
63+
_MOOD_CONGRUENT_BETA: float = 0.15
64+
65+
# Below this absolute compound-valence value the query is treated as
66+
# emotionally neutral and the EMOTIONAL_RETRIEVAL stage no-ops. VADER
67+
# (Hutto & Gilbert, ICWSM 2014) §4 reports |compound| ≥ 0.05 as a useful
68+
# positive/negative cutoff for short text; we use 0.10 so that single
69+
# weakly-loaded tokens do not flip the rerank.
70+
_EMOTIONAL_QUERY_VALENCE_FLOOR: float = 0.10
71+
5572

5673
# ── Helpers ─────────────────────────────────────────────────────────────
5774

@@ -453,3 +470,113 @@ def dendritic_modulate(
453470

454471
modulated.sort(key=lambda c: c.get("score", 0.0), reverse=True)
455472
return modulated
473+
474+
475+
# ── EMOTIONAL_RETRIEVAL stage ───────────────────────────────────────────
476+
# Bower, G.H. (1981). "Mood and Memory." Am. Psychologist 36(2):129-148.
477+
# Mood-congruent recall: candidates whose stored emotional valence matches
478+
# the query's inferred valence are retrieved faster and more accurately.
479+
# Engineering blend via RRF (Cormack et al. SIGIR 2009).
480+
481+
482+
def emotional_retrieval_rerank(
483+
candidates: list[dict[str, Any]],
484+
query: str,
485+
*,
486+
blend_beta: float = _EMOTIONAL_RETRIEVAL_BETA,
487+
valence_floor: float = _EMOTIONAL_QUERY_VALENCE_FLOOR,
488+
) -> list[dict[str, Any]]:
489+
"""Rerank by query-valence ↔ candidate-valence congruence.
490+
491+
Bower (1981): emotionally-congruent material is retrieved faster and
492+
more accurately than incongruent material. We infer the query's
493+
affective load via VADER (Hutto & Gilbert, ICWSM 2014), measure each
494+
candidate's stored ``emotional_valence`` distance from the query
495+
valence, then RRF-blend that rank with the WRRF rank.
496+
497+
A neutral query (|valence| < ``valence_floor``) carries no congruence
498+
signal and the stage no-ops — RRF on a uniform rank would only
499+
add noise.
500+
501+
Disabled when ``CORTEX_ABLATE_EMOTIONAL_RETRIEVAL=1`` — returns input
502+
unchanged. Distinct from MOOD_CONGRUENT_RERANK: this stage uses the
503+
*query's* valence (per-recall), not a session-level user mood state.
504+
505+
Sources:
506+
- Bower, G.H. (1981). "Mood and Memory." Am. Psychologist 36(2).
507+
- Hutto, C.J. & Gilbert, E. (2014). "VADER: A Parsimonious Rule-based
508+
Model for Sentiment Analysis of Social Media Text." ICWSM 2014.
509+
- Cormack, Clarke & Buettcher (2009). RRF blend.
510+
"""
511+
if is_mechanism_disabled(Mechanism.EMOTIONAL_RETRIEVAL):
512+
return candidates
513+
if not candidates:
514+
return candidates
515+
516+
from mcp_server.shared.vader import vader_compound
517+
518+
q_valence = vader_compound(query)
519+
if abs(q_valence) < valence_floor:
520+
# Neutral query — no useful congruence signal to inject.
521+
return candidates
522+
523+
def _distance(c: dict[str, Any]) -> float:
524+
c_v = c.get("emotional_valence", 0.0) or 0.0
525+
return abs(float(c_v) - q_valence)
526+
527+
by_match = sorted(enumerate(candidates), key=lambda x: _distance(x[1]))
528+
mech_ranks = {
529+
candidates[i]["memory_id"]: rank for rank, (i, _) in enumerate(by_match)
530+
}
531+
return _rrf_blend(candidates, mech_ranks, blend_beta)
532+
533+
534+
# ── MOOD_CONGRUENT_RERANK stage ────────────────────────────────────────
535+
# Bower (1981) mood-state-dependent recall: a person in a given mood
536+
# preferentially recalls memories acquired (or stored) in that same mood.
537+
# Distinct from EMOTIONAL_RETRIEVAL — this stage uses a USER session-level
538+
# mood signal, not the per-query valence.
539+
540+
541+
def mood_congruent_rerank(
542+
candidates: list[dict[str, Any]],
543+
user_mood: float | None,
544+
*,
545+
blend_beta: float = _MOOD_CONGRUENT_BETA,
546+
) -> list[dict[str, Any]]:
547+
"""Rerank by user-mood ↔ candidate-valence congruence.
548+
549+
``user_mood`` is a float in [-1, +1] representing the user's current
550+
affective state (e.g., set by an upstream emotion classifier or a
551+
manual ``checkpoint`` annotation). When ``None``, the stage no-ops:
552+
we do NOT fabricate a mood signal in the absence of one.
553+
554+
Default policy from Bower (1981): mood-congruent — candidates whose
555+
stored valence is closer to the user's current mood get a rank boost.
556+
The boost is small (RRF beta=0.15) so the underlying retrieval order
557+
still dominates; this is a tie-breaker, not a filter.
558+
559+
Disabled when ``CORTEX_ABLATE_MOOD_CONGRUENT_RERANK=1`` — returns
560+
input unchanged. Distinct from EMOTIONAL_RETRIEVAL (which uses the
561+
query text's inferred valence).
562+
563+
Sources:
564+
- Bower, G.H. (1981). "Mood and Memory." Am. Psychologist 36(2).
565+
- Cormack, Clarke & Buettcher (2009). RRF blend.
566+
"""
567+
if is_mechanism_disabled(Mechanism.MOOD_CONGRUENT_RERANK):
568+
return candidates
569+
if user_mood is None or not candidates:
570+
return candidates
571+
572+
user_mood_f = float(user_mood)
573+
574+
def _distance(c: dict[str, Any]) -> float:
575+
c_v = c.get("emotional_valence", 0.0) or 0.0
576+
return abs(float(c_v) - user_mood_f)
577+
578+
by_match = sorted(enumerate(candidates), key=lambda x: _distance(x[1]))
579+
mech_ranks = {
580+
candidates[i]["memory_id"]: rank for rank, (i, _) in enumerate(by_match)
581+
}
582+
return _rrf_blend(candidates, mech_ranks, blend_beta)

tasks/ablation-dormant-mechanisms.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ no longer routes through it.
4848
| **HDC** | `core/recall_pipeline.hdc_rerank()` | Skips Kanerva 2009 bipolar HDC similarity rerank — content tokens stop contributing the bipolar bind/bundle signal |
4949
| **SPREADING_ACTIVATION** | `core/recall_pipeline.spreading_activation_expand()` | No graph-side BFS over the entity graph → memories reachable only via 2-3 hops drop out of the result set; observable as new IDs disappearing on ablation |
5050
| **DENDRITIC_CLUSTERS** | `core/recall_pipeline.dendritic_modulate()` | No multiplicative perturbation in [0.9, 1.1] from query-content Jaccard → near-ties shuffle slightly differently |
51+
| **EMOTIONAL_RETRIEVAL** | `core/recall_pipeline.emotional_retrieval_rerank()` | Skips Bower 1981 mood-congruent rerank — candidates whose stored `emotional_valence` is closest to the query's VADER compound no longer get an RRF rank boost; non-neutral queries lose the affect signal |
52+
| **MOOD_CONGRUENT_RERANK** | `core/recall_pipeline.mood_congruent_rerank()` | Skips Bower 1981 mood-state-dependent rerank — when an upstream mood signal is present (`store.get_user_mood()`), candidates congruent with the user's mood lose their rank boost. No-op when no mood signal exists |
5153
| **CO_ACTIVATION** | `handlers/recall.py:_apply_co_activation` (line 188) | Skips Hebbian post-recall edge strengthening — affects subsequent recalls' SR signal |
5254

5355
## State-Only Read-Path Mechanisms
@@ -95,8 +97,12 @@ the wiring.
9597
The §6.3 ablation table now distinguishes:
9698

9799
1. **Active read-path** — ADAPTIVE_DECAY, HOPFIELD, HDC,
98-
SPREADING_ACTIVATION, DENDRITIC_CLUSTERS, CO_ACTIVATION.
99-
All produce non-zero deltas on a single read.
100+
SPREADING_ACTIVATION, DENDRITIC_CLUSTERS, EMOTIONAL_RETRIEVAL,
101+
MOOD_CONGRUENT_RERANK, CO_ACTIVATION.
102+
All produce non-zero deltas on a single read (MOOD_CONGRUENT_RERANK
103+
only when the store exposes a non-None ``get_user_mood()``; the
104+
production store does not yet, but the wiring is live for the future
105+
classifier — and the smoke harness exercises it via a stub).
100106
2. **State-only read-path** — SURPRISE_MOMENTUM. No ranking effect
101107
on a single benchmark pass; effect emerges across consecutive recalls.
102108

@@ -110,4 +116,5 @@ path.
110116
- Commit `099ba1e` (module-level guards added)
111117
- Commit (this) — wired four dormant mechs through `pg_recall.recall`
112118
- Smoke test: `tasks/smoke_recall_pipeline.py`
113-
- Tests: `tests_py/core/test_pg_recall_pipeline.py` (12 tests, all green)
119+
- Tests: `tests_py/core/test_pg_recall_pipeline.py` (21 tests, all green)
120+
- Commit (this) — wired EMOTIONAL_RETRIEVAL + MOOD_CONGRUENT_RERANK; +6 tests; smoke confirms 6 mechanisms produce deltas

tasks/smoke_recall_pipeline.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ def __init__(self, n: int = 12) -> None:
4343
for i in range(n):
4444
tokens = ["alpha", "beta", "gamma", "delta", "epsilon"]
4545
content = f"mem {i} " + " ".join(tokens[: (i % 5) + 1])
46+
# Spread emotional_valence across [-0.9, +0.9] so the
47+
# EMOTIONAL_RETRIEVAL / MOOD_CONGRUENT_RERANK stages have a
48+
# non-degenerate signal to act on. Without varied valence the
49+
# rerank is a uniform-distance no-op even when enabled.
50+
valence = round(((i % 7) / 3.0) - 1.0, 3) # ∈ [-1.0, +1.0]
4651
self._mems[i] = {
4752
"id": i,
4853
"memory_id": i,
@@ -55,6 +60,7 @@ def __init__(self, n: int = 12) -> None:
5560
"importance": 0.5,
5661
"surprise_score": 0.0,
5762
"store_type": "episodic",
63+
"emotional_valence": valence,
5864
}
5965
# SA returns a memory NOT in the WRRF top-K to test injection
6066
self._mems[99] = {
@@ -88,10 +94,17 @@ def recall_memories(self, **kwargs) -> list[dict]:
8894
"importance": 0.5,
8995
"surprise_score": 0.0,
9096
"store_type": "episodic",
97+
"emotional_valence": self._mems[i]["emotional_valence"],
9198
}
9299
for rank, i in enumerate(ids)
93100
]
94101

102+
# Session-level mood for MOOD_CONGRUENT_RERANK smoke. Real production
103+
# PgMemoryStore does not expose this method (April 2026); the smoke
104+
# store simulates the future API so the stage produces a delta.
105+
def get_user_mood(self) -> float:
106+
return 0.7
107+
95108
def get_memory(self, mid: int) -> dict | None:
96109
return self._mems.get(mid)
97110

@@ -124,8 +137,12 @@ def _run(env_name: str | None) -> tuple[list[int], list[float], float]:
124137
embs = _Embeddings()
125138
t0 = time.perf_counter()
126139
with _ablate(env_name):
140+
# Query carries a clear positive VADER compound ("fixed deployed
141+
# excellent") so EMOTIONAL_RETRIEVAL is non-neutral and ablation
142+
# produces a measurable delta. Domain tokens (alpha/beta/gamma)
143+
# keep HDC and SA paths active.
127144
out = recall(
128-
query="alpha beta gamma extra",
145+
query="alpha beta gamma extra fixed deployed excellent",
129146
store=store,
130147
embeddings=embs,
131148
top_k=10,
@@ -147,6 +164,8 @@ def main() -> None:
147164
"CORTEX_ABLATE_HDC",
148165
"CORTEX_ABLATE_SPREADING_ACTIVATION",
149166
"CORTEX_ABLATE_DENDRITIC_CLUSTERS",
167+
"CORTEX_ABLATE_EMOTIONAL_RETRIEVAL",
168+
"CORTEX_ABLATE_MOOD_CONGRUENT_RERANK",
150169
]
151170
deltas = {}
152171
for env in mechs:
@@ -164,7 +183,7 @@ def main() -> None:
164183
print(f" ids: {ids}")
165184
print(f" latency: {dt * 1000:.2f} ms")
166185

167-
# All four must produce a non-trivial delta.
186+
# All six must produce a non-trivial delta.
168187
failures = [
169188
k for k, v in deltas.items() if not (v["ids_changed"] or v["scores_changed"])
170189
]
@@ -177,7 +196,7 @@ def main() -> None:
177196
if failures:
178197
print(f"\nFAIL: zero-delta mechanisms (wiring bug): {failures}")
179198
raise SystemExit(1)
180-
print("\nPASS: all 4 mechanisms produce observable deltas.")
199+
print("\nPASS: all 6 mechanisms produce observable deltas.")
181200

182201

183202
if __name__ == "__main__":

0 commit comments

Comments
 (0)