Coverage for node / src / stigmem_node / lifecycle / decay.py: 95%
57 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-06-18 05:34 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-06-18 05:34 +0000
1"""Configurable decay sweeper — marks stale facts as expired (Phase 6)."""
3from __future__ import annotations
5import uuid
6from datetime import UTC, datetime, timedelta
7from typing import Any
9from ..db import db
10from ..settings import settings
11from .immutability import set_fact_validity_override
14def _resolve_effective_ttl(ttl_seconds: int | None) -> int | None:
15 """Resolve the effective TTL: explicit arg overrides settings; 0 means "expire all"."""
16 if ttl_seconds is not None:
17 return ttl_seconds # 0 is valid: expire everything
18 if settings.decay_ttl_seconds > 0: 18 ↛ 19line 18 didn't jump to line 19 because the condition on line 18 was never true
19 return settings.decay_ttl_seconds
20 return None
23def _resolve_effective_min_conf(min_confidence: float | None) -> float | None:
24 """Resolve the effective confidence floor: explicit arg overrides settings."""
25 if min_confidence is not None and min_confidence > 0.0:
26 return min_confidence
27 if settings.decay_min_confidence > 0.0: 27 ↛ 28line 27 didn't jump to line 28 because the condition on line 27 was never true
28 return settings.decay_min_confidence
29 return None
32def _select_ttl_candidates(
33 conn: Any, effective_ttl: int, scope: str | None, now_dt: datetime, tenant_id: str
34) -> list[str]:
35 """Return fact ids whose timestamp is older than (now - effective_ttl)."""
36 cutoff = (now_dt - timedelta(seconds=effective_ttl)).isoformat()
37 sql = (
38 "SELECT f.id FROM facts f "
39 "LEFT JOIN fact_validity_overrides fvo ON fvo.fact_id = f.id "
40 "WHERE f.timestamp <= ? "
41 "AND f.tenant_id = ? "
42 "AND COALESCE(fvo.valid_until, f.valid_until) IS NULL "
43 "AND NOT (entity LIKE 'stigmem:%' AND entity NOT LIKE 'stigmem://%') "
44 "AND NOT (relation LIKE 'stigmem:%' AND relation NOT LIKE 'stigmem://%')"
45 )
46 params: list[Any] = [cutoff, tenant_id]
47 if scope:
48 sql += " AND scope = ?"
49 params.append(scope)
50 return [r["id"] for r in conn.execute(sql, params).fetchall()]
53def _select_confidence_candidates(
54 conn: Any, effective_min_conf: float, scope: str | None, now: str, tenant_id: str
55) -> list[str]:
56 """Return active fact ids whose confidence is below the floor."""
57 sql = (
58 "SELECT f.id FROM facts f "
59 "LEFT JOIN fact_validity_overrides fvo ON fvo.fact_id = f.id "
60 "WHERE COALESCE(fvo.confidence, f.confidence) < ? "
61 "AND f.tenant_id = ? "
62 "AND COALESCE(fvo.confidence, f.confidence) > 0.0 "
63 "AND (COALESCE(fvo.valid_until, f.valid_until) IS NULL "
64 "OR COALESCE(fvo.valid_until, f.valid_until) > ?) "
65 "AND NOT (entity LIKE 'stigmem:%' AND entity NOT LIKE 'stigmem://%') "
66 "AND NOT (relation LIKE 'stigmem:%' AND relation NOT LIKE 'stigmem://%')"
67 )
68 params: list[Any] = [effective_min_conf, tenant_id, now]
69 if scope:
70 sql += " AND scope = ?"
71 params.append(scope)
72 return [r["id"] for r in conn.execute(sql, params).fetchall()]
75def _apply_decay(conn: Any, candidates: list[str], conf_ids: list[str], now: str) -> None:
76 """Persist decay: mark candidates expired, log confidence retractions, sync graph."""
77 for fact_id in candidates:
78 set_fact_validity_override(
79 conn,
80 fact_id=fact_id,
81 valid_until=now,
82 reason="decay_sweep",
83 updated_by="stigmem:system:decay",
84 )
85 # Append-only retraction log for confidence-floor drops (§24.2.1 c.3)
86 if conf_ids:
87 conn.executemany(
88 "INSERT INTO fact_retractions (id, fact_id, retracted_at, retracted_by) "
89 "VALUES (?,?,?,?)",
90 [(str(uuid.uuid4()), fid, now, "stigmem:system:decay") for fid in conf_ids],
91 )
92 # Graph adjacency index (§20.1.2): propagate expiry to entity_edges
93 from ..recall.graph_index import sync_edge_expiry
95 sync_edge_expiry(conn, candidates, now)
98def run_decay_sweep(
99 ttl_seconds: int | None = None,
100 min_confidence: float | None = None,
101 scope: str | None = None,
102 dry_run: bool = False,
103 *,
104 tenant_id: str,
105) -> dict[str, Any]:
106 """Mark stale facts as expired by setting valid_until to now.
108 - TTL decay: non-expiring facts whose timestamp is at or before (now - ttl_seconds).
109 Passing ttl_seconds=0 expires all non-expiring facts regardless of age.
110 - Confidence decay: active facts whose confidence is below min_confidence.
111 - System facts (stigmem: entity/relation, not stigmem://) are never decayed.
112 - dry_run=True returns counts without writing.
113 - Candidate selection is scoped to ``tenant_id`` so a caller can only
114 expire/count facts in its own tenant (no cross-tenant write or count
115 oracle).
117 Returns {"scanned": N, "decayed": M, "dry_run": bool}.
118 """
119 now_dt = datetime.now(UTC)
120 now = now_dt.isoformat()
122 # Explicit args override settings defaults; settings=0/0.0 means "disabled"
123 effective_ttl = _resolve_effective_ttl(ttl_seconds)
124 effective_min_conf = _resolve_effective_min_conf(min_confidence)
126 ttl_ids: list[str] = []
127 conf_ids: list[str] = []
129 with db() as conn:
130 if effective_ttl is not None:
131 ttl_ids = _select_ttl_candidates(conn, effective_ttl, scope, now_dt, tenant_id)
133 if effective_min_conf is not None:
134 conf_ids = _select_confidence_candidates(
135 conn, effective_min_conf, scope, now, tenant_id
136 )
138 candidates = list({*ttl_ids, *conf_ids})
140 if not dry_run and candidates:
141 _apply_decay(conn, candidates, conf_ids, now)
143 return {
144 "scanned": len(candidates),
145 "decayed": len(candidates) if not dry_run else 0,
146 "dry_run": dry_run,
147 }