Skip to content

feat(decay): native usage-aware memory decay engine + run_decay_scan MCP tool - #45

Open
bertheto wants to merge 4 commits into
ScottRBK:mainfrom
bertheto:fix/upstream-native-decay-engine
Open

bertheto wants to merge 4 commits into
ScottRBK:mainfrom
bertheto:fix/upstream-native-decay-engine

Conversation

@bertheto

@bertheto bertheto commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a native, usage-aware memory decay engine so memory confidence and obsolescence are managed by the server instead of relying on agent-side discipline. Closes the gap surfaced by the 2026 open-source memory MCP landscape review: of 73 memory systems surveyed, only 2 combine decay with scheduled execution, and none track usage as a decay weight.

What changed

Schema (additive, forward-compatible)

  • memories gains access_count (int, default 0, NOT NULL) and last_accessed_at (datetime, nullable) + ix_memories_last_accessed_at.
  • Alembic migration 20260704_add_memory_usage_tracking (upgrade + downgrade both clean; validated on a DB copy before live application).
  • Dual-repo: SQLite + Postgres MemoryTable ORM extended identically; MemoryRepository Protocol gains record_memory_access and get_decay_candidates.
  • Memory Pydantic model exposes the new fields read-only; MemoryUpdate does not — access counters stay internal, updated_at is never distorted by read-side tracking.

Service (app/services/memory_service.py)

  • record_memory_access increments access_count + sets last_accessed_at via a dedicated UPDATE that never mutates updated_at.
  • Read paths query_memory (post-truncation) and get_memory call it best-effort (try/except, never breaks the read).
  • run_decay_scan returns a DecayScanResponse with per-memory DecayCandidateAction (skip / decay / obsolete, delta, proposed_confidence, reason).

Decay formula (_propose_action, classmethod for pure unit-testability):

  • effective_access = last_accessed_at ?? updated_at ?? created_at
  • SKIP if importance >= 8 OR tags in {decision, architecture, critical}
  • SKIP if confidence is None (no safe target without a default-confidence policy)
  • SKIP if age <= 90 days
  • OBSOLETE if age > 180 days AND confidence <= 0.3
  • DECAY: delta = clamp(0.1 - min(access_count, 8) * 0.01, 0.02, 0.1); proposed = max(0.3, confidence - delta); if clamped at floor → skip

MCP tool

  • run_decay_scan(ctx, memory_ids=None, project_id=None, dry_run=True) registered in the memory category, following the existing rebuild_embeddings triad pattern (memory_tools.py + tool_adapters.py + tool_metadata_registry.py).
  • dry_run=True previews; dry_run=False applies via existing update_memory / mark_memory_obsolete (never raw SQL).
  • mutates: True; tags: memory, decay, admin, lifecycle, gc.

Tests (23 new, all green)

  • tests/integration/test_decay_engine.py (16): pure-formula unit tests (protected tags/importance, null confidence, boundary ages 89/90/180+, usage-weight shrink, clamped floor, obsolete path) + integration via InMemoryMemoryRepository (updated_at preservation, get_memory access tracking, dry-run no-write, live confidence decay, live obsolete).
  • tests/integration/test_decay_tool_adapters.py (4): adapter registered, registry metadata bound, dry-run returns DecayScanResponse.
  • tests/e2e_sqlite/test_decay_scan_sqlite.py (3): discover_forgetful_tools lists it, execute_forgetful_tool dry-run + live round-trip.
  • tests/integration/conftest.py: InMemoryMemoryRepository stubs the two new repo methods.
============================= 23 passed in 4.42s ==============================

Backward compatibility

  • New columns have safe defaults (access_count = 0, last_accessed_at = NULL), so existing memories decay as if never accessed — the effective_access fallback to updated_at/created_at keeps the age signal intact.
  • No existing tool signature changes; run_decay_scan is purely additive to the tool registry.
  • Migration downgrade drops the columns + index cleanly.

Test plan

  • pytest tests/integration/test_decay_engine.py tests/integration/test_decay_tool_adapters.py tests/e2e_sqlite/test_decay_scan_sqlite.py → 23 passed
  • Postgres E2E (not run locally; SQLite + Postgres ORM are extended identically)
  • Maintainer review of decay constants (90d fresh-window, 180d obsolete-threshold, 0.3 floor, 0.020.1 delta band) — these are centralized in memory_service.py as module constants and easy to tune.

…MCP tool

Adds a native, usage-aware decay engine so memory confidence and
obsolescence are managed by the server instead of relying on agent-side
discipline.

Schema
- memories gains access_count (int, default 0, NOT NULL) and
  last_accessed_at (datetime, nullable) + index.
- Alembic migration 20260704_add_memory_usage_tracking (upgrade +
  downgrade both clean).
- Dual-repo: SQLite + Postgres MemoryTable ORM extended identically;
  MemoryRepository Protocol gains record_memory_access and
  get_decay_candidates.
- Memory Pydantic model exposes the new fields read-only; MemoryUpdate
  does NOT, so access counters stay internal and updated_at is never
  distorted by read-side tracking.

Service
- record_memory_access increments access_count + sets last_accessed_at
  via a dedicated UPDATE that NEVER mutates updated_at.
- Read paths query_memory (post-truncation) and get_memory call it
  best-effort.
- run_decay_scan returns DecayScanResponse with per-memory
  DecayCandidateAction (skip/decay/obsolete).

Decay formula (_propose_action, classmethod for unit-testability):
- effective_access = last_accessed_at ?? updated_at ?? created_at
- SKIP if importance >= 8 OR tags in {decision, architecture, critical}
- SKIP if confidence is None (no safe target without a default policy)
- SKIP if age <= 90 days
- OBSOLETE if age > 180 days AND confidence <= 0.3
- DECAY: delta = clamp(0.1 - min(access_count, 8) * 0.01, 0.02, 0.1);
  proposed = max(0.3, confidence - delta); clamped at floor -> skip

MCP tool
- run_decay_scan(ctx, memory_ids, project_id, dry_run=True) in the
  memory category, following the rebuild_embeddings triad pattern.
- dry_run=True previews; dry_run=False applies via update_memory /
  mark_memory_obsolete (never raw SQL).
- mutates: True; tags: memory, decay, admin, lifecycle, gc.

Tests (23 new, all green)
- 16 unit + integration (test_decay_engine.py)
- 4 adapter/registry (test_decay_tool_adapters.py)
- 3 SQLite E2E (test_decay_scan_sqlite.py)
- conftest.py: InMemoryMemoryRepository stubs the new repo methods.
@ScottRBK

ScottRBK commented Jul 6, 2026

Copy link
Copy Markdown
Owner

Thanks for the PR, there's clearly a lot of work in here, and memory lifecycle is a real topic
worth tackling.

I'm still thinking about how I want to handle this in Forgetful, so I'm not going to move on the
PR just yet. Where my thinking currently sits: I'm not in favour of time-based decay. Access
recency/frequency isn't a reliable proxy for truth or value in a semantic store, a memory
doesn't become less true because it hasn't been queried for 90 days. I'm more drawn to something
purposeful and agent-driven: monitoring for contradictions, explicit supersession/invalidation of
facts that have actually been superseded, that kind of mechanism, rather than a clock.

I haven't landed on an approach yet though, so I'll leave this open while I think it through, and
I'll follow up here once I've settled on a direction.

Live SQLite returns naive created_at/updated_at/last_accessed_at; comparing
them to datetime.now(UTC) failed every candidate. Normalize via _as_utc and
mirror the guard in get_decay_candidates.
@bertheto

Copy link
Copy Markdown
Contributor Author

Follow-up: naive SQLite datetime fix

Live run_decay_scan failed every candidate with TypeError: can't subtract offset-naive and offset-aware datetimes because SQLite returns naive timestamps while the scan uses datetime.now(UTC).

This commit adds MemoryService._as_utc / UTC-aware _effective_access_date and mirrors the guard in SQLite + Postgres get_decay_candidates. New unit test covers naive model_construct rows. 17 decay-engine tests green.

…flake

Capturing now before _make_memory could make (now - eff).days == 9
instead of 10 when the helper's datetime.now(UTC) is a few us later.
@bertheto

bertheto commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the clear product signal. I agree access recency is a weak proxy for truth in a semantic store.

I'll leave this PR open with green CI as a reference implementation. Next I'll open a smaller follow-up that keeps only usage tracking (access_count / last_accessed_at on read paths) and drops time-based decay entirely.

Happy to help design contradiction detection / explicit supersession when you settle on a direction. No rush on my side.

@bertheto

bertheto commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up opened: #53

Telemetry-only slice (access_count / last_accessed_at on read paths). No time-based decay or run_decay_scan. Leaving this PR open as the reference implementation.

timedelta in memory_service.py and MemoryUpdate in test_decay_engine.py.
ScottRBK pushed a commit that referenced this pull request Aug 9, 2026
* feat(memory): track access_count and last_accessed_at on reads

Additive usage telemetry only. No time-based decay or run_decay_scan.
Responds to maintainer feedback on PR #45.

* feat(memory): move access tracking to EventBus subscribers

Scott's PR #53 review: record access via memory.read / memory.queried
handlers instead of inline writes. Add query_memory integration coverage
and ACTIVITY_TRACK_READS=false negative test.

* test(memory): harden access-tracking tests and DRY event wiring

Impl-review follow-ups: register_access_tracking_handlers helper,
assert updated_at invariants, verify QUERIED snapshot IDs for query path.

* test(e2e): add memory access_count tracking coverage

REST e2e for GET/search access_count with ACTIVITY_TRACK_READS on,
plus negative gate when tracking disabled.
@bertheto

Copy link
Copy Markdown
Contributor Author

Hey Scott, quick check-in when you have a moment.

Your July note on contradiction and explicit supersession still matches how I think about this on my side. No rush on #45 from me, it stays parked as reference.

If it helps, I can draft a short supersession RFC or open a design-only issue. Happy either way.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants