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 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 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 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 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 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 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 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 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 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 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 #[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 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}