247 lines
8.9 KiB
Python
247 lines
8.9 KiB
Python
#!/usr/bin/env python3
|
||
"""drift_cache.py — Drift-aware cache with wash-count-based eviction.
|
||
|
||
The cache contains the most proven items — those that have survived washing.
|
||
Cache priority = wash_count × importance.
|
||
Items with high wash_count stay in cache longer.
|
||
Items with low wash_count get evicted sooner.
|
||
|
||
This is the "most proven layer" — what the agent reaches for first
|
||
because it's been tested and held.
|
||
|
||
Research basis:
|
||
- AMV-L (arXiv:2603.04443): value-driven eviction across lifecycle tiers
|
||
- SmartVector (arXiv:2604.20598): access reinforcement and confidence decay
|
||
|
||
Run under ~/.venvs/memory/bin/python3.
|
||
"""
|
||
|
||
import json
|
||
import math
|
||
import os
|
||
import sys
|
||
import time
|
||
from datetime import datetime, timezone
|
||
from typing import Dict, List, Optional, Tuple
|
||
|
||
SCRIPTS_DIR = os.path.expanduser("~/.config/opencode/scripts")
|
||
CACHE_DIR = os.path.expanduser("~/.config/opencode/memory/cache")
|
||
CACHE_FILE = os.path.join(CACHE_DIR, "drift_cache.json")
|
||
|
||
# Cache configuration
|
||
MAX_CACHE_SIZE = 200 # maximum entries in cache
|
||
EVICTION_BATCH = 20 # entries to evict when cache is full
|
||
MIN_WASH_FOR_CACHE = 1 # minimum wash_count to enter cache
|
||
CACHE_BOOST_ROCK = 2.0 # multiplier for rock-status entries
|
||
CACHE_BOOST_ESTABLISHED = 1.5 # multiplier for established entries
|
||
|
||
|
||
class DriftCache:
|
||
"""Drift-aware cache with wash-count-based priority."""
|
||
|
||
def __init__(self):
|
||
os.makedirs(CACHE_DIR, exist_ok=True)
|
||
self.entries: Dict[int, dict] = {}
|
||
self._load()
|
||
|
||
def _load(self):
|
||
"""Load cache from disk."""
|
||
if os.path.exists(CACHE_FILE):
|
||
try:
|
||
with open(CACHE_FILE) as f:
|
||
data = json.load(f)
|
||
self.entries = {int(k): v for k, v in data.items()}
|
||
except (json.JSONDecodeError, ValueError):
|
||
self.entries = {}
|
||
|
||
def _save(self):
|
||
"""Save cache to disk."""
|
||
with open(CACHE_FILE, "w") as f:
|
||
json.dump({str(k): v for k, v in self.entries.items()}, f, indent=2)
|
||
|
||
def _compute_priority(self, entry: dict) -> float:
|
||
"""Compute cache priority: wash_count × importance × status boost."""
|
||
wash = entry.get("wash_count", 0) or 0
|
||
imp = entry.get("importance", 0.5) or 0.5
|
||
status = entry.get("rock_status", "washable")
|
||
|
||
# Logarithmic wash scaling (diminishing returns)
|
||
wash_factor = min(1.0, math.log1p(wash) / math.log1p(20))
|
||
|
||
# Status boost
|
||
status_boost = 1.0
|
||
if status == "rock":
|
||
status_boost = CACHE_BOOST_ROCK
|
||
elif status == "established":
|
||
status_boost = CACHE_BOOST_ESTABLISHED
|
||
|
||
return wash_factor * imp * status_boost
|
||
|
||
def add(self, entry_id: int, entry: dict):
|
||
"""Add an entry to the cache.
|
||
|
||
Entries qualify for cache if:
|
||
- wash_count >= MIN_WASH_FOR_CACHE (proven through use), OR
|
||
- importance >= 0.8 (high importance, worth caching even if untested)
|
||
"""
|
||
wash = entry.get("wash_count", 0) or 0
|
||
imp = entry.get("importance", 0.5) or 0.5
|
||
if wash < MIN_WASH_FOR_CACHE and imp < 0.8:
|
||
return # Not proven enough and not important enough
|
||
|
||
priority = self._compute_priority(entry)
|
||
self.entries[entry_id] = {
|
||
"priority": priority,
|
||
"added_at": datetime.now(timezone.utc).isoformat(),
|
||
"last_accessed": datetime.now(timezone.utc).isoformat(),
|
||
"access_count": 0,
|
||
"wash_count": wash,
|
||
"rock_status": entry.get("rock_status", "washable"),
|
||
}
|
||
|
||
# Evict if over capacity
|
||
if len(self.entries) > MAX_CACHE_SIZE:
|
||
self._evict()
|
||
|
||
self._save()
|
||
|
||
def access(self, entry_id: int):
|
||
"""Record a cache access (reinforces the entry)."""
|
||
if entry_id in self.entries:
|
||
self.entries[entry_id]["last_accessed"] = datetime.now(timezone.utc).isoformat()
|
||
self.entries[entry_id]["access_count"] = \
|
||
self.entries[entry_id].get("access_count", 0) + 1
|
||
self._save()
|
||
|
||
def contains(self, entry_id: int) -> bool:
|
||
"""Check if an entry is in the cache."""
|
||
return entry_id in self.entries
|
||
|
||
def get_priority(self, entry_id: int) -> float:
|
||
"""Get the cache priority of an entry."""
|
||
if entry_id in self.entries:
|
||
return self.entries[entry_id].get("priority", 0.0)
|
||
return 0.0
|
||
|
||
def _evict(self):
|
||
"""Evict lowest-priority entries."""
|
||
# Sort by priority (ascending), evict lowest
|
||
sorted_entries = sorted(
|
||
self.entries.items(),
|
||
key=lambda x: x[1].get("priority", 0.0)
|
||
)
|
||
to_evict = sorted_entries[:EVICTION_BATCH]
|
||
for entry_id, _ in to_evict:
|
||
del self.entries[entry_id]
|
||
|
||
def update_from_store(self, db):
|
||
"""Sync cache with memory store — update priorities from current wash counts."""
|
||
for entry_id in list(self.entries.keys()):
|
||
row = db.execute(
|
||
"SELECT wash_count, importance, rock_status FROM entries WHERE id = ?",
|
||
(entry_id,)
|
||
).fetchone()
|
||
if row:
|
||
entry = {
|
||
"wash_count": row[0] or 0,
|
||
"importance": row[1] or 0.5,
|
||
"rock_status": row[2] or "washable",
|
||
}
|
||
self.entries[entry_id]["priority"] = self._compute_priority(entry)
|
||
self.entries[entry_id]["wash_count"] = entry["wash_count"]
|
||
self.entries[entry_id]["rock_status"] = entry["rock_status"]
|
||
else:
|
||
# Entry deleted from store — remove from cache
|
||
del self.entries[entry_id]
|
||
self._save()
|
||
|
||
def get_stats(self) -> dict:
|
||
"""Get cache statistics."""
|
||
if not self.entries:
|
||
return {"size": 0, "avg_priority": 0.0, "max_priority": 0.0, "min_priority": 0.0, "rock_count": 0, "established_count": 0, "washable_count": 0}
|
||
|
||
priorities = [e.get("priority", 0.0) for e in self.entries.values()]
|
||
rock_count = sum(1 for e in self.entries.values() if e.get("rock_status") == "rock")
|
||
est_count = sum(1 for e in self.entries.values() if e.get("rock_status") == "established")
|
||
|
||
return {
|
||
"size": len(self.entries),
|
||
"avg_priority": sum(priorities) / len(priorities),
|
||
"max_priority": max(priorities),
|
||
"min_priority": min(priorities),
|
||
"rock_count": rock_count,
|
||
"established_count": est_count,
|
||
"washable_count": len(self.entries) - rock_count - est_count,
|
||
}
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# CLI
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
def main():
|
||
import argparse
|
||
parser = argparse.ArgumentParser(description="Drift-aware cache")
|
||
sub = parser.add_subparsers(dest="cmd")
|
||
|
||
# status
|
||
sub.add_parser("status", help="Show cache statistics")
|
||
|
||
# sync
|
||
sub.add_parser("sync", help="Sync cache with memory store")
|
||
|
||
# add <entry_id>
|
||
p_add = sub.add_parser("add", help="Add entry to cache")
|
||
p_add.add_argument("entry_id", type=int)
|
||
|
||
args = parser.parse_args()
|
||
|
||
if not args.cmd:
|
||
parser.print_help()
|
||
sys.exit(1)
|
||
|
||
cache = DriftCache()
|
||
|
||
if args.cmd == "status":
|
||
stats = cache.get_stats()
|
||
print("=== Drift Cache Status ===")
|
||
print(f"Size: {stats['size']}/{MAX_CACHE_SIZE}")
|
||
print(f"Avg priority: {stats['avg_priority']:.4f}")
|
||
print(f"Rock: {stats['rock_count']}")
|
||
print(f"Established: {stats['established_count']}")
|
||
print(f"Washable: {stats['washable_count']}")
|
||
|
||
elif args.cmd == "sync":
|
||
sys.path.insert(0, SCRIPTS_DIR)
|
||
import memory_store
|
||
db = memory_store.connect()
|
||
cache.update_from_store(db)
|
||
db.close()
|
||
stats = cache.get_stats()
|
||
print(f"Synced. Cache size: {stats['size']}")
|
||
|
||
elif args.cmd == "add":
|
||
sys.path.insert(0, SCRIPTS_DIR)
|
||
import memory_store
|
||
db = memory_store.connect()
|
||
row = db.execute(
|
||
"SELECT id, text, importance, wash_count, rock_status FROM entries WHERE id = ?",
|
||
(args.entry_id,)
|
||
).fetchone()
|
||
if row:
|
||
entry = {
|
||
"text": row[1],
|
||
"importance": row[2] or 0.5,
|
||
"wash_count": row[3] or 0,
|
||
"rock_status": row[4] or "washable",
|
||
}
|
||
cache.add(args.entry_id, entry)
|
||
print(f"Added entry {args.entry_id} to cache")
|
||
else:
|
||
print(f"Entry {args.entry_id} not found")
|
||
db.close()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|