Skip to content

Commit fe8b0bf

Browse files
Hanaclaude
andcommitted
feat(forgetting): thread narrative_weight arc pressure to fade threshold
Open-arc intensity now modulates the forgetting threshold so memories resist fading during heavy narrative periods. effective_fade = FADE_THRESHOLD / (1 + narrative_weight) — at full arc pressure the bar is halved. The unfade path is symmetrically lowered so recovery is also easier. Wire: _run_felt_time_tick now returns IntensityDrivers; run_folded caches the last computed value and passes it to forgetting_run_pass; run_pass threads narrative_weight to policy.next_state. LOST_THRESHOLD is unchanged — arc pressure only delays the fade gate, not the final deletion gate. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 132395a commit fe8b0bf

6 files changed

Lines changed: 168 additions & 7 deletions

File tree

brain/bridge/supervisor.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ def run_folded(
112112
)
113113
last_heartbeat_at = time.monotonic() if heartbeat_interval_s is not None else None
114114
last_soul_review_at = time.monotonic() if soul_review_interval_s is not None else None
115+
_last_intensity_drivers: IntensityDrivers | None = None
115116
last_finalize_at = time.monotonic() if finalize_interval_s is not None else None
116117
last_log_rotation_at = time.monotonic() if log_rotation_interval_s is not None else None
117118
last_initiate_review_at = time.monotonic() if initiate_review_interval_s is not None else None
@@ -191,7 +192,7 @@ def run_folded(
191192
# accessor is identified.
192193
# TODO(v0.0.15): replace 0 chat_turns + 0 reflex_firings with
193194
# real counters from the event bus or engine state.
194-
_run_felt_time_tick(
195+
_last_intensity_drivers = _run_felt_time_tick(
195196
persona_dir,
196197
wall_clock_s_since_last=wall_s,
197198
heartbeats_since_last=1,
@@ -215,7 +216,11 @@ def run_folded(
215216
except Exception:
216217
logger.exception("supervisor soul-review tick raised")
217218
try:
218-
forgetting_run_pass(persona_dir, event_bus=event_bus)
219+
forgetting_run_pass(
220+
persona_dir,
221+
event_bus=event_bus,
222+
intensity_drivers=_last_intensity_drivers,
223+
)
219224
except Exception:
220225
logger.exception("supervisor forgetting pass raised")
221226
# Narrative-memory arc-update runs AFTER forgetting in the same
@@ -302,13 +307,16 @@ def _run_felt_time_tick(
302307
heartbeats_since_last: int,
303308
chat_turns_since_last: int,
304309
reflex_firings_since_last: int,
305-
) -> None:
310+
) -> IntensityDrivers:
306311
"""Fold one supervisor heartbeat cycle into felt-time state.
307312
308313
drivers values are derived from existing body + emotion accessors;
309314
cold-start cases (no body state, no emotion vector) collapse all
310315
drivers to 0.0 so lived-age advances at baseline.
311316
317+
Returns the computed IntensityDrivers so the caller can cache them for
318+
the next forgetting pass (arc pressure modulates the fade threshold).
319+
312320
Fault-isolated upstream: caller wraps in try/except so a raise here
313321
cannot cascade into bridge shutdown or take down the heartbeat loop.
314322
"""
@@ -324,6 +332,7 @@ def _run_felt_time_tick(
324332
drivers=drivers,
325333
)
326334
)
335+
return drivers
327336

328337

329338
def _derive_intensity_drivers(

brain/forgetting/__init__.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from pathlib import Path
1313
from typing import Any
1414

15+
from brain.felt_time.lived_age import IntensityDrivers
1516
from brain.felt_time.state import load_or_recover as load_felt_time
1617
from brain.forgetting import graveyard, policy, salience, tombstone
1718
from brain.forgetting.policy import Transition
@@ -92,7 +93,12 @@ def _load_migration_grace(persona_dir: Path) -> tuple[datetime | None, float]:
9293
return mig, lived
9394

9495

95-
def run_pass(persona_dir: Path, *, event_bus: Any) -> dict[str, int]:
96+
def run_pass(
97+
persona_dir: Path,
98+
*,
99+
event_bus: Any,
100+
intensity_drivers: IntensityDrivers | None = None,
101+
) -> dict[str, int]:
96102
"""Run one forgetting pass over all active+fading memories.
97103
98104
Returns an aggregate summary dict with counts; also publishes a
@@ -164,7 +170,13 @@ def run_pass(persona_dir: Path, *, event_bus: Any) -> dict[str, int]:
164170
next_low = prev_low + 1
165171
else:
166172
next_low = 0
167-
transition = policy.next_state(memory, salience=s, consecutive_low_passes=next_low)
173+
nw = intensity_drivers.narrative_weight if intensity_drivers else 0.0
174+
transition = policy.next_state(
175+
memory,
176+
salience=s,
177+
consecutive_low_passes=next_low,
178+
narrative_weight=nw,
179+
)
168180
if transition == Transition.FADE:
169181
summary_text = tombstone.summarise(memory.content)
170182
store.fade(memory_id, summary=summary_text)

brain/forgetting/policy.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,20 +38,29 @@ def next_state(
3838
*,
3939
salience: float,
4040
consecutive_low_passes: int,
41+
narrative_weight: float = 0.0,
4142
) -> Transition:
4243
"""Compute the transition for one memory in one forgetting pass.
4344
4445
consecutive_low_passes: the count from the persisted forgetting_state.json
4546
tracking how many recent passes saw salience < LOST_THRESHOLD for this
4647
memory. The orchestrator increments/resets this counter; this function
4748
just reads it.
49+
50+
narrative_weight: open-arc pressure in [0, 1] from IntensityDrivers.
51+
A heavy open arc lowers the effective fade threshold so memories resist
52+
fading during intense narrative periods. LOST_THRESHOLD is unchanged —
53+
arc pressure only delays the fade gate, not the final deletion gate.
4854
"""
55+
# Arc pressure lowers the effective threshold proportionally.
56+
# narrative_weight=0 → baseline; narrative_weight=1 → threshold halved.
57+
effective_fade = FADE_THRESHOLD / (1.0 + narrative_weight)
4958
if memory.state == "active":
50-
if salience < FADE_THRESHOLD:
59+
if salience < effective_fade:
5160
return Transition.FADE
5261
return Transition.NONE
5362
if memory.state == "fading":
54-
if salience >= FADE_THRESHOLD:
63+
if salience >= effective_fade:
5564
return Transition.UNFADE
5665
if salience < LOST_THRESHOLD and consecutive_low_passes >= LOST_PASS_COUNT:
5766
return Transition.LOSE

tests/bridge/test_supervisor_forgetting.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,53 @@ def _soul_review_counter(*a, **k):
9797

9898
# Soul-review kept running even though forgetting raised each time.
9999
assert soul_review_calls[0] >= 2
100+
101+
102+
def test_supervisor_passes_intensity_drivers_to_forgetting(
103+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
104+
) -> None:
105+
"""run_folded caches IntensityDrivers from the felt-time tick and forwards them
106+
to forgetting_run_pass so arc pressure can modulate the fade threshold."""
107+
from brain.felt_time.lived_age import IntensityDrivers
108+
109+
persona_dir = tmp_path / "persona"
110+
persona_dir.mkdir()
111+
112+
fake_drivers = IntensityDrivers(narrative_weight=0.7)
113+
captured_drivers: list = []
114+
stop_event = threading.Event()
115+
116+
def _fake_felt_time_tick(*a, **k):
117+
return fake_drivers # supervisor caches this return value
118+
119+
def _fake_forgetting_pass(persona_dir, *, event_bus, intensity_drivers=None, **_):
120+
captured_drivers.append(intensity_drivers)
121+
stop_event.set()
122+
return {"faded": 0, "lost": 0, "total": 0, "exempt": 0, "unfaded": 0, "duration_ms": 0}
123+
124+
monkeypatch.setattr("brain.bridge.supervisor._run_felt_time_tick", _fake_felt_time_tick)
125+
monkeypatch.setattr("brain.bridge.supervisor.forgetting_run_pass", _fake_forgetting_pass)
126+
monkeypatch.setattr("brain.bridge.supervisor._run_soul_review_tick", lambda *a, **k: None)
127+
monkeypatch.setattr("brain.bridge.supervisor._run_heartbeat_tick", lambda *a, **k: None)
128+
monkeypatch.setattr("brain.bridge.supervisor._run_narrative_memory_pass", lambda *a, **k: None)
129+
130+
run_folded(
131+
stop_event,
132+
persona_dir=persona_dir,
133+
provider=MagicMock(),
134+
event_bus=MagicMock(),
135+
tick_interval_s=0.05,
136+
# Both intervals set to 0 so they fire on the first loop iteration.
137+
# Heartbeat block runs first in the loop body, then soul-review — so
138+
# drivers computed during heartbeat are available when forgetting fires.
139+
heartbeat_interval_s=0.0,
140+
soul_review_interval_s=0.0,
141+
finalize_interval_s=None,
142+
initiate_review_interval_s=None,
143+
voice_reflection_interval_s=None,
144+
log_rotation_interval_s=None,
145+
)
146+
147+
assert len(captured_drivers) >= 1, "forgetting pass should have been called"
148+
assert captured_drivers[0] is not None, "intensity_drivers should be forwarded, not None"
149+
assert captured_drivers[0].narrative_weight == pytest.approx(0.7)

tests/forgetting/test_orchestrator.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,48 @@ def test_run_pass_cold_start_no_memories(tmp_path):
108108
assert summary["lost"] == 0
109109

110110

111+
def test_run_pass_intensity_drivers_protects_borderline_memory(tmp_path):
112+
"""intensity_drivers.narrative_weight threads to policy and protects memories from fading."""
113+
from datetime import timedelta
114+
from unittest.mock import patch
115+
116+
from brain.felt_time.lived_age import IntensityDrivers
117+
from brain.forgetting import policy
118+
119+
persist_felt_time(
120+
FeltTimeState(lived_age_hours=100.0, last_tick_ts="2026-05-18T00:00:00+00:00"),
121+
tmp_path,
122+
)
123+
store = MemoryStore(tmp_path / "memories.db")
124+
m = Memory.create_new(content="arc memory", memory_type="episodic", domain="chat", emotions={})
125+
object.__setattr__(m, "created_at", datetime.now(UTC) - timedelta(days=10))
126+
store.create(m)
127+
store.close()
128+
129+
# Pin salience to just below baseline FADE_THRESHOLD but above the arc-adjusted threshold.
130+
# effective_fade at narrative_weight=1.0 → FADE_THRESHOLD / 2 = 0.125
131+
borderline_salience = 0.20 # 0.125 < 0.20 < 0.25
132+
133+
event_bus = MagicMock()
134+
with patch("brain.forgetting.salience.score", return_value=borderline_salience):
135+
# Without arc pressure: salience 0.20 < FADE_THRESHOLD 0.25 → should fade
136+
summary_no_arc = run_pass(tmp_path, event_bus=event_bus)
137+
assert summary_no_arc["faded"] >= 1, "baseline: borderline memory should fade without arc"
138+
139+
# Reload memory to active state
140+
store = MemoryStore(tmp_path / "memories.db")
141+
store._conn.execute("UPDATE memories SET state = 'active' WHERE id = ?", (m.id,))
142+
store._conn.commit()
143+
store.close()
144+
145+
event_bus2 = MagicMock()
146+
drivers = IntensityDrivers(narrative_weight=1.0)
147+
with patch("brain.forgetting.salience.score", return_value=borderline_salience):
148+
# With full arc pressure: effective_fade = 0.125, 0.20 >= 0.125 → should NOT fade
149+
summary_arc = run_pass(tmp_path, event_bus=event_bus2, intensity_drivers=drivers)
150+
assert summary_arc["faded"] == 0, "arc pressure: borderline memory should be protected"
151+
152+
111153
def test_run_pass_recovers_from_corrupt_forgetting_state(persona_with_low_salience_memory):
112154
persona_dir, _mem_id = persona_with_low_salience_memory
113155
# Pre-corrupt the state file.

tests/forgetting/test_policy.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,42 @@ def test_clock_skew_clamped():
163163
assert policy.is_within_import_grace(
164164
mem, migrated_at_utc=mig, lived_age_hours_at_migration=100.0,
165165
current_lived_age_hours=50.0) is True
166+
167+
168+
# ---------------------------------------------------------------------------
169+
# narrative_weight — arc pressure lowers the effective fade threshold
170+
# ---------------------------------------------------------------------------
171+
172+
173+
def test_narrative_weight_protects_borderline_active_memory():
174+
"""Arc pressure lowers effective fade threshold → memory held that would otherwise fade."""
175+
m = _make_memory(state="active")
176+
# Just below the baseline threshold — fades without arc pressure
177+
borderline = FADE_THRESHOLD - 0.01 # 0.24
178+
assert next_state(m, salience=borderline, consecutive_low_passes=0) == Transition.FADE
179+
# Strong arc pressure halves the threshold → 0.24 is now safely above it
180+
assert next_state(
181+
m, salience=borderline, consecutive_low_passes=0, narrative_weight=0.8
182+
) == Transition.NONE
183+
184+
185+
def test_narrative_weight_eases_unfade_for_fading_memory():
186+
"""Under arc pressure the reduced threshold makes it easier to recover a fading memory."""
187+
m = _make_memory(state="fading")
188+
# salience between effective threshold (arc) and baseline threshold
189+
low_salience = 0.18
190+
# Without arc pressure: 0.18 < 0.25 → stays fading
191+
assert next_state(m, salience=low_salience, consecutive_low_passes=0) == Transition.NONE
192+
# With arc pressure: effective_fade ≈ 0.139 → 0.18 >= 0.139 → unfades
193+
assert next_state(
194+
m, salience=low_salience, consecutive_low_passes=0, narrative_weight=0.8
195+
) == Transition.UNFADE
196+
197+
198+
def test_zero_narrative_weight_is_baseline():
199+
"""narrative_weight=0.0 behaves identically to omitting the argument."""
200+
m = _make_memory(state="active")
201+
assert (
202+
next_state(m, salience=0.20, consecutive_low_passes=0, narrative_weight=0.0)
203+
== next_state(m, salience=0.20, consecutive_low_passes=0)
204+
)

0 commit comments

Comments
 (0)