Forgetting
Mechanism: Memory Hygiene — built-in scoring and the default hooks make no LLM calls. Passing
now=fixes the archive-stamp and window boundaries for a run, but not candidate selection end to end — see Per-run caps, the injected clock, and dry-run. A configuredon_retrieveordecay_functionhook executes during the pass and may itself call an LLM or any other external service.
Forgetting is how an Engrava store lets cold, low-signal memories fade. It is
opt-in (off by default): the default action archives a
thought — a soft-retire you can restore — and only a separate, independently
opted-in step ever garbage-collects an archived thought. Beyond that explicit
store.run_hygiene() call, hygiene also runs implicitly as one step of
store.consolidate() whenever a hygiene policy is enabled and due for that
cycle (see Running it) — it is not limited to a call you make
by name.
Dreaming + Forgetting — the two halves of memory maintenance
Forgetting is the subtractive half of a memory-maintenance cycle whose additive half is Dreaming:
- Dreaming (consolidation) promotes — it keeps and strengthens what matters, links related memories with edges, and clusters them into higher-level reflections.
- Forgetting (memory hygiene) demotes — it lets memories that have gone cold and low-signal fade out of the active working set.
Together they mirror how biological memory maintains itself — reinforcing some
traces while letting others fade — so Forgetting can reduce the active working set
over time. Built-in Dreaming and Hygiene scoring both involve no LLM. A
configured on_retrieve or decay_function hook executes during a hygiene
pass and may itself call an LLM or other external service; see
Per-run caps, the injected clock, and dry-run
for what the injected now does and does not fix.
The whole capability is OFF by default: the hygiene loop does nothing until
you enable it — a store that never configures hygiene_policy never scores,
archives, or garbage-collects anything, so the loop itself changes no read or write
path. One thing is not gated on the policy, though: retrieval eligibility. Any
archived row — whether archived by hygiene, by the
TTL archive strategy, or manually — is
excluded from default retrieval (see
Search), so a store is
byte-identical to the pre-feature behaviour only when it holds no archived rows.
Following the same pattern as Dreaming (the concept “Dreaming” over the method
consolidate()), the public API keeps the mechanism’s name: you invoke Forgetting
with store.run_hygiene(...) and configure it under hygiene_policy.
What it does
Memory Hygiene runs outside the normal CRUD path. You invoke it explicitly
with store.run_hygiene(current_cycle=N) (or let it run at the end of a
dreaming cycle — see Running it).
run_hygiene(current_cycle=N)
│
┌─┴──────────────────────────────────────────────────────────┐
│ score eligible ACTIVE/CREATED candidates get a │
│ keep-score from the same signals dreaming uses, │
│ times a decay multiplier -> an eviction-score │
│ 1. archive thoughts below the eviction threshold (and not │
│ (Stage 1) protected) flip to ARCHIVED — reversible, │
│ though the cleared expiry is not restored; │
│ cycle and wall-clock archive stamps are set │
│ 2. gc only when auto_gc_enabled: archived thoughts │
│ (Stage 2) carrying the hygiene archive marker, past │
│ the restore window, are physically deleted │
│ (removing edges/embeddings/actions and, with │
│ sqlite-vec active, purging the matching │
│ vector-index row) │
└─┬──────────────────────────────────────────────────────────┘
│
▼
a smaller, higher-signal working set
Not every ACTIVE/CREATED thought reaches scoring: real-time expiry excludes
expired rows before candidate collection, the two run-level gates described in
Per-run caps, the injected clock, and dry-run
can skip scoring for the whole run, and protected or insufficiently inactive
rows are excluded individually before compute_keep_score() runs.
The keep-score
For each remaining candidate thought, hygiene computes a keep-score as a weighted combination of the reusable scoring signals — the same library dreaming uses — normalised by the total active weight, under hygiene’s own weight vector:
| Signal | Default weight | Higher when… |
|---|---|---|
recency |
0.30 | the thought was updated recently |
frequency |
0.25 | the thought has been accessed often |
confirmation |
0.20 | the same fact has been re-encountered |
confidence |
0.15 | the thought carries a high confidence value |
staleness |
0.10 | the thought has been active over a long span |
Which signals are active is decided once per run, over the whole candidate
pool, by the rules dreaming uses for its default
signals: recency and staleness are always active, and each other signal is
active only when some candidate has its data (frequency also needs access
tracking on). Equal-but-non-default values across the pool do not make a signal
inactive. An inactive signal’s weight is dropped and the remainder renormalised
over the active signals. This keeps the keep-score meaningful on sparse stores
instead of dragging every score toward a constant.
The keep-score is then multiplied by the
decay_function hook
to produce the eviction-score:
keep_score = Σ active-signal weight · signal(thought) (renormalised)
eviction_score = keep_score · decay_function(thought, elapsed_cycles)
archive(thought) ⇐ eviction_score < eviction_threshold AND not protected(thought)
The default decay_function returns 1.0 (no decay), so out of the box the
eviction-score is the keep-score. A custom hook can shape a decay curve; its
return is clamped into [0.0, 1.0], and a non-finite value is treated as 1.0 —
decay multiplies the keep-score by that clamped factor. Because a custom hook
can deliberately make an otherwise high-scoring thought archivable, treat it
as part of the retention policy and preview its effect with dry_run before
enabling mutations.
The Memory Hygiene loop is the only place decay_function is consulted; it
is not part of search, ranking, or promotion.
Protection — what never gets forgotten
A thought is protected (never auto-archived and never auto-GC’d) when:
- it is pinned (
ThoughtRecord.pinned = True) — the durable, node-level never-forget marker; or - its priority is listed in
protected_priorities(default:("P1",)).
from engrava import ThoughtRecord, ThoughtType, Priority, LifecycleStatus
keep_me = ThoughtRecord(
thought_id="user-birthday",
thought_type=ThoughtType.OBSERVATION,
essence="User's birthday is 3 March",
content="The user mentioned their birthday is 3 March.",
priority=Priority.P2,
lifecycle_status=LifecycleStatus.ACTIVE,
created_cycle=0,
updated_cycle=0,
source="user",
pinned=True, # never auto-archived or auto-GC'd, regardless of score
)
confidence is not protection. A high-confidence but cold thought can still
be archived — a model-confidence estimate is not a user keep-decision.
confidence only contributes to the keep-score via its signal.
protected_priorities is a default, not an invariant: an operator who wants
more aggressive hygiene can set it to () so even top-priority thoughts are
eligible. Pinning is the invariant.
The same protection covers the orphan-REFLECTION sweep and engrava gc. The
sweep never retires a pinned or protected-priority REFLECTION (see
Dreaming → When a REFLECTION is retired),
and engrava gc keeps a pinned or protected-priority ARCHIVED row unless
--include-pinned is given (see Data Lifecycle → Running
cleanup). It is not a TTL
exemption: a protected-priority thought whose TTL has passed still expires.
Only pinned exempts a row from TTL cleanup.
Two stages: archive, then (optionally) GC
Stage 1 (archive) is the default action:
- A below-threshold, unprotected thought flips to
ARCHIVEDand itsarchived_at_cycleis set to the current cycle whilearchived_atreceives the run’s wall-clock instant. Itsexpires_atis cleared so it is no longer subject to TTL. This dedicated path accepts eligibleACTIVEandCREATEDrows; it does not require an ordinary lifecycle journey throughDONE. - Restore un-archives a thought:
store.restore_thought(thought_id)transitions it toACTIVE(theARCHIVED → ACTIVElifecycle edge) and clears botharchived_at_cycleandarchived_at— regardless of whether the thought wasACTIVEorCREATEDbefore archival, restore always lands onACTIVE. Theexpires_atcleared on archival is not recovered — restore does not reconstruct a prior expiry. Restoring a thought that is not archived raisesInvalidTransitionError.
Stage 2 (garbage collection) runs only when auto_gc_enabled is set (it is
false by default) — enabling hygiene never implicitly enables deletion
(below, “hygiene-archived” means an archived row carrying the archived_at_cycle
marker):
-
A thought is GC-eligible only when its
archived_at_cyclemarker is set (an archived row with that markerNoneis excluded from GC — see below) and it has cleared both enabled restore windows:- a cycle window —
current_cycle - archived_at_cycle >= gc_min_archive_age_cycles(defaultgc_min_archive_age_cycles = 10cycles). Settinggc_min_archive_age_cycles: 0makes this window always pass — the cycle gate is disabled, symmetric with the wall-clock case below; and - a wall-clock window — the thought has been archived for at least
gc_restore_window_secondsof real time (archived_at <= now - gc_restore_window_seconds, default2592000= 30 days). This exists so a fast-cycling or bulk store cannot burn through the cycle window and delete a just-archived thought before there was any real-time chance torestore_thoughtit. Setgc_restore_window_seconds: 0to disable the wall-clock window (cycle-only, the pre-window behaviour).
With both windows disabled (
0), a hygiene-archived thought has no restore window before it becomes GC-eligible — opt into that only deliberately. - a cycle window —
-
Hygiene stamps
archived_at_cycleon archival and TTL clears it, so ordinarily this marker means hygiene performed the archival — but a raw field write can retain or set it outside hygiene’s own archival path, so the marker by itself does not prove hygiene performed the current archival. -
A hygiene-archived row that predates the wall-clock
archived_atcolumn (soarchived_atisNone) is never GC’d while the wall-clock window is active — the irreversible stage fails closed rather than guess an age. -
Deletion runs an orphan-reflection sweep first (so no REFLECTION is left summarising a cluster the delete would empty), then removes edges/embeddings/actions and, when sqlite-vec is active, purges the matching vector-index row.
Below core schema 12 there is no database cascade. The ON DELETE CASCADE on
edge, embedding and action arrives with the core-12 migration. Garbage
collection does not rely on it: like delete_thought, it issues its own deletes for
the thought’s rows in those three tables, in the same savepoint as the parent delete,
which runs first. See
Deletion on a database that has not been migrated
for what engrava migrate cleans up on a database that already holds dangling
embedding rows.
GC is not erasure. Garbage collection reclaims the live, queryable working set — it does not purge history. When the hash-chain journal is enabled, a GC delete is recorded as a
DELETE_THOUGHTentry that keeps a fullbeforesnapshot, so the content survives in the append-only journal after the live row is gone.
Garbage collection here is cognitive hygiene, not compliance deletion. It is best-effort, window-gated, and opt-in — it offers no deletion guarantee, legal hold, scheduled/enforced retention, or erasure receipt. For the honest deletion mechanics (and the residue a hard delete leaves in the audit journal and backups), see Data lifecycle, retention & deletion.
Per-run caps, the injected clock, and dry-run
- Per-run caps.
max_evictions_per_run(default100) independently caps Stage 1 (archive) and Stage 2 (GC) selection — at most that many archived, and at most that many GC’d, per run. It does not cap everything a run can archive: the pre-GC orphan-reflection sweep above is uncapped and can archive additionalREFLECTIONthoughts that are not counted inHygieneResult.archived_count. - The injected clock. Passing
now=fixes the inactivity-age check, the archive/GC wall-clock stamps, and the restore-window boundaries to that instant, and the same store + config + cycle + injectednowreproduces the same result for those checks when any configured custom hooks are deterministic too. It does not by itself fix candidate selection end to end: expiry filtering during candidate collection reads real wall time on every underlying page oflist_thoughts(), independently ofnow=, so a row can cross its expiry boundary between two runs passed the identicalnow. Ifnowis omitted,run_hygieneresolves current UTC wall time for its own archive-stamp and window checks; that resolution does not change how expiry filtering reads time elsewhere in the same run. When more candidates qualify than the cap allows, the archive stage keeps the lowest-scoring, then oldest, then lowest-id thoughts; the GC stage keeps the oldest-archived, then lowest-id thoughts. - The usage-signal gate, not an “all-flat” fail-safe.
recencyandstalenessare always active, so a brand-new, uniform-cycle store does not leave every keep-score signal inactive. What stops score-based archival on such a store is the separate usage-signal access gate, below: a run’s score-based archive stage archives nothing unless at least one usage-history signal —frequency,confirmation, oraction_outcome— has a data source in the candidate pool. - Dry run. With
dry_run: true,run_hygienecomputes and returns the set it would archive (with a per-thought reason) without hygiene database mutations and without journaling. It does not suppress side effects from a configuredon_retrieveordecay_functionhook — both still run during the preview, so a hook with I/O or an external call executes exactly as it would on a real run.
async def run_hygiene_pass(store, current_cycle: int) -> None:
# Preview vs. real run is set by hygiene_policy.dry_run in config, not by
# this call: under dry_run, archived_count/gc_count are 0 and would_evict
# carries the preview; on a real run, would_evict is empty and
# archived_count/gc_count reflect what was mutated.
result = await store.run_hygiene(current_cycle=current_cycle)
print(result.archived_count, result.gc_count, result.candidates_evaluated)
for reason in result.would_evict:
print(reason.thought_id, reason.eviction_score, reason.signals)
Cold-start safety
Two guards affect a fresh or bulk-imported store, where cycle-recency has no history to work with and degenerates into ingest order: a freshly created row is protected by the recency of its own timestamp, and a run’s score-based archive stage archives nothing without a usage-history signal in the candidate pool. Both only ever add protection; neither can cause an archival the keep-score alone would not.
- Minimum inactivity age. A thought is eligible for archival only after it has
been untouched for at least
min_inactivity_age_secondsof wall-clock time — measured from its last contact (last_accessed_at, elseupdated_at, elsecreated_at). Default604800(7 days). The guard comparesnowagainst that persisted timestamp, not against when the row was created or imported: a freshly created thought is protected because its timestamp is recent, but a restored snapshot or import carrying an oldcreated_at(with no laterlast_accessed_at/updated_at) is eligible immediately, exactly as if it had aged in place.min_inactivity_age_seconds: 0disables the gate (the pre-gate behaviour); a row with no known last-contact time fails closed (protected). - Usage-signal access gate. A run’s score-based archive stage archives
nothing unless at least one usage-history signal —
frequency(reads),confirmation(reinforcements), oraction_outcome— is active across the candidate pool. Without any evidence a thought was ever used, “cold” cannot be told apart from “merely ingested early”, so cycle-recency must not drive eviction on its own. In practice this means Forgetting’s score-based archive stage only has an effect once the store carries genuine usage data: enable access tracking (on by default when dreaming is enabled) or record confirmations. Implicit accesses are buffered;consolidate()flushes them before scoring, while a directrun_hygiene()does not. Callflush_access_buffer()first when a standalone hygiene pass must include the newest pending reads.
Audit trail
Every archival and every garbage-collection is recorded in the
hash-chain journal when journaling is enabled, using the
existing mutation kinds (an archive is an UPDATE_THOUGHT; a GC-delete is a
DELETE_THOUGHT — no new mutation type is introduced). The forgetting
rationale rides in the entry’s delta under a nested eviction_reason:
{
"mechanism": "hygiene",
"keep_score": 0.03,
"eviction_score": 0.03,
"decay_multiplier": 1.0,
"threshold": 0.20,
"signals": { "recency": 0.02, "staleness": 0.0 }
}
When journaling is enabled, each retained entry’s hashed content and linkage to
its neighbours can be verified with verify_journal(), and the chain still
verifies after an archive and a GC. That is not a complete replay record or
local proof that no later decision was removed — verification checks the
retained prefix, not tail completeness (see
Security → Audit journal threat model).
A dry_run preview journals nothing, since nothing was mutated.
Running it
Directly — bypasses the cadence, but only runs a pass when a hygiene
policy is present and enabled and a cycle is available: calling it with no
configured policy raises RuntimeError, and calling it with a
configured-but-disabled policy returns an empty result without scoring
anything, even on this explicit call. With neither an explicit
current_cycle argument nor a configured cycle_provider, it raises
ValueError instead of inventing one. Pass a timezone-aware now when a
replay or benchmark needs fixed wall-clock boundaries:
import asyncio
from datetime import UTC, datetime
from engrava import SqliteEngravaCore
async def main() -> None:
async with await SqliteEngravaCore.from_config("engrava.yaml") as store:
result = await store.run_hygiene(
current_cycle=1000,
now=datetime(2026, 7, 23, tzinfo=UTC),
)
print(result.archived_count, result.gc_count)
asyncio.run(main())
As a convenience at the end of a dreaming cycle — when both dreaming and
hygiene_policy are enabled, store.consolidate(current_cycle=N) runs one
hygiene pass after promotion and the orphan sweep, but only when the cycle
satisfies the cadence (current_cycle % check_every_n_cycles == 0). An explicit
run_hygiene call always bypasses the cadence.
Configuration
See Configuration → hygiene_policy
for the full YAML surface. A minimal enable:
hygiene_policy:
enabled: true # OFF by default
eviction_threshold: 0.20 # archive below this eviction-score
auto_gc_enabled: false # keep GC off until you want physical deletion
min_inactivity_age_seconds: 604800 # 7 days untouched before archivable; 0 disables
gc_restore_window_seconds: 2592000 # 30-day real-time restore window before GC; 0 disables
dry_run: true # preview first
For the exhaustive package reference, see the OSS memory-hygiene.md docs.