memstead_base/validator/
graph.rs1use std::sync::Arc;
12
13use memstead_schema::{TypeDefinition, type_by_name};
14
15use super::ValidationError;
16use crate::entity::ParseResult;
17use crate::entity::store_builder::push_entities_into_store;
18use crate::graph::{LouvainOutput, community::detect_communities};
19use crate::store::Store;
20
21pub const VALIDATOR_LOUVAIN_SEED: u32 = 1;
25
26pub const VALIDATOR_RESOLUTION: f64 = 1.0;
31
32#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
39pub struct DanglingCrossMemEdge {
40 pub entity_path: String,
42 pub target_id: String,
44 pub target_mem: String,
46}
47
48#[derive(Debug)]
51pub struct GraphCheckResult {
52 pub store: Store,
53 pub communities: LouvainOutput,
54 pub dangling_cross_mem_edges: Vec<DanglingCrossMemEdge>,
60}
61
62pub fn build_and_check(
70 parse_results: Vec<ParseResult>,
71 fallback_schema: &TypeDefinition,
72 mem_name: &str,
73 cross_mem_as_error: bool,
74) -> Result<GraphCheckResult, ValidationError> {
75 if cross_mem_as_error
84 && let Some(pr) = parse_results.iter().find(|pr| {
85 pr.entity
86 .relationships
87 .iter()
88 .any(|r| r.target.mem() != mem_name)
89 })
90 {
91 let rel = pr
92 .entity
93 .relationships
94 .iter()
95 .find(|r| r.target.mem() != mem_name)
96 .expect("find guaranteed a match");
97 return Err(ValidationError::CrossMemRelationship {
98 path: pr.entity.file_path.clone(),
99 target: rel.target.as_ref().to_string(),
100 });
101 }
102 let dangling_cross_mem_edges = dangling_cross_mem_edges_in(&parse_results, mem_name);
103
104 let mut store = Store::new();
105 push_entities_into_store(&mut store, parse_results, fallback_schema, None);
108
109 let communities = detect_communities(
110 &store,
111 VALIDATOR_RESOLUTION,
112 VALIDATOR_LOUVAIN_SEED,
113 |rel_type| {
114 fallback_schema.edge_weight(rel_type) as f64
120 },
121 );
122
123 Ok(GraphCheckResult {
124 store,
125 communities,
126 dangling_cross_mem_edges,
127 })
128}
129
130pub fn dangling_cross_mem_edges_in(
136 parse_results: &[ParseResult],
137 mem_name: &str,
138) -> Vec<DanglingCrossMemEdge> {
139 let mut edges = Vec::new();
140 for pr in parse_results {
141 for rel in &pr.entity.relationships {
142 if rel.target.mem() != mem_name {
143 edges.push(DanglingCrossMemEdge {
144 entity_path: pr.entity.file_path.clone(),
145 target_id: rel.target.as_ref().to_string(),
146 target_mem: rel.target.mem().to_string(),
147 });
148 }
149 }
150 }
151 edges
152}
153
154pub fn resolve_fallback_type(config_types: Option<&[String]>) -> Arc<TypeDefinition> {
159 if let Some(name) = config_types.and_then(|v| v.first())
160 && let Some(s) = type_by_name(name)
161 {
162 return s;
163 }
164 crate::engine_fallback_type()
165}
166
167pub fn tally(store: &Store) -> (usize, usize) {
170 let entity_count = store.len();
171 let edge_count: usize = store.all_ids().map(|id| store.outgoing(id).len()).sum();
172 (entity_count, edge_count)
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178 use crate::entity::id::file_path_to_id;
179 use crate::entity::parser::parse_markdown;
180 use memstead_schema::type_by_name;
181
182 const MINIMAL_SPEC: &str = "\
183---
184type: spec
185created_date: 2026-01-15
186last_modified: 2026-01-15
187level: M0
188---
189# Alpha
190
191## Identity
192
193A
194
195## Purpose
196
197B
198
199## Specifies
200
201C
202
203## Constraints
204
205D
206
207## Rationale
208
209E
210
211## Relationships
212
213- **USES**: [[beta]]
214";
215
216 fn spec_type() -> Arc<TypeDefinition> {
217 type_by_name("spec").unwrap()
218 }
219
220 fn parse(path: &str, mem: &str, content: &str) -> ParseResult {
221 parse_markdown(content, path, &spec_type(), mem).unwrap()
222 }
223
224 #[test]
225 fn accepts_single_entity_archive() {
226 let result = build_and_check(
227 vec![parse("alpha.md", "v", MINIMAL_SPEC)],
228 &spec_type(),
229 "v",
230 true,
231 )
232 .unwrap();
233 assert!(result.store.contains(&file_path_to_id("alpha.md", "v")));
234 }
235
236 #[test]
237 fn materializes_stub_for_unresolved_wiki_link() {
238 let result = build_and_check(
239 vec![parse("alpha.md", "v", MINIMAL_SPEC)],
240 &spec_type(),
241 "v",
242 true,
243 )
244 .unwrap();
245 let stub_id = file_path_to_id("beta", "v");
246 let stub = result.store.get(&stub_id).expect("stub materialized");
247 assert!(stub.stub);
248 }
249
250 #[test]
251 fn empty_archive_yields_zero_communities() {
252 let result = build_and_check(vec![], &spec_type(), "v", true).unwrap();
253 assert_eq!(result.communities.count, 0);
254 }
255
256 #[test]
257 fn rejects_cross_mem_relationship() {
258 let cross_mem_pr = || {
262 let mut pr = parse("alpha.md", "v", MINIMAL_SPEC);
263 pr.entity.relationships.push(crate::entity::Relationship {
264 rel_type: "DEPENDS_ON".to_string(),
265 target: crate::entity::EntityId("other-mem--thing".to_string()),
266 description: None,
267 });
268 pr
269 };
270 let err = build_and_check(vec![cross_mem_pr()], &spec_type(), "v", true).unwrap_err();
271 assert!(matches!(err, ValidationError::CrossMemRelationship { .. }));
272
273 let result = build_and_check(vec![cross_mem_pr()], &spec_type(), "v", false).unwrap();
276 assert_eq!(result.dangling_cross_mem_edges.len(), 1);
277 let edge = &result.dangling_cross_mem_edges[0];
278 assert_eq!(edge.target_id, "other-mem--thing");
279 assert_eq!(edge.target_mem, "other-mem");
280 }
281
282 #[test]
283 fn resolve_fallback_type_picks_first_entry() {
284 let s = resolve_fallback_type(Some(&["concept".to_string(), "spec".to_string()]));
285 assert_eq!(s.name.as_str(), "concept");
286 }
287
288 #[test]
289 fn resolve_fallback_type_falls_back_on_unknown() {
290 let s = resolve_fallback_type(Some(&["bogus".to_string()]));
291 assert_eq!(s.name.as_str(), "spec");
293 }
294
295 #[test]
296 fn resolve_fallback_type_falls_back_on_empty() {
297 let s = resolve_fallback_type(None);
298 assert_eq!(s.name.as_str(), "spec");
299 }
300}