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, UniFFI, 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        if self.mount(mem).is_none() {
87            return Err(self.unknown_mem_error(mem));
88        }
89        let store = self.store();
90        let louvain = self.communities();
91
92        let mut nodes = Vec::new();
93        let mut edges = Vec::new();
94        let mut community_sizes: std::collections::BTreeMap<String, usize> =
95            std::collections::BTreeMap::new();
96
97        for entity in store.all_entities() {
98            if entity.mem != mem {
99                continue;
100            }
101            let id = entity.id.to_string();
102            let community = louvain.entity_cluster_map.get(&id).cloned();
103            if let Some(cluster) = &community {
104                *community_sizes.entry(cluster.clone()).or_insert(0) += 1;
105            }
106            nodes.push(TopologyNode {
107                id: id.clone(),
108                title: entity.title.clone(),
109                entity_type: entity.entity_type.clone(),
110                community,
111                stub: entity.stub,
112            });
113            for edge in store.outgoing(&entity.id) {
114                let target_in_mem = store
115                    .get(&edge.target)
116                    .map(|t| t.mem == mem)
117                    .unwrap_or(false);
118                edges.push(TopologyEdge {
119                    source: id.clone(),
120                    target: edge.target.to_string(),
121                    rel_type: edge.rel_type.clone(),
122                    target_in_mem,
123                });
124            }
125        }
126
127        // Deterministic order: stable frames, simple assertions.
128        nodes.sort_by(|a, b| a.id.cmp(&b.id));
129        edges.sort_by(|a, b| {
130            (&a.source, &a.target, &a.rel_type).cmp(&(&b.source, &b.target, &b.rel_type))
131        });
132        let communities = community_sizes
133            .into_iter()
134            .map(|(id, size_in_mem)| TopologyCommunity { id, size_in_mem })
135            .collect();
136
137        Ok(MemTopology {
138            mem: mem.to_string(),
139            nodes,
140            edges,
141            communities,
142        })
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use crate::storage::MemWriter;
149
150    /// Two folder mems, one cross-mem edge (seeded in the markdown so
151    /// no mutation-time policy is involved). The projection must keep
152    /// the cross-mem edge at its source mem only, use global cluster
153    /// ids, carry no layout fields, and refuse unknown mems typed.
154    fn two_mem_engine() -> (crate::Engine, tempfile::TempDir) {
155        let tmp = tempfile::TempDir::new().unwrap();
156        let seed = |dir: &std::path::Path, files: &[(&str, &str)]| {
157            std::fs::create_dir_all(dir).unwrap();
158            let writer = crate::storage::FilesystemMemWriter::new(dir.to_path_buf());
159            for (name, body) in files {
160                writer
161                    .write_entity(std::path::Path::new(name), body.as_bytes())
162                    .unwrap();
163            }
164            // write_entity buffers; the commit flushes to disk.
165            writer
166                .commit("seed", &crate::vcs::CommitContext::internal())
167                .unwrap();
168        };
169        let specs_dir = tmp.path().join("specs");
170        let vendor_dir = tmp.path().join("vendor");
171        seed(
172            &specs_dir,
173            &[
174                (
175                    "alpha.md",
176                    "---\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",
177                ),
178                (
179                    "beta.md",
180                    "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Beta\n\n## Identity\n\nB.\n",
181                ),
182            ],
183        );
184        seed(
185            &vendor_dir,
186            &[(
187                "gamma.md",
188                "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Gamma\n\n## Identity\n\nC.\n",
189            )],
190        );
191        let mount = |mem: &str, path: std::path::PathBuf| {
192            (
193                crate::Mount {
194                    mem: mem.to_string(),
195                    schema: Some(memstead_schema::SchemaRef::new(
196                        "default",
197                        semver::Version::new(1, 0, 0),
198                    )),
199                    storage: crate::MountStorage::Folder { path: path.clone() },
200                    capability: crate::MountCapability::Write,
201                    lifecycle: crate::MountLifecycle::Eager,
202                    cross_linkable: true,
203                    migration_target: None,
204                },
205                Box::new(crate::storage::FilesystemMemWriter::new(path))
206                    as Box<dyn crate::MemBackend>,
207            )
208        };
209        let engine = crate::Engine::from_mounts(vec![
210            mount("specs", specs_dir),
211            mount("vendor", vendor_dir),
212        ])
213        .unwrap();
214        (engine, tmp)
215    }
216
217    #[test]
218    fn projection_is_faithful_composable_and_coordinate_free() {
219        let (engine, _tmp) = two_mem_engine();
220
221        let specs = engine.mem_topology("specs").unwrap();
222        let vendor = engine.mem_topology("vendor").unwrap();
223
224        // Node fidelity: exactly the mem's entities.
225        let specs_ids: Vec<&str> = specs.nodes.iter().map(|n| n.id.as_str()).collect();
226        assert_eq!(specs_ids, vec!["specs--alpha", "specs--beta"]);
227        assert_eq!(vendor.nodes.len(), 1);
228        assert_eq!(vendor.nodes[0].id, "vendor--gamma");
229
230        // Cross-mem composition: the DEPENDS_ON edge rides at its
231        // source mem with the target marked outside; the target mem's
232        // projection does not repeat it — union carries it once.
233        let cross: Vec<_> = specs
234            .edges
235            .iter()
236            .filter(|e| e.rel_type == "DEPENDS_ON")
237            .collect();
238        assert_eq!(cross.len(), 1);
239        assert_eq!(cross[0].target, "vendor--gamma");
240        assert!(!cross[0].target_in_mem);
241        assert!(
242            vendor.edges.iter().all(|e| e.rel_type != "DEPENDS_ON"),
243            "cross-mem edge must not repeat at the target mem: {:?}",
244            vendor.edges
245        );
246        // In-mem edge is marked in-mem.
247        let uses: Vec<_> = specs
248            .edges
249            .iter()
250            .filter(|e| e.rel_type == "USES")
251            .collect();
252        assert_eq!(uses.len(), 1);
253        assert!(uses[0].target_in_mem);
254
255        // Community stability: assignments equal the global partition's
256        // and share ids across the two projections' rosters.
257        let louvain = engine.communities();
258        for node in specs.nodes.iter().chain(vendor.nodes.iter()) {
259            assert_eq!(
260                node.community.as_ref(),
261                louvain.entity_cluster_map.get(&node.id),
262                "node {} must carry the global assignment",
263                node.id
264            );
265        }
266
267        // Coordinate-free: no layout key anywhere in the payload.
268        let json = serde_json::to_string(&specs).unwrap();
269        for forbidden in ["\"x\":", "\"y\":", "\"z\":", "position", "layout"] {
270            assert!(
271                !json.contains(forbidden),
272                "layout field leaked: {forbidden}"
273            );
274        }
275
276        // Unknown mem refuses typed.
277        let err = engine.mem_topology("ghost").unwrap_err();
278        assert!(matches!(err, crate::EngineError::UnknownMem(m) if m == "ghost"));
279    }
280}
281
282#[cfg(test)]
283mod scale_tests {
284    use crate::storage::MemWriter;
285
286    /// Design-target guard (plan criterion: "a mem at the design-target
287    /// entity count is served whole — no page cap, no top-N
288    /// truncation"). 1,500 entities sit mid-target (1,000–5,000); a
289    /// future page cap or truncation lands as a failure here, not
290    /// silently.
291    #[test]
292    fn design_target_mem_is_served_whole() {
293        const N: usize = 1_500;
294        let tmp = tempfile::TempDir::new().unwrap();
295        let dir = tmp.path().join("bulk");
296        std::fs::create_dir_all(&dir).unwrap();
297        let writer = crate::storage::FilesystemMemWriter::new(dir.clone());
298        for i in 0..N {
299            // Chain edges so the edge count scales with the node count.
300            let rel = if i > 0 {
301                format!("\n## Relationships\n\n- **USES**: [[node-{}]]\n", i - 1)
302            } else {
303                String::new()
304            };
305            let body = format!(
306                "---\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}"
307            );
308            writer
309                .write_entity(
310                    std::path::Path::new(&format!("node-{i}.md")),
311                    body.as_bytes(),
312                )
313                .unwrap();
314        }
315        writer
316            .commit("seed bulk", &crate::vcs::CommitContext::internal())
317            .unwrap();
318
319        let mount = crate::Mount {
320            mem: "bulk".to_string(),
321            schema: Some(memstead_schema::SchemaRef::new(
322                "default",
323                semver::Version::new(1, 0, 0),
324            )),
325            storage: crate::MountStorage::Folder { path: dir.clone() },
326            capability: crate::MountCapability::Write,
327            lifecycle: crate::MountLifecycle::Eager,
328            cross_linkable: false,
329            migration_target: None,
330        };
331        let backend =
332            Box::new(crate::storage::FilesystemMemWriter::new(dir)) as Box<dyn crate::MemBackend>;
333        let engine = crate::Engine::from_mounts(vec![(mount, backend)]).unwrap();
334
335        let topology = engine.mem_topology("bulk").unwrap();
336        assert_eq!(topology.nodes.len(), N, "every node, no cap");
337        assert_eq!(topology.edges.len(), N - 1, "every edge, no cap");
338    }
339}