Skip to main content

memstead_base/graph/
topology.rs

1//! Bulk per-mem topology projection — `{nodes, edges, communities}`
2//! for one mem, in one call, coordinate-free.
3//!
4//! "Projection" here is a render view of the live store, not the
5//! pipeline's projection-binding; the two nouns are unrelated. The
6//! shape exists for UI consumers (HTTP surfaces, CLI) that
7//! would otherwise assemble topology from paged list reads plus
8//! per-entity relation calls — the measured N+1 path — or re-derive it
9//! per surface (the `serve/` precedent this module hoists).
10//!
11//! Contract decisions, deliberate and shared by every consumer:
12//!
13//! - **Coordinate-free.** Layout is the consumer's job; no x/y/z ever.
14//! - **Unpaged, untruncated.** The design target is 1,000–5,000
15//!   entities per mem, served whole. Any future scale cut must be
16//!   declared on-surface, never silent.
17//! - **Cross-mem edges ride, source-in-mem only.** An edge whose
18//!   source entity lives in the projected mem is included even when
19//!   its target lives elsewhere (`target_in_mem: false`) — the
20//!   engine's established asymmetric convention (community bridges,
21//!   health). Composing every mem's projection therefore yields the
22//!   complete workspace graph with each cross-mem edge exactly once.
23//! - **Global community ids.** Assignments come from the
24//!   workspace-global Louvain partition (never re-run per mem), keyed
25//!   by the partition's own cluster ids — the same cluster carries the
26//!   same id across per-mem projections from one snapshot, so
27//!   multi-mem consumers can compose without renumbering.
28
29use serde::Serialize;
30
31/// One entity as a topology node. Coordinate-free by design.
32#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
33pub struct TopologyNode {
34    pub id: String,
35    pub title: String,
36    pub entity_type: String,
37    /// Global Louvain cluster id from the workspace partition; `None`
38    /// when the partition carries no assignment for this entity.
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub community: Option<String>,
41    /// True for stub entities (unresolved references) — consumers
42    /// choose their own rendering; nothing is dropped here.
43    pub stub: bool,
44}
45
46/// One directed relationship edge whose source lives in the projected
47/// mem.
48#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
49pub struct TopologyEdge {
50    pub source: String,
51    pub target: String,
52    pub rel_type: String,
53    /// `false` marks a cross-mem edge: the target entity lives outside
54    /// the projected mem (reported here, at the source mem, only).
55    pub target_in_mem: bool,
56}
57
58/// One global cluster's presence in the projected mem.
59#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
60pub struct TopologyCommunity {
61    /// Global cluster id (workspace partition).
62    pub id: String,
63    /// How many of the cluster's members live in the projected mem.
64    pub size_in_mem: usize,
65}
66
67/// The full per-mem projection.
68#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
69pub struct MemTopology {
70    pub mem: String,
71    pub nodes: Vec<TopologyNode>,
72    pub edges: Vec<TopologyEdge>,
73    /// Clusters with at least one member in the mem, sorted by id.
74    pub communities: Vec<TopologyCommunity>,
75}
76
77impl crate::Engine {
78    /// Project `mem`'s current topology from the live store — every
79    /// entity in the mem, every relationship edge sourced in the mem
80    /// (cross-mem targets marked), and the mem's community roster from
81    /// the global partition. Recomputed on every call, never
82    /// incremental: deleted or renamed entities are simply absent from
83    /// the next projection. Unknown mems refuse with
84    /// [`crate::EngineError::UnknownMem`].
85    pub fn mem_topology(&self, mem: &str) -> Result<MemTopology, crate::EngineError> {
86        self.mem_topology_scoped(mem, None)
87    }
88
89    /// [`Self::mem_topology`] reduced to a chain: only the mem's entities
90    /// in `chain` become nodes, and only the edges whose both ends are in
91    /// the chain (the target possibly in another mem, marked) become
92    /// edges — the subgraph the chain induces. `None` is the whole mem,
93    /// byte-identical to the unscoped projection.
94    pub fn mem_topology_scoped(
95        &self,
96        mem: &str,
97        chain: Option<&crate::graph::chain::ChainSet>,
98    ) -> Result<MemTopology, crate::EngineError> {
99        if self.mount(mem).is_none() {
100            return Err(self.unknown_mem_error(mem));
101        }
102        let store = self.store();
103        let louvain = self.communities();
104
105        let mut nodes = Vec::new();
106        let mut edges = Vec::new();
107        let mut community_sizes: std::collections::BTreeMap<String, usize> =
108            std::collections::BTreeMap::new();
109
110        for entity in store.all_entities() {
111            if entity.mem != mem {
112                continue;
113            }
114            if let Some(chain) = chain
115                && !chain.contains(&entity.id)
116            {
117                continue;
118            }
119            let id = entity.id.to_string();
120            let community = louvain.entity_cluster_map.get(&id).cloned();
121            if let Some(cluster) = &community {
122                *community_sizes.entry(cluster.clone()).or_insert(0) += 1;
123            }
124            nodes.push(TopologyNode {
125                id: id.clone(),
126                title: entity.title.clone(),
127                entity_type: entity.entity_type.clone(),
128                community,
129                stub: entity.stub,
130            });
131            for edge in store.outgoing(&entity.id) {
132                if let Some(chain) = chain
133                    && !chain.contains(&edge.target)
134                {
135                    continue;
136                }
137                let target_in_mem = store
138                    .get(&edge.target)
139                    .map(|t| t.mem == mem)
140                    .unwrap_or(false);
141                edges.push(TopologyEdge {
142                    source: id.clone(),
143                    target: edge.target.to_string(),
144                    rel_type: edge.rel_type.clone(),
145                    target_in_mem,
146                });
147            }
148        }
149
150        // Deterministic order: stable frames, simple assertions.
151        nodes.sort_by(|a, b| a.id.cmp(&b.id));
152        edges.sort_by(|a, b| {
153            (&a.source, &a.target, &a.rel_type).cmp(&(&b.source, &b.target, &b.rel_type))
154        });
155        let communities = community_sizes
156            .into_iter()
157            .map(|(id, size_in_mem)| TopologyCommunity { id, size_in_mem })
158            .collect();
159
160        Ok(MemTopology {
161            mem: mem.to_string(),
162            nodes,
163            edges,
164            communities,
165        })
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use crate::storage::MemWriter;
172
173    /// Two folder mems, one cross-mem edge (seeded in the markdown so
174    /// no mutation-time policy is involved). The projection must keep
175    /// the cross-mem edge at its source mem only, use global cluster
176    /// ids, carry no layout fields, and refuse unknown mems typed.
177    fn two_mem_engine() -> (crate::Engine, tempfile::TempDir) {
178        let tmp = tempfile::TempDir::new().unwrap();
179        let seed = |dir: &std::path::Path, files: &[(&str, &str)]| {
180            std::fs::create_dir_all(dir).unwrap();
181            let writer = crate::storage::FilesystemMemWriter::new(dir.to_path_buf());
182            for (name, body) in files {
183                writer
184                    .write_entity(std::path::Path::new(name), body.as_bytes())
185                    .unwrap();
186            }
187            // write_entity buffers; the commit flushes to disk.
188            writer
189                .commit("seed", &crate::vcs::CommitContext::internal())
190                .unwrap();
191        };
192        let specs_dir = tmp.path().join("specs");
193        let vendor_dir = tmp.path().join("vendor");
194        seed(
195            &specs_dir,
196            &[
197                (
198                    "alpha.md",
199                    "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Alpha\n\n## Identity\n\nA.\n\n## Relationships\n\n- **USES**: [[beta]]\n- **DEPENDS_ON**: [[vendor--gamma]]\n",
200                ),
201                (
202                    "beta.md",
203                    "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Beta\n\n## Identity\n\nB.\n",
204                ),
205            ],
206        );
207        seed(
208            &vendor_dir,
209            &[(
210                "gamma.md",
211                "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Gamma\n\n## Identity\n\nC.\n",
212            )],
213        );
214        let mount = |mem: &str, path: std::path::PathBuf| {
215            (
216                crate::Mount {
217                    mem: mem.to_string(),
218                    schema: Some(memstead_schema::SchemaRef::new(
219                        "default",
220                        semver::Version::new(1, 0, 0),
221                    )),
222                    storage: crate::MountStorage::Folder { path: path.clone() },
223                    capability: crate::MountCapability::Write,
224                    lifecycle: crate::MountLifecycle::Eager,
225                    cross_linkable: true,
226                    migration_target: None,
227                },
228                Box::new(crate::storage::FilesystemMemWriter::new(path))
229                    as Box<dyn crate::MemBackend>,
230            )
231        };
232        let engine = crate::Engine::from_mounts(vec![
233            mount("specs", specs_dir),
234            mount("vendor", vendor_dir),
235        ])
236        .unwrap();
237        (engine, tmp)
238    }
239
240    #[test]
241    fn projection_is_faithful_composable_and_coordinate_free() {
242        let (engine, _tmp) = two_mem_engine();
243
244        let specs = engine.mem_topology("specs").unwrap();
245        let vendor = engine.mem_topology("vendor").unwrap();
246
247        // Node fidelity: exactly the mem's entities.
248        let specs_ids: Vec<&str> = specs.nodes.iter().map(|n| n.id.as_str()).collect();
249        assert_eq!(specs_ids, vec!["specs--alpha", "specs--beta"]);
250        assert_eq!(vendor.nodes.len(), 1);
251        assert_eq!(vendor.nodes[0].id, "vendor--gamma");
252
253        // Cross-mem composition: the DEPENDS_ON edge rides at its
254        // source mem with the target marked outside; the target mem's
255        // projection does not repeat it — union carries it once.
256        let cross: Vec<_> = specs
257            .edges
258            .iter()
259            .filter(|e| e.rel_type == "DEPENDS_ON")
260            .collect();
261        assert_eq!(cross.len(), 1);
262        assert_eq!(cross[0].target, "vendor--gamma");
263        assert!(!cross[0].target_in_mem);
264        assert!(
265            vendor.edges.iter().all(|e| e.rel_type != "DEPENDS_ON"),
266            "cross-mem edge must not repeat at the target mem: {:?}",
267            vendor.edges
268        );
269        // In-mem edge is marked in-mem.
270        let uses: Vec<_> = specs
271            .edges
272            .iter()
273            .filter(|e| e.rel_type == "USES")
274            .collect();
275        assert_eq!(uses.len(), 1);
276        assert!(uses[0].target_in_mem);
277
278        // Community stability: assignments equal the global partition's
279        // and share ids across the two projections' rosters.
280        let louvain = engine.communities();
281        for node in specs.nodes.iter().chain(vendor.nodes.iter()) {
282            assert_eq!(
283                node.community.as_ref(),
284                louvain.entity_cluster_map.get(&node.id),
285                "node {} must carry the global assignment",
286                node.id
287            );
288        }
289
290        // Coordinate-free: no layout key anywhere in the payload.
291        let json = serde_json::to_string(&specs).unwrap();
292        for forbidden in ["\"x\":", "\"y\":", "\"z\":", "position", "layout"] {
293            assert!(
294                !json.contains(forbidden),
295                "layout field leaked: {forbidden}"
296            );
297        }
298
299        // Unknown mem refuses typed.
300        let err = engine.mem_topology("ghost").unwrap_err();
301        assert!(matches!(err, crate::EngineError::UnknownMem(m) if m == "ghost"));
302    }
303}
304
305#[cfg(test)]
306mod scale_tests {
307    use crate::storage::MemWriter;
308
309    /// Design-target guard (plan criterion: "a mem at the design-target
310    /// entity count is served whole — no page cap, no top-N
311    /// truncation"). 1,500 entities sit mid-target (1,000–5,000); a
312    /// future page cap or truncation lands as a failure here, not
313    /// silently.
314    #[test]
315    fn design_target_mem_is_served_whole() {
316        const N: usize = 1_500;
317        let tmp = tempfile::TempDir::new().unwrap();
318        let dir = tmp.path().join("bulk");
319        std::fs::create_dir_all(&dir).unwrap();
320        let writer = crate::storage::FilesystemMemWriter::new(dir.clone());
321        for i in 0..N {
322            // Chain edges so the edge count scales with the node count.
323            let rel = if i > 0 {
324                format!("\n## Relationships\n\n- **USES**: [[node-{}]]\n", i - 1)
325            } else {
326                String::new()
327            };
328            let body = format!(
329                "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Node {i}\n\n## Identity\n\nBulk node {i}.\n{rel}"
330            );
331            writer
332                .write_entity(
333                    std::path::Path::new(&format!("node-{i}.md")),
334                    body.as_bytes(),
335                )
336                .unwrap();
337        }
338        writer
339            .commit("seed bulk", &crate::vcs::CommitContext::internal())
340            .unwrap();
341
342        let mount = crate::Mount {
343            mem: "bulk".to_string(),
344            schema: Some(memstead_schema::SchemaRef::new(
345                "default",
346                semver::Version::new(1, 0, 0),
347            )),
348            storage: crate::MountStorage::Folder { path: dir.clone() },
349            capability: crate::MountCapability::Write,
350            lifecycle: crate::MountLifecycle::Eager,
351            cross_linkable: false,
352            migration_target: None,
353        };
354        let backend =
355            Box::new(crate::storage::FilesystemMemWriter::new(dir)) as Box<dyn crate::MemBackend>;
356        let engine = crate::Engine::from_mounts(vec![(mount, backend)]).unwrap();
357
358        let topology = engine.mem_topology("bulk").unwrap();
359        assert_eq!(topology.nodes.len(), N, "every node, no cap");
360        assert_eq!(topology.edges.len(), N - 1, "every edge, no cap");
361    }
362}