"""Tests for mempalace.dynamics — living-connection math for halls + tunnels. Covers Hebbian potentiation, Ebbinghaus exponential decay, the Cepeda spacing effect, and safe field initialization for connections created before L7 dynamics shipped. Pure math; no I/O; no chromadb required. All ``now`` arguments are injected explicitly so tests are deterministic. """ from __future__ import annotations import math from datetime import datetime, timedelta, timezone import pytest from mempalace.dynamics import ( DEFAULT_STABILITY, DEFAULT_STRENGTH, MAX_STRENGTH, POTENTIATION_INCREMENT, STABILITY_INCREMENT, STRENGTH_FLOOR, apply_decay, initialize_dynamics_fields, potentiate, ) # Fixed reference point so test arithmetic is exact. T0 = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc) def _fresh_connection(created_at=None, **overrides): """Build a minimal connection dict in the shape persisted by hallways.py or palace_graph.py — no dynamics fields yet.""" base = { "id": "test_conn_001", "wing": "test_wing", "entity_a": "Alpha", "entity_b": "Beta", "co_occurrence_count": 3, "rooms": ["r1"], "label": "Alpha ↔ Beta", "created_at": (created_at or T0).isoformat(), "created_by": "auto", } base.update(overrides) return base # ───────────────────────────────────────────────────────────────────────────── # initialize_dynamics_fields — backfill for pre-L7 records # ───────────────────────────────────────────────────────────────────────────── class TestInitializeDynamicsFields: def test_populates_strength_default_on_missing_field(self): conn = _fresh_connection() assert "strength" not in conn initialize_dynamics_fields(conn, now=T0) assert conn["strength"] == DEFAULT_STRENGTH def test_populates_stability_default_on_missing_field(self): conn = _fresh_connection() initialize_dynamics_fields(conn, now=T0) assert conn["stability"] == DEFAULT_STABILITY def test_populates_last_activated_from_created_at(self): """A fresh connection's last_activated should anchor to its created_at — decay starts from creation, not init-call-time.""" conn = _fresh_connection() initialize_dynamics_fields(conn, now=T0 + timedelta(days=5)) assert conn["last_activated"] == T0.isoformat() def test_populates_access_count_zero(self): conn = _fresh_connection() initialize_dynamics_fields(conn, now=T0) assert conn["access_count"] == 0 def test_does_not_overwrite_existing_fields(self): """Records that already have dynamics fields are passed through unchanged — this is a backfill helper, not a reset.""" conn = _fresh_connection() conn["strength"] = 2.3 conn["stability"] = 1.7 conn["last_activated"] = (T0 + timedelta(days=2)).isoformat() conn["access_count"] = 17 initialize_dynamics_fields(conn, now=T0 + timedelta(days=5)) assert conn["strength"] == 2.3 assert conn["stability"] == 1.7 assert conn["last_activated"] == (T0 + timedelta(days=2)).isoformat() assert conn["access_count"] == 17 def test_safe_on_record_missing_created_at(self): """Defensive: even if created_at is somehow missing, init should not crash — falls back to ``now``.""" conn = _fresh_connection() del conn["created_at"] initialize_dynamics_fields(conn, now=T0) # Just verify no crash and basic fields populated. assert "strength" in conn assert "last_activated" in conn # ───────────────────────────────────────────────────────────────────────────── # potentiate — Hebbian strengthening on co-access # ───────────────────────────────────────────────────────────────────────────── class TestPotentiate: def test_increments_strength_by_default_increment(self): conn = _fresh_connection() potentiate(conn, now=T0 + timedelta(hours=2)) assert conn["strength"] == pytest.approx(DEFAULT_STRENGTH + POTENTIATION_INCREMENT) def test_respects_custom_increment(self): conn = _fresh_connection() potentiate(conn, increment=0.2, now=T0 + timedelta(hours=2)) assert conn["strength"] == pytest.approx(DEFAULT_STRENGTH + 0.2) def test_caps_at_max_strength(self): conn = _fresh_connection() conn["strength"] = MAX_STRENGTH - 0.01 potentiate(conn, increment=1.0, now=T0 + timedelta(hours=2)) assert conn["strength"] == MAX_STRENGTH def test_updates_last_activated(self): conn = _fresh_connection() new_time = T0 + timedelta(days=3) potentiate(conn, now=new_time) assert conn["last_activated"] == new_time.isoformat() def test_increments_access_count(self): conn = _fresh_connection() potentiate(conn, now=T0 + timedelta(hours=2)) assert conn["access_count"] == 1 potentiate(conn, now=T0 + timedelta(hours=4)) assert conn["access_count"] == 2 def test_grows_stability_on_spaced_reinforcement(self): """Cepeda spacing effect: when the gap since last activation is ``≥ SPACED_INTERVAL_HOURS``, stability grows by ``STABILITY_INCREMENT``.""" conn = _fresh_connection() # Initialize at T0; potentiate at T0 + 2 hours (well above the 1-hour # spacing threshold) — stability should grow. potentiate(conn, now=T0 + timedelta(hours=2)) assert conn["stability"] == pytest.approx(DEFAULT_STABILITY + STABILITY_INCREMENT) def test_does_not_grow_stability_on_rapid_reinforcement(self): """Rapid bursts of co-access don't build stability — only spaced reinforcement does. Sub-spacing-threshold gap → stability unchanged.""" conn = _fresh_connection() # Initial state has last_activated == created_at == T0. # Potentiate just 10 minutes later — well below the 1-hour spacing. potentiate(conn, now=T0 + timedelta(minutes=10)) assert conn["stability"] == DEFAULT_STABILITY def test_chains_via_returned_dict(self): """potentiate mutates and returns the same dict for chaining.""" conn = _fresh_connection() result = potentiate(conn, now=T0 + timedelta(hours=2)) assert result is conn def test_works_on_record_without_dynamics_fields(self): """Backwards-compatibility: a pre-L7 record (no strength field yet) can be potentiated directly; missing fields backfill safely.""" conn = _fresh_connection() assert "strength" not in conn potentiate(conn, now=T0 + timedelta(hours=2)) assert conn["strength"] == pytest.approx(DEFAULT_STRENGTH + POTENTIATION_INCREMENT) assert conn["access_count"] == 1 # ───────────────────────────────────────────────────────────────────────────── # apply_decay — Ebbinghaus exponential decay # ───────────────────────────────────────────────────────────────────────────── class TestApplyDecay: def test_reduces_strength_proportionally_to_elapsed_time(self): """At ``days_since == stability``, strength multiplies by ``exp(-1)`` — about 0.3679 of original. This is the Ebbinghaus baseline.""" conn = _fresh_connection() conn["strength"] = 1.0 conn["stability"] = 1.0 conn["last_activated"] = T0.isoformat() apply_decay(conn, now=T0 + timedelta(days=1)) assert conn["strength"] == pytest.approx(math.exp(-1.0), rel=1e-6) def test_higher_stability_decays_slower(self): """Same elapsed time, double the stability → strength stays higher. Implements the durability benefit of spaced reinforcement.""" conn_low = _fresh_connection() conn_low["strength"] = 1.0 conn_low["stability"] = 1.0 conn_low["last_activated"] = T0.isoformat() conn_high = _fresh_connection() conn_high["strength"] = 1.0 conn_high["stability"] = 2.0 conn_high["last_activated"] = T0.isoformat() apply_decay(conn_low, now=T0 + timedelta(days=1)) apply_decay(conn_high, now=T0 + timedelta(days=1)) assert conn_high["strength"] > conn_low["strength"] def test_floors_at_strength_floor(self): """Strength never decays below ``STRENGTH_FLOOR`` — the palace doesn't forget; salience just drops.""" conn = _fresh_connection() conn["strength"] = 1.0 conn["stability"] = 1.0 conn["last_activated"] = T0.isoformat() apply_decay(conn, now=T0 + timedelta(days=10_000)) assert conn["strength"] == STRENGTH_FLOOR def test_idempotent_at_same_instant(self): """Calling apply_decay twice at the same ``now`` (without potentiation between) produces the same result as calling once.""" conn_a = _fresh_connection() conn_a["strength"] = 1.0 conn_a["stability"] = 1.0 conn_a["last_activated"] = T0.isoformat() conn_b = dict(conn_a) apply_decay(conn_a, now=T0 + timedelta(days=3)) apply_decay(conn_b, now=T0 + timedelta(days=3)) apply_decay(conn_b, now=T0 + timedelta(days=3)) # Both have decayed once total — but conn_b's SECOND apply_decay # at the same ``now`` should be a no-op since last_activated hasn't # moved. Result: both end at the same strength. assert conn_a["strength"] == pytest.approx(conn_b["strength"]) def test_no_decay_when_no_time_has_passed(self): """If ``now == last_activated``, decay is a no-op.""" conn = _fresh_connection() conn["strength"] = 1.5 conn["stability"] = 1.0 conn["last_activated"] = T0.isoformat() apply_decay(conn, now=T0) assert conn["strength"] == 1.5 def test_handles_missing_fields_via_backfill(self): """Pre-L7 records (no strength/stability/last_activated) get safe defaults via initialize_dynamics_fields — no crash.""" conn = _fresh_connection() assert "strength" not in conn # Should not raise; should not corrupt the record. apply_decay(conn, now=T0 + timedelta(days=1)) assert "strength" in conn # With last_activated falling back to created_at (T0) and a 1-day # gap at default stability, expect strength == exp(-1). assert conn["strength"] == pytest.approx(math.exp(-1.0), rel=1e-6) def test_returns_same_dict_for_chaining(self): conn = _fresh_connection() conn["strength"] = 1.0 conn["stability"] = 1.0 conn["last_activated"] = T0.isoformat() result = apply_decay(conn, now=T0 + timedelta(days=1)) assert result is conn # ───────────────────────────────────────────────────────────────────────────── # Integration scenarios — potentiate + decay interleaved # ───────────────────────────────────────────────────────────────────────────── class TestIntegrationScenarios: def test_potentiation_after_decay_restores_strength(self): """A connection that decayed substantially can recover its strength through subsequent co-access. This is the live-organism behavior: attention rebuilds salience.""" conn = _fresh_connection() conn["strength"] = 1.0 conn["stability"] = 1.0 conn["last_activated"] = T0.isoformat() # Decay for 3 days. apply_decay(conn, now=T0 + timedelta(days=3)) decayed_strength = conn["strength"] assert decayed_strength < 1.0 # Potentiate at day 3 — strength should jump up. potentiate(conn, now=T0 + timedelta(days=3)) assert conn["strength"] > decayed_strength def test_repeated_spaced_reinforcement_grows_stability(self): """Cepeda spacing effect over time: daily reinforcement should steadily grow stability, making the connection durable.""" conn = _fresh_connection() stability_history = [] for day in range(1, 6): potentiate(conn, now=T0 + timedelta(days=day)) stability_history.append(conn["stability"]) # Stability should have grown monotonically. for i in range(1, len(stability_history)): assert stability_history[i] > stability_history[i - 1] def test_burst_reinforcement_does_not_grow_stability(self): """Rapid bursts within the spacing window don't build durability — only access_count grows.""" conn = _fresh_connection() for minutes_offset in (5, 10, 15, 20, 25): potentiate(conn, now=T0 + timedelta(minutes=minutes_offset)) assert conn["stability"] == DEFAULT_STABILITY assert conn["access_count"] == 5