1use serde::Serialize;
30
31#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
33pub struct TopologyNode {
34 pub id: String,
35 pub title: String,
36 pub entity_type: String,
37 #[serde(skip_serializing_if = "Option::is_none")]
40 pub community: Option<String>,
41 pub stub: bool,
44}
45
46#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
49pub struct TopologyEdge {
50 pub source: String,
51 pub target: String,
52 pub rel_type: String,
53 pub target_in_mem: bool,
56}
57
58#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
60pub struct TopologyCommunity {
61 pub id: String,
63 pub size_in_mem: usize,
65}
66
67#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
69pub struct MemTopology {
70 pub mem: String,
71 pub nodes: Vec<TopologyNode>,
72 pub edges: Vec<TopologyEdge>,
73 pub communities: Vec<TopologyCommunity>,
75}
76
77impl crate::Engine {
78 pub fn mem_topology(&self, mem: &str) -> Result<MemTopology, crate::EngineError> {
86 self.mem_topology_scoped(mem, None)
87 }
88
89 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 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 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 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 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 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 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 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 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 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 #[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 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}