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