From 911bcd47e9e2060e62b06243f6e1b6815ce61bd8 Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Thu, 14 May 2026 18:23:51 -0700 Subject: [PATCH 1/2] test: regression for #833 demotion_pressure post-DROP read Seeds a DB with the pre-DROP belief schema (v3.1.0 column set including `demotion_pressure`), opens it through MemoryStore so the `ALTER TABLE beliefs DROP COLUMN demotion_pressure` migration fires, and asserts `get_belief()` round-trips without `IndexError`. Second case verifies the drop is idempotent across re-opens. --- tests/test_demotion_pressure_drop.py | 118 +++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 tests/test_demotion_pressure_drop.py diff --git a/tests/test_demotion_pressure_drop.py b/tests/test_demotion_pressure_drop.py new file mode 100644 index 000000000..244fc5f34 --- /dev/null +++ b/tests/test_demotion_pressure_drop.py @@ -0,0 +1,118 @@ +"""Regression for #833: a DB with a `demotion_pressure` column must +remain readable after the v3.1.1+ migration drops it. + +Before #833's fix, the v3.1.0 reader had an unconditional +`row["demotion_pressure"]` in `_row_to_belief`. The migration in +`_MIGRATION_STATEMENTS` drops the column on first open by a post-#814 +build, leaving the v3.1.0 reader to crash with +`IndexError: No item with that key` on any subsequent `get_belief()`. + +This test seeds a DB that still has the column (the v3.1.0 schema +shape), opens it with the current `MemoryStore` so the DROP COLUMN +migration runs, and verifies `get_belief()` round-trips a row whose +`Belief` no longer carries `demotion_pressure`. +""" +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +from aelfrice.models import BELIEF_FACTUAL, LOCK_NONE +from aelfrice.store import MemoryStore + + +def _seed_v3_1_0_store(path: Path) -> None: + """Create a DB with the full pre-DROP belief schema and one row. + + Column set mirrors the v3.1.0 release: every column that any + `ALTER TABLE ADD COLUMN` entry in `_MIGRATION_STATEMENTS` could + have created, plus `demotion_pressure` which the post-#814 + migration drops. + """ + conn = sqlite3.connect(str(path)) + try: + conn.execute( + """ + CREATE TABLE beliefs ( + id TEXT PRIMARY KEY, + content TEXT NOT NULL, + content_hash TEXT NOT NULL, + alpha REAL NOT NULL, + beta REAL NOT NULL, + type TEXT NOT NULL, + lock_level TEXT NOT NULL, + locked_at TEXT, + demotion_pressure INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + last_retrieved_at TEXT, + session_id TEXT, + origin TEXT NOT NULL DEFAULT 'unknown', + corroboration_count INTEGER NOT NULL DEFAULT 0, + hibernation_score REAL, + activation_condition TEXT, + retention_class TEXT, + valid_to TEXT, + scope TEXT NOT NULL DEFAULT 'project' + ) + """ + ) + conn.execute( + "INSERT INTO beliefs (id, content, content_hash, alpha, beta, " + "type, lock_level, demotion_pressure, created_at) " + "VALUES (?,?,?,?,?,?,?,?,?)", + ( + "b833", + "demotion-pressure-skew regression", + "h_b833", + 1.0, + 1.0, + BELIEF_FACTUAL, + LOCK_NONE, + 0, + "2026-05-14T00:00:00Z", + ), + ) + conn.commit() + finally: + conn.close() + + +def test_demotion_pressure_column_dropped_on_open(tmp_path: Path) -> None: + db = tmp_path / "v3_1_0.db" + _seed_v3_1_0_store(db) + # Sanity: the seeded DB has the column. + raw = sqlite3.connect(str(db)) + cols = {r[1] for r in raw.execute("PRAGMA table_info(beliefs)").fetchall()} + assert "demotion_pressure" in cols + raw.close() + + s = MemoryStore(str(db)) + try: + # Migration must have dropped the column. + cols = { + r[1] + for r in s._conn.execute( # noqa: SLF001 + "PRAGMA table_info(beliefs)" + ).fetchall() + } + assert "demotion_pressure" not in cols + + # And get_belief must round-trip without IndexError. + got = s.get_belief("b833") + assert got is not None + assert got.content == "demotion-pressure-skew regression" + finally: + s.close() + + +def test_drop_idempotent_on_reopen(tmp_path: Path) -> None: + db = tmp_path / "v3_1_0.db" + _seed_v3_1_0_store(db) + MemoryStore(str(db)).close() + # Second open: column is already gone; migration tolerates it. + s = MemoryStore(str(db)) + try: + got = s.get_belief("b833") + assert got is not None + finally: + s.close() From 840a792409792f6de5f920c86636f6f39574e78a Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Thu, 14 May 2026 20:01:19 -0700 Subject: [PATCH 2/2] =?UTF-8?q?release:=20v3.2.0=20=E2=80=94=20fix=20demot?= =?UTF-8?q?ion=5Fpressure=20schema/reader=20skew=20(#833)=20+=20ship=20[Un?= =?UTF-8?q?released]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the prior `release: v3.1.1` commit (`e028573d`) per the PR review thread: tagging from current `main` ships #816, #817, #758 (Added), #834, #798, #809 (Fixed), and #814 (Removed). Of those, #814 documents four explicit breaking API changes (`apply_feedback(propagate=)` kwarg → TypeError, `FeedbackResult.{pressured_locks,demoted_locks}` → AttributeError, `aelf locked --pressured` CLI flag, `aelf:feedback` / `aelf:confirm` MCP payload keys → KeyError). SemVer requires a minor bump for those removals → v3.2.0, not v3.1.1. #833's user-visible fix (defensive `_row_to_belief` against post-DROP schema) is already on `main` via the column removal in fed650ca; the regression test in the prior commit guards the contract. Cutting from `main` is sufficient — no hotfix-from-tag needed. Changes: - pyproject.toml + uv.lock: 3.1.0 → 3.2.0. - CHANGELOG/v3.md: - Rename `## [Unreleased]` → `## [3.2.0] - 2026-05-15`; open a new empty `## [Unreleased]` above it (project policy in top-level CHANGELOG.md: "On release, move [Unreleased] content into a dated ## [X.Y.Z] section"). - Prepend the #833 Fixed entry to the new [3.2.0] section. - Update compare-link footers: `[Unreleased]: ...v3.2.0...HEAD`, new `[3.2.0]: ...v3.1.0...v3.2.0`. The prior `[Unreleased]: ...v3.0.1...HEAD` was stale (should have been v3.1.0...HEAD after the v3.1.0 ship); fixed in passing. Closes #833. --- CHANGELOG/v3.md | 7 ++++++- pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG/v3.md b/CHANGELOG/v3.md index 149d41127..aaf210cbe 100644 --- a/CHANGELOG/v3.md +++ b/CHANGELOG/v3.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.2.0] - 2026-05-15 + ### Added - **Hot-path touch state v1 storage substrate** ([#816](https://github.com/robotrocketscience/aelfrice/issues/816), closes [#748](https://github.com/robotrocketscience/aelfrice/issues/748)'s storage axis). Lands the per-(belief, session) touch sidecar specified by `experiments/hot-path/DESIGN.md` v1 (lab `6b40538`) after the R0..R7c campaign — sidecar table over wide-row columns (H1 PASS via R1/R1b), boolean "touched in last K fires" decay over exponential τ (H2 REFUTED via R2c/R2d), INJECTION-only event kind (H4 REFUTED via R4..R4e/R5; `retrieve_hit` adds zero observable surface at top-K Jaccard = 1.000). **No retrieval consumer wired in v1** — the rerank multiplier path is gated on the H3 fidelity test post-R7c (DESIGN.md v1 ship list item 7). New `belief_touches(belief_id, session_id, last_fire_idx, touch_count, event_kinds_bitmask)` table with composite PK + FK CASCADE to `beliefs` + `(session_id, last_fire_idx DESC)` index; `MemoryStore` APIs `record_touch` (INSERT ... ON CONFLICT DO UPDATE — last_fire_idx refresh, touch_count bump, event_kinds_bitmask OR-in), `read_touch_set_in_window` (boolean window predicate), `count_touches_for_session`, `list_touch_sessions`. New `src/aelfrice/hot_path.py` module with `is_hot()` pure predicate, `DEFAULT_TOUCH_WINDOW_K = 50` (R2c canonical cell), and `TOUCH_EVENT_KIND_*` bitmask constants (only `INJECTION` populated; `RETRIEVE_HIT` / `BFS_VISIT` / `USER_ACTION` bits reserved per DESIGN.md "Out of scope"). Hook integration writes touches alongside the existing #744 JSON injection ring at the UPS site, sharing the ring's monotonic `fire_idx` so both substrates track the same counter. Forward-only — the hook records only the current turn's injection set; ring entries that predate this table are not backfilled (an earlier revision did backfill via `record_touch`, but the replay was non-idempotent under `ON CONFLICT DO UPDATE` and was dropped before merge). Determinism (#605): `fire_idx` is a monotonic integer, never wall-clock; same query + same store + same fire_idx → same window contents. Federation (#661): composite PK `(belief_id, session_id)` keeps foreign federated beliefs cold every read by construction. New `aelf doctor --hot-path` read-only diagnostic surface lists every session_id with at least one touch row plus row count and max fire_idx. Fail-soft throughout the hook — touch state is opportunistic substrate; a write failure must not break the user-visible context-injection contract. 22 new tests in `tests/test_hot_path_touch_state.py` cover schema, store API round-trip, ON CONFLICT semantics, bitmask OR-in, window-boundary read, per-session isolation property, ordering of `list_touch_sessions`, determinism property, FK CASCADE on belief delete, hook forward-only writes (current-turn only; pre-substrate ring entries NOT backfilled), `touch_count`-matches-actual-inject-count regression across repeated UPS fires, and missing-DB fail-soft. Concept doc at `docs/feature-hot-path.md`. @@ -17,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **`aelf` v3.1.0 crash on any DB previously touched by post-#814 code** ([#833](https://github.com/robotrocketscience/aelfrice/issues/833)). The v3.1.0 reader had an unconditional `row["demotion_pressure"]` in `_row_to_belief` at `store.py:651`. Commit `fed650ca` (`refactor(store): drop beliefs.demotion_pressure column`, [#814](https://github.com/robotrocketscience/aelfrice/issues/814)) landed on `main` after the v3.1.0 tag and added an unconditional `ALTER TABLE beliefs DROP COLUMN demotion_pressure` migration. Any user running an installed `aelfrice==3.1.0` against a `~/.aelfrice/store.db` that had already been opened by a post-`fed650ca` build (e.g. `uv run aelf ` from a worktree on current `main`) hit `IndexError: No item with that key` on any `get_belief()`-backed command — `aelf wonder`, `aelf reason`, `aelf search` graph-walks, and the `/aelf:reason` skill. The v3.2.0 cut ships current `main`, where `_row_to_belief` no longer references the dropped column and the `Belief` dataclass has no `demotion_pressure` field. New regression test at `tests/test_demotion_pressure_drop.py` seeds a DB with the pre-DROP column shape, opens it through `MemoryStore` (running the migration), and asserts both that the column is gone and that `get_belief()` round-trips a row without crashing; second case verifies the drop is idempotent across re-opens. Migration-policy CI guard against destructive `_MIGRATION_STATEMENTS` entries arriving without paired reader updates is deferred to a follow-up. + - **Auto-install gated to uv-tool installs only** ([#834](https://github.com/robotrocketscience/aelfrice/issues/834)). `auto_install_at_cli_entry` previously fired on every CLI invocation, including `uv run aelf ` from a project worktree's local `.venv`. With `~/.aelfrice/installed-manifest-version` stamped at one version and a worktree pinned to a different version (the routine multi-worktree workflow), the merge ran against the worktree's bundled manifest and rewrote the user's global `~/.claude/settings.json` — silently re-pinning hook entries to the worktree's source-tree paths and re-stamping the version backwards. The bug-reporter saw the symptom as "opted out" of six hooks: pre-existing `~/.aelfrice/opt-out-hooks.json` entries that were correctly skipped, but the surrounding stamp downgrade and settings rewrite arrived without consent. Fix adds `is_running_from_uv_tool_install()` (delegates to `lifecycle._is_uv_tool_install`) and gates the entry function on it — when the running aelfrice resolves outside `~/.local/share/uv/tools/aelfrice/`, the merge short-circuits before reading the manifest or touching the stamp. Worktree devs and contributors running `uv run aelf` / `pytest` no longer mutate global state; explicit `aelf setup` remains the in-band way to merge a worktree's manifest. Symmetric with the existing `AELFRICE_NO_AUTO_INSTALL` env override. Three new tests in `tests/test_auto_install.py` cover the gate (skips when False, runs when True, delegation to `lifecycle._is_uv_tool_install`); `tests/test_cli_auto_install.py` updated to monkeypatch the new gate where it exercises the merge path. - **Rebuilder pack accounting now honours `use_type_aware_compression`** ([#798](https://github.com/robotrocketscience/aelfrice/issues/798)). `rebuild_v14` was re-packing `retrieve()`'s candidate set with verbatim token cost regardless of the flag, so any ON-arm extras `retrieve()` admitted at compressed cost got trimmed back to the OFF-arm count. The downstream A4 continuation-fidelity bench gate ([#775](https://github.com/robotrocketscience/aelfrice/issues/775) / [PR #776](https://github.com/robotrocketscience/aelfrice/pull/776)) was therefore structurally vacuous — per-row fidelity delta = 0 by construction, regardless of corpus. Fix resolves the flag once at `rebuild_v14` entry (`resolve_use_type_aware_compression(use_type_aware_compression)`), threads it into the `retrieve()` call and into `_estimate_belief_tokens(b, *, compress_on=...)` at all three pack sites (L0 init, session tier, L1 / L2.5 tier). The rebuild block content itself stays verbatim — the change is in *how many* beliefs survive the budget, not what each surviving belief renders as. Default-OFF and the legacy `_retrieve_for_rebuild` (v1.2.0a0 alpha contract) are byte-identical. Unblocks the A4 axis of the [#769](https://github.com/robotrocketscience/aelfrice/issues/769) flip-default decision. Operator-decision history: Option A per [#798 thread](https://github.com/robotrocketscience/aelfrice/issues/798); Options B (rebuilder emits `compressed_beliefs[i].rendered`) and C (drop A4 from #769 acceptance) declined. Two new tests in `tests/test_context_rebuilder.py` (`test_rebuild_v14_pack_size_matches_compression_flag`, `test_rebuild_v14_compression_off_byte_identical_default`). @@ -240,7 +244,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`urllib3` 2.6.3 → 2.7.0 for CVE patches** ([#640](https://github.com/robotrocketscience/aelfrice/issues/640)). Lockfile-only dependency bump pulled in via `uv lock` to clear two CVEs flagged by GitHub's dependency scanner. `urllib3` is a transitive dep (via `requests` in the publish workflow and the `gh` CLI's Python shim); aelfrice itself does not import it. No source change. -[Unreleased]: https://github.com/robotrocketscience/aelfrice/compare/v3.0.1...HEAD +[Unreleased]: https://github.com/robotrocketscience/aelfrice/compare/v3.2.0...HEAD +[3.2.0]: https://github.com/robotrocketscience/aelfrice/compare/v3.1.0...v3.2.0 [3.1.0]: https://github.com/robotrocketscience/aelfrice/compare/v3.0.1...v3.1.0 [3.0.1]: https://github.com/robotrocketscience/aelfrice/compare/v3.0.0...v3.0.1 [3.0.0]: https://github.com/robotrocketscience/aelfrice/compare/v2.1.0...v3.0.0 diff --git a/pyproject.toml b/pyproject.toml index ab33b8a22..0cf5c61a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "aelfrice" -version = "3.1.0" +version = "3.2.0" description = "Persistent memory for AI agents. Set up once. Stays out of your way. Local SQLite, auditable, no GPU, no network." readme = "README.md" requires-python = ">=3.12" diff --git a/uv.lock b/uv.lock index fb4a5e199..1d9af8b4b 100644 --- a/uv.lock +++ b/uv.lock @@ -12,7 +12,7 @@ resolution-markers = [ [[package]] name = "aelfrice" -version = "3.1.0" +version = "3.2.0" source = { editable = "." } dependencies = [ { name = "numpy" },