dc5dc94d79
Multi-channel retrieval sidecar over Universal Memory Protocol: - 3-channel RRF (UMP FTS5 + Qdrant vector + knowledge graph) - ACT-R re-ranking (Anderson 1983) with access tracking - Co-occurrence graph edges (Phase 6) for dense traversal - Memory lifecycle decay (Phase 4) with per-kind confidence - MCP shim routes recall through sidecar, falls back to canonical UMP Architecture: - src/server.js HTTP sidecar on port 4380 - src/graph.js 2592-node / 111-edge graph from UMP (or +cooccur: 13k+) - src/actr.js A_i = -d*ln(age) + beta*log1p(freq) + epsilon*conf - src/access_log.js per-URN counter + last_accessed_at - src/ump-recall-mcp.js MCP shim (recall via sidecar, others passthrough) Eval results (851-record UMP corpus): - 2ch RRF over baseline: +50pp recall@10 - 3ch RRF (+graph): +60pp, 12 unique wins - ACT-R re-rank: 4/20 #1 changes, 84% top-5 retention Tests: 76/76 passing across graph (27), actr (27), access_log (28), decay (20), mcp-shim (sidecar + fallback). Run with: npm test Inspired by AIAppsAPI/adaptive-recall but built from scratch against existing DNS2 infrastructure (UMP at :4317, Qdrant at :6333, Ollama at :11434). No paid SaaS, MIT-licensed.
489 lines
16 KiB
Python
489 lines
16 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
ump_decay.py — Apply per-kind confidence decay to UMP memory records.
|
||
|
||
Decay rates (per day since last access):
|
||
identity λ=0.0001 (very slow, never decays much)
|
||
semantic λ=0.001 (slow, facts decay over months)
|
||
procedural λ=0.005 (medium, skills fade if unused)
|
||
working λ=0.05 (fast, session context fades fast)
|
||
episodic λ=0.01 (events fade over weeks)
|
||
note λ=0.003 (medium-slow, session notes)
|
||
|
||
Formula: confidence_new = confidence_old * exp(-λ * days_since_reference)
|
||
where days_since_reference =
|
||
days since r.time.modified if present
|
||
else days since r.time.created
|
||
|
||
Floor: 0.05 (never zero).
|
||
|
||
Status transitions (after applying decay):
|
||
candidate -> active if confidence >= 0.5
|
||
active -> archived if confidence < 0.2
|
||
archived stays (no resurrection)
|
||
tombstoned stays (skip in retrieval, but update confidence for log)
|
||
|
||
Add fields on first run if missing:
|
||
r.time.modified (default to r.time.created if missing)
|
||
|
||
Usage:
|
||
ump_decay.py --dry-run # show what would change, don't write
|
||
ump_decay.py --apply # actually modify memory.ump.json
|
||
ump_decay.py --apply --archive-summary # also print archive candidates
|
||
|
||
Backups:
|
||
Before --apply, copy memory.ump.json -> memory.ump.json.bak.YYYY-MM-DDTHHMMSSZ
|
||
|
||
Pure function:
|
||
apply_decay(records, dry_run=True) -> (records, report)
|
||
Mutates a copy of records in-place. Does NOT touch the disk.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import math
|
||
import os
|
||
import shutil
|
||
import sys
|
||
from collections import Counter
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
# ---- Decay configuration --------------------------------------------------
|
||
|
||
DECAY_RATES: dict[str, float] = {
|
||
"identity": 0.0001,
|
||
"semantic": 0.001,
|
||
"procedural": 0.005,
|
||
"working": 0.05,
|
||
"episodic": 0.01,
|
||
"note": 0.003,
|
||
}
|
||
|
||
CONF_FLOOR = 0.05
|
||
ARCHIVE_THRESHOLD = 0.20
|
||
PROMOTE_THRESHOLD = 0.50
|
||
|
||
DEFAULT_PATH = Path(
|
||
"/root/.openclaw/agents/main/workspace/state/ump-local/memory.ump.json"
|
||
)
|
||
|
||
|
||
# ---- Time parsing ---------------------------------------------------------
|
||
|
||
def parse_iso(ts: Any) -> datetime | None:
|
||
"""Parse an ISO 8601 timestamp; return None on failure or missing."""
|
||
if not ts or not isinstance(ts, str):
|
||
return None
|
||
s = ts.replace("Z", "+00:00")
|
||
try:
|
||
dt = datetime.fromisoformat(s)
|
||
except ValueError:
|
||
return None
|
||
if dt.tzinfo is None:
|
||
dt = dt.replace(tzinfo=timezone.utc)
|
||
return dt
|
||
|
||
|
||
# ---- Field accessors (safe) ----------------------------------------------
|
||
|
||
def _get_time(rec: dict) -> dict:
|
||
t = rec.get("time")
|
||
return t if isinstance(t, dict) else {}
|
||
|
||
|
||
def _get_lifecycle(rec: dict) -> dict:
|
||
lc = rec.get("lifecycle")
|
||
return lc if isinstance(lc, dict) else {}
|
||
|
||
|
||
def get_kind(rec: dict) -> str:
|
||
k = rec.get("kind")
|
||
return k if isinstance(k, str) else "unknown"
|
||
|
||
|
||
def get_status(rec: dict) -> str:
|
||
s = _get_lifecycle(rec).get("status")
|
||
return s if isinstance(s, str) else "active"
|
||
|
||
|
||
def get_confidence(rec: dict) -> float:
|
||
c = _get_lifecycle(rec).get("confidence")
|
||
if isinstance(c, (int, float)):
|
||
return float(c)
|
||
return 1.0
|
||
|
||
|
||
def get_created_at(rec: dict) -> datetime | None:
|
||
return parse_iso(_get_time(rec).get("created"))
|
||
|
||
|
||
def get_modified_at(rec: dict) -> datetime | None:
|
||
return parse_iso(_get_time(rec).get("modified"))
|
||
|
||
|
||
# ---- Normalization --------------------------------------------------------
|
||
|
||
def normalize_record(rec: dict) -> dict:
|
||
"""
|
||
Ensure required fields exist; defaults added in-place.
|
||
- time.modified = time.created if missing
|
||
- lifecycle.confidence = 1.0 if missing
|
||
- lifecycle.status = 'active' if missing
|
||
"""
|
||
t = rec.setdefault("time", {})
|
||
if "created" in t and ("modified" not in t or t["modified"] is None):
|
||
t["modified"] = t["created"]
|
||
lc = rec.setdefault("lifecycle", {})
|
||
if "confidence" not in lc or lc["confidence"] is None:
|
||
lc["confidence"] = 1.0
|
||
if "status" not in lc or lc["status"] is None:
|
||
lc["status"] = "active"
|
||
return rec
|
||
|
||
|
||
# ---- Decay math -----------------------------------------------------------
|
||
|
||
def compute_new_confidence(old: float, lam: float, days_since: float) -> float:
|
||
raw = old * math.exp(-lam * days_since)
|
||
if raw < CONF_FLOOR:
|
||
return CONF_FLOOR
|
||
return raw
|
||
|
||
|
||
# ---- Pure batch function (testable) ---------------------------------------
|
||
|
||
def apply_decay(
|
||
records: list[dict],
|
||
dry_run: bool = True,
|
||
) -> tuple[list[dict], dict]:
|
||
"""
|
||
Apply decay + status transitions to a list of records. Pure function.
|
||
|
||
Args:
|
||
records: list of memory records (dicts). Will be COPIED at the top level
|
||
so the caller's list is not mutated, but record dicts themselves
|
||
are mutated in place (this is intentional — preserves nested
|
||
references and matches the schema's mutability expectations).
|
||
dry_run: if True, don't mutate any records; just compute what would change.
|
||
|
||
Returns:
|
||
(records, report) where report is a dict:
|
||
{
|
||
'total': int,
|
||
'before': Counter[status],
|
||
'after': Counter[status],
|
||
'changes': [change_dict, ...], # one per record
|
||
'archive_candidates': [...], # active -> archived transitions
|
||
'promotion_candidates': [...], # candidate -> active transitions
|
||
'skipped_tombstoned': int,
|
||
'edge_cases': [str, ...], # weird records, parsing issues
|
||
}
|
||
"""
|
||
now = datetime.now(timezone.utc)
|
||
changes: list[dict] = []
|
||
edge_cases: list[str] = []
|
||
archive_candidates: list[dict] = []
|
||
promotion_candidates: list[dict] = []
|
||
|
||
before_status: Counter[str] = Counter()
|
||
after_status: Counter[str] = Counter()
|
||
skipped_tombstoned = 0
|
||
|
||
# Copy records (shallow) so dry_run really is non-mutating.
|
||
if dry_run:
|
||
records = [dict(r) for r in records]
|
||
|
||
for rec in records:
|
||
if not isinstance(rec, dict):
|
||
edge_cases.append(f"non-dict record: {type(rec).__name__}")
|
||
continue
|
||
|
||
# Normalize first (adds defaults, mutates rec).
|
||
try:
|
||
normalize_record(rec)
|
||
except Exception as e:
|
||
edge_cases.append(f"normalize failed for {rec.get('id','?')}: {e!r}")
|
||
continue
|
||
|
||
urn = rec.get("id", "<no-id>")
|
||
kind = get_kind(rec)
|
||
old_status = get_status(rec)
|
||
old_conf = get_confidence(rec)
|
||
|
||
before_status[old_status] += 1
|
||
|
||
change: dict[str, Any] = {
|
||
"urn": urn,
|
||
"kind": kind,
|
||
"old_status": old_status,
|
||
"new_status": old_status,
|
||
"old_confidence": old_conf,
|
||
"new_confidence": old_conf,
|
||
"days_since": 0.0,
|
||
"lambda": DECAY_RATES.get(kind, 0.0),
|
||
"status_changed": False,
|
||
"skipped_reason": None,
|
||
}
|
||
|
||
# Skip tombstoned entirely (no decay, no transitions).
|
||
if old_status == "tombstoned":
|
||
change["skipped_reason"] = "tombstoned"
|
||
skipped_tombstoned += 1
|
||
after_status[old_status] += 1
|
||
changes.append(change)
|
||
continue
|
||
|
||
# Unknown kind -> no decay rate -> skip.
|
||
if kind not in DECAY_RATES:
|
||
change["skipped_reason"] = f"unknown_kind:{kind}"
|
||
edge_cases.append(f"unknown kind {kind!r} for {urn}")
|
||
after_status[old_status] += 1
|
||
changes.append(change)
|
||
continue
|
||
|
||
# Reference time: prefer time.modified, fall back to time.created.
|
||
ref_time = get_modified_at(rec) or get_created_at(rec)
|
||
if ref_time is None:
|
||
change["skipped_reason"] = "no_time_reference"
|
||
edge_cases.append(f"no time reference for {urn}")
|
||
after_status[old_status] += 1
|
||
changes.append(change)
|
||
continue
|
||
|
||
days = max((now - ref_time).total_seconds() / 86400.0, 0.0)
|
||
change["days_since"] = days
|
||
|
||
lam = DECAY_RATES[kind]
|
||
new_conf = compute_new_confidence(old=old_conf, lam=lam, days_since=days)
|
||
change["new_confidence"] = new_conf
|
||
|
||
# Mutate record (or skip if dry_run on the math side, but dry_run already
|
||
# copied records above; we mutate the copy).
|
||
rec["lifecycle"]["confidence"] = new_conf
|
||
|
||
# Status transitions.
|
||
new_status = old_status
|
||
if old_status == "candidate" and new_conf >= PROMOTE_THRESHOLD:
|
||
new_status = "active"
|
||
change["new_status"] = "active"
|
||
change["status_changed"] = True
|
||
elif old_status == "active" and new_conf < ARCHIVE_THRESHOLD:
|
||
new_status = "archived"
|
||
change["new_status"] = "archived"
|
||
change["status_changed"] = True
|
||
|
||
if new_status != old_status:
|
||
rec["lifecycle"]["status"] = new_status
|
||
|
||
after_status[new_status] += 1
|
||
changes.append(change)
|
||
|
||
# Track top candidates.
|
||
if old_status == "active" and new_status == "archived":
|
||
archive_candidates.append({
|
||
"urn": urn,
|
||
"kind": kind,
|
||
"old_confidence": old_conf,
|
||
"new_confidence": new_conf,
|
||
"days_since": days,
|
||
"lambda": lam,
|
||
"reason": f"λ={lam} × {days:.1f}d drops conf {old_conf:.3f}→{new_conf:.3f}",
|
||
})
|
||
elif old_status == "candidate" and new_status == "active":
|
||
promotion_candidates.append({
|
||
"urn": urn,
|
||
"kind": kind,
|
||
"old_confidence": old_conf,
|
||
"new_confidence": new_conf,
|
||
"days_since": days,
|
||
"lambda": lam,
|
||
})
|
||
|
||
report = {
|
||
"total": len(records),
|
||
"before": before_status,
|
||
"after": after_status,
|
||
"changes": changes,
|
||
"archive_candidates": archive_candidates,
|
||
"promotion_candidates": promotion_candidates,
|
||
"skipped_tombstoned": skipped_tombstoned,
|
||
"edge_cases": edge_cases,
|
||
}
|
||
return records, report
|
||
|
||
|
||
# ---- I/O ------------------------------------------------------------------
|
||
|
||
def read_records(path: Path) -> list[dict]:
|
||
"""Read records from memory.ump.json (JSON array). Robust to mid-write."""
|
||
try:
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
except json.JSONDecodeError as e:
|
||
raise ValueError(f"{path}: invalid JSON: {e}") from e
|
||
if not isinstance(data, list):
|
||
raise ValueError(f"{path}: top-level JSON is not an array (got {type(data).__name__})")
|
||
return data
|
||
|
||
|
||
def write_records(path: Path, records: list[dict]) -> None:
|
||
"""Atomic write: tmp file in same dir, fsync, rename. .tmp removed by os.replace."""
|
||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||
try:
|
||
with open(tmp, "w", encoding="utf-8") as f:
|
||
json.dump(records, f, ensure_ascii=False, indent=2)
|
||
f.flush()
|
||
os.fsync(f.fileno())
|
||
os.replace(tmp, path)
|
||
except Exception:
|
||
# Clean up the half-written tmp on failure.
|
||
if tmp.exists():
|
||
try:
|
||
tmp.unlink()
|
||
except OSError:
|
||
pass
|
||
raise
|
||
|
||
|
||
def backup_file(path: Path) -> Path:
|
||
"""Copy file to a timestamped backup alongside the original."""
|
||
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%SZ")
|
||
backup = path.with_suffix(path.suffix + f".bak.{ts}")
|
||
shutil.copy2(path, backup)
|
||
return backup
|
||
|
||
|
||
# ---- Reporting ------------------------------------------------------------
|
||
|
||
def bucket_for(c: float) -> str:
|
||
if c < 0.20:
|
||
return "[0.00-0.20)"
|
||
if c < 0.40:
|
||
return "[0.20-0.40)"
|
||
if c < 0.60:
|
||
return "[0.40-0.60)"
|
||
if c < 0.80:
|
||
return "[0.60-0.80)"
|
||
return "[0.80-1.00]"
|
||
|
||
|
||
def print_report(report: dict, *, archive_summary: bool = False) -> None:
|
||
total = report["total"]
|
||
before = report["before"]
|
||
after = report["after"]
|
||
changes = report["changes"]
|
||
|
||
print("\n=== Decay Report ===")
|
||
print(f"Total records: {total}")
|
||
print(f"Skipped tombstone: {report['skipped_tombstoned']}")
|
||
|
||
print("\nStatus distribution (before -> after):")
|
||
statuses = ["active", "candidate", "archived", "tombstoned", "unknown"]
|
||
for s in statuses:
|
||
b = before.get(s, 0)
|
||
a = after.get(s, 0)
|
||
delta = a - b
|
||
sign = "+" if delta > 0 else ""
|
||
print(f" {s:11s}: {b:4d} -> {a:4d} ({sign}{delta:+d})")
|
||
|
||
# New-confidence histogram (only for records that had decay applied).
|
||
decayed = [c for c in changes if c["skipped_reason"] is None]
|
||
buckets = Counter(bucket_for(c["new_confidence"]) for c in decayed)
|
||
print("\nNew-confidence histogram (decayed records only):")
|
||
for b in ["[0.00-0.20)", "[0.20-0.40)", "[0.40-0.60)", "[0.60-0.80)", "[0.80-1.00]"]:
|
||
print(f" {b}: {buckets.get(b, 0)}")
|
||
|
||
# Top 10 archive candidates.
|
||
archives = sorted(
|
||
report["archive_candidates"],
|
||
key=lambda c: (c["old_confidence"] - c["new_confidence"]),
|
||
reverse=True,
|
||
)
|
||
print(f"\nTop 10 active -> archived ({len(archives)} total):")
|
||
for c in archives[:10]:
|
||
print(f" {c['urn'][:60]:60s} kind={c['kind']:9s} "
|
||
f"conf {c['old_confidence']:.3f}->{c['new_confidence']:.3f} "
|
||
f"days={c['days_since']:.0f}")
|
||
|
||
# Top 10 promotion candidates.
|
||
promotions = sorted(
|
||
report["promotion_candidates"],
|
||
key=lambda c: c["new_confidence"],
|
||
reverse=True,
|
||
)
|
||
print(f"\nTop 10 candidate -> active ({len(promotions)} total):")
|
||
for c in promotions[:10]:
|
||
print(f" {c['urn'][:60]:60s} kind={c['kind']:9s} "
|
||
f"conf {c['old_confidence']:.3f}->{c['new_confidence']:.3f} "
|
||
f"days={c['days_since']:.0f}")
|
||
|
||
if archive_summary:
|
||
print("\n=== Archive Candidates (with reason) ===")
|
||
for c in archives[:10]:
|
||
print(f" {c['urn']}")
|
||
print(f" {c['reason']}")
|
||
|
||
# Edge cases.
|
||
if report["edge_cases"]:
|
||
print(f"\nEdge cases ({len(report['edge_cases'])}):")
|
||
for e in report["edge_cases"][:20]:
|
||
print(f" - {e}")
|
||
if len(report["edge_cases"]) > 20:
|
||
print(f" ... ({len(report['edge_cases']) - 20} more)")
|
||
|
||
|
||
# ---- Main -----------------------------------------------------------------
|
||
|
||
def main(argv: list[str] | None = None) -> int:
|
||
p = argparse.ArgumentParser(description="Apply UMP memory confidence decay.")
|
||
g = p.add_mutually_exclusive_group(required=True)
|
||
g.add_argument("--dry-run", action="store_true",
|
||
help="Show what would change, don't write.")
|
||
g.add_argument("--apply", action="store_true",
|
||
help="Actually modify memory.ump.json (with backup).")
|
||
p.add_argument("--path", type=Path, default=DEFAULT_PATH,
|
||
help="Path to memory.ump.json.")
|
||
p.add_argument("--archive-summary", action="store_true",
|
||
help="Print archive candidates with reasons.")
|
||
args = p.parse_args(argv)
|
||
|
||
if not args.path.exists():
|
||
print(f"ERROR: memory file not found: {args.path}", file=sys.stderr)
|
||
return 1
|
||
|
||
print(f"Reading: {args.path}")
|
||
try:
|
||
records = read_records(args.path)
|
||
except ValueError as e:
|
||
print(f"ERROR parsing memory file: {e}", file=sys.stderr)
|
||
return 1
|
||
print(f"Loaded {len(records)} records.")
|
||
|
||
# Parse errors: count records that aren't dicts before passing through.
|
||
parse_errors = sum(1 for r in records if not isinstance(r, dict))
|
||
if parse_errors:
|
||
print(f"WARN: {parse_errors} non-dict records will be skipped.", file=sys.stderr)
|
||
|
||
new_records, report = apply_decay(records, dry_run=True)
|
||
print_report(report, archive_summary=args.archive_summary)
|
||
|
||
if args.apply:
|
||
backup = backup_file(args.path)
|
||
print(f"\nBackup created: {backup}")
|
||
write_records(args.path, new_records)
|
||
print(f"Wrote {len(new_records)} records to {args.path}")
|
||
else:
|
||
print("\n(dry-run: no changes written)")
|
||
|
||
if parse_errors:
|
||
print(f"\nERROR: {parse_errors} records failed to parse.", file=sys.stderr)
|
||
return 1
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main()) |