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.

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions