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.
339    ///
340    /// V80: this used `bundle_into` from an all-`-1` HV. `bundle_into` keeps the
341    /// accumulator's bit on a tie, and every tie starts as `-1`, so the result
342    /// collapsed toward all-`-1` (or, once two tokens tie, stayed put) — every
343    /// query scored a near-uniform 1.0 against every memory. Encode by the same
344    /// deterministic majority vote as `encode_tokens`: for each dim set +1 iff
345    /// more than half the *recognised* token HVs vote +1. Unknown tokens are
346    /// ignored (the store has no vector for them).
347    pub fn encode_query(&self, query: &str) -> Hypervector {
348        let tokens = scan_tokens(query);
349        let hvs: Vec<&Hypervector> = tokens.iter()
350            .filter_map(|t| self.token_hvs.get(t))
351            .collect();
352        let mut hv = Hypervector::new(self.dims);
353        let k = hvs.len();
354        if k == 0 { return hv; }
355        for i in 0..self.dims {
356            let ones = hvs.iter().filter(|h| h.get(i) == 1).count();
357            if ones * 2 > k { hv.set(i, 1); } else { hv.set(i, -1); }
358        }
359        hv
360    }
361
362    /// Search memories by query similarity.
363    /// M4: Uses select_nth_unstable for O(N) top-K instead of O(N log N) full sort.
364    pub fn recall(&self, query: &str, top_n: usize) -> Vec<ScoredMemory> {
365        if self.memories.is_empty() { return vec![]; }
366        let q_hv = self.encode_query(query);
367        let mut scored: Vec<(usize, f64)> = self.memories.iter().enumerate().map(|(i, m)| {
368            let m_hv = self.encode_query(&m.content);
369            let score = q_hv.cosine(&m_hv);
370            (i, score)
371        }).collect();
372        // M4: Partial sort — O(N) average for top-K.
373        if top_n < scored.len() {
374            scored.select_nth_unstable_by(top_n, |a, b| {
375                b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
376            });
377        }
378        scored.truncate(top_n);
379        scored.into_iter().map(|(i, score)| {
380            ScoredMemory { memory: self.memories[i].clone(), score }
381        }).collect()
382    }
383
384    /// Co-occurrence prediction: given query tokens, predict most associated tokens
385    pub fn predict(&self, query: &str, top_n: usize) -> Vec<(String, u64)> {
386        let tokens = scan_tokens(query);
387        let mut scores: FxHashMap<String, u64> = FxHashMap::default();
388        for t in &tokens {
389            for ((a, b), count) in &self.cooccur {
390                if a == t { *scores.entry(b.clone()).or_insert(0) += count; }
391                if b == t { *scores.entry(a.clone()).or_insert(0) += count; }
392            }
393        }
394        let mut sorted: Vec<(String, u64)> = scores.into_iter().collect();
395        // M4: Partial sort for top-K.
396        if top_n < sorted.len() {
397            sorted.select_nth_unstable_by(top_n, |a, b| b.1.cmp(&a.1));
398        }
399        sorted.truncate(top_n);
400        sorted
401    }
402
403    /// Promote memories to higher tiers
404    pub fn consolidate(&mut self) {
405        let now = chrono::Utc::now().timestamp();
406        for m in &mut self.memories {
407            if m.recall_count >= 10 && m.tier < 2 { m.tier = 2; }
408            if m.recall_count >= 3 && m.tier < 1 && now - m.timestamp < 7 * 86400 { m.tier = 1; }
409        }
410    }
411}
412
413/// Grammar-free identifier scanning
414/// M12: to_ascii_lowercase instead of to_lowercase (no Unicode table).
415pub fn fnv_seed(bytes:&[u8])->u64{fnv1a(bytes)}
416/// FNV-1a 64-bit hash — deterministic per-token seeding.
417fn fnv1a(bytes: &[u8]) -> u64 {
418    let mut h: u64 = 0xcbf29ce484222325;
419    for &b in bytes {
420        h ^= b as u64;
421        h = h.wrapping_mul(0x100000001b3);
422    }
423    h
424}
425
426pub fn scan_tokens(text: &str) -> Vec<String> {
427    text.split(|c: char| !c.is_alphanumeric() && c != '_')
428        .filter(|t| t.len() >= 3 && t.chars().any(|c| c.is_alphabetic()))
429        .map(|t| t.to_ascii_lowercase())
430        .collect()
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    #[test]
438    fn test_make_hv() {
439        let hv = make_hv(42, 10000);
440        assert_eq!(hv.dims, 10000);
441        // Check that HV has both +1 and -1 values (bipolar).
442        let mut has_pos = false;
443        let mut has_neg = false;
444        for i in 0..100 {
445            match hv.get(i) {
446                1 => has_pos = true,
447                -1 => has_neg = true,
448                _ => {}
449            }
450        }
451        assert!(has_pos, "HV should contain +1 values");
452        assert!(has_neg, "HV should contain -1 values");
453    }
454
455    #[test]
456    fn test_bundle_and_clamp() {
457        let mut a = Hypervector::new(100);
458        let mut b = Hypervector::new(100);
459        // Set all of a to +1, all of b to -1
460        for i in 0..100 { a.set(i, 1); b.set(i, -1); }
461        bundle(&mut a, &b);
462        // Bundle of +1 and -1 = 0 → sign(0) = 0 → bit stays 0 → get returns -1.
463        // Actually for majority vote: sum=0, sign is undefined. We use > 0 → set.
464        // With equal +1 and -1, sum=0, bit stays 0 (clear) → get returns -1.
465        // This is acceptable for HDC — ties resolve to -1.
466    }
467
468    #[test]
469    fn test_retain_and_recall() {
470        let mut store = MemoryStore::new(100);
471        store.retain("Alice works at Google", "user", false);
472        let results = store.recall("Alice", 5);
473        assert_eq!(results.len(), 1);
474        assert!(results[0].score > 0.0);
475    }
476
477    #[test]
478    fn test_prediction() {
479        let mut store = MemoryStore::new(100);
480        store.retain("Alice works at Google", "user", false);
481        store.retain("Bob works at Google too", "user", false);
482        let preds = store.predict("Alice", 5);
483        assert!(!preds.is_empty());
484        assert!(preds.iter().any(|(t, _)| t.contains("google")));
485    }
486
487    #[test]
488    fn test_empty_store() {
489        let store = MemoryStore::new(100);
490        assert!(store.recall("anything", 5).is_empty());
491    }
492}
493
494#[cfg(test)]
495mod v80_recall_tests {
496    use super::*;
497    #[test]
498    fn recall_discriminates_between_queries() {
499        let mut store = MemoryStore::new(2048);
500        store.retain("Alice works at Google", "user", false);
501        store.retain("Bob writes Rust compilers", "user", false);
502        store.retain("weather is sunny today", "user", false);
503        let alice = store.recall("Alice works at Google", 3);
504        let zzz = store.recall("zzzz qqqq unrelated", 3);
505        // The top result for the exact query must outscore an unrelated query's
506        // top result; both must NOT be a uniform 1.0.
507        assert!(alice[0].score > zzz[0].score, "scores collapse: exact={} unrelated={}", alice[0].score, zzz[0].score);
508        assert!(alice[0].score > 0.2, "exact-query score too low: {}", alice[0].score);
509    }
510}