Skip to content

fix(compress): count only episodes actually deleted, not candidates - #52

Merged
TAIPANBOX merged 2 commits into
mainfrom
fix/compress-removed-count-matches-deletions
Sep 16, 2026
Merged

TAIPANBOX merged 2 commits into
mainfrom
fix/compress-removed-count-matches-deletions

Conversation

@TAIPANBOX

Copy link
Copy Markdown
Owner

What

Engram.compress() incremented CompressionRun.episodes_removed once per
candidate episode in a batch regardless of whether store.delete_episode()
actually deleted it. When a candidate was already gone by the time
compress() tried to delete it (for example a forget() racing compress()
between candidate selection and the delete call, where delete_episode
returns False), the count still went up. The memory_forgotten events,
built from the ids actually deleted, stayed correct; the number a caller or a
downstream governance service reads did not.

Fix: move removed += 1 inside the existing if self._store.delete_episode(ep.id):
branch, so removed == len(deleted_ids) always.

Report

Tier:       T1, ordinary code (no boundary, no external contract)
Scenarios:  none added; this repo's default is the binding gate over
            features/*.feature, which this repo does not yet have, and the
            fix is a one-line counting bug, not new behaviour
Red first:  tests/test_compress.py::test_compress_removed_count_excludes_already_deleted_candidates
            wraps store.delete_episode so it deletes the row and reports
            False for one of five candidates (simulating a forget() that won
            the race), then asserts episodes_removed equals the number of
            memory_forgotten events emitted.
            Run against the unfixed code:
              assert result.episodes_removed == len(forgotten)
              E       AssertionError: assert 5 == 4
              E        +  where 5 = CompressionRun(episodes_removed=5, summaries_created=1,
                             model_used='stub', cost_tokens=0).episodes_removed
              E        +  and   4 = len([... four memory_forgotten events ...])
              FAILED tests/test_compress.py::test_compress_removed_count_excludes_already_deleted_candidates
            Green after the fix, full tests/test_compress.py: 13 passed.
Tests:      1 new, 520 total (519 base + this one; 527 with the encryption
            extra), all green
Gate:       invariant 10 added to CLAUDE.md, naming this test as its marker
Metrics:    ruff check: all checks passed. ruff format --check: 59 files
            already formatted. mypy engram: could not run in this .venv, see
            NOT proven. pytest: 519 passed, 2 skipped (sqlcipher3 not
            installed; engram console script not on PATH for test_manifest)
            before this change, 520 passed after. No coverage percentage is
            measured in this repo (see the companion PR).
Ran it:     ruff check ., ruff format --check ., pytest (full suite, twice:
            before and after), ./scripts/no-network-at-write.sh (OK),
            ./scripts/readme-numbers.sh (caught the stale 519/526 heading
            from the new test, fixed, then OK), ./scripts/local-first.sh
            (OK), ./scripts/gates-have-teeth.sh (all 8 cases OK, tree left
            clean). All from the repo's own .venv, with it activated so the
            gate script's bare `python3` resolves to it.
NOT proven: mypy engram could not be run: this .venv's Python is 3.14.7,
            outside the 3.11-3.13 CI matrix, and mypy 2.3.1 fails to parse a
            PEP 695 `type` statement in the installed numpy 2.5.3's stub
            file regardless of the configured target python_version. Verified
            pre-existing and unrelated to this change (same failure on
            unmodified main in this .venv, checked via git stash). CI itself
            pins Python 3.11/3.12/3.13 and was not run here (no local runner
            for it); the gates above are full local parity apart from mypy
            and the two suites CI runs on their own jobs (encryption,
            pip-audit), neither touched by this change.

🤖 Generated with Claude Code

TAIPANBOX and others added 2 commits September 16, 2026 13:50
Engram.compress() incremented episodes_removed once per candidate in
each batch regardless of whether store.delete_episode() succeeded, so
CompressionRun.episodes_removed overstated when a candidate was gone
by the time compress() tried to delete it (for example a forget()
racing compress() between candidate selection and deletion, where
delete_episode returns False because the row is already gone). The
memory_forgotten events, built from the ids actually deleted, stayed
correct; the count a caller or a downstream governance service reads
did not.

Move the increment inside the existing "if delete_episode(...)" branch
so removed == len(deleted_ids) always.

Red first: tests/test_compress.py::test_compress_removed_count_excludes_already_deleted_candidates
simulates the race by wrapping store.delete_episode so it deletes the
row and reports False for one candidate, then asserts
episodes_removed equals the number of memory_forgotten events. Against
the unfixed code this failed with episodes_removed=5 against 4 emitted
events; it is green after the fix.

Adds invariant 10 to CLAUDE.md with its test marker.

README's gated test-coverage heading moves 519/526 to 520/527 for the
new test (scripts/readme-numbers.sh, invariant 2).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…the events

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@TAIPANBOX
TAIPANBOX merged commit ef67fd3 into main Sep 16, 2026
6 checks passed
@TAIPANBOX
TAIPANBOX deleted the fix/compress-removed-count-matches-deletions branch September 16, 2026 14:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant