From e840ef4be3b12be9563c09e3c558d0db4881b10d Mon Sep 17 00:00:00 2001 From: Anto01 Date: Sat, 18 Apr 2026 16:50:20 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=207D=20=E2=80=94=20confidence=20d?= =?UTF-8?q?ecay=20on=20unreferenced=20cold=20memories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Daily job multiplies confidence by 0.97 (~2-month half-life) for active memories with reference_count=0 AND idle > 30 days. Below 0.3 → auto-supersede with audit. Reversible via reinforcement (which already bumps confidence back up). Rationale: stale memories currently rank equal to fresh ones in retrieval. Without decay, the brain accumulates obsolete facts that compete with fresh knowledge for context-pack slots. With decay, memories earn their longevity via reference. - decay_unreferenced_memories() in service.py (stdlib-only, no cron infra needed) - POST /admin/memory/decay-run endpoint - Nightly Step F4 in batch-extract.sh - Exempt: reinforced (refcount > 0), graduated, superseded, invalid - Audit row per supersession ("decayed below floor, no references"), actor="confidence-decay". Per-decay rows skipped (chatty, no human value — status change is the meaningful signal). - Configurable via env: ATOCORE_DECAY_* (exposed through endpoint body) Tests: +13 (basic decay, reinforcement protection, supersede at floor, audit trail, graduated/superseded exemption, reinforcement reversibility, threshold tuning, parameter validation, cross-run stacking). 401 → 414. Next in Phase 7: 7C tag canonicalization (weekly), then 7B contradiction detection. Co-Authored-By: Claude Opus 4.7 (1M context) --- deploy/dalidou/batch-extract.sh | 11 ++ src/atocore/api/routes.py | 33 +++++ src/atocore/memory/service.py | 111 ++++++++++++++ tests/test_confidence_decay.py | 251 ++++++++++++++++++++++++++++++++ 4 files changed, 406 insertions(+) create mode 100644 tests/test_confidence_decay.py diff --git a/deploy/dalidou/batch-extract.sh b/deploy/dalidou/batch-extract.sh index a69ec97..8bd515b 100644 --- a/deploy/dalidou/batch-extract.sh +++ b/deploy/dalidou/batch-extract.sh @@ -166,6 +166,17 @@ curl -sSf -X POST "$ATOCORE_URL/admin/memory/extend-reinforced" \ log "WARN: extend-reinforced failed (non-blocking)" } +# Step F4: Confidence decay on unreferenced cold memories (Phase 7D) +# Daily: memories with reference_count=0 AND idle > 30 days → confidence × 0.97. +# Below 0.3 → auto-supersede with audit. Reversible via reinforcement. +log "Step F4: confidence decay" +curl -sSf -X POST "$ATOCORE_URL/admin/memory/decay-run" \ + -H 'Content-Type: application/json' \ + -d '{"idle_days_threshold": 30, "daily_decay_factor": 0.97, "supersede_confidence_floor": 0.30}' \ + 2>&1 | tail -5 || { + log "WARN: decay-run failed (non-blocking)" +} + # Step B3: Memory dedup scan (Phase 7A) # Nightly at 0.90 (tight — only near-duplicates). Sundays run a deeper # pass at 0.85 to catch semantically-similar-but-differently-worded memories. diff --git a/src/atocore/api/routes.py b/src/atocore/api/routes.py index 83769dd..f35c2a1 100644 --- a/src/atocore/api/routes.py +++ b/src/atocore/api/routes.py @@ -1517,6 +1517,39 @@ def api_graduation_status() -> dict: return out +class DecayRunBody(BaseModel): + idle_days_threshold: int = 30 + daily_decay_factor: float = 0.97 + supersede_confidence_floor: float = 0.30 + + +@router.post("/admin/memory/decay-run") +def api_decay_run(body: DecayRunBody | None = None) -> dict: + """Phase 7D — confidence decay on unreferenced memories. + + One-shot run (daily cron or on-demand). For active memories with + reference_count=0 and idle for >30 days: multiply confidence by + 0.97 (~2-month half-life). Below 0.3 → auto-supersede with audit. + + Reversible: reinforcement bumps confidence back up. Non-destructive: + superseded memories stay queryable with status filter. + """ + from atocore.memory.service import decay_unreferenced_memories + + b = body or DecayRunBody() + result = decay_unreferenced_memories( + idle_days_threshold=b.idle_days_threshold, + daily_decay_factor=b.daily_decay_factor, + supersede_confidence_floor=b.supersede_confidence_floor, + ) + return { + "decayed_count": len(result["decayed"]), + "superseded_count": len(result["superseded"]), + "decayed": result["decayed"][:20], # cap payload + "superseded": result["superseded"][:20], + } + + @router.post("/admin/memory/extend-reinforced") def api_extend_reinforced() -> dict: """Phase 6 C.3 — batch transient-to-durable extension. diff --git a/src/atocore/memory/service.py b/src/atocore/memory/service.py index ff841c7..d62a853 100644 --- a/src/atocore/memory/service.py +++ b/src/atocore/memory/service.py @@ -691,6 +691,117 @@ def extend_reinforced_valid_until( return extended +def decay_unreferenced_memories( + idle_days_threshold: int = 30, + daily_decay_factor: float = 0.97, + supersede_confidence_floor: float = 0.30, + actor: str = "confidence-decay", +) -> dict[str, list]: + """Phase 7D — daily confidence decay on cold memories. + + For every active, non-graduated memory with ``reference_count == 0`` + AND whose last activity (``last_referenced_at`` if set, else + ``created_at``) is older than ``idle_days_threshold``: multiply + confidence by ``daily_decay_factor`` (0.97/day ≈ 2-month half-life). + + If the decayed confidence falls below ``supersede_confidence_floor``, + auto-supersede the memory with note "decayed, no references". + Supersession is non-destructive — the row stays queryable via + ``status='superseded'`` for audit. + + Reinforcement already bumps confidence back up, so a decayed memory + that later gets referenced reverses its trajectory naturally. + + The job is idempotent-per-day: running it multiple times in one day + decays extra, but the cron runs once/day so this stays on-policy. + If a day's cron gets skipped, we under-decay (safe direction — + memories age slower, not faster, than the policy). + + Returns {"decayed": [...], "superseded": [...]} with per-memory + before/after snapshots for audit/observability. + """ + from datetime import timedelta + + if not (0.0 < daily_decay_factor < 1.0): + raise ValueError("daily_decay_factor must be between 0 and 1 (exclusive)") + if not (0.0 <= supersede_confidence_floor <= 1.0): + raise ValueError("supersede_confidence_floor must be in [0,1]") + + cutoff_dt = datetime.now(timezone.utc) - timedelta(days=idle_days_threshold) + cutoff_str = cutoff_dt.strftime("%Y-%m-%d %H:%M:%S") + now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + + decayed: list[dict] = [] + superseded: list[dict] = [] + + with get_connection() as conn: + # COALESCE(last_referenced_at, created_at) is the effective "last + # activity" — if a memory was never reinforced, we measure age + # from creation. "IS NOT status graduated" is enforced to keep + # graduated memories (which are frozen pointers to entities) + # out of the decay pool. + rows = conn.execute( + "SELECT id, confidence, last_referenced_at, created_at " + "FROM memories " + "WHERE status = 'active' " + "AND COALESCE(reference_count, 0) = 0 " + "AND COALESCE(last_referenced_at, created_at) < ?", + (cutoff_str,), + ).fetchall() + + for r in rows: + mid = r["id"] + old_conf = float(r["confidence"]) + new_conf = max(0.0, old_conf * daily_decay_factor) + + if new_conf < supersede_confidence_floor: + # Auto-supersede + conn.execute( + "UPDATE memories SET status = 'superseded', " + "confidence = ?, updated_at = ? WHERE id = ?", + (new_conf, now_str, mid), + ) + superseded.append({ + "memory_id": mid, + "old_confidence": old_conf, + "new_confidence": new_conf, + }) + else: + conn.execute( + "UPDATE memories SET confidence = ?, updated_at = ? WHERE id = ?", + (new_conf, now_str, mid), + ) + decayed.append({ + "memory_id": mid, + "old_confidence": old_conf, + "new_confidence": new_conf, + }) + + # Audit rows outside the transaction. We skip per-decay audit because + # it would be too chatty (potentially hundreds of rows/day for no + # human value); supersessions ARE audited because those are + # status-changing events humans may want to review. + for entry in superseded: + _audit_memory( + memory_id=entry["memory_id"], + action="superseded", + actor=actor, + before={"status": "active", "confidence": entry["old_confidence"]}, + after={"status": "superseded", "confidence": entry["new_confidence"]}, + note=f"decayed below floor {supersede_confidence_floor}, no references", + ) + + if decayed or superseded: + log.info( + "confidence_decay_run", + decayed=len(decayed), + superseded=len(superseded), + idle_days_threshold=idle_days_threshold, + daily_decay_factor=daily_decay_factor, + ) + return {"decayed": decayed, "superseded": superseded} + + def expire_stale_candidates( max_age_days: int = 14, ) -> list[str]: diff --git a/tests/test_confidence_decay.py b/tests/test_confidence_decay.py new file mode 100644 index 0000000..1a74473 --- /dev/null +++ b/tests/test_confidence_decay.py @@ -0,0 +1,251 @@ +"""Phase 7D — confidence decay tests. + +Covers: + - idle unreferenced memories decay at the expected rate + - fresh / reinforced memories are untouched + - below floor → auto-supersede with audit + - graduated memories exempt + - reinforcement reverses decay (integration with Phase 9 Commit B) +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest + +from atocore.memory.service import ( + create_memory, + decay_unreferenced_memories, + get_memory_audit, + reinforce_memory, +) +from atocore.models.database import get_connection, init_db + + +def _force_old(mem_id: str, days_ago: int) -> None: + """Force last_referenced_at and created_at to N days in the past.""" + ts = (datetime.now(timezone.utc) - timedelta(days=days_ago)).strftime("%Y-%m-%d %H:%M:%S") + with get_connection() as conn: + conn.execute( + "UPDATE memories SET last_referenced_at = ?, created_at = ? WHERE id = ?", + (ts, ts, mem_id), + ) + + +def _set_confidence(mem_id: str, c: float) -> None: + with get_connection() as conn: + conn.execute("UPDATE memories SET confidence = ? WHERE id = ?", (c, mem_id)) + + +def _set_reference_count(mem_id: str, n: int) -> None: + with get_connection() as conn: + conn.execute("UPDATE memories SET reference_count = ? WHERE id = ?", (n, mem_id)) + + +def _get(mem_id: str) -> dict: + with get_connection() as conn: + row = conn.execute("SELECT * FROM memories WHERE id = ?", (mem_id,)).fetchone() + return dict(row) if row else {} + + +def _set_status(mem_id: str, status: str) -> None: + with get_connection() as conn: + conn.execute("UPDATE memories SET status = ? WHERE id = ?", (status, mem_id)) + + +# --- Basic decay mechanics --- + + +def test_decay_applies_to_idle_unreferenced(tmp_data_dir): + init_db() + m = create_memory("knowledge", "cold fact", confidence=0.8) + _force_old(m.id, days_ago=60) + _set_reference_count(m.id, 0) + + result = decay_unreferenced_memories() + assert len(result["decayed"]) == 1 + assert result["decayed"][0]["memory_id"] == m.id + + row = _get(m.id) + # 0.8 * 0.97 = 0.776 + assert row["confidence"] == pytest.approx(0.776) + assert row["status"] == "active" # still above floor + + +def test_decay_skips_fresh_memory(tmp_data_dir): + """A memory created today shouldn't decay even if reference_count=0.""" + init_db() + m = create_memory("knowledge", "just-created fact", confidence=0.8) + # Don't force old — it's fresh + result = decay_unreferenced_memories() + assert not any(e["memory_id"] == m.id for e in result["decayed"]) + assert not any(e["memory_id"] == m.id for e in result["superseded"]) + + row = _get(m.id) + assert row["confidence"] == pytest.approx(0.8) + + +def test_decay_skips_reinforced_memory(tmp_data_dir): + """Any reinforcement protects the memory from decay.""" + init_db() + m = create_memory("knowledge", "referenced fact", confidence=0.8) + _force_old(m.id, days_ago=90) + _set_reference_count(m.id, 1) # just one reference is enough + + result = decay_unreferenced_memories() + assert not any(e["memory_id"] == m.id for e in result["decayed"]) + + row = _get(m.id) + assert row["confidence"] == pytest.approx(0.8) + + +# --- Auto-supersede at floor --- + + +def test_decay_supersedes_below_floor(tmp_data_dir): + init_db() + m = create_memory("knowledge", "very cold fact", confidence=0.31) + _force_old(m.id, days_ago=60) + _set_reference_count(m.id, 0) + + # 0.31 * 0.97 = 0.3007 which is still above the default floor 0.30. + # Drop it a hair lower to cross the floor in one step. + _set_confidence(m.id, 0.305) + + result = decay_unreferenced_memories(supersede_confidence_floor=0.30) + # 0.305 * 0.97 = 0.29585 → below 0.30, supersede + assert len(result["superseded"]) == 1 + assert result["superseded"][0]["memory_id"] == m.id + + row = _get(m.id) + assert row["status"] == "superseded" + assert row["confidence"] < 0.30 + + +def test_supersede_writes_audit_row(tmp_data_dir): + init_db() + m = create_memory("knowledge", "will decay out", confidence=0.305) + _force_old(m.id, days_ago=60) + _set_reference_count(m.id, 0) + + decay_unreferenced_memories(supersede_confidence_floor=0.30) + + audit = get_memory_audit(m.id) + actions = [a["action"] for a in audit] + assert "superseded" in actions + entry = next(a for a in audit if a["action"] == "superseded") + assert entry["actor"] == "confidence-decay" + assert "decayed below floor" in entry["note"] + + +# --- Exemptions --- + + +def test_decay_skips_graduated_memory(tmp_data_dir): + """Graduated memories are frozen pointers to entities — never decay.""" + init_db() + m = create_memory("knowledge", "graduated fact", confidence=0.8) + _force_old(m.id, days_ago=90) + _set_reference_count(m.id, 0) + _set_status(m.id, "graduated") + + result = decay_unreferenced_memories() + assert not any(e["memory_id"] == m.id for e in result["decayed"]) + + row = _get(m.id) + assert row["confidence"] == pytest.approx(0.8) # unchanged + + +def test_decay_skips_superseded_memory(tmp_data_dir): + """Already superseded memories don't decay further.""" + init_db() + m = create_memory("knowledge", "old news", confidence=0.5) + _force_old(m.id, days_ago=90) + _set_reference_count(m.id, 0) + _set_status(m.id, "superseded") + + result = decay_unreferenced_memories() + assert not any(e["memory_id"] == m.id for e in result["decayed"]) + + +# --- Reversibility --- + + +def test_reinforcement_reverses_decay(tmp_data_dir): + """A memory that decayed then got reinforced comes back up.""" + init_db() + m = create_memory("knowledge", "will come back", confidence=0.8) + _force_old(m.id, days_ago=60) + _set_reference_count(m.id, 0) + + decay_unreferenced_memories() + # Now at 0.776 + reinforce_memory(m.id, confidence_delta=0.05) + row = _get(m.id) + assert row["confidence"] == pytest.approx(0.826) + assert row["reference_count"] >= 1 + + +def test_reinforced_memory_no_longer_decays(tmp_data_dir): + """Once reinforce_memory bumps reference_count, decay skips it.""" + init_db() + m = create_memory("knowledge", "protected", confidence=0.8) + _force_old(m.id, days_ago=90) + # Simulate reinforcement + reinforce_memory(m.id) + + result = decay_unreferenced_memories() + assert not any(e["memory_id"] == m.id for e in result["decayed"]) + + +# --- Parameter validation --- + + +def test_decay_rejects_invalid_factor(tmp_data_dir): + init_db() + with pytest.raises(ValueError): + decay_unreferenced_memories(daily_decay_factor=1.0) + with pytest.raises(ValueError): + decay_unreferenced_memories(daily_decay_factor=0.0) + with pytest.raises(ValueError): + decay_unreferenced_memories(daily_decay_factor=-0.5) + + +def test_decay_rejects_invalid_floor(tmp_data_dir): + init_db() + with pytest.raises(ValueError): + decay_unreferenced_memories(supersede_confidence_floor=1.5) + with pytest.raises(ValueError): + decay_unreferenced_memories(supersede_confidence_floor=-0.1) + + +# --- Threshold tuning --- + + +def test_decay_threshold_tight_excludes_newer(tmp_data_dir): + """With idle_days_threshold=90, a 60-day-old memory should NOT decay.""" + init_db() + m = create_memory("knowledge", "60-day-old", confidence=0.8) + _force_old(m.id, days_ago=60) + _set_reference_count(m.id, 0) + + result = decay_unreferenced_memories(idle_days_threshold=90) + assert not any(e["memory_id"] == m.id for e in result["decayed"]) + + +# --- Idempotency-ish (multiple runs apply additional decay) --- + + +def test_decay_stacks_across_runs(tmp_data_dir): + """Running decay twice (simulating two days) compounds the factor.""" + init_db() + m = create_memory("knowledge", "aging fact", confidence=0.8) + _force_old(m.id, days_ago=60) + _set_reference_count(m.id, 0) + + decay_unreferenced_memories() + decay_unreferenced_memories() + row = _get(m.id) + # 0.8 * 0.97 * 0.97 = 0.75272 + assert row["confidence"] == pytest.approx(0.75272, rel=1e-4)