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 configured on_retrieve or decay_function hook 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 ARCHIVED and its archived_at_cycle is set to the current cycle while archived_at receives the run’s wall-clock instant. Its expires_at is cleared so it is no longer subject to TTL. This dedicated path accepts eligible ACTIVE and CREATED rows; it does not require an ordinary lifecycle journey through DONE.
  • Restore un-archives a thought: store.restore_thought(thought_id) transitions it to ACTIVE (the ARCHIVED → ACTIVE lifecycle edge) and clears both archived_at_cycle and archived_at — regardless of whether the thought was ACTIVE or CREATED before archival, restore always lands on ACTIVE. The expires_at cleared on archival is not recovered — restore does not reconstruct a prior expiry. Restoring a thought that is not archived raises InvalidTransitionError.

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_cycle marker is set (an archived row with that marker None is 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 (default gc_min_archive_age_cycles = 10 cycles). Setting gc_min_archive_age_cycles: 0 makes 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_seconds of real time (archived_at <= now - gc_restore_window_seconds, default 2592000 = 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 to restore_thought it. Set gc_restore_window_seconds: 0 to 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.

  • Hygiene stamps archived_at_cycle on 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_at column (so archived_at is None) 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_THOUGHT entry that keeps a full before snapshot, 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 (default 100) 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 additional REFLECTION thoughts that are not counted in HygieneResult.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 + injected now reproduces 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 of list_thoughts(), independently of now=, so a row can cross its expiry boundary between two runs passed the identical now. If now is omitted, run_hygiene resolves 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. recency and staleness are 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, or action_outcome — has a data source in the candidate pool.
  • Dry run. With dry_run: true, run_hygiene computes 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 configured on_retrieve or decay_function hook — 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_seconds of wall-clock time — measured from its last contact (last_accessed_at, else updated_at, else created_at). Default 604800 (7 days). The guard compares now against 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 old created_at (with no later last_accessed_at / updated_at) is eligible immediately, exactly as if it had aged in place. min_inactivity_age_seconds: 0 disables 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), or action_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 direct run_hygiene() does not. Call flush_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.