Skip to main content

memory_crystal/
index.rs

1use std::collections::{BTreeMap, HashMap, HashSet};
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6use crate::tile::TileId;
7
8/// Lightweight metadata for indexing tiles in memory.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct TileMeta {
11    pub id: TileId,
12    pub valence: f64,
13    pub created: DateTime<Utc>,
14    pub accessed: DateTime<Utc>,
15    pub constraints: HashSet<String>,
16}
17
18/// In-memory index for fast recall queries.
19#[derive(Debug, Default, Serialize, Deserialize)]
20pub struct CrystalIndex {
21    tiles: HashMap<TileId, TileMeta>,
22    by_valence: BTreeMap<OrderedValence, Vec<TileId>>,
23    by_time: BTreeMap<i64, TileId>,
24    constraint_index: HashMap<String, HashSet<TileId>>,
25}
26
27/// Wrapper for f64 that implements Ord (BTreeMap requires it).
28#[derive(Debug, Clone, Serialize, Deserialize)]
29struct OrderedValence(pub f64);
30
31impl PartialEq for OrderedValence {
32    fn eq(&self, other: &Self) -> bool {
33        self.0 == other.0
34    }
35}
36
37impl Eq for OrderedValence {}
38
39impl PartialOrd for OrderedValence {
40    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
41        self.0.partial_cmp(&other.0)
42    }
43}
44
45impl Ord for OrderedValence {
46    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
47        self.partial_cmp(other).unwrap_or(std::cmp::Ordering::Equal)
48    }
49}
50
51impl CrystalIndex {
52    /// Create a new empty index.
53    pub fn new() -> Self {
54        Self::default()
55    }
56
57    /// Insert a tile into the index.
58    pub fn insert(&mut self, meta: TileMeta) {
59        let id = meta.id.clone();
60        let valence = meta.valence;
61        let created_ts = meta.created.timestamp();
62        let constraints = meta.constraints.clone();
63
64        // Main index.
65        self.tiles.insert(id.clone(), meta);
66
67        // Valence index.
68        self.by_valence
69            .entry(OrderedValence(valence))
70            .or_default()
71            .push(id.clone());
72
73        // Time index.
74        self.by_time.insert(created_ts, id.clone());
75
76        // Constraint index.
77        for key in &constraints {
78            self.constraint_index
79                .entry(key.clone())
80                .or_default()
81                .insert(id.clone());
82        }
83    }
84
85    /// Remove a tile from the index.
86    pub fn remove(&mut self, id: &TileId) -> Option<TileMeta> {
87        let meta = self.tiles.remove(id)?;
88
89        // Remove from valence index.
90        if let Some(ids) = self.by_valence.get_mut(&OrderedValence(meta.valence)) {
91            ids.retain(|i| i != id);
92            if ids.is_empty() {
93                self.by_valence.remove(&OrderedValence(meta.valence));
94            }
95        }
96
97        // Remove from time index.
98        let ts = meta.created.timestamp();
99        self.by_time.remove(&ts);
100
101        // Remove from constraint index.
102        for key in &meta.constraints {
103            if let Some(set) = self.constraint_index.get_mut(key) {
104                set.remove(id);
105                if set.is_empty() {
106                    self.constraint_index.remove(key);
107                }
108            }
109        }
110
111        Some(meta)
112    }
113
114    /// Query tiles by constraint keywords, returning up to `limit` matches.
115    pub fn query(&self, constraints: &[&str], limit: usize) -> Vec<TileId> {
116        let mut candidates: HashMap<TileId, usize> = HashMap::new();
117
118        for key in constraints {
119            if let Some(ids) = self.constraint_index.get(*key) {
120                for id in ids {
121                    *candidates.entry(id.clone()).or_default() += 1;
122                }
123            }
124        }
125
126        // Sort by number of matching constraints (descending).
127        let mut ranked: Vec<_> = candidates.into_iter().collect();
128        ranked.sort_by(|a, b| b.1.cmp(&a.1));
129
130        ranked.into_iter().take(limit).map(|(id, _)| id).collect()
131    }
132
133    /// Get top tiles by valence.
134    pub fn top_by_valence(&self, limit: usize) -> Vec<TileId> {
135        self.by_valence
136            .iter()
137            .rev() // Highest valence first.
138            .flat_map(|(_, ids)| ids.iter().cloned())
139            .take(limit)
140            .collect()
141    }
142
143    /// Get most recent tiles.
144    pub fn recent(&self, limit: usize) -> Vec<TileId> {
145        self.by_time
146            .iter()
147            .rev() // Most recent first.
148            .map(|(_, id)| id.clone())
149            .take(limit)
150            .collect()
151    }
152
153    /// Get a tile's metadata.
154    pub fn get(&self, id: &TileId) -> Option<&TileMeta> {
155        self.tiles.get(id)
156    }
157
158    /// Total number of indexed tiles.
159    pub fn len(&self) -> usize {
160        self.tiles.len()
161    }
162
163    /// Is the index empty?
164    pub fn is_empty(&self) -> bool {
165        self.tiles.is_empty()
166    }
167
168    /// Get all tile IDs.
169    pub fn all_ids(&self) -> Vec<TileId> {
170        self.tiles.keys().cloned().collect()
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    fn meta(valence: f64, constraints: &[&str]) -> TileMeta {
179        TileMeta {
180            id: TileId::new(),
181            valence,
182            created: Utc::now(),
183            accessed: Utc::now(),
184            constraints: constraints.iter().map(|s| s.to_string()).collect(),
185        }
186    }
187
188    #[test]
189    fn insert_and_query() {
190        let mut index = CrystalIndex::new();
191        let m = meta(0.5, &["rust", "programming"]);
192        let id = m.id.clone();
193        index.insert(m);
194
195        let results = index.query(&["rust"], 10);
196        assert_eq!(results.len(), 1);
197        assert_eq!(results[0], id);
198    }
199
200    #[test]
201    fn top_by_valence() {
202        let mut index = CrystalIndex::new();
203        let m1 = meta(0.3, &[]);
204        let m2 = meta(0.9, &[]);
205        let m3 = meta(0.6, &[]);
206        let high_id = m2.id.clone();
207        index.insert(m1);
208        index.insert(m2);
209        index.insert(m3);
210
211        let top = index.top_by_valence(1);
212        assert_eq!(top.len(), 1);
213        assert_eq!(top[0], high_id);
214    }
215
216    #[test]
217    fn recent() {
218        let mut index = CrystalIndex::new();
219        let mut m1 = meta(0.5, &[]);
220        m1.created = Utc::now() - chrono::Duration::days(1);
221        let m2 = meta(0.5, &[]);
222        let recent_id = m2.id.clone();
223        index.insert(m1);
224        index.insert(m2);
225
226        let recent = index.recent(1);
227        assert_eq!(recent.len(), 1);
228        assert_eq!(recent[0], recent_id);
229    }
230
231    #[test]
232    fn remove_tile() {
233        let mut index = CrystalIndex::new();
234        let m = meta(0.5, &["test"]);
235        let id = m.id.clone();
236        index.insert(m);
237        assert_eq!(index.len(), 1);
238
239        index.remove(&id);
240        assert!(index.is_empty());
241        assert!(index.query(&["test"], 10).is_empty());
242    }
243
244    #[test]
245    fn multi_constraint_query() {
246        let mut index = CrystalIndex::new();
247        let m1 = meta(0.5, &["rust", "systems"]);
248        let m2 = meta(0.5, &["rust", "web"]);
249        index.insert(m1);
250        index.insert(m2);
251
252        // Both match "rust", only one matches "systems".
253        let results = index.query(&["rust"], 10);
254        assert_eq!(results.len(), 2);
255
256        let results = index.query(&["systems"], 10);
257        assert_eq!(results.len(), 1);
258    }
259
260    #[test]
261    fn empty_query() {
262        let index = CrystalIndex::new();
263        let results = index.query(&["nonexistent"], 10);
264        assert!(results.is_empty());
265    }
266}