You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 EPISODICMemoryItem 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 consumesis_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.
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 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.
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.
Problem
SqliteMemoryStore(src/forge_loop/memory/store.py) exposesput/get/list_active/supersedebut no compaction. The memory-generation paths insrc/forge_loop/runner/learning.py(record_merged_outcomes,record_failed_outcomes) upsert one durableEPISODICMemoryItemper issue and never reclaim it, whileassemble_boot_contextloads every active item. Over hundreds of issues the per-issuefailed/mergedepisodes grow monotonically and bury the load-bearing decisions a maestro needs after a reset.The event log already solved the mirror problem —
eventlog/sqlite.pycompaction preserves everythingeventlog/models.is_load_bearingaccepts 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 EPISODICfailed/mergedepisodes. Boot context is 99% noise. Afterstore.compact_episodic(keep_recent=50)it returns ~58 items: all 8 load-bearing items survive, plus the 50 most-recent episodes;542is 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.pyor 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) -> intexists and returns the number of items pruned.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.superseded_by IS NULLANDis_load_bearing_memory(item)is False, i.e. EPISODIC episodes), the most-recentkeep_recentare retained and the older surplus is deleted. "Most-recent" = highestrowid(insertion order), matchinglist_active'sORDER BY rowid ASC.keep_recent=k, after compaction all D survive and exactlymin(M, k)most-recent prunable items remain.0the second (already at/under cap is a no-op). Returns0whenM <= keep_recent.keep_recent=0prunes all prunable items and preserves all load-bearing items.keep_recent< 0 raisesValueError(mirror the validation style inmodels.py).with self._connection:transaction (mirrorsupersede); 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):keep_recent=2→ returns3; survivors are both load-bearing items + the 2 newest episodes (assert bymemory_id).REJECTED_PATH_TAG-tagged item plus old EPISODIC items,keep_recent=0→ the rejected-path item survives, all episodes pruned.compact_episodic(2)twice; second call returns0and leaves the set unchanged.keep_recent=10→ returns0, nothing deleted.SqliteMemoryStore(db)again) and assert survivors persisted (mirrorstest_active_..._round_trip_after_reopen).keep_recent=-1raisesValueError; AND a store of only load-bearing items (M=0) → returns0, deletes nothing even withkeep_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
compact_episodicinto any caller (runner/learning.py, boot, scheduler) — call-site integration is a separate follow-up.is_load_bearing_memorypredicate here — that is Add a pure is_load_bearing_memory(item) predicate for curated memory #427.memory_integrityprobe — that is Surface prunable-memory backlog in the forge-loop doctor memory_integrity probe #429.MemoryStoreProtocol orFakeMemoryStoreunless a test needs it; keep the change to the concreteSqliteMemoryStore. (If you do add it to the Protocol, you MUST also implement it insrc/forge_loop/_testing/memory_store.pyto keep the contract test green.)DELETEof episodic noise.memory_itemstable androwid.File pointers
src/forge_loop/memory/store.py— addcompact_episodicmethod onSqliteMemoryStore(nearsupersede). Reuseis_load_bearing_memory,_select, and thewith self._connection:pattern.src/forge_loop/memory/models.py— source ofis_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.src/forge_loop/eventlog/sqlite.py+src/forge_loop/eventlog/models.pyis_load_bearingfor the preserve/prune design.Verification gate:
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.