Adds the L7 living-connection dynamics layer for the hallway primitive (PR #1558) and the tunnel primitive (PR #1565). Connections now carry strength, stability, last_activated, and access_count fields; pure-math helpers in a new ``mempalace.dynamics`` module update these via Hebbian potentiation (Hebb 1949) on co-access and Ebbinghaus exponential decay (Ebbinghaus 1885) on time-since-use, with the Cepeda spacing effect (Cepeda et al. 2006) growing stability when reinforcement is distributed rather than massed. Existing palaces continue to work unchanged — the new fields populate via safe defaults (strength=1.0, stability=1.0, last_activated=created_at, access_count=0) lazily on any potentiate/decay call. No migration step required. No schema break. Old records work; new records ship with full L7 state from creation. ## What this commit does 1. New module ``mempalace/dynamics.py`` — pure math, no I/O, no chromadb. Three public helpers: - ``initialize_dynamics_fields(connection, *, now=None)`` Backfill helper. Populates missing strength / stability / last_activated / access_count fields. Existing fields are NOT overwritten. Safe for pre-L7 records. - ``potentiate(connection, *, increment=POTENTIATION_INCREMENT, now=None)`` Hebbian strengthening on a co-access event. Increments strength (capped at MAX_STRENGTH), updates last_activated, increments access_count. Grows stability by STABILITY_INCREMENT ONLY when the gap since the prior activation is at least SPACED_INTERVAL_HOURS — implements the Cepeda spacing effect (rapid bursts don't build durability; distributed practice does). - ``apply_decay(connection, *, now=None)`` Ebbinghaus exponential decay. New strength = old * exp(-days_since / stability), floored at STRENGTH_FLOOR (0.05) so connections never reach zero — only become dim. Idempotent at the same instant. Higher stability = slower decay. 2. Integration in ``mempalace/hallways.py:compute_hallways_for_wing`` — before persisting the new hallway list for a wing, looks up the existing wing's hallways and builds a (entity_a, entity_b) → dynamics lookup. Each new record gets the preserved dynamics if the entity pair existed before, OR default dynamics via ``initialize_dynamics_fields`` if it's a brand-new pair. Without this preservation, every mine would wipe accumulated connection weights — defeating the whole L7 layer. 3. Integration in ``mempalace/palace_graph.py:create_tunnel`` — the existing dedup-on-canonical-id path now also preserves the four dynamics fields from the existing record onto the recreated tunnel (in addition to the already-preserved ``created_at``). Brand-new tunnels and legacy records (created before L7) get default dynamics via ``initialize_dynamics_fields``. ## Tests (RED-first) Twenty-five new tests in ``tests/test_dynamics.py`` covering the pure math: field initialization defaults, potentiation strength increment + cap, last_activated update, access_count increment, spaced vs. rapid reinforcement (Cepeda spacing effect), decay rate (exp(-days/stability)), floor clamping, idempotency at same instant, higher-stability slower-decay behavior, no-time-passed no-op, backfill on missing fields, mutate-and- return-same-dict chaining. Plus integration scenarios: potentiation restoring decayed strength, repeated spaced reinforcement growing stability monotonically, burst reinforcement not growing stability. Six new integration tests: test_new_hallway_record_carries_all_dynamics_fields test_recompute_preserves_accumulated_strength test_recompute_initializes_dynamics_for_brand_new_pairs test_new_tunnel_carries_all_dynamics_fields test_recreate_tunnel_preserves_accumulated_dynamics test_recreate_tunnel_initializes_dynamics_for_legacy_records All RED-first (KeyError: 'strength' before the integration commits). All GREEN after. ## Backward compatibility Pre-existing palaces work unchanged: - Old hallway records with no dynamics fields are still readable; the next ``compute_hallways_for_wing`` recompute populates the missing fields via ``initialize_dynamics_fields``. - Old tunnel records similarly; the next ``create_tunnel`` recreate (or any future call passing the record through ``initialize_dynamics_fields``) populates the missing fields. - A ``test_recreate_tunnel_initializes_dynamics_for_legacy_records`` test pins this contract explicitly. No forced migration. No schema break. Old records work; new behavior kicks in on first touch. ## What this does NOT do (intentionally scoped out) - Spreading activation at search time — that's a search-layer change; bigger PR; separate work. - Lazy decay-on-read in ``list_hallways`` / ``list_tunnels`` — the math is here; wiring it into the read path is a follow-up. - Config exposure of the tunable constants — STRENGTH_FLOOR, MAX_STRENGTH, POTENTIATION_INCREMENT etc. are hardcoded module-level for v1. If empirical palace data shows the defaults need tuning, expose via MempalaceConfig in a follow-up. - Decay-aware ranking in search — the strength field is now there; the search ranker doesn't yet use it. Follow-up. ## Verification pytest tests/test_dynamics.py → 25 passed (RED before this commit; GREEN after) pytest tests/test_hallways.py::TestHallwayDynamicsIntegration → 3 passed (RED before; GREEN after) pytest tests/test_palace_graph_tunnels.py::TestTunnelDynamicsIntegration → 3 passed (RED before; GREEN after) pytest -q (full mempalace suite) → 2111 passed, 3 skipped, 0 regressions ruff check mempalace/dynamics.py mempalace/hallways.py mempalace/palace_graph.py tests/ → All checks passed! ruff format --check ... → 6 files already formatted (pinned 0.15.9) ## Research grounding - Hebb, D. O. (1949). The Organization of Behavior. Wiley. → "Neurons that fire together, wire together" → potentiate() - Ebbinghaus, H. (1885). Über das Gedächtnis. → Exponential forgetting curve → apply_decay() - Cepeda, N. J., Pashler, H., Vul, E., Wixted, J. T., & Rohrer, D. (2006). Distributed practice in verbal recall tasks: A review and quantitative synthesis. Psychological Bulletin, 132(3), 354-380. → Spacing effect → stability growth on spaced reinforcement
312 lines
14 KiB
Python
312 lines
14 KiB
Python
"""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
|