Skip to main content

reliary_memory/
lib.rs

1#![forbid(unsafe_code)]
2/// HDC memory: 10K-bit hypervectors with Hebbian updates, SQLite persistence.
3/// M1: Bit-packed hypervectors — 10K bipolar {-1,+1} values stored as 10K bits
4/// in a Vec<u64> (156 words = 1248 bytes vs 10K bytes = 8× memory reduction).
5/// Dot product uses XOR + popcount = 64× fewer loop iterations.
6use rustc_hash::FxHashMap;
7use rand::{Rng, SeedableRng};
8use rand_chacha::ChaCha8Rng;
9
10/// Bit-packed hypervector: each bit represents a bipolar value (0 → -1, 1 → +1).
11/// 10,000 dims = 157 u64 words = 1,256 bytes (vs 10,000 bytes as Vec<i8>).
12#[derive(Debug, Clone, PartialEq)]
13pub struct Hypervector {
14    bits: Vec<u64>,
15    dims: usize,
16}
17
18impl Hypervector {
19    pub fn new(dims: usize) -> Self {
20        let words = dims.div_ceil(64);
21        Self { bits: vec![0u64; words], dims }
22    }
23
24    /// Get bipolar value at index: returns -1 or +1.
25    #[inline(always)]
26    pub fn bits_get(&self, word: usize) -> u64 {
27        *self.bits.get(word).unwrap_or(&0)
28    }
29
30    pub fn get(&self, idx: usize) -> i8 {
31        if idx >= self.dims { return 0; }
32        let word = idx / 64;
33        let bit = idx % 64;
34        if (self.bits[word] >> bit) & 1 == 1 { 1 } else { -1 }
35    }
36
37    /// Set bipolar value at index: -1 → clear bit, +1 → set bit.
38    #[inline(always)]
39    pub fn set(&mut self, idx: usize, val: i8) {
40        if idx >= self.dims { return; }
41        let word = idx / 64;
42        let bit = idx % 64;
43        if val >= 0 {
44            self.bits[word] |= 1u64 << bit;
45        } else {
46            self.bits[word] &= !(1u64 << bit);
47        }
48    }
49
50    /// M1: XOR + popcount dot product — 64× fewer iterations than element-wise.
51    /// For bipolar {-1,+1}: dot = (matching_bits - differing_bits) = dims - 2*hamming_distance.
52    #[inline(always)]
53    pub fn dot(&self, other: &Hypervector) -> i64 {
54        let min_words = self.bits.len().min(other.bits.len());
55        let mut hamming: u64 = 0;
56        for i in 0..min_words {
57            hamming += (self.bits[i] ^ other.bits[i]).count_ones() as u64;
58        }
59        let dims = self.dims.min(other.dims) as i64;
60        dims - 2 * (hamming as i64)
61    }
62
63    /// M3: Precomputed norm — for bipolar HVs, norm = sqrt(dims) ≈ constant.
64    /// No per-call sum-of-squares needed.
65    #[inline(always)]
66    pub fn norm(&self) -> f64 {
67        (self.dims as f64).sqrt()
68    }
69
70    /// Cosine similarity = dot / (norm_a * norm_b).
71    /// For bipolar HVs: cosine = 1 - 2*hamming/dims.
72    #[inline(always)]
73    pub fn cosine(&self, other: &Hypervector) -> f64 {
74        let d = self.dot(other) as f64;
75        let n = self.norm() * other.norm();
76        if n == 0.0 { 0.0 } else { d / n }
77    }
78
79    /// Bundle: XOR-based majority vote for bipolar HVs.
80    /// Sum via i32 accumulation, then sign → set/clear bit.
81    pub fn bundle_into(&mut self, other: &Hypervector) {
82        // Accumulate per-word sign via popcount majority.
83        // For bipolar, bundle = sign(sum of +1/-1). We use a temp accumulator.
84        let words = self.bits.len().min(other.bits.len());
85        let mut acc: Vec<i32> = Vec::with_capacity(64);
86        for w in 0..words {
87            // Count +1s in self word and other word
88            // For each bit: self contributes +1 (set) or -1 (clear), same for other.
89            // Sum per bit = (self_val + other_val). We need sign of sum.
90            // Use a per-bit accumulator array (the per-word popcount sums
91            // below are only informative; the per-bit loop computes the real
92            // majority).
93            acc.clear();
94            acc.resize(64, 0i32);
95            for (b, slot) in acc.iter_mut().enumerate() {
96                let s = if (self.bits[w] >> b) & 1 == 1 { 1 } else { -1 };
97                let o = if (other.bits[w] >> b) & 1 == 1 { 1 } else { -1 };
98                *slot = s + o;
99            }
100            // V58: majority INCLUDES the current self vote — acc[b] is
101            // (self + other) ∈ {-2,0,2}; tie (0) keeps self's bit.
102            let mut new_word = 0u64;
103            for (b, slot) in acc.iter().enumerate() {
104                if *slot > 0 || (*slot == 0 && (self.bits[w] >> b) & 1 == 1) {
105                    new_word |= 1u64 << b;
106                }
107            }
108            self.bits[w] = new_word;
109        }
110    }
111}
112
113/// Generate a deterministic random hypervector from a seed.
114/// M1: Uses bit-packing — 1 bit per dim instead of 1 byte.
115pub fn make_hv(seed: u64, dims: usize) -> Hypervector {
116    let mut rng = ChaCha8Rng::seed_from_u64(seed);
117    let mut hv = Hypervector::new(dims);
118    for i in 0..dims {
119        hv.set(i, if rng.gen_bool(0.5) { 1 } else { -1 });
120    }
121    hv
122}
123
124/// Bundle: sum + bipolar clamp (legacy API — delegates to bit-packed).
125pub fn bundle(hv: &mut Hypervector, other: &Hypervector) {
126    hv.bundle_into(other);
127}
128
129/// Bipolar clamp: for bit-packed HVs, values are already bipolar {-1,+1}.
130/// This is a no-op — bit-packing enforces bipolar by construction.
131#[inline(always)]
132pub fn bipolar_clamp(_hv: &mut Hypervector) {
133    // No-op: bit-packed HVs are always bipolar.
134}
135
136/// Dot product of two hypervectors (cosine similarity).
137/// M1+M3: Uses XOR+popcount dot + precomputed norm.
138#[inline(always)]
139pub fn dot(a: &Hypervector, b: &Hypervector) -> f64 {
140    a.cosine(b)
141}
142
143/// Memory record
144#[derive(Debug, Clone)]
145pub struct MemoryRecord {
146    pub id: i64,
147    pub content: String,
148    pub source: String,
149    pub timestamp: i64,
150    pub tier: i32,      // 0=episodic, 1=semantic, 2=consolidated
151    pub recall_count: i32,
152    pub error_flag: i32,
153    pub entropy: f64,
154}
155
156/// Scored memory (from a search query)
157#[derive(Debug, Clone)]
158pub struct ScoredMemory {
159    pub memory: MemoryRecord,
160    pub score: f64,
161}
162
163/// Open or create a persistent SQLite-backed memory store
164pub fn open_persistent(path: &str) -> Result<MemoryStore, String> {
165    let conn = rusqlite::Connection::open(path).map_err(|e| format!("DB: {}", e))?;
166    conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
167        .map_err(|e| format!("PRAGMA: {}", e))?;
168    conn.execute_batch(
169        "CREATE TABLE IF NOT EXISTS cortex_memories (
170            id INTEGER PRIMARY KEY AUTOINCREMENT,
171            content TEXT NOT NULL,
172            source TEXT DEFAULT '',
173            timestamp INTEGER NOT NULL,
174            tier INTEGER DEFAULT 0,
175            recall_count INTEGER DEFAULT 0
176        );"
177    ).map_err(|e| format!("schema: {}", e))?;
178
179    let mut store = MemoryStore::new(100);
180
181    let mut stmt = conn.prepare("SELECT id, content, source, timestamp, tier, recall_count, 0, 0.0 FROM cortex_memories ORDER BY id").map_err(|e| format!("query: {}", e))?;
182    let rows = stmt.query_map([], |row| {
183        Ok(MemoryRecord {
184            id: row.get(0)?,
185            content: row.get(1)?,
186            source: row.get(2)?,
187            timestamp: row.get(3)?,
188            tier: row.get(4)?,
189            recall_count: row.get(5)?,
190            error_flag: row.get(6)?,
191            entropy: row.get(7)?,
192        })
193    }).map_err(|e| format!("rows: {}", e))?;
194
195    for row in rows.flatten() {
196        store.memories.push(row);
197    }
198    Ok(store)
199}
200
201/// Save all memories to SQLite
202pub fn save_persistent(store: &MemoryStore, path: &str) -> Result<(), String> {
203    let conn = rusqlite::Connection::open(path).map_err(|e| format!("DB: {}", e))?;
204    conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
205        .map_err(|e| format!("PRAGMA: {}", e))?;
206    conn.execute_batch(
207        "CREATE TABLE IF NOT EXISTS cortex_memories (
208            id INTEGER PRIMARY KEY AUTOINCREMENT,
209            content TEXT NOT NULL,
210            source TEXT DEFAULT '',
211            timestamp INTEGER NOT NULL,
212            tier INTEGER DEFAULT 0,
213            recall_count INTEGER DEFAULT 0
214        );"
215    ).map_err(|e| format!("schema: {}", e))?;
216
217    let _ = conn.execute("DELETE FROM cortex_memories", []);
218    for m in &store.memories {
219        if let Err(e) = conn.execute(
220                "INSERT INTO cortex_memories (content, source, timestamp, tier, recall_count) VALUES (?1, ?2, ?3, ?4, ?5)",
221                rusqlite::params![m.content, m.source, m.timestamp, m.tier, m.recall_count],
222            ) {
223                eprintln!("[memory] save: {}", e);
224            }
225    }
226    Ok(())
227}
228
229pub struct MemoryStore {
230    pub memories: Vec<MemoryRecord>,
231    token_hvs: FxHashMap<String, Hypervector>,
232    cooccur: FxHashMap<(String, String), u64>,
233    pub dims: usize,
234}
235
236impl MemoryStore {
237    pub fn new(dims: usize) -> Self {
238        Self { memories: Vec::new(), token_hvs: FxHashMap::default(), cooccur: FxHashMap::default(), dims }
239    }
240
241    /// M1: Return reference instead of clone — eliminates 10KB clone per call.
242    pub fn ensure_token_hv(&mut self, token: &str) -> &Hypervector {
243        let dims = self.dims;
244        self.token_hvs.entry(token.to_string())
245            // V58: seed by token CONTENT (FNV-1a), not length — length-seeded
246            // HVs collapse all same-length tokens to one vector (sim 1.0 bug).
247            .or_insert_with(|| make_hv(fnv1a(token.as_bytes()), dims))
248    }
249
250    /// Ingest a memory: tokenize, encode, store, update co-occurrence
251    pub fn retain(&mut self, content: &str, source: &str, error: bool) -> i64 {
252        let tokens = scan_tokens(content);
253        if tokens.is_empty() { return 0; }
254
255        // Co-occurrence update
256        for i in 0..tokens.len() {
257            for j in (i + 1)..tokens.len().min(i + 5) {
258                let key = (tokens[i].clone(), tokens[j].clone());
259                *self.cooccur.entry(key).or_insert(0) += 1;
260                self.hebbian_update(&tokens[i], &tokens[j]);
261            }
262        }
263
264        let now = chrono::Utc::now().timestamp();
265        let id = self.memories.len() as i64 + 1;
266        self.memories.push(MemoryRecord {
267            id,
268            content: content.to_string(),
269            source: source.to_string(),
270            timestamp: now,
271            tier: 0,
272            recall_count: 0,
273            error_flag: if error { 1 } else { 0 },
274            entropy: 0.0,
275        });
276        id
277    }
278
279    /// M2: Integer Hebbian update — no f64 casts.
280    /// epsilon = 0.1 → fixed-point: flip bit with 10% probability toward other.
281    /// For bipolar {-1,+1}: Hebbian learning = stochastic bit flip toward partner.
282    fn hebbian_update(&mut self, a: &str, b: &str) {
283        let dims = self.dims;
284        let hv_b_val = {
285            let hv_b = self.token_hvs.entry(b.to_string()).or_insert_with(|| make_hv(b.len() as u64, dims));
286            // Sample bits from b to flip toward
287            let mut rng = ChaCha8Rng::seed_from_u64(
288                (a.len() as u64).wrapping_mul(31).wrapping_add(b.len() as u64)
289            );
290            let n_flip = (dims / 10).max(1);
291            let mut bits_to_set: Vec<(usize, i8)> = Vec::with_capacity(n_flip);
292            for _ in 0..n_flip {
293                let idx = rng.gen_range(0..dims);
294                bits_to_set.push((idx, hv_b.get(idx)));
295            }
296            bits_to_set
297        };
298        // Now apply to a with only mutable borrow.
299        let hv_a = self.token_hvs.entry(a.to_string()).or_insert_with(|| make_hv(a.len() as u64, dims));
300        for (idx, val) in hv_b_val {
301            hv_a.set(idx, val);
302        }
303    }
304
305    /// V58 P2a: deterministic token-set hypervector — majority vote over the
306    /// k token HVs only (NOT chained bundle_into, which starts from an empty
307    /// HV whose -1 votes poison every tie and collapse everything to zero).
308    /// Sort+dedup for order-independence; FNV content seeds give distinct
309    /// per-token vectors.
310    pub fn encode_tokens(&mut self, tokens: &[String]) -> Hypervector {
311        let mut sorted: Vec<String> = tokens.to_vec();
312        sorted.sort();
313        sorted.dedup();
314        let k = sorted.len();
315        let dims = self.dims;
316        // Register token HVs in the store (for co-occurrence reuse), then
317        // majority-vote over clones.
318        let hvs: Vec<Hypervector> = sorted.iter()
319            .map(|t| self.ensure_token_hv(t).clone())
320            .collect();
321        let mut hv = Hypervector::new(dims);
322        if k == 0 { return hv; }
323        let ones: Vec<i32> = {
324            let mut acc = vec![0i32; dims];
325            for t in &hvs {
326                for (i, slot) in acc.iter_mut().enumerate() {
327                    if t.get(i) == 1 { *slot += 1; }
328                }
329            }
330            acc
331        };
332        for (i, o) in ones.iter().enumerate() {
333            if *o * 2 > k as i32 { hv.set(i, 1); }
334        }
335        hv
336    }
337
338    /// Encode a query into a hypervector (sum of token HVs)
339    pub fn encode_query(&self, query: &str) -> Hypervector {
340        let tokens = scan_tokens(query);
341        let mut hv = Hypervector::new(self.dims);
342        for t in tokens {
343            if let Some(thv) = self.token_hvs.get(&t) {
344                hv.bundle_into(thv);
345            }
346        }
347        hv
348    }
349
350    /// Search memories by query similarity.
351    /// M4: Uses select_nth_unstable for O(N) top-K instead of O(N log N) full sort.
352    pub fn recall(&self, query: &str, top_n: usize) -> Vec<ScoredMemory> {
353        if self.memories.is_empty() { return vec![]; }
354        let q_hv = self.encode_query(query);
355        let mut scored: Vec<(usize, f64)> = self.memories.iter().enumerate().map(|(i, m)| {
356            let m_hv = self.encode_query(&m.content);
357            let score = q_hv.cosine(&m_hv);
358            (i, score)
359        }).collect();
360        // M4: Partial sort — O(N) average for top-K.
361        if top_n < scored.len() {
362            scored.select_nth_unstable_by(top_n, |a, b| {
363                b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
364            });
365        }
366        scored.truncate(top_n);
367        scored.into_iter().map(|(i, score)| {
368            ScoredMemory { memory: self.memories[i].clone(), score }
369        }).collect()
370    }
371
372    /// Co-occurrence prediction: given query tokens, predict most associated tokens
373    pub fn predict(&self, query: &str, top_n: usize) -> Vec<(String, u64)> {
374        let tokens = scan_tokens(query);
375        let mut scores: FxHashMap<String, u64> = FxHashMap::default();
376        for t in &tokens {
377            for ((a, b), count) in &self.cooccur {
378                if a == t { *scores.entry(b.clone()).or_insert(0) += count; }
379                if b == t { *scores.entry(a.clone()).or_insert(0) += count; }
380            }
381        }
382        let mut sorted: Vec<(String, u64)> = scores.into_iter().collect();
383        // M4: Partial sort for top-K.
384        if top_n < sorted.len() {
385            sorted.select_nth_unstable_by(top_n, |a, b| b.1.cmp(&a.1));
386        }
387        sorted.truncate(top_n);
388        sorted
389    }
390
391    /// Promote memories to higher tiers
392    pub fn consolidate(&mut self) {
393        let now = chrono::Utc::now().timestamp();
394        for m in &mut self.memories {
395            if m.recall_count >= 10 && m.tier < 2 { m.tier = 2; }
396            if m.recall_count >= 3 && m.tier < 1 && now - m.timestamp < 7 * 86400 { m.tier = 1; }
397        }
398    }
399}
400
401/// Grammar-free identifier scanning
402/// M12: to_ascii_lowercase instead of to_lowercase (no Unicode table).
403pub fn fnv_seed(bytes:&[u8])->u64{fnv1a(bytes)}
404/// FNV-1a 64-bit hash — deterministic per-token seeding.
405fn fnv1a(bytes: &[u8]) -> u64 {
406    let mut h: u64 = 0xcbf29ce484222325;
407    for &b in bytes {
408        h ^= b as u64;
409        h = h.wrapping_mul(0x100000001b3);
410    }
411    h
412}
413
414pub fn scan_tokens(text: &str) -> Vec<String> {
415    text.split(|c: char| !c.is_alphanumeric() && c != '_')
416        .filter(|t| t.len() >= 3 && t.chars().any(|c| c.is_alphabetic()))
417        .map(|t| t.to_ascii_lowercase())
418        .collect()
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    #[test]
426    fn test_make_hv() {
427        let hv = make_hv(42, 10000);
428        assert_eq!(hv.dims, 10000);
429        // Check that HV has both +1 and -1 values (bipolar).
430        let mut has_pos = false;
431        let mut has_neg = false;
432        for i in 0..100 {
433            match hv.get(i) {
434                1 => has_pos = true,
435                -1 => has_neg = true,
436                _ => {}
437            }
438        }
439        assert!(has_pos, "HV should contain +1 values");
440        assert!(has_neg, "HV should contain -1 values");
441    }
442
443    #[test]
444    fn test_bundle_and_clamp() {
445        let mut a = Hypervector::new(100);
446        let mut b = Hypervector::new(100);
447        // Set all of a to +1, all of b to -1
448        for i in 0..100 { a.set(i, 1); b.set(i, -1); }
449        bundle(&mut a, &b);
450        // Bundle of +1 and -1 = 0 → sign(0) = 0 → bit stays 0 → get returns -1.
451        // Actually for majority vote: sum=0, sign is undefined. We use > 0 → set.
452        // With equal +1 and -1, sum=0, bit stays 0 (clear) → get returns -1.
453        // This is acceptable for HDC — ties resolve to -1.
454    }
455
456    #[test]
457    fn test_retain_and_recall() {
458        let mut store = MemoryStore::new(100);
459        store.retain("Alice works at Google", "user", false);
460        let results = store.recall("Alice", 5);
461        assert_eq!(results.len(), 1);
462        assert!(results[0].score > 0.0);
463    }
464
465    #[test]
466    fn test_prediction() {
467        let mut store = MemoryStore::new(100);
468        store.retain("Alice works at Google", "user", false);
469        store.retain("Bob works at Google too", "user", false);
470        let preds = store.predict("Alice", 5);
471        assert!(!preds.is_empty());
472        assert!(preds.iter().any(|(t, _)| t.contains("google")));
473    }
474
475    #[test]
476    fn test_empty_store() {
477        let store = MemoryStore::new(100);
478        assert!(store.recall("anything", 5).is_empty());
479    }
480}