Skip to main content

memstead_base/
store.rs

1//! In-memory graph store. Dumb data structure — no validation, no side effects.
2//! All mutations go through Engine methods.
3
4use crate::entity::{Entity, EntityId};
5use std::collections::HashMap;
6
7/// Edge in the graph.
8#[derive(Debug, Clone, PartialEq)]
9pub struct Edge {
10    pub rel_type: String,
11    pub target: EntityId,
12    pub source: EdgeSource,
13}
14
15/// Where an edge was declared. Under the alias model every authored
16/// edge is `Explicit` (an entry in the auto-managed `## Relationships`
17/// section); `Hierarchy` is a derived view over `PART_OF` rather than
18/// an authoring channel; `BodyLink` is engine-emitted from a body
19/// wiki-link via the alias-synthesis pass (rel-type equals the source
20/// schema's `alias_target_rel_type` pointer).
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum EdgeSource {
23    /// Declared in the Relationships section.
24    Explicit,
25    /// Derived from PART_OF hierarchy.
26    Hierarchy,
27    /// Engine-emitted from a body wiki-link via the alias-synthesis
28    /// pass. The discriminator is store-side only — derived at
29    /// store-build time from `rel_type == schema.alias_target_rel_type()`.
30    BodyLink,
31}
32
33/// Incoming edge — stored in in_edges for efficient reverse lookups.
34#[derive(Debug, Clone, PartialEq)]
35pub struct InEdge {
36    pub rel_type: String,
37    pub from: EntityId,
38    pub source: EdgeSource,
39}
40
41/// The graph store. Three maps: nodes, outgoing edges, incoming edges.
42///
43/// `Clone` backs the atomic-batch rollback: `batch_update` snapshots
44/// the store before preparing items so a refused batch can restore the
45/// pre-call graph wholesale.
46#[derive(Debug, Clone)]
47pub struct Store {
48    nodes: HashMap<EntityId, Entity>,
49    out_edges: HashMap<EntityId, Vec<Edge>>,
50    in_edges: HashMap<EntityId, Vec<InEdge>>,
51}
52
53impl Store {
54    pub fn new() -> Self {
55        Self {
56            nodes: HashMap::new(),
57            out_edges: HashMap::new(),
58            in_edges: HashMap::new(),
59        }
60    }
61
62    /// Insert or update a node. If the node already exists, replace it.
63    pub fn upsert(&mut self, id: EntityId, entity: Entity) {
64        if !self.out_edges.contains_key(&id) {
65            self.out_edges.insert(id.clone(), Vec::new());
66        }
67        if !self.in_edges.contains_key(&id) {
68            self.in_edges.insert(id.clone(), Vec::new());
69        }
70        self.nodes.insert(id, entity);
71    }
72
73    /// Remove a node and cascade-delete all its edges.
74    pub fn remove(&mut self, id: &EntityId) -> Option<Entity> {
75        // Remove outgoing edges and their mirrors in in_edges
76        if let Some(out) = self.out_edges.remove(id) {
77            for edge in &out {
78                if let Some(in_list) = self.in_edges.get_mut(&edge.target) {
79                    in_list.retain(|e| &e.from != id);
80                }
81            }
82        }
83        // Remove incoming edges and their mirrors in out_edges
84        if let Some(inc) = self.in_edges.remove(id) {
85            for edge in &inc {
86                if let Some(out_list) = self.out_edges.get_mut(&edge.from) {
87                    out_list.retain(|e| &e.target != id);
88                }
89            }
90        }
91        self.nodes.remove(id)
92    }
93
94    pub fn get(&self, id: &EntityId) -> Option<&Entity> {
95        self.nodes.get(id)
96    }
97
98    pub fn get_mut(&mut self, id: &EntityId) -> Option<&mut Entity> {
99        self.nodes.get_mut(id)
100    }
101
102    pub fn contains(&self, id: &EntityId) -> bool {
103        self.nodes.contains_key(id)
104    }
105
106    pub fn all_ids(&self) -> impl Iterator<Item = &EntityId> {
107        self.nodes.keys()
108    }
109
110    pub fn all_entities(&self) -> impl Iterator<Item = &Entity> {
111        self.nodes.values()
112    }
113
114    /// Drop every entity whose `EntityId::mem()` matches `mem`,
115    /// cascading edges via the existing [`Store::remove`] mechanism.
116    /// Returns the number of entities removed (excluding edge-only
117    /// cascades — same accounting as `remove`).
118    ///
119    /// Used by [`Engine::reload_one_mem`] to clear one mem's slice
120    /// of the store before reloading entities from the on-disk branch
121    /// tip. Pure-iteration implementation: walks `all_ids()`, filters
122    /// by mem, then calls `remove` on each. The 132-entity workspace
123    /// today reloads the whole store in <1 s so the per-mem filtered
124    /// case is microseconds; if the workspace ever grows past
125    /// 10k entities the loop can switch to a mem-keyed bucket on
126    /// `Store` without changing this signature.
127    pub fn remove_entities_by_mem(&mut self, mem: &str) -> usize {
128        let to_remove: Vec<EntityId> = self
129            .nodes
130            .keys()
131            .filter(|id| id.mem() == mem)
132            .cloned()
133            .collect();
134        let count = to_remove.len();
135        for id in to_remove {
136            self.remove(&id);
137        }
138        count
139    }
140
141    pub fn len(&self) -> usize {
142        self.nodes.len()
143    }
144
145    pub fn is_empty(&self) -> bool {
146        self.nodes.is_empty()
147    }
148
149    /// Add an edge. Idempotent: if (from, to, type) exists, update source; else append.
150    /// Stores in both out_edges and in_edges for bidirectional traversal.
151    pub fn add_edge(&mut self, from: EntityId, edge: Edge) {
152        let target = edge.target.clone();
153        let rel_type = edge.rel_type.clone();
154        let source = edge.source.clone();
155
156        // Ensure adjacency lists exist
157        self.out_edges.entry(from.clone()).or_default();
158        self.in_edges.entry(target.clone()).or_default();
159
160        // Check for existing edge (same from, to, type)
161        let out_list = self.out_edges.get_mut(&from).unwrap();
162        if let Some(existing) = out_list
163            .iter_mut()
164            .find(|e| e.target == target && e.rel_type == rel_type)
165        {
166            existing.source = source.clone();
167            // Update mirror
168            if let Some(in_list) = self.in_edges.get_mut(&target)
169                && let Some(mirror) = in_list
170                    .iter_mut()
171                    .find(|e| e.from == from && e.rel_type == rel_type)
172            {
173                mirror.source = source;
174            }
175        } else {
176            out_list.push(edge);
177            self.in_edges.get_mut(&target).unwrap().push(InEdge {
178                rel_type,
179                from,
180                source,
181            });
182        }
183    }
184
185    /// Remove a specific edge by (from, to, type).
186    pub fn remove_edge(&mut self, from: &EntityId, to: &EntityId, rel_type: &str) {
187        if let Some(out_list) = self.out_edges.get_mut(from) {
188            out_list.retain(|e| !(e.target == *to && e.rel_type == rel_type));
189        }
190        if let Some(in_list) = self.in_edges.get_mut(to) {
191            in_list.retain(|e| !(e.from == *from && e.rel_type == rel_type));
192        }
193    }
194
195    /// Remove all outgoing edges from a node (and their mirrors).
196    pub fn remove_edges_from(&mut self, id: &EntityId) {
197        if let Some(out) = self.out_edges.get_mut(id) {
198            let edges = std::mem::take(out);
199            for edge in edges {
200                if let Some(in_list) = self.in_edges.get_mut(&edge.target) {
201                    in_list.retain(|e| &e.from != id);
202                }
203            }
204        }
205    }
206
207    /// Get all outgoing edges for a node.
208    pub fn outgoing(&self, id: &EntityId) -> &[Edge] {
209        self.out_edges.get(id).map_or(&[], |v| v.as_slice())
210    }
211
212    /// Get all incoming edges for a node.
213    pub fn incoming(&self, id: &EntityId) -> &[InEdge] {
214        self.in_edges.get(id).map_or(&[], |v| v.as_slice())
215    }
216
217    /// Rename a node. Updates all edge references.
218    pub fn rename_node(&mut self, old_id: &EntityId, new_id: EntityId) -> bool {
219        if old_id == &new_id {
220            return false;
221        }
222        let Some(mut entity) = self.nodes.remove(old_id) else {
223            return false;
224        };
225        entity.id = new_id.clone();
226        self.nodes.insert(new_id.clone(), entity);
227
228        // Move edge lists
229        let out = self.out_edges.remove(old_id).unwrap_or_default();
230        let inc = self.in_edges.remove(old_id).unwrap_or_default();
231        self.out_edges.insert(new_id.clone(), out);
232        self.in_edges.insert(new_id.clone(), inc);
233
234        // Update all edges referencing old_id
235        for edges in self.out_edges.values_mut() {
236            for e in edges.iter_mut() {
237                if e.target == *old_id {
238                    e.target = new_id.clone();
239                }
240            }
241        }
242        for edges in self.in_edges.values_mut() {
243            for e in edges.iter_mut() {
244                if e.from == *old_id {
245                    e.from = new_id.clone();
246                }
247            }
248        }
249
250        // Update `entity.relationships` on every node. This is the list that
251        // `write_entity` renders into the markdown frontmatter; without this
252        // walk a self-loop (`target == old_id`) would be written to disk
253        // under the old id, then re-parsed back as a fresh edge pointing at
254        // an auto-stubbed copy of the old id. Out/in edges alone aren't
255        // enough — the on-disk form is the source of truth that survives
256        // the post-rename re-parse cycle in `engine::mutation::rename`.
257        for entity in self.nodes.values_mut() {
258            for rel in entity.relationships.iter_mut() {
259                if rel.target == *old_id {
260                    rel.target = new_id.clone();
261                }
262            }
263        }
264        true
265    }
266
267    /// Total edge count (outgoing edges only, since in_edges are mirrors).
268    pub fn edge_count(&self) -> usize {
269        self.out_edges.values().map(|v| v.len()).sum()
270    }
271
272    /// Clear all nodes and edges.
273    pub fn clear(&mut self) {
274        self.nodes.clear();
275        self.out_edges.clear();
276        self.in_edges.clear();
277    }
278}
279
280impl Default for Store {
281    fn default() -> Self {
282        Self::new()
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use crate::Relationship;
290    use indexmap::IndexMap;
291
292    fn stub_entity(id: &str, mem: &str) -> Entity {
293        Entity {
294            id: EntityId(id.to_string()),
295            title: id.to_string(),
296            entity_type: "spec".to_string(),
297            mem: mem.to_string(),
298            file_path: String::new(),
299            metadata: IndexMap::new(),
300            sections: IndexMap::new(),
301            relationships: Vec::new(),
302            content_hash: String::new(),
303            stub: true,
304            stub_kind: None,
305            heading_spans: std::collections::HashMap::new(),
306            raw_section_headings: Vec::new(),
307        }
308    }
309
310    #[test]
311    fn new_store_is_empty() {
312        let store = Store::new();
313        assert!(store.is_empty());
314        assert_eq!(store.len(), 0);
315        assert_eq!(store.edge_count(), 0);
316    }
317
318    #[test]
319    fn upsert_and_get() {
320        let mut store = Store::new();
321        let id = EntityId("specs--test".to_string());
322        store.upsert(id.clone(), stub_entity("specs--test", "specs"));
323        assert_eq!(store.len(), 1);
324        assert!(store.get(&id).is_some());
325        assert_eq!(store.get(&id).unwrap().title, "specs--test");
326    }
327
328    #[test]
329    fn upsert_replaces_existing() {
330        let mut store = Store::new();
331        let id = EntityId("specs--test".to_string());
332        store.upsert(id.clone(), stub_entity("specs--test", "specs"));
333        let mut updated = stub_entity("specs--test", "specs");
334        updated.title = "Updated Title".to_string();
335        store.upsert(id.clone(), updated);
336        assert_eq!(store.len(), 1);
337        assert_eq!(store.get(&id).unwrap().title, "Updated Title");
338    }
339
340    #[test]
341    fn remove_node_cascades_edges() {
342        let mut store = Store::new();
343        let a = EntityId("specs--a".to_string());
344        let b = EntityId("specs--b".to_string());
345        store.upsert(a.clone(), stub_entity("specs--a", "specs"));
346        store.upsert(b.clone(), stub_entity("specs--b", "specs"));
347        store.add_edge(
348            a.clone(),
349            Edge {
350                rel_type: "USES".to_string(),
351                target: b.clone(),
352                source: EdgeSource::Explicit,
353            },
354        );
355        assert_eq!(store.edge_count(), 1);
356        store.remove(&b);
357        assert_eq!(store.len(), 1);
358        assert_eq!(store.edge_count(), 0);
359        assert!(store.outgoing(&a).is_empty());
360    }
361
362    #[test]
363    fn add_edge_idempotent() {
364        let mut store = Store::new();
365        let a = EntityId("specs--a".to_string());
366        let b = EntityId("specs--b".to_string());
367        store.upsert(a.clone(), stub_entity("specs--a", "specs"));
368        store.upsert(b.clone(), stub_entity("specs--b", "specs"));
369
370        store.add_edge(
371            a.clone(),
372            Edge {
373                rel_type: "USES".to_string(),
374                target: b.clone(),
375                source: EdgeSource::Explicit,
376            },
377        );
378        // Add same edge again — idempotent on (from, to, rel_type)
379        store.add_edge(
380            a.clone(),
381            Edge {
382                rel_type: "USES".to_string(),
383                target: b.clone(),
384                source: EdgeSource::Hierarchy,
385            },
386        );
387        assert_eq!(store.edge_count(), 1);
388        assert_eq!(store.outgoing(&a)[0].source, EdgeSource::Hierarchy);
389        assert_eq!(store.incoming(&b)[0].source, EdgeSource::Hierarchy);
390    }
391
392    #[test]
393    fn bidirectional_edges() {
394        let mut store = Store::new();
395        let a = EntityId("specs--a".to_string());
396        let b = EntityId("specs--b".to_string());
397        store.upsert(a.clone(), stub_entity("specs--a", "specs"));
398        store.upsert(b.clone(), stub_entity("specs--b", "specs"));
399        store.add_edge(
400            a.clone(),
401            Edge {
402                rel_type: "USES".to_string(),
403                target: b.clone(),
404                source: EdgeSource::Explicit,
405            },
406        );
407        assert_eq!(store.outgoing(&a).len(), 1);
408        assert_eq!(store.outgoing(&a)[0].target, b);
409        assert_eq!(store.incoming(&b).len(), 1);
410        assert_eq!(store.incoming(&b)[0].from, a);
411    }
412
413    #[test]
414    fn remove_edges_from() {
415        let mut store = Store::new();
416        let a = EntityId("specs--a".to_string());
417        let b = EntityId("specs--b".to_string());
418        let c = EntityId("specs--c".to_string());
419        store.upsert(a.clone(), stub_entity("specs--a", "specs"));
420        store.upsert(b.clone(), stub_entity("specs--b", "specs"));
421        store.upsert(c.clone(), stub_entity("specs--c", "specs"));
422        store.add_edge(
423            a.clone(),
424            Edge {
425                rel_type: "USES".to_string(),
426                target: b.clone(),
427                source: EdgeSource::Explicit,
428            },
429        );
430        store.add_edge(
431            a.clone(),
432            Edge {
433                rel_type: "USES".to_string(),
434                target: c.clone(),
435                source: EdgeSource::Explicit,
436            },
437        );
438        assert_eq!(store.edge_count(), 2);
439        store.remove_edges_from(&a);
440        assert_eq!(store.edge_count(), 0);
441        assert!(store.outgoing(&a).is_empty());
442        assert!(store.incoming(&b).is_empty());
443        assert!(store.incoming(&c).is_empty());
444    }
445
446    #[test]
447    fn remove_specific_edge() {
448        let mut store = Store::new();
449        let a = EntityId("specs--a".to_string());
450        let b = EntityId("specs--b".to_string());
451        store.upsert(a.clone(), stub_entity("specs--a", "specs"));
452        store.upsert(b.clone(), stub_entity("specs--b", "specs"));
453        store.add_edge(
454            a.clone(),
455            Edge {
456                rel_type: "USES".to_string(),
457                target: b.clone(),
458                source: EdgeSource::Explicit,
459            },
460        );
461        store.add_edge(
462            a.clone(),
463            Edge {
464                rel_type: "PART_OF".to_string(),
465                target: b.clone(),
466                source: EdgeSource::Explicit,
467            },
468        );
469        assert_eq!(store.edge_count(), 2);
470        store.remove_edge(&a, &b, "USES");
471        assert_eq!(store.edge_count(), 1);
472        assert_eq!(store.outgoing(&a)[0].rel_type, "PART_OF");
473    }
474
475    #[test]
476    fn rename_node() {
477        let mut store = Store::new();
478        let a = EntityId("specs--a".to_string());
479        let b = EntityId("specs--b".to_string());
480        let new_a = EntityId("specs--a-renamed".to_string());
481        store.upsert(a.clone(), stub_entity("specs--a", "specs"));
482        store.upsert(b.clone(), stub_entity("specs--b", "specs"));
483        store.add_edge(
484            a.clone(),
485            Edge {
486                rel_type: "USES".to_string(),
487                target: b.clone(),
488                source: EdgeSource::Explicit,
489            },
490        );
491        store.add_edge(
492            b.clone(),
493            Edge {
494                rel_type: "PART_OF".to_string(),
495                target: a.clone(),
496                source: EdgeSource::Explicit,
497            },
498        );
499
500        assert!(store.rename_node(&a, new_a.clone()));
501        assert!(store.get(&a).is_none());
502        assert!(store.get(&new_a).is_some());
503        assert_eq!(store.outgoing(&new_a).len(), 1);
504        assert_eq!(store.incoming(&new_a).len(), 1);
505        assert_eq!(store.incoming(&new_a)[0].from, b);
506        // Edge from b to old_a should now point to new_a
507        assert_eq!(store.outgoing(&b)[0].target, new_a);
508    }
509
510    #[test]
511    fn rename_node_rewrites_self_loop_in_relationships_vec() {
512        // Regression: a self-loop edge (target == self) stored in both the
513        // Store's adjacency HashMaps *and* inside `entity.relationships`
514        // used to have only the adjacency side rewritten on rename. The
515        // `relationships` Vec kept the old id, which then leaked onto disk
516        // via `write_entity` and auto-stubbed on re-parse.
517        let mut store = Store::new();
518        let old_id = EntityId("specs--selfie".to_string());
519        let new_id = EntityId("specs--selfie-renamed".to_string());
520        let mut entity = stub_entity("specs--selfie", "specs");
521        entity.stub = false;
522        entity.relationships.push(Relationship {
523            rel_type: "REFERENCES".to_string(),
524            target: old_id.clone(),
525            description: None,
526        });
527        store.upsert(old_id.clone(), entity);
528        store.add_edge(
529            old_id.clone(),
530            Edge {
531                rel_type: "REFERENCES".to_string(),
532                target: old_id.clone(),
533                source: EdgeSource::Explicit,
534            },
535        );
536
537        assert!(store.rename_node(&old_id, new_id.clone()));
538
539        let renamed = store.get(&new_id).expect("renamed entity exists");
540        assert_eq!(renamed.relationships.len(), 1);
541        assert_eq!(
542            renamed.relationships[0].target, new_id,
543            "self-loop target inside entity.relationships must be rewritten \
544             to new_id — otherwise write_entity leaks old id to disk"
545        );
546        // And the adjacency side stayed consistent (single self-loop, not
547        // duplicated into a dangling-to-old-id edge).
548        assert_eq!(store.outgoing(&new_id).len(), 1);
549        assert_eq!(store.outgoing(&new_id)[0].target, new_id);
550        assert_eq!(store.incoming(&new_id).len(), 1);
551        assert_eq!(store.incoming(&new_id)[0].from, new_id);
552    }
553
554    #[test]
555    fn clear_empties_store() {
556        let mut store = Store::new();
557        let a = EntityId("specs--a".to_string());
558        store.upsert(a, stub_entity("specs--a", "specs"));
559        store.clear();
560        assert!(store.is_empty());
561        assert_eq!(store.edge_count(), 0);
562    }
563
564    #[test]
565    fn outgoing_empty_for_unknown_id() {
566        let store = Store::new();
567        assert!(store.outgoing(&EntityId("unknown".to_string())).is_empty());
568    }
569}