Skip to main content

kedge_cache/
lib.rs

1//! # kedge-cache
2//!
3//! A deterministic, content-addressed cache for **AST compaction** — never for
4//! LLM responses.
5//!
6//! Caching LLM output is a production hazard: cache invalidation is hard, and
7//! returning a stale/hallucinated answer because a prompt *looked* similar breaks
8//! things silently. Tree-sitter compaction, by contrast, is a pure function of the
9//! file's bytes: `sha256(file_contents)` is a perfect key. If a file hasn't
10//! changed, its skeleton hasn't either — return the cached result and skip the
11//! parser entirely.
12
13use std::path::Path;
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::sync::Mutex;
16use std::time::{SystemTime, UNIX_EPOCH};
17
18use kedge_compact::{CompactResult, Compactor};
19use rusqlite::{Connection, OptionalExtension};
20use sha2::{Digest, Sha256};
21use thiserror::Error;
22
23#[derive(Debug, Error)]
24pub enum CacheError {
25    #[error("sqlite error: {0}")]
26    Sqlite(#[from] rusqlite::Error),
27    #[error("compaction error: {0}")]
28    Compact(#[from] kedge_compact::CompactError),
29    #[error("cache lock poisoned")]
30    Poisoned,
31}
32
33pub type Result<T> = std::result::Result<T, CacheError>;
34
35const SCHEMA: &str = r#"
36CREATE TABLE IF NOT EXISTS ast_cache (
37    hash             TEXT PRIMARY KEY,   -- sha256 of the source bytes
38    lang             TEXT NOT NULL,
39    text             TEXT NOT NULL,      -- the compacted skeleton
40    original_tokens  INTEGER NOT NULL,
41    compacted_tokens INTEGER NOT NULL,
42    elided_bodies    INTEGER NOT NULL,
43    created_ms       INTEGER NOT NULL
44);
45"#;
46
47/// The sha256 content hash used as the cache key.
48pub fn content_hash(source: &str) -> String {
49    let mut h = Sha256::new();
50    h.update(source.as_bytes());
51    format!("{:x}", h.finalize())
52}
53
54fn now_ms() -> i64 {
55    SystemTime::now()
56        .duration_since(UNIX_EPOCH)
57        .map(|d| d.as_millis() as i64)
58        .unwrap_or(0)
59}
60
61/// A SQLite-backed cache of compaction results, keyed by content hash.
62pub struct AstCache {
63    conn: Mutex<Connection>,
64    hits: AtomicU64,
65    misses: AtomicU64,
66}
67
68impl AstCache {
69    /// Open (or create) a cache at `path`.
70    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
71        Self::init(Connection::open(path)?)
72    }
73
74    /// An ephemeral in-memory cache (tests, one-shot runs).
75    pub fn in_memory() -> Result<Self> {
76        Self::init(Connection::open_in_memory()?)
77    }
78
79    fn init(conn: Connection) -> Result<Self> {
80        conn.execute_batch(SCHEMA)?;
81        Ok(AstCache {
82            conn: Mutex::new(conn),
83            hits: AtomicU64::new(0),
84            misses: AtomicU64::new(0),
85        })
86    }
87
88    /// Compact `source`, returning a cached result on a content-hash hit and only
89    /// invoking the Tree-sitter parser on a miss.
90    pub fn outline_cached(&self, compactor: &mut Compactor, source: &str) -> Result<CompactResult> {
91        let hash = content_hash(source);
92        if let Some(hit) = self.load(&hash)? {
93            self.hits.fetch_add(1, Ordering::SeqCst);
94            return Ok(hit);
95        }
96        self.misses.fetch_add(1, Ordering::SeqCst);
97        let result = compactor.outline(source)?;
98        self.store(&hash, compactor.language().name(), &result)?;
99        Ok(result)
100    }
101
102    /// Cache hits observed so far.
103    pub fn hits(&self) -> u64 {
104        self.hits.load(Ordering::SeqCst)
105    }
106
107    /// Cache misses (i.e. parses actually run) so far.
108    pub fn misses(&self) -> u64 {
109        self.misses.load(Ordering::SeqCst)
110    }
111
112    fn load(&self, hash: &str) -> Result<Option<CompactResult>> {
113        let conn = self.conn.lock().map_err(|_| CacheError::Poisoned)?;
114        let row = conn
115            .query_row(
116                "SELECT text, original_tokens, compacted_tokens, elided_bodies
117                 FROM ast_cache WHERE hash = ?1",
118                [hash],
119                |r| {
120                    Ok(CompactResult {
121                        text: r.get(0)?,
122                        original_tokens: r.get::<_, i64>(1)? as usize,
123                        compacted_tokens: r.get::<_, i64>(2)? as usize,
124                        elided_bodies: r.get::<_, i64>(3)? as usize,
125                    })
126                },
127            )
128            .optional()?;
129        Ok(row)
130    }
131
132    fn store(&self, hash: &str, lang: &str, r: &CompactResult) -> Result<()> {
133        let conn = self.conn.lock().map_err(|_| CacheError::Poisoned)?;
134        conn.execute(
135            "INSERT OR REPLACE INTO ast_cache
136             (hash, lang, text, original_tokens, compacted_tokens, elided_bodies, created_ms)
137             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
138            rusqlite::params![
139                hash,
140                lang,
141                r.text,
142                r.original_tokens as i64,
143                r.compacted_tokens as i64,
144                r.elided_bodies as i64,
145                now_ms(),
146            ],
147        )?;
148        Ok(())
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    const SRC: &str = r#"
157        /// A greeter.
158        pub fn hello(name: &str) -> String {
159            let mut s = String::new();
160            s.push_str("hi ");
161            s.push_str(name);
162            s
163        }
164    "#;
165
166    #[test]
167    fn hash_is_stable_and_content_sensitive() {
168        assert_eq!(content_hash("abc"), content_hash("abc"));
169        assert_ne!(content_hash("abc"), content_hash("abd"));
170    }
171
172    #[test]
173    fn miss_then_hit_skips_the_parser() {
174        let cache = AstCache::in_memory().unwrap();
175        let mut c = Compactor::rust().unwrap();
176
177        // First call: miss → parses and stores.
178        let first = cache.outline_cached(&mut c, SRC).unwrap();
179        assert_eq!(cache.misses(), 1);
180        assert_eq!(cache.hits(), 0);
181
182        // Second call, identical source: hit → no parse, same skeleton.
183        let second = cache.outline_cached(&mut c, SRC).unwrap();
184        assert_eq!(cache.hits(), 1);
185        assert_eq!(cache.misses(), 1);
186        assert_eq!(first.text, second.text);
187        assert_eq!(first.compacted_tokens, second.compacted_tokens);
188
189        // A changed file: miss again.
190        let changed = format!("{SRC}\n// touched");
191        cache.outline_cached(&mut c, &changed).unwrap();
192        assert_eq!(cache.misses(), 2);
193    }
194
195    #[test]
196    fn cache_survives_reopen_on_disk() {
197        let dir = tempfile::tempdir().unwrap();
198        let path = dir.path().join("ast.sqlite");
199        {
200            let cache = AstCache::open(&path).unwrap();
201            let mut c = Compactor::rust().unwrap();
202            cache.outline_cached(&mut c, SRC).unwrap();
203            assert_eq!(cache.misses(), 1);
204        }
205        // Reopen: the earlier result is a hit, no parse needed.
206        let cache = AstCache::open(&path).unwrap();
207        let mut c = Compactor::rust().unwrap();
208        let r = cache.outline_cached(&mut c, SRC).unwrap();
209        assert_eq!(cache.hits(), 1);
210        assert_eq!(cache.misses(), 0);
211        assert!(r.text.contains("hello"));
212    }
213}