Skip to content

fix(forgetting): a restored episode keeps the time it happened - #191

Merged
Cipher208 merged 1 commit into
masterfrom
fix/restore-keeps-created-at
Nov 4, 2026
Merged

Cipher208 merged 1 commit into
masterfrom
fix/restore-keeps-created-at

Conversation

@Cipher208

Copy link
Copy Markdown
Owner

Что

_restore_episode вызывает EpisodicMemory.save(...) без created_at, поэтому восстановленный эпизод получал время возврата.

Это не косметика: created_at читают затухание, окно purge и порядок промоушена в L4 (consolidation.py сортирует oldest-first). Восстановленный старый эпизод не старел, не попадал под cutoff и вставал в очередь как новый.

Время терялось раньше

В archived_memories не было колонки created_at — только archived_at.

база архивных эпизодов восстановимы
Hermes 3 159 нет
Mimocode 2 нет
CowAgent 0 —

Значение было в руках при архивации (archive_old делает SELECT *), просто не сохранялось. Эти 3159 строк невосстановимы: выдавать за них archived_at было бы выдумкой — это время удаления, а не события.

Фикс

  • archive() принимает original_created_at, и 7 путей архивации эпизодов передают собственное время: core/episodic.archive_old, features/compression, обе эпизодные ветки примитива forget, dedup_episode_twins, эпизодные ветки purge_foreign_rows и purge_speaker_layer_leaks.
  • Пути core_memory не тронуты — другая таблица, другая находка.
  • Колонка добавляется в ensure() идемпотентным ALTER TABLE: ensure() зовут конструкторы, которые миграций не запускают. Успех решается перечитыванием схемы, а не текстом ошибки, поэтому настоящий сбой по-прежнему пробрасывается, а проигранная гонка считается успехом.
  • Alembic-ревизия продолжает цепочку от 20261004_2200_g27.
  • NULL означает «строка старше колонки» → restore падает на прежнее поведение (now), без исключения.

Проверка

  • 2857 passed (2853 + 4 новых);
  • ruff чисто, format 755 файлов;
  • mypy 63 → 42, новых ошибок 0 (сверено нормализованными множествами против чистого 7b88d04). Падение — из-за переименования в dedup_episode_twins, где d использовалась и для результата classify(), и как переменная цикла; переименование не меняет поведения (дифференциальный прогон на идентичных базах: dry-run байт-в-байт, apply — те же id и строки);
  • blind verifier: 19 проверок, «Does a restored episode keep its original creation time, and is the schema change safe on an existing database? — YES». Отдельно доказано на scratch: старая схема + строки → ensure() добавляет колонку, строки целы; 4 процесса × 3 раунда гонки ALTER — ни одного исключения; alembic upgrade head на базе с 5 строками — строки не изменились.

Найдено попутно, не тронуто

scripts/purge_speaker_layer_leaks.py:226 пишет memory_type="episodes" (множественное число), а route_of распознаёт только "episode" — такие строки при restore ушли бы в core_memory вместо episodes. Баг существовал до этого PR; чинится одной строкой, но это отдельная находка.

`_restore_episode` calls `EpisodicMemory.save(...)` without `created_at`, so a
restored episode was stamped with the moment of its return. That is not only a
display problem: `created_at` is what decay, the purge window and the L4
promotion order (`consolidation.py`, oldest first) all read, so a restored old
episode never aged, never fell under a purge cutoff and queued as if it were new.

The time was already lost before the restore: `archived_memories` had no
`created_at` column, only `archived_at`. Measured on the live bases, that is
3 159 archived episodes in Hermes, 2 in Mimocode, 0 in CowAgent — all of them
irrecoverable, because nothing recorded when they happened.

`archive()` now takes `original_created_at` and every path that archives an
episode passes the episode's own value (7 paths: `core/episodic.archive_old`,
`features/compression`, both episode branches of the `forget` primitive,
`dedup_episode_twins`, and the episode branches of `purge_foreign_rows` and
`purge_speaker_layer_leaks`). Core-memory archive paths are untouched — that is
a different table and a different finding.

The column is added by `ensure()` with an idempotent `ALTER TABLE`, because
`ensure()` is called from constructors that never run migrations: success is
decided by re-reading the schema, never by the text of the error, so a real
failure still raises and a lost race is treated as success. A matching alembic
revision continues the chain from `20261004_2200_g27`; `ALTER TABLE ADD COLUMN`
is schema-only, so the 3 159 existing rows are not rewritten and read as NULL.

A NULL means "this row predates the column": the restore falls back to `now`,
exactly as before. Substituting `archived_at` was considered and rejected — that
is when the row was removed, not when it happened, and inventing a time is worse
than admitting there is none.

mypy drops 63 -> 42 with no new errors: passing `created_at` through
`dedup_episode_twins` surfaced a pre-existing shadowing bug there (`d` used for
both the classify() result and the loop variable), which is renamed here. The
differential run on identical scratch bases produces byte-identical dry-run
output and the same applied row sets.
@coderabbitai

coderabbitai Bot commented Nov 4, 2026

Copy link
Copy Markdown

Important

  • Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

Run configuration
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@github-actions github-actions Bot added the fix label Nov 4, 2026
@Cipher208
Cipher208 merged commit 26a301b into master Nov 4, 2026
19 checks passed
@Cipher208
Cipher208 deleted the fix/restore-keeps-created-at branch November 4, 2026 00:33
Cipher208 added a commit that referenced this pull request Nov 4, 2026
…nto (#192)

`purge_speaker_layer_leaks` archived an episode with `memory_type="episodes"`.
`route_of` names the TABLE the row came from and expects the singular `episode`;
anything it does not recognise falls through to the `core_memory` default. A
restore would therefore have written an episode's summary into `core_memory`.
Measured on the live bases, no row carries the wrong label, so the defect was
latent — but any replay that reintroduces the leak class would have produced
them. Fixed end-to-end: the episode now lands back in `episodes`.

While proving that, a second and opposite error turned up. Since #191 the restore
carries `created_at` when the bin recorded it, but `_CARRIED_INTO_EPISODES` is a
static set, so `not_recorded` still listed `created_at` as lost for a row whose
time WAS restored. That report is exactly what a caller reads to learn the limits
of undo, so it was understating what came back.

`_carried_into_episodes(row)` now answers per row: `created_at` joins the carried
set only when `original_created_at` is present and not NULL. The check goes
through `row.keys()` rather than `"col" in row` — on a `sqlite3.Row` the latter
tests VALUES and silently answers False — and a bin older than the column behaves
like NULL, which is what it means.

Audited every `memory_type=` written in the repository: no other label routes to
the wrong table. `graph:*` and `project_archive` are refused by design, the
`memory_kind` values and `core_memory` all route to `core_memory`, and a missing
label is refused rather than guessed.

Not fixed here, reported instead: `memory_type="core_memory"` routes to the right
table but `_restore_core` writes that label into `memory_kind`, which is not a
valid kind — such rows are skipped by type-aware decay and invisible to
kind-filtered reads. That is a separate finding with its own decision.

Co-authored-by: Cipher208 <269750686+Cipher208@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant