Skip to content

Add SqliteMemoryStore.compact_episodic(keep_recent) that prunes only prunable items past a cap #428

Description

@hadamrd

Problem

SqliteMemoryStore (src/forge_loop/memory/store.py) exposes put / get / list_active / supersede but no compaction. The memory-generation paths in src/forge_loop/runner/learning.py (record_merged_outcomes, record_failed_outcomes) upsert one durable EPISODIC MemoryItem per issue and never reclaim it, while assemble_boot_context loads every active item. Over hundreds of issues the per-issue failed/merged episodes grow monotonically and bury the load-bearing decisions a maestro needs after a reset.

The event log already solved the mirror problem — eventlog/sqlite.py compaction preserves everything eventlog/models.is_load_bearing accepts and prunes the rest. Curated memory needs the same paired arc.

Concrete example: A repo runs 300 issues. list_active() returns ~3 SEMANTIC decisions, ~5 PROCEDURAL skills, and ~592 EPISODIC failed/merged episodes. Boot context is 99% noise. After store.compact_episodic(keep_recent=50) it returns ~58 items: all 8 load-bearing items survive, plus the 50 most-recent episodes; 542 is the returned prune count.

Dependency

This ticket consumes is_load_bearing_memory(item) from sibling #427. Before implementing, confirm that predicate exists in the memory package (src/forge_loop/memory/models.py or a sibling module) and import it. If #427 has not landed, STOP and surface the blocker rather than duplicating the classifier — the epic requires a single source of truth for load-bearing classification.

Acceptance criteria

  • SqliteMemoryStore.compact_episodic(keep_recent: int) -> int exists and returns the number of items pruned.
  • Classification uses is_load_bearing_memory (from Add a pure is_load_bearing_memory(item) predicate for curated memory #427) — never an inline re-implementation. Load-bearing items (SEMANTIC, REJECTED_PATH_TAG-tagged, PROCEDURAL) are never deleted regardless of age or count.
  • Among active prunable items (superseded_by IS NULL AND is_load_bearing_memory(item) is False, i.e. EPISODIC episodes), the most-recent keep_recent are retained and the older surplus is deleted. "Most-recent" = highest rowid (insertion order), matching list_active's ORDER BY rowid ASC.
  • Primary acceptance: given D load-bearing items + M prunable episodic items and keep_recent=k, after compaction all D survive and exactly min(M, k) most-recent prunable items remain.
  • Deterministic & idempotent: running twice in a row prunes a non-negative count the first call and exactly 0 the second (already at/under cap is a no-op). Returns 0 when M <= keep_recent.
  • keep_recent=0 prunes all prunable items and preserves all load-bearing items. keep_recent < 0 raises ValueError (mirror the validation style in models.py).
  • Deletes are wrapped in a with self._connection: transaction (mirror supersede); the prune count derives from the actual delete, not an estimate.

Test matrix

Add to tests/test_memory_store.py (follow the existing _item(...) helper + tmp_path / "memory.db" conventions):

  • Unit — primary: D=2 (one SEMANTIC, one PROCEDURAL) + M=5 EPISODIC, keep_recent=2 → returns 3; survivors are both load-bearing items + the 2 newest episodes (assert by memory_id).
  • Unit — load-bearing immunity: a REJECTED_PATH_TAG-tagged item plus old EPISODIC items, keep_recent=0 → the rejected-path item survives, all episodes pruned.
  • Unit — idempotency: call compact_episodic(2) twice; second call returns 0 and leaves the set unchanged.
  • Unit — under cap no-op: M=3, keep_recent=10 → returns 0, nothing deleted.
  • Unit — durability: after compaction, reopen the DB (SqliteMemoryStore(db) again) and assert survivors persisted (mirrors test_active_..._round_trip_after_reopen).
  • Adversarial / sad-path: keep_recent=-1 raises ValueError; AND a store of only load-bearing items (M=0) → returns 0, deletes nothing even with keep_recent=0.

No integration/e2e changes required for this ticket (compaction is not yet wired into a runner caller — see Out of scope).

Out of scope

  • Do not wire compact_episodic into any caller (runner/learning.py, boot, scheduler) — call-site integration is a separate follow-up.
  • Do not add the is_load_bearing_memory predicate here — that is Add a pure is_load_bearing_memory(item) predicate for curated memory #427.
  • Do not touch the doctor memory_integrity probe — that is Surface prunable-memory backlog in the forge-loop doctor memory_integrity probe #429.
  • Do not add the method to the MemoryStore Protocol or FakeMemoryStore unless a test needs it; keep the change to the concrete SqliteMemoryStore. (If you do add it to the Protocol, you MUST also implement it in src/forge_loop/_testing/memory_store.py to keep the contract test green.)
  • Do not soft-delete / supersede prunable items — this is a hard DELETE of episodic noise.
  • No schema/migration changes; reuse the existing memory_items table and rowid.

File pointers

  • src/forge_loop/memory/store.py — add compact_episodic method on SqliteMemoryStore (near supersede). Reuse is_load_bearing_memory, _select, and the with self._connection: pattern.
  • src/forge_loop/memory/models.py — source of is_load_bearing_memory (from Add a pure is_load_bearing_memory(item) predicate for curated memory #427); read-only here.
  • tests/test_memory_store.py — all new tests.
  • Reference mirror (read-only): src/forge_loop/eventlog/sqlite.py + src/forge_loop/eventlog/models.py is_load_bearing for the preserve/prune design.

Verification gate:

uv run pytest tests/test_memory_store.py -q
uv run ruff check src/forge_loop/memory tests/test_memory_store.py
uv run mypy src/forge_loop/memory tests/test_memory_store.py

Original report

Parent: #426

Part of epic: "Bound curated memory with a load-bearing-preserving compaction arc"

Add one method SqliteMemoryStore.compact_episodic(keep_recent: int) that deletes the oldest prunable (per is_load_bearing_memory) memory items beyond keep_recent, retaining the most-recent ones, and NEVER deleting a load-bearing item regardless of age. Deterministic and idempotent (re-running with the same cap is a no-op once at/under the cap). Returns the count pruned. Primary acceptance: given D load-bearing items + M prunable episodic items and keep_recent=k, after compaction all D survive and exactly min(M,k) most-recent prunable items remain. ~100 net LOC incl. tests.

Customer story

A maestro resuming forge-loop after context loss is the customer whose boot context this bounds — the reclaim arc that stops episodic memory from growing monotonically while preserving every load-bearing decision.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions