Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG/v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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 <cmd>` 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 <cmd>` 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`).
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
118 changes: 118 additions & 0 deletions tests/test_demotion_pressure_drop.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading