Skip to main content

memstead_base/ops/
health.rs

1//! Health checks — missing required fields, staleness, scoring.
2//!
3//! Checks each entity against its schema's requirements:
4//! - Required metadata fields present and non-empty
5//! - Required sections present and non-empty
6//! - Staleness: days since last_modified > schema threshold
7//! - Undeclared relationships — existing entities whose
8//!   `relationships:` include a name that is not in the per-mem
9//!   schema's vocabulary surface as soft warnings rather than hard
10//!   load-time failures. Agents can fix either the entity or the
11//!   schema; undeclared *types* on load are decision-3 hard errors
12//!   and covered elsewhere.
13
14use std::collections::HashMap;
15use std::sync::Arc;
16
17use memstead_schema::{Schema, TypeDefinition, type_by_name};
18
19use super::{
20    DanglingLink, FoldedTag, HealthIssue, HealthReport, HealthSummary, StaleEntity,
21    TagDistribution, TagVariant, UntaggedStats,
22};
23use crate::entity::MetadataValue;
24use crate::graph::query;
25use crate::store::Store;
26
27/// Allowed `include` keys for `memstead_health` — the single source of
28/// truth shared across the lean MCP server, full MCP server, and the
29/// lean CLI's `health` command. Adding a new include key here lights
30/// it up uniformly; agents see the same `UNKNOWN_INCLUDE_KEY` warning
31/// shape whether they reach health via MCP or CLI.
32pub const HEALTH_INCLUDE_KEYS: &[&str] = &[
33    "orphans",
34    "stubs",
35    "most_connected",
36    "missing_fields",
37    "stale",
38    "dangling_links",
39    "tags",
40    "missing_required_outgoing",
41    "conformance",
42    "integrity",
43];
44
45/// Compute health reports for all entities in the store.
46///
47/// `mem_schemas` maps mem name → `Arc<Schema>`. Entities whose mem
48/// is missing from this map fall back to the builtin `default` schema
49/// relationship vocabulary (keeps legacy fixtures green; real production
50/// paths always register a mem schema).
51pub fn compute_health(
52    store: &Store,
53    default_schema: &TypeDefinition,
54    mem_schemas: &HashMap<String, Arc<Schema>>,
55) -> HealthSummary {
56    let mut missing_fields = Vec::new();
57    let mut stale_entities = Vec::new();
58
59    let today_days = days_since_epoch();
60
61    for entity in store.all_entities() {
62        if entity.stub {
63            continue;
64        }
65
66        // Resolve the entity's `TypeDefinition` against the entity's
67        // own mem's schema first. `type_by_name` only knows the
68        // builtin `default` schema; falling through to it on a mem
69        // pinned to a non-default schema (e.g. `planning@0.1.0`) would
70        // silently use `default_schema` (effectively `spec`) for every
71        // entity and report `spec`'s `health_required_fields` —
72        // `[identity, purpose]` — even on entities of types like
73        // `goal` / `option` / `decision`.
74        let resolved = mem_schemas
75            .get(entity.mem.as_str())
76            .and_then(|s| s.types.get(entity.entity_type.as_str()).cloned())
77            .or_else(|| type_by_name(&entity.entity_type));
78        let schema: &TypeDefinition = resolved.as_deref().unwrap_or(default_schema);
79        let mut issues = Vec::new();
80
81        // Check health_required_fields
82        for field in &schema.health_required_fields {
83            // Check if it's a section or metadata field
84            if schema.section(field).is_some() {
85                // It's a section
86                let content = entity.sections.get(field.as_str());
87                if content.is_none_or(|c| c.trim().is_empty()) {
88                    issues.push(HealthIssue {
89                        field: field.clone(),
90                        message: format!("required section '{field}' is empty"),
91                    });
92                }
93            } else {
94                // It's a metadata field. Treat missing AND empty /
95                // whitespace-only values as gaps so the scan matches
96                // the section branch's `trim().is_empty()` semantics
97                // — an empty `MetadataValue::String("")` is just as
98                // unhelpful to an agent as an absent key.
99                let value = entity.metadata.get(field.as_str());
100                let is_empty = match value {
101                    None => true,
102                    Some(v) => v.to_frontmatter_string().trim().is_empty(),
103                };
104                if is_empty {
105                    issues.push(HealthIssue {
106                        field: field.clone(),
107                        message: format!("required field '{field}' is missing"),
108                    });
109                }
110            }
111        }
112
113        // Undeclared-relationship warning. Scan the entity's
114        // relationship list against the mem's schema vocabulary; every
115        // unknown name becomes a soft HealthIssue (same severity as a
116        // missing section) so agents running a health sweep after a
117        // schema version bump see drift without a crashed load.
118        //
119        // Shape-violation scan: when the mem's schema declares
120        // `source_types` / `target_types` on a relationship and an
121        // existing edge violates the shape, surface as a soft
122        // HealthIssue. The relate-add path enforces shape going
123        // forward; this scan catches edges authored before the
124        // constraint landed (or via inline `relations:` on
125        // memstead_create, which does not yet shape-check). The
126        // remove-path on `memstead_relate` skips shape validation so the
127        // cleanup is always reachable.
128        if let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) {
129            let mut seen_unknown = std::collections::HashSet::new();
130            for rel in &entity.relationships {
131                if !mem_schema.relationship_known(&rel.rel_type) {
132                    if seen_unknown.insert(rel.rel_type.clone()) {
133                        let suggestion = mem_schema
134                            .suggest_relationship(&rel.rel_type)
135                            .map(|s| format!(" Did you mean '{s}'?"))
136                            .unwrap_or_default();
137                        let (schema_name, schema_version) = mem_schema.id();
138                        issues.push(HealthIssue {
139                            field: "relationships".to_string(),
140                            message: format!(
141                                "relationship '{}' is not declared in schema \
142                                 '{schema_name}@{schema_version}'.{suggestion}",
143                                rel.rel_type
144                            ),
145                        });
146                    }
147                    continue;
148                }
149
150                let target_type = store
151                    .get(&rel.target)
152                    .map(|t| t.entity_type.clone())
153                    .filter(|t| !t.is_empty());
154                if let Err(crate::runtime_validator::ValidationError::InvalidRelationshipShape {
155                    rel_type,
156                    from_type,
157                    to_type,
158                    allowed_source_types,
159                    allowed_target_types,
160                    ..
161                }) = crate::runtime_validator::validate_rel_shape(
162                    &rel.rel_type,
163                    entity.entity_type.as_str(),
164                    target_type.as_deref(),
165                    mem_schema.as_ref(),
166                ) {
167                    let allowed_src = if allowed_source_types.is_empty() {
168                        "<any>".to_string()
169                    } else {
170                        allowed_source_types.join(", ")
171                    };
172                    let allowed_tgt = if allowed_target_types.is_empty() {
173                        "<any>".to_string()
174                    } else {
175                        allowed_target_types.join(", ")
176                    };
177                    issues.push(HealthIssue {
178                        field: "relationships".to_string(),
179                        message: format!(
180                            "INVALID_REL_SHAPE: edge '{rel_type}' from \
181                             '{from_type}' to '{to_type}' (target {target}) \
182                             violates declared shape — allowed_source_types: \
183                             [{allowed_src}], allowed_target_types: \
184                             [{allowed_tgt}]. Remove via \
185                             `memstead_relate from={from_id} to={target} \
186                             type={rel_type} remove=true`.",
187                            target = rel.target,
188                            from_id = entity.id,
189                        ),
190                    });
191                }
192            }
193        }
194
195        // Staleness check
196        let auto_ts_field = schema.metadata_fields.iter().find(|f| f.auto_timestamp);
197
198        if let Some(ts_field) = auto_ts_field
199            && let Some(val) = entity.metadata.get(ts_field.key.as_str())
200        {
201            let date_str = val.to_frontmatter_string();
202            if let Some(modified_days) = parse_iso_to_days(&date_str) {
203                let days_since = today_days.saturating_sub(modified_days);
204                if days_since > schema.staleness_threshold_days as u64 {
205                    stale_entities.push(StaleEntity {
206                        id: entity.id.clone(),
207                        title: entity.title.clone(),
208                        days_since_modified: days_since,
209                    });
210                }
211            }
212        }
213
214        if !issues.is_empty() {
215            // Compute a simple health score: (total_fields - issues) / total_fields.
216            // `issues.len()` can exceed `total_fields` once the
217            // relationship-vocabulary issues are added on top, so saturate
218            // the subtraction rather than underflow. A score of 0.0 is the
219            // natural floor — agents treat it as "maximally broken".
220            let total = schema.health_required_fields.len();
221            let score = if total > 0 {
222                (total.saturating_sub(issues.len()) as f32) / (total as f32)
223            } else {
224                1.0
225            };
226
227            missing_fields.push(HealthReport {
228                id: entity.id.clone(),
229                title: entity.title.clone(),
230                score,
231                issues,
232            });
233        }
234    }
235
236    // Sort stale entities by days_since_modified descending
237    stale_entities.sort_by_key(|e| std::cmp::Reverse(e.days_since_modified));
238
239    // Structural counts
240    let orphan_count = query::find_orphans(store).len();
241    let stub_count = query::find_stubs(store).len();
242
243    HealthSummary {
244        stale_entities,
245        missing_fields,
246        orphan_count,
247        stub_count,
248        warnings: Vec::new(),
249        dangling_links: None,
250        findings: None,
251        tag_distribution: None,
252        tag_distribution_folded: None,
253        untagged_entities: None,
254    }
255}
256
257/// Scan every non-stub entity's `tags` metadata and aggregate (tag → count,
258/// per-entity-type breakdown) plus untagged coverage. Comma-separated parser
259/// with per-segment trim; empty segments drop. Comparison is case-sensitive
260/// on the primary surface — case drift is surfaced separately via
261/// [`TagDistribution`] siblings folded by the caller if desired.
262///
263/// `mem_filter` narrows both aggregation passes to entities in that mem;
264/// `limit` caps the returned `tag_distribution` array after sorting by count
265/// descending (tie-break by tag ascending for deterministic output).
266///
267/// Also returns `FoldedTag` entries for any canonical (lowercase) tag where
268/// two or more authored casings appear — drift-flag only; empty when no
269/// collisions exist.
270pub fn collect_tag_distribution(
271    store: &Store,
272    mem_filter: Option<&str>,
273    limit: usize,
274) -> (Vec<TagDistribution>, Vec<FoldedTag>, UntaggedStats) {
275    // tag → (count, per_type_count)
276    let mut counts: HashMap<String, (usize, HashMap<String, usize>)> = HashMap::new();
277    let mut untagged = UntaggedStats {
278        total: 0,
279        by_entity_type: HashMap::new(),
280    };
281
282    for entity in store.all_entities() {
283        if entity.stub {
284            continue;
285        }
286        if let Some(v) = mem_filter
287            && entity.mem != v
288        {
289            continue;
290        }
291
292        let tags_raw = entity
293            .metadata
294            .get("tags")
295            .and_then(|v| match v {
296                MetadataValue::String(s) => Some(s.as_str()),
297                _ => None,
298            })
299            .unwrap_or("");
300
301        let mut any_tag = false;
302        for tag in tags_raw.split(',').map(str::trim).filter(|s| !s.is_empty()) {
303            any_tag = true;
304            let entry = counts
305                .entry(tag.to_string())
306                .or_insert_with(|| (0, HashMap::new()));
307            entry.0 += 1;
308            *entry.1.entry(entity.entity_type.clone()).or_insert(0) += 1;
309        }
310        if !any_tag {
311            untagged.total += 1;
312            *untagged
313                .by_entity_type
314                .entry(entity.entity_type.clone())
315                .or_insert(0) += 1;
316        }
317    }
318
319    // Primary distribution — case-sensitive.
320    let mut entries: Vec<TagDistribution> = counts
321        .iter()
322        .map(|(tag, (count, by_type))| TagDistribution {
323            tag: tag.clone(),
324            count: *count,
325            by_entity_type: by_type.clone(),
326        })
327        .collect();
328    entries.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.tag.cmp(&b.tag)));
329    entries.truncate(limit);
330
331    // Case-drift sidecar: group by lowercase canonical; surface only entries
332    // with ≥2 distinct authored casings. Operates on the full counts map, not
333    // the truncated primary surface, so drift hidden below `limit` still
334    // surfaces.
335    let mut by_canonical: HashMap<String, Vec<(String, usize)>> = HashMap::new();
336    for (tag, (count, _)) in counts.iter() {
337        by_canonical
338            .entry(tag.to_lowercase())
339            .or_default()
340            .push((tag.clone(), *count));
341    }
342    let mut folded: Vec<FoldedTag> = by_canonical
343        .into_iter()
344        .filter(|(_, v)| v.len() > 1)
345        .map(|(canonical, mut variants)| {
346            variants.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
347            let total = variants.iter().map(|(_, c)| *c).sum();
348            FoldedTag {
349                canonical,
350                total,
351                variants: variants
352                    .into_iter()
353                    .map(|(tag, count)| TagVariant { tag, count })
354                    .collect(),
355            }
356        })
357        .collect();
358    folded.sort_by(|a, b| {
359        b.total
360            .cmp(&a.total)
361            .then_with(|| a.canonical.cmp(&b.canonical))
362    });
363
364    (entries, folded, untagged)
365}
366
367/// Scan every non-stub entity's section bodies for body wiki-links that
368/// either (a) resolve to a stub target (missing on-disk file) or
369/// (b) lack a backing explicit relation in the referrer (alias-orphan
370/// under the alias model). Both cases surface through the same
371/// `DanglingLink` shape — the existing field set continues to round-trip;
372/// alias-orphans are detectable by the target *not* being a stub while
373/// the referrer's relationships list omits it.
374///
375/// The scan also covers the `## Relationships` table: a typed-relation
376/// target whose entity vanished (out-of-band file edit, historical
377/// cross-mem corruption from the pre-F15 mem-delete path, etc.)
378/// would otherwise stay invisible to the diagnostic surface.
379/// Relationship-section danglers ship the same envelope shape with
380/// `section: None` — the Option marks the source axis without requiring
381/// a magic-string sentinel.
382///
383/// `mem_filter` narrows *scanning* to entities in that mem; resolution
384/// stays global so cross-mem links whose target is a real entity
385/// elsewhere are not flagged as missing.
386pub fn collect_dangling_links(store: &Store, mem_filter: Option<&str>) -> Vec<DanglingLink> {
387    use crate::entity::parser::extract_inline_links_lenient;
388    use std::collections::HashSet;
389
390    let mut out = Vec::new();
391    for entity in store.all_entities() {
392        if entity.stub {
393            continue;
394        }
395        if let Some(v) = mem_filter
396            && entity.mem != v
397        {
398            continue;
399        }
400        let explicit_targets: HashSet<_> = entity
401            .relationships
402            .iter()
403            .map(|r| r.target.clone())
404            .collect();
405        for (section_key, section_body) in &entity.sections {
406            for target_id in extract_inline_links_lenient(section_body, &entity.mem) {
407                let target_missing = store.get(&target_id).map(|e| e.stub).unwrap_or(true);
408                let alias_orphan = !target_missing && !explicit_targets.contains(&target_id);
409                if target_missing || alias_orphan {
410                    out.push(DanglingLink {
411                        from: entity.id.clone(),
412                        target_id: target_id.clone(),
413                        target_path: target_id.path().to_string(),
414                        section: Some(section_key.clone()),
415                    });
416                }
417            }
418        }
419        // Relationship-table dangler scan. The `## Relationships`
420        // section is structurally distinct from body sections — its
421        // rows materialise from `entity.relationships` rather than a
422        // free-text body — so `section: None` marks the source axis.
423        //
424        // Discrimination differs from the body scan: a relationship
425        // target that resolves to a stub is a legitimate forward
426        // reference (the alias machinery auto-stubs absent targets
427        // by design), not corruption. Only a target that's *fully
428        // absent* from the store — neither stub nor real — flags as
429        // dangling. In practice this only fires for out-of-band file
430        // edits or historical cross-mem-delete corruption that
431        // dropped the stub along with the deleted mem.
432        //
433        // Dedup against the body-scan output so a target that
434        // surfaces from both axes doesn't double-emit.
435        for rel in &entity.relationships {
436            if store.get(&rel.target).is_some() {
437                continue;
438            }
439            let already_reported = out
440                .iter()
441                .any(|d| d.from == entity.id && d.target_id == rel.target);
442            if already_reported {
443                continue;
444            }
445            out.push(DanglingLink {
446                from: entity.id.clone(),
447                target_id: rel.target.clone(),
448                target_path: rel.target.path().to_string(),
449                section: None,
450            });
451        }
452    }
453    out
454}
455
456/// Collect every non-stub entity whose type declares `required_outgoing`
457/// blocks that the entity's current outgoing edges leave unsatisfied.
458/// Results are deterministic — sorted
459/// by `(mem, id)` — so the agent can diff successive sweeps without
460/// the underlying HashMap iteration order leaking through.
461///
462/// `mem_filter` narrows scanning to entities in that mem when set;
463/// `mem_schemas` resolves the entity's type definition against the
464/// mem's pinned schema. Entities whose mem has no schema in the
465/// map are skipped (no schema → no `required_outgoing` to evaluate).
466pub fn collect_missing_required_outgoing(
467    store: &Store,
468    mem_filter: Option<&str>,
469    mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
470) -> Vec<MissingRequiredOutgoingReport> {
471    let mut out = Vec::new();
472    for entity in store.all_entities() {
473        if entity.stub {
474            continue;
475        }
476        if let Some(v) = mem_filter
477            && entity.mem != v
478        {
479            continue;
480        }
481        let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) else {
482            continue;
483        };
484        let Some(td) = mem_schema.types.get(entity.entity_type.as_str()) else {
485            continue;
486        };
487        if td.required_outgoing.is_empty() {
488            continue;
489        }
490        let unsatisfied: Vec<MissingOutgoingBlock> = td
491            .required_outgoing
492            .iter()
493            .filter(|block| {
494                let count = entity
495                    .relationships
496                    .iter()
497                    .filter(|rel| block.relationships.iter().any(|name| name == &rel.rel_type))
498                    .count();
499                !block.admits(count)
500            })
501            .map(|block| MissingOutgoingBlock {
502                relationships: block.relationships.clone(),
503                cardinality: block.cardinality.to_string(),
504            })
505            .collect();
506        if unsatisfied.is_empty() {
507            continue;
508        }
509        out.push(MissingRequiredOutgoingReport {
510            id: entity.id.clone(),
511            title: entity.title.clone(),
512            entity_type: entity.entity_type.clone(),
513            mem: entity.mem.clone(),
514            missing: unsatisfied,
515        });
516    }
517    out.sort_by(|a, b| a.mem.cmp(&b.mem).then_with(|| a.id.0.cmp(&b.id.0)));
518    out
519}
520
521/// One entity's unsatisfied `required_outgoing` blocks, surfaced from
522/// the health-time scan. Wire shape mirrors the per-write
523/// `MISSING_REQUIRED_OUTGOING` warning's `details` payload but adds
524/// the `mem` name (the warning's `entity_id` already encodes it via
525/// the mem prefix, but health is multi-mem by default and an
526/// explicit field is cheaper for downstream filters).
527#[derive(Debug, Clone, serde::Serialize)]
528pub struct MissingRequiredOutgoingReport {
529    pub id: crate::entity::EntityId,
530    pub title: String,
531    pub entity_type: String,
532    pub mem: String,
533    pub missing: Vec<MissingOutgoingBlock>,
534}
535
536#[derive(Debug, Clone, serde::Serialize)]
537pub struct MissingOutgoingBlock {
538    pub relationships: Vec<String>,
539    pub cardinality: String,
540}
541
542/// Get a single entity's health report.
543pub fn entity_health(entity: &crate::entity::Entity, schema: &TypeDefinition) -> HealthReport {
544    let mut issues = Vec::new();
545
546    for field in &schema.health_required_fields {
547        if schema.section(field).is_some() {
548            let content = entity.sections.get(field.as_str());
549            if content.is_none_or(|c| c.trim().is_empty()) {
550                issues.push(HealthIssue {
551                    field: field.clone(),
552                    message: format!("required section '{field}' is empty"),
553                });
554            }
555        } else {
556            let value = entity.metadata.get(field.as_str());
557            if value.is_none() {
558                issues.push(HealthIssue {
559                    field: field.clone(),
560                    message: format!("required field '{field}' is missing"),
561                });
562            }
563        }
564    }
565
566    let total = schema.health_required_fields.len();
567    let score = if total > 0 {
568        ((total - issues.len()) as f32) / (total as f32)
569    } else {
570        1.0
571    };
572
573    HealthReport {
574        id: entity.id.clone(),
575        title: entity.title.clone(),
576        score,
577        issues,
578    }
579}
580
581// ---------------------------------------------------------------------------
582// Date helpers
583// ---------------------------------------------------------------------------
584
585/// Get current days since Unix epoch.
586///
587/// `SystemTime::now()` is unimplemented on `wasm32-unknown-unknown` —
588/// it traps with `RuntimeError: unreachable` and poisons the wasm
589/// instance (cold-start F11) — so the wasm build reads the JS-backed
590/// clock instead. Same value, same summary shape on every target.
591fn days_since_epoch() -> u64 {
592    #[cfg(target_arch = "wasm32")]
593    {
594        (js_sys::Date::now() / 1000.0) as u64 / 86400
595    }
596    #[cfg(not(target_arch = "wasm32"))]
597    {
598        std::time::SystemTime::now()
599            .duration_since(std::time::UNIX_EPOCH)
600            .unwrap_or_default()
601            .as_secs()
602            / 86400
603    }
604}
605
606/// Parse an ISO 8601 date string to days since epoch.
607/// Supports `YYYY-MM-DD` and `YYYY-MM-DDTHH:MM:SSZ`.
608fn parse_iso_to_days(date: &str) -> Option<u64> {
609    let date_part = date.split('T').next()?;
610    let parts: Vec<&str> = date_part.split('-').collect();
611    if parts.len() != 3 {
612        return None;
613    }
614    let year: u64 = parts[0].parse().ok()?;
615    let month: u64 = parts[1].parse().ok()?;
616    let day: u64 = parts[2].parse().ok()?;
617    Some(ymd_to_days(year, month, day))
618}
619
620/// Convert (year, month, day) to days since Unix epoch.
621/// Inverse of the algorithm in generator.rs.
622fn ymd_to_days(year: u64, month: u64, day: u64) -> u64 {
623    // Algorithm from http://howardhinnant.github.io/date_algorithms.html
624    let y = if month <= 2 { year - 1 } else { year };
625    let m = if month <= 2 { month + 9 } else { month - 3 };
626    let era = y / 400;
627    let yoe = y - era * 400;
628    let doy = (153 * m + 2) / 5 + day - 1;
629    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
630    let days = era * 146097 + doe;
631    days - 719468
632}
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637    use crate::entity::{Entity, EntityId, MetadataValue};
638    use crate::store::Store;
639    use indexmap::IndexMap;
640    use memstead_schema::type_by_name;
641
642    fn make_entity(name: &str, has_required: bool) -> Entity {
643        let mut metadata = IndexMap::new();
644        metadata.insert("level".into(), MetadataValue::String("M0".into()));
645        metadata.insert("type".into(), MetadataValue::String("spec".into()));
646        metadata.insert(
647            "created_date".into(),
648            MetadataValue::String("2026-01-15".into()),
649        );
650        metadata.insert(
651            "last_modified".into(),
652            MetadataValue::String("2026-04-12".into()),
653        );
654
655        let mut sections = IndexMap::new();
656        if has_required {
657            sections.insert("identity".into(), "Has identity.".into());
658            sections.insert("purpose".into(), "Has purpose.".into());
659        }
660
661        Entity {
662            id: EntityId::new("specs", name),
663            title: name.into(),
664            entity_type: "spec".into(),
665            mem: "specs".into(),
666            file_path: format!("{name}.md"),
667            metadata,
668            sections,
669            relationships: Vec::new(),
670            content_hash: String::new(),
671            stub: false,
672            stub_kind: None,
673            heading_spans: std::collections::HashMap::new(),
674        }
675    }
676
677    fn make_concept_entity(name: &str, with_definition: bool) -> Entity {
678        let mut metadata = IndexMap::new();
679        metadata.insert("type".into(), MetadataValue::String("concept".into()));
680        metadata.insert("maturity".into(), MetadataValue::String("emerging".into()));
681        metadata.insert(
682            "abstraction_level".into(),
683            MetadataValue::String("concrete".into()),
684        );
685        metadata.insert(
686            "created_date".into(),
687            MetadataValue::String("2026-01-15".into()),
688        );
689        metadata.insert(
690            "last_modified".into(),
691            MetadataValue::String("2026-04-12".into()),
692        );
693
694        let mut sections = IndexMap::new();
695        if with_definition {
696            sections.insert("definition".into(), "Precise definition.".into());
697        }
698        sections.insert("explanation".into(), "How it works.".into());
699
700        Entity {
701            id: EntityId::new("concepts", name),
702            title: name.into(),
703            entity_type: "concept".into(),
704            mem: "concepts".into(),
705            file_path: format!("{name}.md"),
706            metadata,
707            sections,
708            relationships: Vec::new(),
709            content_hash: String::new(),
710            stub: false,
711            stub_kind: None,
712            heading_spans: std::collections::HashMap::new(),
713        }
714    }
715
716    #[test]
717    fn health_concept_missing_definition_reports_definition_field() {
718        let schema = &type_by_name("concept").unwrap();
719        let entity = make_concept_entity("clarity", false);
720        let report = entity_health(&entity, schema);
721
722        // The missing-field issue must name the concept schema's required
723        // section ("definition"), not spec's "identity".
724        assert!(report.issues.iter().any(|i| i.field == "definition"));
725        assert!(!report.issues.iter().any(|i| i.field == "identity"));
726        assert!(!report.issues.iter().any(|i| i.field == "purpose"));
727        assert!(report.score < 1.0);
728
729        // An entity with the definition filled in has no issue for that field.
730        let healthy = make_concept_entity("clarity-ok", true);
731        let healthy_report = entity_health(&healthy, schema);
732        assert!(
733            !healthy_report
734                .issues
735                .iter()
736                .any(|i| i.field == "definition")
737        );
738    }
739
740    #[test]
741    fn health_detects_missing_sections() {
742        let schema = &type_by_name("spec").unwrap();
743        let entity = make_entity("incomplete", false);
744        let report = entity_health(&entity, schema);
745        assert!(!report.issues.is_empty());
746        assert!(report.score < 1.0);
747    }
748
749    #[test]
750    fn health_clean_entity() {
751        let schema = &type_by_name("spec").unwrap();
752        let entity = make_entity("complete", true);
753        let report = entity_health(&entity, schema);
754        // May still have issues for other required fields, but identity/purpose are covered
755        let section_issues: Vec<_> = report
756            .issues
757            .iter()
758            .filter(|i| i.field == "identity" || i.field == "purpose")
759            .collect();
760        assert!(section_issues.is_empty());
761    }
762
763    #[test]
764    fn health_summary_counts() {
765        let mut store = Store::new();
766        let e1 = make_entity("healthy", true);
767        let e2 = make_entity("unhealthy", false);
768        store.upsert(e1.id.clone(), e1);
769        store.upsert(e2.id.clone(), e2);
770
771        let schema = &type_by_name("spec").unwrap();
772        let summary = compute_health(&store, schema, &HashMap::new());
773        assert_eq!(summary.orphan_count, 2); // No edges between them
774        assert_eq!(summary.stub_count, 0);
775    }
776
777    #[test]
778    fn health_surfaces_invalid_rel_shape_on_existing_edges() {
779        // software@0.1.0 declares `source_types: [actor]` on OWNS.
780        // Seed a non-actor source with an outgoing OWNS edge — the
781        // health scan must surface `INVALID_REL_SHAPE` in the
782        // entity's issues so an agent running a sweep can identify
783        // edges to clean up via `memstead_relate remove=true`.
784        use crate::entity::Relationship;
785        use memstead_schema::SchemaRegistry;
786
787        let registry = SchemaRegistry::builtin();
788        let software = registry
789            .resolve_by_name("software")
790            .unwrap()
791            .expect("software schema ships as a builtin");
792
793        let mut store = Store::new();
794        // Source entity is `spec`, not `actor`. Add an OWNS edge to
795        // a target whose type doesn't matter for source-side shape.
796        let mut bad = make_entity("bad-owns-source", true);
797        bad.entity_type = "spec".into();
798        bad.metadata
799            .insert("level".into(), MetadataValue::String("M0".into()));
800        bad.metadata
801            .insert("stability".into(), MetadataValue::String("evolving".into()));
802        bad.relationships.push(Relationship {
803            rel_type: "OWNS".into(),
804            target: EntityId::new("specs", "victim"),
805            description: None,
806        });
807        let mut victim = make_entity("victim", true);
808        victim.entity_type = "spec".into();
809        store.upsert(bad.id.clone(), bad);
810        store.upsert(victim.id.clone(), victim);
811
812        let mut mem_schemas = HashMap::new();
813        mem_schemas.insert("specs".to_string(), software);
814
815        let schema = &type_by_name("spec").unwrap();
816        let summary = compute_health(&store, schema, &mem_schemas);
817        let report = summary
818            .missing_fields
819            .iter()
820            .find(|r| r.id.as_ref() == "specs--bad-owns-source")
821            .expect("shape-violating entity must surface");
822        let issue = report
823            .issues
824            .iter()
825            .find(|i| i.field == "relationships" && i.message.contains("INVALID_REL_SHAPE"))
826            .expect("shape violation must produce an INVALID_REL_SHAPE issue");
827        assert!(
828            issue.message.contains("OWNS"),
829            "issue must name the offending rel_type: {}",
830            issue.message
831        );
832        assert!(
833            issue.message.contains("spec"),
834            "issue must name the actual source type: {}",
835            issue.message
836        );
837        assert!(
838            issue.message.contains("actor"),
839            "issue must name the allowed source type: {}",
840            issue.message
841        );
842        assert!(
843            issue.message.contains("remove=true"),
844            "issue must surface the recovery path: {}",
845            issue.message
846        );
847    }
848
849    #[test]
850    fn health_does_not_flag_shape_compliant_edges() {
851        // Sanity counterpart: an actor source with OWNS edge satisfies
852        // `source_types: [actor]` — no INVALID_REL_SHAPE issue surfaces.
853        use crate::entity::Relationship;
854        use memstead_schema::SchemaRegistry;
855
856        let registry = SchemaRegistry::builtin();
857        let software = registry
858            .resolve_by_name("software")
859            .unwrap()
860            .expect("software schema ships as a builtin");
861
862        let mut store = Store::new();
863        let mut owner = make_entity("owner", true);
864        owner.entity_type = "actor".into();
865        owner
866            .metadata
867            .insert("kind".into(), MetadataValue::String("team".into()));
868        owner
869            .metadata
870            .insert("active".into(), MetadataValue::Bool(true));
871        owner
872            .metadata
873            .insert("handle".into(), MetadataValue::String("owner".into()));
874        owner.relationships.push(Relationship {
875            rel_type: "OWNS".into(),
876            target: EntityId::new("specs", "owned"),
877            description: None,
878        });
879        let mut owned = make_entity("owned", true);
880        owned.entity_type = "spec".into();
881        store.upsert(owner.id.clone(), owner);
882        store.upsert(owned.id.clone(), owned);
883
884        let mut mem_schemas = HashMap::new();
885        mem_schemas.insert("specs".to_string(), software);
886
887        let schema = &type_by_name("spec").unwrap();
888        let summary = compute_health(&store, schema, &mem_schemas);
889        let shape_issue = summary
890            .missing_fields
891            .iter()
892            .flat_map(|r| r.issues.iter())
893            .find(|i| i.message.contains("INVALID_REL_SHAPE"));
894        assert!(
895            shape_issue.is_none(),
896            "shape-compliant edge must not surface a shape issue, got: {shape_issue:?}"
897        );
898    }
899
900    #[test]
901    fn health_warns_on_undeclared_relationship_in_existing_entity() {
902        use crate::entity::Relationship;
903        use memstead_schema::Schema;
904
905        let mut store = Store::new();
906        let mut entity = make_entity("with-bad-rel", true);
907        // Author an edge using a name that does not exist in the default
908        // schema's vocabulary. The load-side contract per decision 3 is
909        // about unknown *types*; unknown *relationships* on an already-
910        // loaded entity land in the soft health surface instead so an
911        // agent running `memstead_health` after a schema edit sees the drift.
912        entity.relationships.push(Relationship {
913            rel_type: "CONJURES".into(),
914            target: EntityId::new("specs", "unknown"),
915            description: None,
916        });
917        store.upsert(entity.id.clone(), entity);
918
919        let mut mem_schemas = HashMap::new();
920        mem_schemas.insert("specs".to_string(), Schema::builtin_default());
921
922        let schema = &type_by_name("spec").unwrap();
923        let summary = compute_health(&store, schema, &mem_schemas);
924        let report = summary
925            .missing_fields
926            .iter()
927            .find(|r| r.id.as_ref() == "specs--with-bad-rel")
928            .expect("entity must surface in missing_fields");
929        let rel_issue = report
930            .issues
931            .iter()
932            .find(|i| i.field == "relationships")
933            .expect("undeclared relationship must produce an issue");
934        assert!(
935            rel_issue.message.contains("CONJURES"),
936            "issue message must name the offending relationship: {}",
937            rel_issue.message
938        );
939        assert!(
940            rel_issue.message.contains("default@1.0.0"),
941            "issue must name the schema pin: {}",
942            rel_issue.message
943        );
944    }
945
946    // -------------------------------------------------------------------
947    // Dangling wiki-link detection
948    // -------------------------------------------------------------------
949
950    /// Build an entity with an arbitrary section body so the test can seed
951    /// inline wiki-links at will. Mem defaults to `specs`.
952    fn make_entity_with_body(name: &str, section_key: &str, body: &str) -> Entity {
953        let mut entity = make_entity(name, true);
954        entity.sections.insert(section_key.into(), body.to_string());
955        entity
956    }
957
958    #[test]
959    fn dangling_link_detected_after_delete() {
960        use crate::entity::store_builder::make_stub;
961
962        let mut store = Store::new();
963        let a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
964        store.upsert(a.id.clone(), a.clone());
965
966        // Seed b as a stub — the signal that its markdown file is gone
967        // (post-delete, pre-recreate, or never authored).
968        let b_id = EntityId::new("specs", "b");
969        store.upsert(b_id.clone(), make_stub(b_id.clone()));
970
971        let dangling = super::collect_dangling_links(&store, None);
972        assert_eq!(dangling.len(), 1, "exactly one dangling link expected");
973        let d = &dangling[0];
974        assert_eq!(d.from, a.id);
975        assert_eq!(d.target_id, b_id);
976        assert_eq!(d.target_path, "b");
977        assert_eq!(d.section.as_deref(), Some("purpose"));
978    }
979
980    #[test]
981    fn dangling_link_does_not_flag_stub_target_of_explicit_relationship() {
982        use crate::entity::Relationship;
983        use crate::entity::store_builder::make_stub;
984
985        let mut store = Store::new();
986        // A has NO inline link in its body — only an explicit relationship
987        // edge pointing at a stub.
988        let mut a = make_entity("a", true);
989        let b_id = EntityId::new("specs", "b");
990        a.relationships.push(Relationship {
991            rel_type: "REFERENCES".into(),
992            target: b_id.clone(),
993            description: None,
994        });
995        store.upsert(a.id.clone(), a);
996        store.upsert(b_id.clone(), make_stub(b_id));
997
998        let dangling = super::collect_dangling_links(&store, None);
999        assert!(
1000            dangling.is_empty(),
1001            "explicit relationships to stubs are valid by design \
1002             (stubs are first-class placeholders); only inline-body \
1003             wiki-links to stubs must surface"
1004        );
1005    }
1006
1007    #[test]
1008    fn dangling_link_does_not_flag_real_reference() {
1009        use crate::entity::Relationship;
1010
1011        let mut store = Store::new();
1012        let mut a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
1013        // Backing relation makes the body link a valid alias.
1014        a.relationships.push(Relationship {
1015            rel_type: "REFERENCES".into(),
1016            target: EntityId::new("specs", "b"),
1017            description: None,
1018        });
1019        let b = make_entity("b", true);
1020        store.upsert(a.id.clone(), a);
1021        store.upsert(b.id.clone(), b);
1022
1023        let dangling = super::collect_dangling_links(&store, None);
1024        assert!(
1025            dangling.is_empty(),
1026            "real reference backed by relation — not dangling, not alias-orphan"
1027        );
1028    }
1029
1030    /// F12: a `## Relationships` row pointing at a fully-absent target
1031    /// (out-of-band file edit, mem-delete corruption) must surface.
1032    /// The scan covers both axes; relationship-table danglers ship
1033    /// `section: None` to mark the source axis.
1034    #[test]
1035    fn dangling_link_relationship_section_target_absent() {
1036        use crate::entity::Relationship;
1037
1038        let mut store = Store::new();
1039        let mut a = make_entity("a", true);
1040        // Note: NO stub in the store for `gone` — out-of-band edit
1041        // removed the stub but left the relationship row.
1042        a.relationships.push(Relationship {
1043            rel_type: "DEPENDS_ON".into(),
1044            target: EntityId::new("specs", "gone"),
1045            description: None,
1046        });
1047        store.upsert(a.id.clone(), a.clone());
1048
1049        let dangling = super::collect_dangling_links(&store, None);
1050        assert_eq!(
1051            dangling.len(),
1052            1,
1053            "exactly one relationship-section dangler"
1054        );
1055        let d = &dangling[0];
1056        assert_eq!(d.from, a.id);
1057        assert_eq!(d.target_id, EntityId::new("specs", "gone"));
1058        assert!(
1059            d.section.is_none(),
1060            "relationship-section danglers ship `section: None`, got {:?}",
1061            d.section
1062        );
1063    }
1064
1065    /// Relationship rows pointing at stubs are NOT flagged. Auto-stub
1066    /// is the alias machinery's forward-reference mechanism; flagging
1067    /// stubs would conflate the "engine-managed placeholder" case with
1068    /// corruption.
1069    #[test]
1070    fn dangling_link_relationship_section_stub_target_not_flagged() {
1071        use crate::entity::Relationship;
1072        use crate::entity::store_builder::make_stub;
1073
1074        let mut store = Store::new();
1075        let mut a = make_entity("a", true);
1076        let b_id = EntityId::new("specs", "b");
1077        a.relationships.push(Relationship {
1078            rel_type: "DEPENDS_ON".into(),
1079            target: b_id.clone(),
1080            description: None,
1081        });
1082        store.upsert(a.id.clone(), a);
1083        store.upsert(b_id.clone(), make_stub(b_id));
1084
1085        let dangling = super::collect_dangling_links(&store, None);
1086        assert!(
1087            dangling.is_empty(),
1088            "relationship targets that resolve to stubs are forward-references, not corruption"
1089        );
1090    }
1091
1092    /// When both the body and the relationship section point at the
1093    /// same fully-absent target, the dangler dedupes to a single entry
1094    /// on whichever axis fired first (body-scan runs
1095    /// before relationship-scan in the implementation; the body axis
1096    /// wins). Stub-shaped duplicates are not possible because the
1097    /// relationship-section scan skips stubs.
1098    #[test]
1099    fn dangling_link_dedups_across_body_and_relations() {
1100        use crate::entity::Relationship;
1101        use crate::entity::store_builder::make_stub;
1102
1103        let mut store = Store::new();
1104        let mut a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
1105        let b_id = EntityId::new("specs", "b");
1106        a.relationships.push(Relationship {
1107            rel_type: "REFERENCES".into(),
1108            target: b_id.clone(),
1109            description: None,
1110        });
1111        store.upsert(a.id.clone(), a.clone());
1112        store.upsert(b_id.clone(), make_stub(b_id.clone()));
1113
1114        let dangling = super::collect_dangling_links(&store, None);
1115        assert_eq!(
1116            dangling.len(),
1117            1,
1118            "body + relations both pointing at the same stub should dedup"
1119        );
1120        // Body scan fires first; the surviving entry carries
1121        // `section: Some(_)`.
1122        assert!(dangling[0].section.is_some(), "body axis wins the dedup");
1123    }
1124
1125    #[test]
1126    fn dangling_links_scope_to_mem_filter() {
1127        use crate::entity::store_builder::make_stub;
1128
1129        let mut store = Store::new();
1130
1131        // specs--a with body [[gone]] → dangling in specs.
1132        let a = make_entity_with_body("a", "purpose", "Refers to [[gone]] in prose.");
1133        store.upsert(a.id.clone(), a);
1134        let gone_specs = EntityId::new("specs", "gone");
1135        store.upsert(gone_specs.clone(), make_stub(gone_specs));
1136
1137        // web--x with body [[gone]] → dangling in web (different stub).
1138        let mut x = make_entity("x", true);
1139        x.id = EntityId::new("web", "x");
1140        x.mem = "web".into();
1141        x.file_path = "x.md".into();
1142        x.sections
1143            .insert("purpose".into(), "Refers to [[gone]] in prose.".into());
1144        store.upsert(x.id.clone(), x);
1145        let gone_web = EntityId::new("web", "gone");
1146        store.upsert(gone_web.clone(), make_stub(gone_web));
1147
1148        let all = super::collect_dangling_links(&store, None);
1149        assert_eq!(all.len(), 2);
1150
1151        let specs_only = super::collect_dangling_links(&store, Some("specs"));
1152        assert_eq!(specs_only.len(), 1);
1153        assert_eq!(specs_only[0].from.mem(), "specs");
1154
1155        let web_only = super::collect_dangling_links(&store, Some("web"));
1156        assert_eq!(web_only.len(), 1);
1157        assert_eq!(web_only[0].from.mem(), "web");
1158    }
1159
1160    #[test]
1161    fn parse_iso_date() {
1162        let days = parse_iso_to_days("2026-04-12").unwrap();
1163        assert!(days > 0);
1164
1165        let days_with_time = parse_iso_to_days("2026-04-12T10:00:00Z").unwrap();
1166        assert_eq!(days, days_with_time);
1167    }
1168
1169    #[test]
1170    fn ymd_roundtrip() {
1171        // 2026-01-01
1172        let days = ymd_to_days(2026, 1, 1);
1173        assert!(days > 20000); // sanity check
1174    }
1175
1176    // ---------------------------------------------------------------------
1177    // collect_tag_distribution — #18
1178    // ---------------------------------------------------------------------
1179
1180    fn make_entity_with_tags(name: &str, mem: &str, entity_type: &str, tags: &str) -> Entity {
1181        let mut e = make_entity(name, true);
1182        e.id = EntityId::new(mem, name);
1183        e.mem = mem.into();
1184        e.entity_type = entity_type.into();
1185        e.metadata
1186            .insert("tags".into(), MetadataValue::String(tags.into()));
1187        e
1188    }
1189
1190    fn make_entity_no_tags(name: &str) -> Entity {
1191        make_entity(name, true)
1192    }
1193
1194    #[test]
1195    fn tag_distribution_aggregates_across_entities() {
1196        let mut store = Store::new();
1197        let a = make_entity_with_tags("a", "specs", "spec", "decision, plan");
1198        let b = make_entity_with_tags("b", "specs", "spec", "decision, plan");
1199        let c = make_entity_with_tags("c", "specs", "spec", "plan");
1200        store.upsert(a.id.clone(), a);
1201        store.upsert(b.id.clone(), b);
1202        store.upsert(c.id.clone(), c);
1203
1204        let (dist, _folded, untagged) = collect_tag_distribution(&store, None, 10);
1205        assert_eq!(dist.len(), 2);
1206        assert_eq!(dist[0].tag, "plan");
1207        assert_eq!(dist[0].count, 3);
1208        assert_eq!(dist[0].by_entity_type.get("spec"), Some(&3));
1209        assert_eq!(dist[1].tag, "decision");
1210        assert_eq!(dist[1].count, 2);
1211        assert_eq!(untagged.total, 0);
1212    }
1213
1214    #[test]
1215    fn tag_distribution_case_sensitive() {
1216        let mut store = Store::new();
1217        let a = make_entity_with_tags("a", "specs", "spec", "Decision");
1218        let b = make_entity_with_tags("b", "specs", "spec", "decision");
1219        store.upsert(a.id.clone(), a);
1220        store.upsert(b.id.clone(), b);
1221
1222        let (dist, folded, _untagged) = collect_tag_distribution(&store, None, 10);
1223        assert_eq!(dist.len(), 2, "`decision` and `Decision` stay distinct");
1224        let tags: std::collections::HashSet<&str> = dist.iter().map(|t| t.tag.as_str()).collect();
1225        assert!(tags.contains("decision"));
1226        assert!(tags.contains("Decision"));
1227
1228        // Drift sidecar surfaces the collision.
1229        assert_eq!(folded.len(), 1);
1230        assert_eq!(folded[0].canonical, "decision");
1231        assert_eq!(folded[0].total, 2);
1232        assert_eq!(folded[0].variants.len(), 2);
1233    }
1234
1235    #[test]
1236    fn untagged_entities_counts_missing_and_empty() {
1237        let mut store = Store::new();
1238        let a = make_entity_no_tags("a"); // no `tags` metadata
1239        let b = make_entity_with_tags("b", "specs", "spec", "");
1240        let c = make_entity_with_tags("c", "specs", "spec", " , , ");
1241        store.upsert(a.id.clone(), a);
1242        store.upsert(b.id.clone(), b);
1243        store.upsert(c.id.clone(), c);
1244
1245        let (dist, _folded, untagged) = collect_tag_distribution(&store, None, 10);
1246        assert!(dist.is_empty(), "no effective tags → empty distribution");
1247        assert_eq!(untagged.total, 3);
1248        assert_eq!(untagged.by_entity_type.get("spec"), Some(&3));
1249    }
1250
1251    #[test]
1252    fn tag_distribution_respects_mem_filter() {
1253        let mut store = Store::new();
1254        let a = make_entity_with_tags("a", "specs", "spec", "decision");
1255        let b = make_entity_with_tags("b", "memos", "memo", "observation");
1256        let c = make_entity_no_tags("c");
1257        store.upsert(a.id.clone(), a);
1258        store.upsert(b.id.clone(), b);
1259        store.upsert(c.id.clone(), c);
1260
1261        let (dist, _folded, untagged) = collect_tag_distribution(&store, Some("memos"), 10);
1262        assert_eq!(dist.len(), 1);
1263        assert_eq!(dist[0].tag, "observation");
1264        assert_eq!(untagged.total, 0, "untagged scoped to filter mem");
1265    }
1266
1267    #[test]
1268    fn tag_distribution_respects_limit() {
1269        let mut store = Store::new();
1270        for (name, tag) in [
1271            ("a", "t-alpha"),
1272            ("b", "t-beta"),
1273            ("c", "t-gamma"),
1274            ("d", "t-delta"),
1275            ("e", "t-epsilon"),
1276        ] {
1277            let e = make_entity_with_tags(name, "specs", "spec", tag);
1278            store.upsert(e.id.clone(), e);
1279        }
1280
1281        let (dist, _folded, _untagged) = collect_tag_distribution(&store, None, 3);
1282        assert_eq!(dist.len(), 3);
1283        // Every tag appears once → ties across all 5; deterministic tie-break is
1284        // lex ascending: alpha, beta, delta (first 3 sorted).
1285        assert_eq!(dist[0].tag, "t-alpha");
1286        assert_eq!(dist[1].tag, "t-beta");
1287        assert_eq!(dist[2].tag, "t-delta");
1288    }
1289
1290    // ----------------------------------------------------------------------
1291    // required_outgoing health collector
1292    // ----------------------------------------------------------------------
1293
1294    /// Build a minimal schema fixture pinning `decision` with two
1295    /// `required_outgoing` blocks (CHOSEN + REJECTED), `note` with none.
1296    fn required_outgoing_fixture_schema() -> std::sync::Arc<memstead_schema::Schema> {
1297        let manifest = r#"name: tests-ro-health
1298version: 0.1.0
1299description: required_outgoing health test schema
1300when_to_use: tests
1301types:
1302  - decision
1303  - note
1304relationships:
1305  mode: strict
1306  definitions:
1307    - name: PART_OF
1308      description: Hier
1309      default_weight: 3.0
1310      acyclic: true
1311    - name: CHOSEN
1312      description: ch
1313      default_weight: 3.0
1314    - name: REJECTED
1315      description: rj
1316      default_weight: 2.0
1317    - name: REFERENCES
1318      description: ref
1319      default_weight: 0.5
1320    - name: _default
1321      description: Fallback
1322      default_weight: 1.0
1323community:
1324  resolution: 1.0
1325  seed: 42
1326"#;
1327        let body_section = "sections:\n  - key: body\n    heading: Body\n    required: true\n    search_weight: 10.0\n    catch_all: true\n    write_rules: []\nmetadata_fields: []\ntitle_weight: 100.0\ntext_fields:\n  - body\nhierarchy_relationship: PART_OF\npropagating_relationships: []\nupdatable_fields:\n  - title\n  - body\nhealth_required_fields:\n  - body\nstaleness_threshold_days: 90\nwrite_rules: []\n";
1328        let decision_yaml = format!(
1329            "name: decision\ndescription: t\nwhen_to_use: Here\n{body_section}required_outgoing:\n  - relationships: [CHOSEN]\n    cardinality: at_least_one\n  - relationships: [REJECTED]\n    cardinality: at_least_one\n",
1330        );
1331        let note_yaml = format!("name: note\ndescription: t\nwhen_to_use: Here\n{body_section}",);
1332        std::sync::Arc::new(
1333            memstead_schema::load_schema_from_memory(
1334                manifest,
1335                &[
1336                    ("decision".to_string(), decision_yaml),
1337                    ("note".to_string(), note_yaml),
1338                ],
1339            )
1340            .expect("ro fixture schema must parse"),
1341        )
1342    }
1343
1344    fn make_typed_entity(mem: &str, slug: &str, entity_type: &str) -> crate::entity::Entity {
1345        use crate::entity::MetadataValue;
1346        let mut metadata = IndexMap::new();
1347        metadata.insert("type".into(), MetadataValue::String(entity_type.into()));
1348        let mut sections = IndexMap::new();
1349        sections.insert("body".into(), "Body.".into());
1350        crate::entity::Entity {
1351            id: EntityId::new(mem, slug),
1352            title: slug.to_string(),
1353            entity_type: entity_type.into(),
1354            mem: mem.into(),
1355            file_path: format!("{slug}.md"),
1356            metadata,
1357            sections,
1358            relationships: Vec::new(),
1359            content_hash: String::new(),
1360            stub: false,
1361            stub_kind: None,
1362            heading_spans: std::collections::HashMap::new(),
1363        }
1364    }
1365
1366    #[test]
1367    fn missing_required_outgoing_collects_violators_only() {
1368        let schema = required_outgoing_fixture_schema();
1369        let mut store = Store::new();
1370        // Two decisions: one without any edges (violates 2 blocks), one
1371        // with both edges satisfied. One note (no requirement).
1372        let mut violator = make_typed_entity("plan", "stalled", "decision");
1373        let mut satisfied = make_typed_entity("plan", "wired", "decision");
1374        let opt_a = make_typed_entity("plan", "a", "note");
1375        let opt_b = make_typed_entity("plan", "b", "note");
1376        let happy_note = make_typed_entity("plan", "side", "note");
1377        satisfied.relationships.push(crate::entity::Relationship {
1378            rel_type: "CHOSEN".into(),
1379            target: opt_a.id.clone(),
1380            description: None,
1381        });
1382        satisfied.relationships.push(crate::entity::Relationship {
1383            rel_type: "REJECTED".into(),
1384            target: opt_b.id.clone(),
1385            description: None,
1386        });
1387        for e in [violator.clone(), satisfied, opt_a, opt_b, happy_note] {
1388            store.upsert(e.id.clone(), e);
1389        }
1390
1391        let mut mem_schemas = HashMap::new();
1392        mem_schemas.insert("plan".to_string(), schema);
1393
1394        let reports = collect_missing_required_outgoing(&store, None, &mem_schemas);
1395        assert_eq!(
1396            reports.len(),
1397            1,
1398            "exactly one violator (the empty decision); got {reports:?}"
1399        );
1400        let r = &reports[0];
1401        assert_eq!(r.id, violator.id);
1402        assert_eq!(r.entity_type, "decision");
1403        assert_eq!(r.mem, "plan");
1404        assert_eq!(r.missing.len(), 2);
1405        let names: Vec<&str> = r
1406            .missing
1407            .iter()
1408            .flat_map(|b| b.relationships.iter().map(String::as_str))
1409            .collect();
1410        assert!(names.contains(&"CHOSEN"));
1411        assert!(names.contains(&"REJECTED"));
1412
1413        // mark warning still doesn't propagate when violator is removed.
1414        violator.relationships.push(crate::entity::Relationship {
1415            rel_type: "CHOSEN".into(),
1416            target: EntityId::new("plan", "x"),
1417            description: None,
1418        });
1419    }
1420
1421    #[test]
1422    fn missing_required_outgoing_respects_mem_filter() {
1423        // Plan: "a write to mem A doesn't surface mem B's violations
1424        // in memstead_health mem=A; mem-scoped aggregation is correct."
1425        let schema = required_outgoing_fixture_schema();
1426        let mut store = Store::new();
1427        let v_a = make_typed_entity("alpha", "stalled", "decision");
1428        let v_b = make_typed_entity("beta", "stalled", "decision");
1429        store.upsert(v_a.id.clone(), v_a);
1430        store.upsert(v_b.id.clone(), v_b.clone());
1431
1432        let mut mem_schemas = HashMap::new();
1433        mem_schemas.insert("alpha".to_string(), schema.clone());
1434        mem_schemas.insert("beta".to_string(), schema);
1435
1436        let alpha_only = collect_missing_required_outgoing(&store, Some("alpha"), &mem_schemas);
1437        assert_eq!(alpha_only.len(), 1);
1438        assert_eq!(alpha_only[0].mem, "alpha");
1439
1440        let both = collect_missing_required_outgoing(&store, None, &mem_schemas);
1441        assert_eq!(both.len(), 2);
1442    }
1443
1444    #[test]
1445    fn missing_required_outgoing_skips_stubs_and_unschemaed_mems() {
1446        // Stubs have no entity_type; unschemaed mems can't be evaluated
1447        // — both must be silently skipped.
1448        let schema = required_outgoing_fixture_schema();
1449        let mut store = Store::new();
1450        let mut stub = make_typed_entity("plan", "ghost", "");
1451        stub.stub = true;
1452        stub.entity_type = String::new();
1453        let other = make_typed_entity("uncharted", "lonely", "decision");
1454        store.upsert(stub.id.clone(), stub);
1455        store.upsert(other.id.clone(), other);
1456
1457        let mut mem_schemas = HashMap::new();
1458        mem_schemas.insert("plan".to_string(), schema);
1459
1460        let reports = collect_missing_required_outgoing(&store, None, &mem_schemas);
1461        assert!(
1462            reports.is_empty(),
1463            "stub (no schema lookup) and unschemaed mem must be skipped; got {reports:?}",
1464        );
1465    }
1466}