File size: 8,134 Bytes
a28ec65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
"""SQLite-backed cache for interpretation function results (spec v0.4 Β§4.7).

Per-firing LLM calls are the dominant cost of the `--explain` pipeline (one
HTTP call per firing Γ— ~10s on bundled Qwen Γ— dozens of firings on a
real package). Caching the (deterministic) inputs β†’ outputs makes repeat
invocations near-free; spec target is <100ms cache hit.

Cache key (hashed to a fixed-length hex digest):
    sha256(prompt + "\\0" + backend + "\\0" + model + "\\0" + interp_version)

The prompt already encodes the structured-output payload (the template
substitutes firing fields into it), so we don't need a separate hash of
the structured output. `backend` and `model` separate cached results
across providers (spec Β§4.7: "switching from Ollama to Anthropic produces
different cached results"). `interp_version` invalidates everything when
the envelope schema changes.

What's deliberately NOT in the key:
- API keys (spec Β§6.4 Rule 2 β€” never in cache)
- Timestamps (would force every run to miss)
- The raw structured_output (it's already inside the prompt; double-counting
  would make the key brittle)

Storage: simple SQLite table at `~/.uofa/cache/explain.db`. One row per
(key) β†’ (value JSON, created_at, accessed_at). No expiry policy yet β€” the
key includes interp_version, so a release bump invalidates everything
implicitly.
"""

from __future__ import annotations

import hashlib
import json
import sqlite3
import time
from contextlib import contextmanager
from pathlib import Path

# Bumped together with envelope.INTERPRETATION_VERSION when output shape
# changes. Independent constant here so we can invalidate the cache
# without changing the envelope's user-facing version field.
CACHE_SCHEMA_VERSION = 1


def default_db_path() -> Path:
    """Return `<XDG cache>/uofa/explain.db` (or `~/.uofa/cache/explain.db`)."""
    from uofa_cli import setup_state
    return setup_state.uofa_data_dir() / "cache" / "explain.db"


def compute_key(
    *,
    prompt: str,
    backend: str,
    model: str,
    interp_version: str,
) -> str:
    """Stable hex digest for the (prompt, backend, model, version) tuple.

    NUL byte separators prevent collisions of the form
    `("ab", "cd") == ("a", "bcd")`. SHA-256 truncated to 16 hex chars
    (64-bit prefix) β€” plenty of collision resistance for a single user's
    cache, and short enough to fit in tight SQLite indexes.
    """
    h = hashlib.sha256()
    for part in (prompt, backend, model, interp_version):
        h.update(part.encode("utf-8"))
        h.update(b"\x00")
    return h.hexdigest()[:32]


class ExplanationCache:
    """Lightweight SQLite KV store for interpretation results.

    Used as a context manager so the connection is closed cleanly:

        with ExplanationCache() as cache:
            cached = cache.get(key)
            if cached is None:
                cached = expensive_call(...)
                cache.put(key, cached)
    """

    def __init__(self, db_path: Path | None = None):
        self.db_path = db_path or default_db_path()
        self._conn: sqlite3.Connection | None = None

    # ── Lifecycle ────────────────────────────────────────────

    def open(self) -> "ExplanationCache":
        if self._conn is not None:
            return self
        self.db_path.parent.mkdir(parents=True, exist_ok=True)
        self._conn = sqlite3.connect(str(self.db_path))
        self._conn.row_factory = sqlite3.Row
        self._init_schema()
        return self

    def close(self) -> None:
        if self._conn is not None:
            self._conn.close()
            self._conn = None

    def __enter__(self) -> "ExplanationCache":
        return self.open()

    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
        self.close()

    # ── Public KV API ────────────────────────────────────────

    def get(self, key: str) -> dict | None:
        """Return the cached value for `key`, or None on miss.

        Updates the row's `accessed_at` so consumers can prune by LRU later
        if needed. Returns None on any deserialization error rather than
        raising β€” a corrupt cache entry should never break the live call.
        """
        conn = self._require_open()
        row = conn.execute(
            "SELECT value FROM entries WHERE key = ?", (key,),
        ).fetchone()
        if row is None:
            return None
        try:
            value = json.loads(row["value"])
        except (json.JSONDecodeError, TypeError):
            # Corrupt entry β€” drop it and signal miss.
            conn.execute("DELETE FROM entries WHERE key = ?", (key,))
            conn.commit()
            return None
        conn.execute(
            "UPDATE entries SET accessed_at = ? WHERE key = ?",
            (time.time(), key),
        )
        conn.commit()
        return value

    def put(self, key: str, value: dict) -> None:
        conn = self._require_open()
        now = time.time()
        try:
            payload = json.dumps(value)
        except (TypeError, ValueError):
            # Caller's responsibility β€” values must be JSON-serializable.
            # We skip rather than raise so caching failures don't break
            # the live call path.
            return
        conn.execute(
            "INSERT OR REPLACE INTO entries (key, value, created_at, accessed_at) "
            "VALUES (?, ?, ?, ?)",
            (key, payload, now, now),
        )
        conn.commit()

    def clear(self) -> int:
        """Drop all entries; returns the number deleted."""
        conn = self._require_open()
        cur = conn.execute("DELETE FROM entries")
        conn.commit()
        return cur.rowcount

    def stats(self) -> dict:
        conn = self._require_open()
        row = conn.execute(
            "SELECT COUNT(*) AS n, MIN(created_at) AS oldest, MAX(accessed_at) AS newest FROM entries"
        ).fetchone()
        return {
            "entries": row["n"] or 0,
            "oldest_seconds": (time.time() - row["oldest"]) if row["oldest"] else None,
            "newest_seconds": (time.time() - row["newest"]) if row["newest"] else None,
            "db_path": str(self.db_path),
            "db_size_bytes": self.db_path.stat().st_size if self.db_path.exists() else 0,
        }

    # ── Internals ────────────────────────────────────────────

    def _require_open(self) -> sqlite3.Connection:
        if self._conn is None:
            self.open()
        assert self._conn is not None
        return self._conn

    def _init_schema(self) -> None:
        conn = self._conn
        assert conn is not None
        conn.executescript(
            """
            CREATE TABLE IF NOT EXISTS schema_version (
                version INTEGER PRIMARY KEY
            );
            CREATE TABLE IF NOT EXISTS entries (
                key TEXT PRIMARY KEY,
                value TEXT NOT NULL,
                created_at REAL NOT NULL,
                accessed_at REAL NOT NULL
            );
            CREATE INDEX IF NOT EXISTS idx_entries_accessed ON entries (accessed_at);
            """
        )
        # Detect schema-version mismatch and rebuild if needed.
        row = conn.execute("SELECT version FROM schema_version").fetchone()
        current = row["version"] if row else None
        if current != CACHE_SCHEMA_VERSION:
            conn.execute("DELETE FROM entries")
            conn.execute("DELETE FROM schema_version")
            conn.execute("INSERT INTO schema_version (version) VALUES (?)", (CACHE_SCHEMA_VERSION,))
        conn.commit()


@contextmanager
def open_default_cache():
    """Convenience: `with open_default_cache() as c: ...` for the default
    db path. Functions wanting a custom path instantiate ExplanationCache
    directly."""
    cache = ExplanationCache()
    try:
        cache.open()
        yield cache
    finally:
        cache.close()