From 7f2302680ceb64516fc789ee31a33bc48be762fe Mon Sep 17 00:00:00 2001 From: Olivier BERTHET Date: Sat, 4 Jul 2026 01:04:40 +0200 Subject: [PATCH] feat(decay): native usage-aware memory decay engine + run_decay_scan 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. --- .../20260704_add_memory_usage_tracking.py | 59 ++++ app/models/memory_models.py | 75 ++++ app/protocols/memory_protocol.py | 37 ++ .../postgres/memory_repository.py | 91 ++++- app/repositories/postgres/postgres_tables.py | 6 + app/repositories/sqlite/memory_repository.py | 92 ++++- app/repositories/sqlite/sqlite_tables.py | 6 + app/routes/mcp/memory_tools.py | 84 +++++ app/routes/mcp/tool_adapters.py | 43 +++ app/routes/mcp/tool_metadata_registry.py | 54 +++ app/services/memory_service.py | 217 ++++++++++++ tests/e2e_sqlite/test_decay_scan_sqlite.py | 108 ++++++ tests/integration/conftest.py | 53 +++ tests/integration/test_decay_engine.py | 330 ++++++++++++++++++ tests/integration/test_decay_tool_adapters.py | 77 ++++ 15 files changed, 1330 insertions(+), 2 deletions(-) create mode 100644 alembic/versions/20260704_add_memory_usage_tracking.py create mode 100644 tests/e2e_sqlite/test_decay_scan_sqlite.py create mode 100644 tests/integration/test_decay_engine.py create mode 100644 tests/integration/test_decay_tool_adapters.py diff --git a/alembic/versions/20260704_add_memory_usage_tracking.py b/alembic/versions/20260704_add_memory_usage_tracking.py new file mode 100644 index 0000000..218378d --- /dev/null +++ b/alembic/versions/20260704_add_memory_usage_tracking.py @@ -0,0 +1,59 @@ +"""add usage tracking columns to memories (decay engine) + +Revision ID: 20260704_usage_tracking +Revises: 20260408_provenance_all +Create Date: 2026-07-04 + +Adds two columns to the `memories` table for the forgetful-hulkito decay +engine (plan `native_memory_decay_engine`): + +- `access_count` (INTEGER NOT NULL DEFAULT 0) — incremented each time a + memory is returned by a real read path (query_memory final results, + get_memory). Mutated only by the internal `record_memory_access` + repository method so `updated_at` is not distorted. +- `last_accessed_at` (DATETIME WITH TIME ZONE, nullable) — last time the + memory was returned by a read path. Nullable because existing rows have + never been accessed through the new path. + +Also adds an index `ix_memories_last_accessed_at` to support the +`get_decay_candidates` query. + +Both columns are backwards-compatible: existing rows get `access_count=0` +and `last_accessed_at=NULL`, and the decay formula treats NULL +`last_accessed_at` as `updated_at ?? created_at`. +""" +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "20260704_usage_tracking" +down_revision: str | Sequence[str] | None = "20260408_provenance_all" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Add access_count + last_accessed_at columns and index.""" + op.add_column( + "memories", + sa.Column("access_count", sa.Integer(), nullable=False, server_default="0"), + ) + op.add_column( + "memories", + sa.Column("last_accessed_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_index( + "ix_memories_last_accessed_at", + "memories", + ["last_accessed_at"], + ) + + +def downgrade() -> None: + """Remove access_count + last_accessed_at columns and index.""" + op.drop_index("ix_memories_last_accessed_at", table_name="memories") + op.drop_column("memories", "last_accessed_at") + op.drop_column("memories", "access_count") diff --git a/app/models/memory_models.py b/app/models/memory_models.py index d9eed16..163f944 100644 --- a/app/models/memory_models.py +++ b/app/models/memory_models.py @@ -286,6 +286,15 @@ class Memory(MemoryCreate): superseded_by: int | None = Field(default=None, description="ID of memory that supersedes this one") obsoleted_at: datetime | None = Field(default=None, description="When this memory was marked obsolete") + # Usage tracking (read-only from the API surface; mutated only by the + # internal `record_memory_access` repository method so `updated_at` is not + # distorted and external callers cannot fake read counters). + access_count: int = Field(default=0, description="Number of times this memory has been returned by a read path") + last_accessed_at: datetime | None = Field( + default=None, + description="Last time this memory was returned by a read path (null = never accessed)", + ) + model_config = ConfigDict(from_attributes=True) class MemorySummary(BaseModel): @@ -404,4 +413,70 @@ def validate_related_ids(cls, v, info): return v +# --------------------------------------------------------------------------- +# Memory decay engine (forgetful-hulkito fork) +# +# Usage tracking columns `access_count` / `last_accessed_at` are populated on +# real read paths only (query_memory final results, get_memory). The +# `run_decay_scan` MCP tool uses them together with age to propose confidence +# decay and obsolete candidates. See plan `native_memory_decay_engine`. +# --------------------------------------------------------------------------- + + +class DecayScanRequest(BaseModel): + """Scope parameters for a decay scan. + + Args: + memory_ids: Explicit ids to scan (already user-scoped server-side). + project_id: Restrict to a single project owned by the caller. + dry_run: When True, only report proposed changes; do not write. + """ + + memory_ids: list[int] | None = Field( + default=None, + description="Explicit memory ids to scan; leave null to use project_id or global user scope", + ) + project_id: int | None = Field( + default=None, + description="Restrict the scan to memories of a single project owned by the caller", + ) + dry_run: bool = Field( + default=True, + description="Preview-only mode (no writes). Set to False to apply confidence decay and obsolete mutations.", + ) + + +class DecayCandidateAction(BaseModel): + """A single proposed mutation for one memory. + + `kind` is one of: + - "decay": lower `confidence` by `delta` (clamped at 0.0). + - "obsolete": mark the memory obsolete with `reason`. + - "skip": report the candidate without mutating it (used when + `confidence is None` or another guard fires); `reason` explains why. + """ + + kind: str + memory_id: int + title: str | None = None + current_confidence: float | None = None + proposed_confidence: float | None = None + delta: float | None = None + reason: str | None = None + age_days: int | None = None + days_since_access: int | None = None + access_count: int | None = None + + +class DecayScanResponse(BaseModel): + """Result of a decay scan (dry-run or live).""" + + dry_run: bool + scanned: int + actions: list[DecayCandidateAction] = Field(default_factory=list) + applied: int = Field(0, description="Number of actions actually applied (0 in dry-run)") + failed: list[dict] = Field(default_factory=list, description="Per-memory failures: {memory_id, reason}") + + + diff --git a/app/protocols/memory_protocol.py b/app/protocols/memory_protocol.py index 95801d0..c849a73 100644 --- a/app/protocols/memory_protocol.py +++ b/app/protocols/memory_protocol.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from datetime import datetime from typing import Any, Protocol from uuid import UUID @@ -265,4 +266,40 @@ async def validate_search_works(self) -> bool: """Run a smoke-test semantic search""" ... + # ------------------------------------------------------------------ + # Usage tracking + decay engine (forgetful-hulkito fork) + # ------------------------------------------------------------------ + + async def record_memory_access( + self, + user_id: UUID, + memory_ids: list[int], + accessed_at: datetime | None = None, + ) -> int: + """Increment `access_count` and update `last_accessed_at` for the given + memory ids, scoped to `user_id`. + + MUST NOT mutate `updated_at` — usage tracking is an internal read-side + concern and must not distort normal memory recency. Returns the number + of rows actually updated (owned + non-obsolete). + """ + ... + + async def get_decay_candidates( + self, + user_id: UUID, + memory_ids: list[int] | None = None, + project_id: int | None = None, + max_age_days: int | None = None, + ) -> list[Memory]: + """Return non-obsolete memories owned by `user_id` that are eligible + for decay review, ordered oldest first. + + Filters: explicit `memory_ids` (already user-scoped) and/or a single + `project_id`. `max_age_days` optionally limits to memories whose + effective access date (`last_accessed_at ?? updated_at ?? created_at`) + is older than the given threshold. + """ + ... + diff --git a/app/repositories/postgres/memory_repository.py b/app/repositories/postgres/memory_repository.py index 15ab19b..f3a83c6 100644 --- a/app/repositories/postgres/memory_repository.py +++ b/app/repositories/postgres/memory_repository.py @@ -1,6 +1,6 @@ """Memory repository for postgres data access operations """ -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from typing import Any from uuid import UUID @@ -1759,3 +1759,92 @@ async def get_subgraph_nodes( }) return nodes, truncated + + # ------------------------------------------------------------------ + # Usage tracking + decay engine (forgetful-hulkito fork) + # ------------------------------------------------------------------ + + async def record_memory_access( + self, + user_id: UUID, + memory_ids: list[int], + accessed_at: datetime | None = None, + ) -> int: + """Increment `access_count` and update `last_accessed_at` for the given + memory ids, scoped to `user_id`. Does NOT mutate `updated_at`. + + Returns the number of rows actually updated (owned + non-obsolete). + """ + if not memory_ids: + return 0 + ts = accessed_at or datetime.now(UTC) + async with self.db_adapter.system_session() as session: + stmt = ( + update(MemoryTable) + .where( + MemoryTable.user_id == user_id, + MemoryTable.is_obsolete.is_(False), + MemoryTable.id.in_(memory_ids), + ) + .values( + access_count=MemoryTable.access_count + 1, + last_accessed_at=ts, + ) + ) + result = await session.execute(stmt) + return result.rowcount or 0 + + async def get_decay_candidates( + self, + user_id: UUID, + memory_ids: list[int] | None = None, + project_id: int | None = None, + max_age_days: int | None = None, + ) -> list[Memory]: + """Return non-obsolete memories owned by `user_id` eligible for decay + review, ordered oldest (by effective access date) first. + + Effective access date = `last_accessed_at ?? updated_at ?? created_at`. + The optional `max_age_days` filter is applied in Python after reading + to keep the COALESCE-on-nullable-datetime logic backend-agnostic. + """ + conditions = [ + MemoryTable.user_id == user_id, + MemoryTable.is_obsolete.is_(False), + ] + if memory_ids: + conditions.append(MemoryTable.id.in_(memory_ids)) + + stmt = ( + select(MemoryTable) + .options( + selectinload(MemoryTable.projects), + selectinload(MemoryTable.linked_memories), + selectinload(MemoryTable.linking_memories), + selectinload(MemoryTable.code_artifacts), + selectinload(MemoryTable.documents), + selectinload(MemoryTable.files), + selectinload(MemoryTable.skills), + ) + .where(*conditions) + ) + if project_id is not None: + stmt = stmt.join(MemoryTable.projects).where( + ProjectsTable.id == project_id, + ProjectsTable.user_id == user_id, + ) + + async with self.db_adapter.system_session() as session: + result = await session.execute(stmt) + memories_orm = result.scalars().unique().all() + memories = [Memory.model_validate(m) for m in memories_orm] + + def effective_access(mem: Memory) -> datetime: + return mem.last_accessed_at or mem.updated_at or mem.created_at + + if max_age_days is not None: + cutoff = datetime.now(UTC) - timedelta(days=max_age_days) + memories = [m for m in memories if effective_access(m) <= cutoff] + + memories.sort(key=lambda m: (effective_access(m), m.id)) + return memories diff --git a/app/repositories/postgres/postgres_tables.py b/app/repositories/postgres/postgres_tables.py index 120f6eb..f87254b 100644 --- a/app/repositories/postgres/postgres_tables.py +++ b/app/repositories/postgres/postgres_tables.py @@ -212,6 +212,11 @@ class MemoryTable(Base): superseded_by: Mapped[int] = mapped_column(Integer, ForeignKey("memories.id", ondelete="SET NULL"), nullable=True) obsoleted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True) + # Usage tracking (decay engine). Mutated only by the internal + # `record_memory_access` repository method so `updated_at` is not distorted. + access_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + last_accessed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True) + # Timestamps created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), @@ -401,6 +406,7 @@ def entity_ids(self) -> list[int]: Index("ix_memories_is_obsolete", "is_obsolete"), Index("ix_memories_superseded_by", "superseded_by"), Index("ix_memories_confidence", "confidence"), + Index("ix_memories_last_accessed_at", "last_accessed_at"), ) class MemoryLinkTable(Base): diff --git a/app/repositories/sqlite/memory_repository.py b/app/repositories/sqlite/memory_repository.py index 4c2af4c..1689703 100644 --- a/app/repositories/sqlite/memory_repository.py +++ b/app/repositories/sqlite/memory_repository.py @@ -1,6 +1,6 @@ """Memory repository for SQLite data access operations with sqlite-vec integration """ -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from typing import Any from uuid import UUID @@ -1984,3 +1984,93 @@ async def get_subgraph_nodes( }) return nodes, truncated + + # ------------------------------------------------------------------ + # Usage tracking + decay engine (forgetful-hulkito fork) + # ------------------------------------------------------------------ + + async def record_memory_access( + self, + user_id: UUID, + memory_ids: list[int], + accessed_at: datetime | None = None, + ) -> int: + """Increment `access_count` and update `last_accessed_at` for the given + memory ids, scoped to `user_id`. Does NOT mutate `updated_at`. + + Returns the number of rows actually updated (owned + non-obsolete). + """ + if not memory_ids: + return 0 + ts = accessed_at or datetime.now(UTC) + async with self.db_adapter.system_session() as session: + stmt = ( + update(MemoryTable) + .where( + MemoryTable.user_id == str(user_id), + MemoryTable.is_obsolete.is_(False), + MemoryTable.id.in_(memory_ids), + ) + .values( + access_count=MemoryTable.access_count + 1, + last_accessed_at=ts, + ) + ) + result = await session.execute(stmt) + return result.rowcount or 0 + + async def get_decay_candidates( + self, + user_id: UUID, + memory_ids: list[int] | None = None, + project_id: int | None = None, + max_age_days: int | None = None, + ) -> list[Memory]: + """Return non-obsolete memories owned by `user_id` eligible for decay + review, ordered oldest (by effective access date) first. + + Effective access date = `last_accessed_at ?? updated_at ?? created_at`. + SQLite has no native COALESCE-on-datetime with index support, so the + optional `max_age_days` filter is applied in Python after reading. + """ + conditions = [ + MemoryTable.user_id == str(user_id), + MemoryTable.is_obsolete.is_(False), + ] + if memory_ids: + conditions.append(MemoryTable.id.in_(memory_ids)) + + stmt = ( + select(MemoryTable) + .options( + selectinload(MemoryTable.projects), + selectinload(MemoryTable.linked_memories), + selectinload(MemoryTable.linking_memories), + selectinload(MemoryTable.code_artifacts), + selectinload(MemoryTable.documents), + selectinload(MemoryTable.files), + selectinload(MemoryTable.skills), + ) + .where(*conditions) + ) + if project_id is not None: + stmt = stmt.join(MemoryTable.projects).where( + ProjectsTable.id == project_id, + ProjectsTable.user_id == str(user_id), + ) + + async with self.db_adapter.system_session() as session: + result = await session.execute(stmt) + memories_orm = result.scalars().unique().all() + memories = [Memory.model_validate(m) for m in memories_orm] + + # Effective access date = last_accessed_at ?? updated_at ?? created_at + def effective_access(mem: Memory) -> datetime: + return mem.last_accessed_at or mem.updated_at or mem.created_at + + if max_age_days is not None: + cutoff = datetime.now(UTC) - timedelta(days=max_age_days) + memories = [m for m in memories if effective_access(m) <= cutoff] + + memories.sort(key=lambda m: (effective_access(m), m.id)) + return memories diff --git a/app/repositories/sqlite/sqlite_tables.py b/app/repositories/sqlite/sqlite_tables.py index b7a334e..4987129 100644 --- a/app/repositories/sqlite/sqlite_tables.py +++ b/app/repositories/sqlite/sqlite_tables.py @@ -216,6 +216,11 @@ class MemoryTable(Base): superseded_by: Mapped[int] = mapped_column(Integer, ForeignKey("memories.id", ondelete="SET NULL"), nullable=True) obsoleted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True) + # Usage tracking (decay engine). Mutated only by the internal + # `record_memory_access` repository method so `updated_at` is not distorted. + access_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + last_accessed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True) + # Timestamps created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False, @@ -402,6 +407,7 @@ def entity_ids(self) -> list[int]: Index("ix_memories_is_obsolete", "is_obsolete"), Index("ix_memories_superseded_by", "superseded_by"), Index("ix_memories_confidence", "confidence"), + Index("ix_memories_last_accessed_at", "last_accessed_at"), ) diff --git a/app/routes/mcp/memory_tools.py b/app/routes/mcp/memory_tools.py index 0a81b77..34e9bdb 100644 --- a/app/routes/mcp/memory_tools.py +++ b/app/routes/mcp/memory_tools.py @@ -767,3 +767,87 @@ async def rebuild_embeddings( "error_message": str(e), }) raise ToolError(f"INTERNAL_ERROR: Rebuilding embeddings failed - {type(e).__name__}: {e!s}") + + @mcp.tool() + async def run_decay_scan( + ctx: Context, + memory_ids: list[int] | None = None, + project_id: int | None = None, + dry_run: bool = True, + ) -> dict: + """Run a usage-aware memory decay scan (forgetful-hulkito fork). + + WHEN: Periodic corpus health maintenance. Replaces the prompt-level + "oldest 5, -0.1 if >90d" rule with a deterministic, usage-aware + formula that protects high-importance memories and decision / + architecture / critical tags. + + BEHAVIOR: Reviews non-obsolete memories owned by the authenticated + user, scoped by `memory_ids` and/or `project_id`. For each candidate + it proposes one of: `decay` (lower confidence by a usage-weighted + delta, clamped at 0.3), `obsolete` (mark obsolete when very old and + already low confidence), or `skip` (protected, too recent, or + `confidence is None`). + + - `dry_run=True` (default): no writes. Returns proposed actions only. + - `dry_run=False`: applies confidence decay through the validated + `update_memory` path and obsolescence through + `mark_memory_obsolete`. Never raw SQL. + + NOT-USE: Don't run with `dry_run=False` without first reviewing a + dry-run on the same scope, and always back up the live DB before the + first live run (same convention as the `rebuild_embeddings` + v0.4.1 rollout). + + Args: + memory_ids: Explicit ids to scan (already user-scoped server-side). + project_id: Restrict to a single project owned by the caller. + dry_run: Preview-only mode (no writes). Default True. + + Returns: + Dict with keys: dry_run (bool), scanned (int), + actions (list of {kind, memory_id, title, current_confidence, + proposed_confidence, delta, reason, age_days, + days_since_access, access_count}), applied (int, 0 in dry-run), + failed (list of {memory_id, reason}). + """ + try: + logger.info("MCP Tool -> run_decay_scan", extra={ + "memory_ids": memory_ids, + "project_id": project_id, + "dry_run": dry_run, + }) + + user = await get_user_from_auth(ctx) + memory_service = ctx.fastmcp.memory_service + + if memory_ids is not None and len(memory_ids) == 0: + raise ToolError( + "VALIDATION_ERROR: memory_ids must be non-empty if provided; " + "pass null for global scope", + ) + + from app.models.memory_models import DecayScanRequest + response = await memory_service.run_decay_scan( + user_id=user.id, + request=DecayScanRequest( + memory_ids=memory_ids, + project_id=project_id, + dry_run=dry_run, + ), + ) + return response.model_dump(mode="json") + except ToolError: + raise + except ValidationError as e: + logger.debug("MCP Tool - run_decay_scan validation error", extra={ + "error_type": "ValidationError", + "error_message": str(e), + }) + raise ToolError(f"VALIDATION_ERROR: {e!s}") + except Exception as e: + logger.error("MCP Tool -> run_decay_scan failed", exc_info=True, extra={ + "error_type": type(e).__name__, + "error_message": str(e), + }) + raise ToolError(f"INTERNAL_ERROR: Decay scan failed - {type(e).__name__}: {e!s}") diff --git a/app/routes/mcp/tool_adapters.py b/app/routes/mcp/tool_adapters.py index a6bdc7d..7254d88 100644 --- a/app/routes/mcp/tool_adapters.py +++ b/app/routes/mcp/tool_adapters.py @@ -28,6 +28,8 @@ EntityUpdate, ) from app.models.memory_models import ( + DecayScanRequest, + DecayScanResponse, Memory, MemoryCreate, MemoryCreateResponse, @@ -513,6 +515,46 @@ async def rebuild_embeddings( "failed": result.failed, } + async def run_decay_scan( + self, + ctx: Context, + memory_ids: list[int] | None = None, + project_id: int | None = None, + dry_run: bool = True, + ) -> DecayScanResponse: + """Adapter for `run_decay_scan` MCP tool (forgetful-hulkito fork). + + Runs the usage-aware decay scan over a user-scoped subset of + memories. `dry_run=True` (default) is no-write; `dry_run=False` + applies confidence decay through the validated `update_memory` path + and obsolescence through `mark_memory_obsolete`. + """ + logger.info( + "MCP Tool -> run_decay_scan", + extra={ + "memory_ids": memory_ids, + "project_id": project_id, + "dry_run": dry_run, + }, + ) + + user = await get_user_from_auth(ctx) + + if memory_ids is not None and len(memory_ids) == 0: + raise ToolError( + "VALIDATION_ERROR: memory_ids must be non-empty if provided; " + "pass null for global scope", + ) + + return await self.memory_service.run_decay_scan( + user_id=user.id, + request=DecayScanRequest( + memory_ids=memory_ids, + project_id=project_id, + dry_run=dry_run, + ), + ) + def create_memory_adapters( memory_service: MemoryService, user_service: UserService, @@ -529,6 +571,7 @@ def create_memory_adapters( "mark_memory_obsolete": adapters.mark_memory_obsolete, "get_recent_memories": adapters.get_recent_memories, "rebuild_embeddings": adapters.rebuild_embeddings, + "run_decay_scan": adapters.run_decay_scan, } diff --git a/app/routes/mcp/tool_metadata_registry.py b/app/routes/mcp/tool_metadata_registry.py index c47fc97..1ecfb18 100644 --- a/app/routes/mcp/tool_metadata_registry.py +++ b/app/routes/mcp/tool_metadata_registry.py @@ -765,6 +765,60 @@ def register_memory_tools_metadata( ], "tags": ["memory", "embeddings", "admin", "repair"], }, + { + "name": "run_decay_scan", + "mutates": True, + "description": ( + "Run a usage-aware memory decay scan (forgetful-hulkito fork). " + "Reviews non-obsolete memories owned by the caller and proposes " + "confidence decay or obsolescence based on age + access_count. " + "Protects importance>=8 and decision/architecture/critical tags. " + "dry_run=True (default) is no-write; dry_run=False applies confidence " + "decay through update_memory and obsolescence through mark_memory_obsolete." + ), + "parameters": [ + { + "name": "ctx", + "type": "Context", + "description": "FastMCP Context (automatically injected)", + "required": True, + }, + { + "name": "memory_ids", + "type": "Optional[List[int]]", + "description": "Explicit memory ids owned by the caller; leave null to use project_id or global user scope", + "required": False, + "default": None, + "example": [123, 124], + }, + { + "name": "project_id", + "type": "Optional[int]", + "description": "Restrict the scan to memories of a single project owned by the caller", + "required": False, + "default": None, + "example": 1, + }, + { + "name": "dry_run", + "type": "bool", + "description": "Preview-only mode (no writes). Default True. Set to False to apply confidence decay and obsolete mutations.", + "required": False, + "default": True, + "example": True, + }, + ], + "returns": ( + "Dict with dry_run (bool), scanned (int), actions (List[Dict]), " + "applied (int, 0 in dry-run), failed (List[Dict[memory_id, reason]])" + ), + "examples": [ + 'execute_forgetful_tool("run_decay_scan", {"dry_run": true})', + 'execute_forgetful_tool("run_decay_scan", {"project_id": 1, "dry_run": true})', + 'execute_forgetful_tool("run_decay_scan", {"memory_ids": [123, 124], "dry_run": false})', + ], + "tags": ["memory", "decay", "admin", "lifecycle", "gc"], + }, ] for tool_def in tools: diff --git a/app/services/memory_service.py b/app/services/memory_service.py index b7fdce1..386cbbe 100644 --- a/app/services/memory_service.py +++ b/app/services/memory_service.py @@ -6,7 +6,9 @@ - Memory updates - Manual linking between memories - Retrieval with project associations + - Usage-aware decay scan (forgetful-hulkito fork, plan `native_memory_decay_engine`) """ +from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING from uuid import UUID @@ -14,6 +16,9 @@ from app.config.settings import settings from app.models.activity_models import ActionType, ActivityEvent, ActorType, EntityType from app.models.memory_models import ( + DecayCandidateAction, + DecayScanRequest, + DecayScanResponse, LinkedMemory, Memory, MemoryCreate, @@ -36,6 +41,19 @@ logger = logging.getLogger(__name__) +# Decay engine tuning constants (forgetful-hulkito fork). See plan +# `native_memory_decay_engine` for the rationale. +_DECAY_MIN_AGE_DAYS = 90 # don't decay memories accessed in the last 90 days +_DECAY_OBSOLETE_AGE_DAYS = 180 # consider obsolescence only after 180 days +_DECAY_MIN_CONFIDENCE = 0.3 # never decay below this floor via the delta path +_DECAY_BASE_DELTA = 0.1 # baseline confidence decrement +_DECAY_USAGE_DELTA_FLOOR = 0.02 # minimum delta even for heavily-used memories +_DECAY_USAGE_DECAY_PER_ACCESS = 0.01 # delta shrinks by this per recorded access +_DECAY_USAGE_ACCESS_CAP = 8 # access_count beyond this no longer reduces delta +_PROTECTED_TAGS = {"decision", "architecture", "critical"} +_PROTECTED_IMPORTANCE = 8 # importance >= this is never decayed + + class MemoryService: """Service layer for memory operations @@ -146,6 +164,23 @@ async def query_memory( }, ) + # Usage tracking: record access for the memories actually returned + # (post-truncation). Intermediate dense/rerank candidates are NOT + # counted. Best-effort: never let access tracking break a query. + accessed_ids = [m.id for m in final_primaries] + accessed_ids.extend(lm.memory.id for lm in final_linked) + if accessed_ids: + try: + await self.memory_repo.record_memory_access( + user_id=user_id, + memory_ids=accessed_ids, + ) + except Exception as exc: # noqa: BLE001 - intentional broad guard + logger.warning( + "record_memory_access failed during query_memory", + extra={"user_id": str(user_id), "count": len(accessed_ids), "error": str(exc)}, + ) + return MemoryQueryResult( query=memory_query.query, primary_memories=final_primaries, @@ -334,6 +369,174 @@ async def mark_memory_obsolete( return success + # ------------------------------------------------------------------ + # Usage-aware decay engine (forgetful-hulkito fork) + # ------------------------------------------------------------------ + + @staticmethod + def _effective_access_date(memory: Memory) -> datetime: + """last_accessed_at ?? updated_at ?? created_at (fallback chain).""" + return memory.last_accessed_at or memory.updated_at or memory.created_at + + @classmethod + def _is_protected(cls, memory: Memory) -> bool: + """Protected memories are never decayed: high importance or + decision/architecture/critical tags.""" + if memory.importance >= _PROTECTED_IMPORTANCE: + return True + if memory.tags and set(memory.tags) & _PROTECTED_TAGS: + return True + return False + + @classmethod + def _usage_weighted_delta(cls, access_count: int) -> float: + """Confidence decrement shrinks with usage: heavily accessed memories + decay slower. Floored at `_DECAY_USAGE_DELTA_FLOOR`.""" + reduction = min(access_count, _DECAY_USAGE_ACCESS_CAP) * _DECAY_USAGE_DECAY_PER_ACCESS + return max(_DECAY_USAGE_DELTA_FLOOR, _DECAY_BASE_DELTA - reduction) + + @classmethod + def _propose_action( + cls, + memory: Memory, + now: datetime, + ) -> DecayCandidateAction: + """Compute the proposed decay action for a single memory (pure).""" + effective = cls._effective_access_date(memory) + age_days = max(0, (now - effective).days) + + common = { + "memory_id": memory.id, + "title": memory.title, + "current_confidence": memory.confidence, + "age_days": age_days, + "days_since_access": age_days, + "access_count": memory.access_count, + } + + if cls._is_protected(memory): + return DecayCandidateAction( + kind="skip", + reason="protected (importance>=8 or decision/architecture/critical tag)", + **common, + ) + + if memory.confidence is None: + # No confidence score to decay. Surface as skip so the operator + # can decide on a default-confidence policy instead of silently + # inventing one. + return DecayCandidateAction( + kind="skip", + reason="confidence is None; no safe decay target without a default-confidence policy", + **common, + ) + + if age_days <= _DECAY_MIN_AGE_DAYS: + return DecayCandidateAction( + kind="skip", + reason=f"age {age_days}d <= {_DECAY_MIN_AGE_DAYS}d decay threshold", + **common, + ) + + # Obsolescence path: very old and already low confidence. + if age_days > _DECAY_OBSOLETE_AGE_DAYS and memory.confidence <= _DECAY_MIN_CONFIDENCE: + return DecayCandidateAction( + kind="obsolete", + reason=( + f"GC: aged out ({age_days}d > {_DECAY_OBSOLETE_AGE_DAYS}d) and " + f"confidence {memory.confidence:.2f} <= {_DECAY_MIN_CONFIDENCE:.2f}" + ), + **common, + ) + + # Standard decay path: lower confidence by usage-weighted delta, + # clamped at the decay floor. + delta = cls._usage_weighted_delta(memory.access_count) + proposed = max(_DECAY_MIN_CONFIDENCE, memory.confidence - delta) + if proposed >= memory.confidence: + return DecayCandidateAction( + kind="skip", + reason="computed proposed confidence not lower than current (clamped at floor)", + **common, + ) + return DecayCandidateAction( + kind="decay", + delta=delta, + proposed_confidence=proposed, + reason=f"age {age_days}d > {_DECAY_MIN_AGE_DAYS}d, confidence {memory.confidence:.2f} > {_DECAY_MIN_CONFIDENCE:.2f}", + **common, + ) + + async def run_decay_scan( + self, + user_id: UUID, + request: DecayScanRequest, + ) -> DecayScanResponse: + """Run a usage-aware decay scan over a user-scoped subset of memories. + + Dry-run (`dry_run=True`, default): returns proposed actions only, + nothing is written. + + Live mode (`dry_run=False`): applies confidence decay through the + validated `update_memory` path and obsolescence through + `mark_memory_obsolete` — never raw SQL. + """ + candidates = await self.memory_repo.get_decay_candidates( + user_id=user_id, + memory_ids=request.memory_ids, + project_id=request.project_id, + max_age_days=None, # the formula handles age thresholds itself + ) + + now = datetime.now(UTC) + actions: list[DecayCandidateAction] = [] + applied = 0 + failed: list[dict] = [] + + for memory in candidates: + try: + action = self._propose_action(memory=memory, now=now) + actions.append(action) + + if request.dry_run or action.kind == "skip": + continue + + if action.kind == "decay" and action.proposed_confidence is not None: + await self.update_memory( + user_id=user_id, + memory_id=memory.id, + updated_memory=MemoryUpdate(confidence=action.proposed_confidence), + ) + applied += 1 + elif action.kind == "obsolete": + await self.mark_memory_obsolete( + user_id=user_id, + memory_id=memory.id, + reason=action.reason or "GC: aged out, no longer relevant", + ) + applied += 1 + except Exception as exc: # noqa: BLE001 - per-memory isolation + failed.append({ + "memory_id": memory.id, + "reason": f"{type(exc).__name__}: {exc!s}", + }) + logger.warning( + "decay scan per-memory failure", + extra={ + "user_id": str(user_id), + "memory_id": memory.id, + "error": str(exc), + }, + ) + + return DecayScanResponse( + dry_run=request.dry_run, + scanned=len(candidates), + actions=actions, + applied=applied, + failed=failed, + ) + async def get_memory( self, user_id: UUID, @@ -363,6 +566,20 @@ async def get_memory( snapshot=memory.model_dump(mode="json"), ) + # Usage tracking: record access for the fetched memory. Best-effort: + # never let access tracking break a get_memory call. + if memory is not None: + try: + await self.memory_repo.record_memory_access( + user_id=user_id, + memory_ids=[memory_id], + ) + except Exception as exc: # noqa: BLE001 - intentional broad guard + logger.warning( + "record_memory_access failed during get_memory", + extra={"user_id": str(user_id), "memory_id": memory_id, "error": str(exc)}, + ) + return memory async def get_recent_memories( diff --git a/tests/e2e_sqlite/test_decay_scan_sqlite.py b/tests/e2e_sqlite/test_decay_scan_sqlite.py new file mode 100644 index 0000000..f8a02e4 --- /dev/null +++ b/tests/e2e_sqlite/test_decay_scan_sqlite.py @@ -0,0 +1,108 @@ +"""End-to-end SQLite tests for the `run_decay_scan` MCP tool. + +Verifies the full stack: HTTP → FastMCP Client → MCP Protocol → Meta-tools +(discover + execute) → Service → SQLite repository → schema. + +Requires the SQLite MCP server fixture (see tests/e2e_sqlite/conftest.py). +""" +import pytest + + +@pytest.mark.asyncio +async def test_run_decay_scan_is_discoverable_e2e(mcp_client): + """`discover_forgetful_tools` must list `run_decay_scan` under the memory category.""" + result = await mcp_client.call_tool( + "discover_forgetful_tools", + {"category": "memory"}, + ) + assert result.data is not None + memory_tools = result.data["tools_by_category"]["memory"] + names = {t["name"] for t in memory_tools} + assert "run_decay_scan" in names, "run_decay_scan must be discoverable via meta-tools" + + +@pytest.mark.asyncio +async def test_run_decay_scan_dry_run_e2e(mcp_client): + """`execute_forgetful_tool('run_decay_scan', {dry_run: true})` must return a preview.""" + # First create a memory so the scan has something to look at + create_result = await mcp_client.call_tool( + "execute_forgetful_tool", + { + "tool_name": "create_memory", + "arguments": { + "title": "Decay scan E2E stub", + "content": "Memory used to verify the run_decay_scan MCP tool", + "context": "E2E test for run_decay_scan", + "keywords": ["decay", "e2e"], + "tags": ["test"], + "importance": 5, + }, + }, + ) + assert create_result.data is not None + memory_id = ( + create_result.data["id"] + if isinstance(create_result.data, dict) + else create_result.data.id + ) + + # Dry-run scan scoped to the freshly created memory + result = await mcp_client.call_tool( + "execute_forgetful_tool", + { + "tool_name": "run_decay_scan", + "arguments": { + "memory_ids": [memory_id], + "dry_run": True, + }, + }, + ) + assert result.data is not None + data = result.data if isinstance(result.data, dict) else result.data.model_dump() + assert data["dry_run"] is True + assert data["scanned"] >= 1 + assert data["applied"] == 0 + # A freshly created memory is too recent to decay -> at least one skip action + kinds = {a["kind"] for a in data["actions"]} + assert "skip" in kinds or len(data["actions"]) == 0 + + +@pytest.mark.asyncio +async def test_run_decay_scan_live_e2e(mcp_client): + """`execute_forgetful_tool('run_decay_scan', {dry_run: false})` executes without error.""" + # Create a fresh memory; live scan on a fresh memory is a no-op (skip) + create_result = await mcp_client.call_tool( + "execute_forgetful_tool", + { + "tool_name": "create_memory", + "arguments": { + "title": "Decay scan E2E live stub", + "content": "Memory used to verify live run_decay_scan", + "context": "E2E test for live run_decay_scan", + "keywords": ["decay", "e2e"], + "tags": ["test"], + "importance": 5, + }, + }, + ) + assert create_result.data is not None + memory_id = ( + create_result.data["id"] + if isinstance(create_result.data, dict) + else create_result.data.id + ) + + result = await mcp_client.call_tool( + "execute_forgetful_tool", + { + "tool_name": "run_decay_scan", + "arguments": { + "memory_ids": [memory_id], + "dry_run": False, + }, + }, + ) + assert result.data is not None + data = result.data if isinstance(result.data, dict) else result.data.model_dump() + assert data["dry_run"] is False + assert data["scanned"] >= 1 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 5f0477b..33dc082 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -530,6 +530,58 @@ async def validate_embedding_dimensions(self) -> bool: async def validate_search_works(self) -> bool: return True + # ------------------------------------------------------------------ + # Usage tracking + decay engine (forgetful-hulkito fork) + # ------------------------------------------------------------------ + + async def record_memory_access( + self, + user_id: UUID, + memory_ids: list[int], + accessed_at: datetime | None = None, + ) -> int: + """In-memory access tracking. Does NOT mutate `updated_at`.""" + if not memory_ids: + return 0 + ts = accessed_at or datetime.now(UTC) + user_memories = self._memories.get(user_id, {}) + updated = 0 + for mid in memory_ids: + mem = user_memories.get(mid) + if mem is None or mem.is_obsolete: + continue + mem.access_count = (mem.access_count or 0) + 1 + mem.last_accessed_at = ts + updated += 1 + return updated + + async def get_decay_candidates( + self, + user_id: UUID, + memory_ids: list[int] | None = None, + project_id: int | None = None, + max_age_days: int | None = None, + ) -> list[Memory]: + """In-memory decay-candidate selection mirroring the repo impl.""" + from datetime import timedelta + user_memories = self._memories.get(user_id, {}) + memories = [m for m in user_memories.values() if not m.is_obsolete] + if memory_ids: + id_set = set(memory_ids) + memories = [m for m in memories if m.id in id_set] + if project_id is not None: + memories = [m for m in memories if project_id in m.project_ids] + + def effective_access(mem: Memory) -> datetime: + return mem.last_accessed_at or mem.updated_at or mem.created_at + + if max_age_days is not None: + cutoff = datetime.now(UTC) - timedelta(days=max_age_days) + memories = [m for m in memories if effective_access(m) <= cutoff] + + memories.sort(key=lambda m: (effective_access(m), m.id)) + return memories + @pytest.fixture def mock_embeddings_adapter(): @@ -1681,6 +1733,7 @@ async def list_tasks_for_user( self, user_id: UUID, plan_ids: list[int] | None = None, + project_id: int | None = None, ) -> list[TaskSummary]: if plan_ids is not None and len(plan_ids) == 0: return [] diff --git a/tests/integration/test_decay_engine.py b/tests/integration/test_decay_engine.py new file mode 100644 index 0000000..f9a96ae --- /dev/null +++ b/tests/integration/test_decay_engine.py @@ -0,0 +1,330 @@ +"""Unit tests for the usage-aware memory decay engine (forgetful-hulkito fork). + +Covers the pure decay math in `MemoryService._propose_action` and the +`_usage_weighted_delta` / `_is_protected` / `_effective_access_date` helpers, +plus end-to-end `run_decay_scan` behaviour against the in-memory repository +fixture (no Postgres/SQLite dependency). + +Risk class: schema + lifecycle mutation. Verifies: +- protected tags / importance are never decayed +- null confidence is reported as a skip, not silently decayed +- boundary ages (89/90/180+ days) behave correctly +- usage weighting shrinks the delta as access_count grows +- `updated_at` is NOT mutated by `record_memory_access` +- dry-run proposes but does not apply; live mode applies via the validated + `update_memory` / `mark_memory_obsolete` paths +""" +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +import pytest + +from app.models.memory_models import ( + DecayScanRequest, + Memory, + MemoryCreate, + MemoryUpdate, +) +from app.services.memory_service import MemoryService + + +def _make_memory( + *, + id_: int = 1, + title: str = "stub", + importance: int = 5, + tags: list[str] | None = None, + confidence: float | None = 0.8, + age_days: int = 0, + last_accessed_days_ago: int | None = None, + access_count: int = 0, +) -> Memory: + now = datetime.now(UTC) + created = now - timedelta(days=age_days) + last_access = ( + now - timedelta(days=last_accessed_days_ago) if last_accessed_days_ago is not None else None + ) + return Memory( + id=id_, + title=title, + content="stub content", + context="stub context", + keywords=["stub"], + tags=tags or ["stub"], + importance=importance, + confidence=confidence, + access_count=access_count, + last_accessed_at=last_access, + created_at=created, + updated_at=created, # not touched since creation + project_ids=[], + linked_memory_ids=[], + ) + + +# --------------------------------------------------------------------------- +# Pure-formula unit tests +# --------------------------------------------------------------------------- + + +def test_protected_by_importance_is_skipped(): + mem = _make_memory(importance=8, confidence=0.9, age_days=400) + action = MemoryService._propose_action(memory=mem, now=datetime.now(UTC)) + assert action.kind == "skip" + assert "protected" in (action.reason or "") + + +def test_protected_by_tag_is_skipped(): + for tag in ("decision", "architecture", "critical"): + mem = _make_memory(importance=3, tags=[tag], confidence=0.9, age_days=400) + action = MemoryService._propose_action(memory=mem, now=datetime.now(UTC)) + assert action.kind == "skip", f"tag {tag} should be protected" + assert "protected" in (action.reason or "") + + +def test_null_confidence_is_skipped_with_reason(): + mem = _make_memory(confidence=None, age_days=400) + action = MemoryService._propose_action(memory=mem, now=datetime.now(UTC)) + assert action.kind == "skip" + assert "confidence is None" in (action.reason or "") + + +def test_recent_memory_is_skipped(): + mem = _make_memory(confidence=0.8, age_days=30) + action = MemoryService._propose_action(memory=mem, now=datetime.now(UTC)) + assert action.kind == "skip" + assert "age" in (action.reason or "") + + +def test_boundary_age_90_days_decays(): + # 91 days old, confidence above floor -> decay + mem = _make_memory(confidence=0.8, age_days=91, access_count=0) + action = MemoryService._propose_action(memory=mem, now=datetime.now(UTC)) + assert action.kind == "decay" + assert action.delta is not None + assert action.proposed_confidence is not None + assert action.proposed_confidence < 0.8 + + +def test_boundary_age_89_days_skipped(): + mem = _make_memory(confidence=0.8, age_days=89, access_count=0) + action = MemoryService._propose_action(memory=mem, now=datetime.now(UTC)) + assert action.kind == "skip" + + +def test_decay_clamped_at_floor(): + # confidence exactly at floor: proposed = max(0.3, 0.3 - 0.1) = 0.3, + # which is NOT lower than current -> skip (clamped). + mem = _make_memory(confidence=0.3, age_days=120, access_count=0) + action = MemoryService._propose_action(memory=mem, now=datetime.now(UTC)) + assert action.kind == "skip" + assert "clamped" in (action.reason or "") + + +def test_decay_just_above_floor_decays_to_floor(): + # confidence 0.31 -> proposed 0.30 (clamped), still lower than 0.31 -> decay. + mem = _make_memory(confidence=0.31, age_days=120, access_count=0) + action = MemoryService._propose_action(memory=mem, now=datetime.now(UTC)) + assert action.kind == "decay" + assert action.proposed_confidence == pytest.approx(0.3) + + +def test_obsolete_path_when_very_old_and_low_confidence(): + mem = _make_memory(confidence=0.2, age_days=200, access_count=0) + action = MemoryService._propose_action(memory=mem, now=datetime.now(UTC)) + assert action.kind == "obsolete" + assert "aged out" in (action.reason or "") + + +def test_usage_weighting_shrinks_delta(): + # access_count=0 -> delta 0.1 + assert MemoryService._usage_weighted_delta(0) == pytest.approx(0.1) + # access_count=5 -> delta 0.1 - 5*0.01 = 0.05 + assert MemoryService._usage_weighted_delta(5) == pytest.approx(0.05) + # access_count=8 -> delta 0.1 - 8*0.01 = 0.02 (floor) + assert MemoryService._usage_weighted_delta(8) == pytest.approx(0.02) + # access_count=20 (above cap) -> still 0.02 (cap at 8) + assert MemoryService._usage_weighted_delta(20) == pytest.approx(0.02) + + +def test_effective_access_date_fallback_chain(): + now = datetime.now(UTC) + # last_accessed_at wins + mem = _make_memory(age_days=200, last_accessed_days_ago=10) + eff = MemoryService._effective_access_date(mem) + assert (now - eff).days == 10 + # updated_at fallback when last_accessed_at is None + mem2 = _make_memory(age_days=200, last_accessed_days_ago=None) + eff2 = MemoryService._effective_access_date(mem2) + assert eff2 == mem2.updated_at + + +# --------------------------------------------------------------------------- +# Integration tests against the in-memory repo fixture +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_record_memory_access_does_not_mutate_updated_at(test_memory_service): + """Access tracking must update access_count + last_accessed_at but NOT updated_at.""" + user_id = uuid4() + mem_data = MemoryCreate( + title="Decay stub", + content="Memory used to test access tracking", + context="Verifying updated_at is preserved", + keywords=["decay", "test"], + tags=["stub"], + importance=5, + ) + mem, _ = await test_memory_service.create_memory(user_id=user_id, memory_data=mem_data) + updated_at_before = mem.updated_at + + # Use the repo directly to bypass any service-level touches + count = await test_memory_service.memory_repo.record_memory_access( + user_id=user_id, + memory_ids=[mem.id], + ) + assert count == 1 + + refreshed = await test_memory_service.memory_repo.get_memory_by_id(user_id=user_id, memory_id=mem.id) + assert refreshed is not None + assert refreshed.access_count == 1 + assert refreshed.last_accessed_at is not None + assert refreshed.updated_at == updated_at_before, "updated_at must not move on access" + + +@pytest.mark.asyncio +async def test_get_memory_records_access(test_memory_service): + """get_memory should bump access_count via the service path.""" + user_id = uuid4() + mem_data = MemoryCreate( + title="Get stub", + content="Memory used to test get_memory access tracking", + context="Verifying get_memory bumps access_count", + keywords=["decay", "get"], + tags=["stub"], + importance=5, + ) + mem, _ = await test_memory_service.create_memory(user_id=user_id, memory_data=mem_data) + + await test_memory_service.get_memory(user_id=user_id, memory_id=mem.id) + refreshed = await test_memory_service.memory_repo.get_memory_by_id(user_id=user_id, memory_id=mem.id) + assert refreshed is not None + assert refreshed.access_count == 1 + assert refreshed.last_accessed_at is not None + + +@pytest.mark.asyncio +async def test_run_decay_scan_dry_run_does_not_write(test_memory_service): + """Dry-run must propose without applying.""" + user_id = uuid4() + now = datetime.now(UTC) + old_ts = now - timedelta(days=200) + mem = Memory( + id=1001, + title="Old stub", + content="Memory that should be a decay candidate", + context="Decay scan dry-run test", + keywords=["decay"], + tags=["stub"], + importance=5, + confidence=0.8, + access_count=0, + last_accessed_at=None, + created_at=old_ts, + updated_at=old_ts, + project_ids=[], + linked_memory_ids=[], + ) + repo = test_memory_service.memory_repo + repo._memories.setdefault(user_id, {})[mem.id] = mem + + response = await test_memory_service.run_decay_scan( + user_id=user_id, + request=DecayScanRequest(memory_ids=[mem.id], dry_run=True), + ) + assert response.dry_run is True + assert response.scanned == 1 + assert response.applied == 0 + assert any(a.kind == "decay" for a in response.actions) + + # Memory confidence unchanged + refreshed = await repo.get_memory_by_id(user_id=user_id, memory_id=mem.id) + assert refreshed is not None + assert refreshed.confidence == 0.8 + + +@pytest.mark.asyncio +async def test_run_decay_scan_live_applies_confidence_decay(test_memory_service): + """Live mode applies confidence decay through the validated update path.""" + user_id = uuid4() + old_ts = datetime.now(UTC) - timedelta(days=200) + mem = Memory( + id=1002, + title="Old stub live", + content="Memory that should be decayed in live mode", + context="Decay scan live test", + keywords=["decay"], + tags=["stub"], + importance=5, + confidence=0.8, + access_count=0, + last_accessed_at=None, + created_at=old_ts, + updated_at=old_ts, + project_ids=[], + linked_memory_ids=[], + ) + repo = test_memory_service.memory_repo + repo._memories.setdefault(user_id, {})[mem.id] = mem + + response = await test_memory_service.run_decay_scan( + user_id=user_id, + request=DecayScanRequest(memory_ids=[mem.id], dry_run=False), + ) + assert response.dry_run is False + assert response.applied == 1 + assert any(a.kind == "decay" for a in response.actions) + + refreshed = await repo.get_memory_by_id(user_id=user_id, memory_id=mem.id) + assert refreshed is not None + assert refreshed.confidence is not None + assert refreshed.confidence < 0.8 + + +@pytest.mark.asyncio +async def test_run_decay_scan_live_obsolete_path(test_memory_service): + """Live mode applies obsolescence when the obsolete path is selected.""" + user_id = uuid4() + old_ts = datetime.now(UTC) - timedelta(days=400) + mem = Memory( + id=1003, + title="Very old low-confidence stub", + content="Memory that should be marked obsolete", + context="Decay scan obsolete test", + keywords=["decay"], + tags=["stub"], + importance=5, + confidence=0.2, + access_count=0, + last_accessed_at=None, + created_at=old_ts, + updated_at=old_ts, + project_ids=[], + linked_memory_ids=[], + ) + repo = test_memory_service.memory_repo + repo._memories.setdefault(user_id, {})[mem.id] = mem + + response = await test_memory_service.run_decay_scan( + user_id=user_id, + request=DecayScanRequest(memory_ids=[mem.id], dry_run=False), + ) + assert response.dry_run is False + assert response.applied == 1 + assert any(a.kind == "obsolete" for a in response.actions) + + refreshed = await repo.get_memory_by_id(user_id=user_id, memory_id=mem.id) + assert refreshed is not None + assert refreshed.is_obsolete is True + assert refreshed.obsolete_reason is not None diff --git a/tests/integration/test_decay_tool_adapters.py b/tests/integration/test_decay_tool_adapters.py new file mode 100644 index 0000000..5e93749 --- /dev/null +++ b/tests/integration/test_decay_tool_adapters.py @@ -0,0 +1,77 @@ +"""Adapter + registry tests for the `run_decay_scan` MCP tool. + +Verifies that: +- `create_memory_adapters` exposes a `run_decay_scan` callable +- `register_memory_tools_metadata` registers the tool under the MEMORY category +- The adapter returns a `DecayScanResponse` (dry-run, no writes) + +This is the registry-level equivalent of the E2E round-trip — it proves the +tool is discoverable and executable through the meta-tool surface without +requiring a Postgres container. +""" +from unittest.mock import patch +from uuid import uuid4 + +import pytest + +from app.models.memory_models import DecayScanResponse +from app.routes.mcp.tool_adapters import create_memory_adapters +from app.routes.mcp.tool_metadata_registry import register_memory_tools_metadata +from app.routes.mcp.tool_registry import ToolRegistry + + +@pytest.fixture +def decay_adapters(test_memory_service, test_user_service): + """Build memory adapters against the in-memory test services.""" + return create_memory_adapters(test_memory_service, test_user_service) + + +@pytest.fixture +def decay_registry(decay_adapters): + """Build a ToolRegistry with the memory tool metadata registered.""" + registry = ToolRegistry() + register_memory_tools_metadata(registry, decay_adapters) + return registry + + +def test_run_decay_scan_adapter_is_registered(decay_adapters): + """`create_memory_adapters` must expose `run_decay_scan`.""" + assert "run_decay_scan" in decay_adapters + assert callable(decay_adapters["run_decay_scan"]) + + +def test_run_decay_scan_registry_metadata(decay_registry): + """The metadata registry must register `run_decay_scan` so meta-tools can discover it.""" + tools = decay_registry.list_all_tools() + names = {t.name for t in tools} + assert "run_decay_scan" in names, "run_decay_scan must be discoverable in the registry" + + +def test_run_decay_scan_registry_implementation_bound(decay_registry): + """The registry-bound implementation must be the adapter callable.""" + tool = decay_registry.get_tool("run_decay_scan") + assert tool is not None + assert callable(tool.implementation) + + +@pytest.mark.asyncio +async def test_run_decay_scan_adapter_dry_run_returns_response(decay_adapters): + """Calling the adapter with dry_run=True returns a DecayScanResponse and writes nothing.""" + fake_user = type("FakeUser", (), {"id": uuid4()})() + fake_ctx = type("FakeCtx", (), {})() + + with patch( + "app.routes.mcp.tool_adapters.get_user_from_auth", + return_value=fake_user, + ): + result = await decay_adapters["run_decay_scan"]( + ctx=fake_ctx, + memory_ids=None, + project_id=None, + dry_run=True, + ) + + assert isinstance(result, DecayScanResponse) + assert result.dry_run is True + assert result.applied == 0 +