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.
586fn days_since_epoch() -> u64 {
587    std::time::SystemTime::now()
588        .duration_since(std::time::UNIX_EPOCH)
589        .unwrap_or_default()
590        .as_secs()
591        / 86400
592}
593
594/// Parse an ISO 8601 date string to days since epoch.
595/// Supports `YYYY-MM-DD` and `YYYY-MM-DDTHH:MM:SSZ`.
596fn parse_iso_to_days(date: &str) -> Option<u64> {
597    let date_part = date.split('T').next()?;
598    let parts: Vec<&str> = date_part.split('-').collect();
599    if parts.len() != 3 {
600        return None;
601    }
602    let year: u64 = parts[0].parse().ok()?;
603    let month: u64 = parts[1].parse().ok()?;
604    let day: u64 = parts[2].parse().ok()?;
605    Some(ymd_to_days(year, month, day))
606}
607
608/// Convert (year, month, day) to days since Unix epoch.
609/// Inverse of the algorithm in generator.rs.
610fn ymd_to_days(year: u64, month: u64, day: u64) -> u64 {
611    // Algorithm from http://howardhinnant.github.io/date_algorithms.html
612    let y = if month <= 2 { year - 1 } else { year };
613    let m = if month <= 2 { month + 9 } else { month - 3 };
614    let era = y / 400;
615    let yoe = y - era * 400;
616    let doy = (153 * m + 2) / 5 + day - 1;
617    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
618    let days = era * 146097 + doe;
619    days - 719468
620}
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625    use crate::entity::{Entity, EntityId, MetadataValue};
626    use crate::store::Store;
627    use indexmap::IndexMap;
628    use memstead_schema::type_by_name;
629
630    fn make_entity(name: &str, has_required: bool) -> Entity {
631        let mut metadata = IndexMap::new();
632        metadata.insert("level".into(), MetadataValue::String("M0".into()));
633        metadata.insert("type".into(), MetadataValue::String("spec".into()));
634        metadata.insert(
635            "created_date".into(),
636            MetadataValue::String("2026-01-15".into()),
637        );
638        metadata.insert(
639            "last_modified".into(),
640            MetadataValue::String("2026-04-12".into()),
641        );
642
643        let mut sections = IndexMap::new();
644        if has_required {
645            sections.insert("identity".into(), "Has identity.".into());
646            sections.insert("purpose".into(), "Has purpose.".into());
647        }
648
649        Entity {
650            id: EntityId::new("specs", name),
651            title: name.into(),
652            entity_type: "spec".into(),
653            mem: "specs".into(),
654            file_path: format!("{name}.md"),
655            metadata,
656            sections,
657            relationships: Vec::new(),
658            content_hash: String::new(),
659            stub: false,
660            stub_kind: None,
661            heading_spans: std::collections::HashMap::new(),
662        }
663    }
664
665    fn make_concept_entity(name: &str, with_definition: bool) -> Entity {
666        let mut metadata = IndexMap::new();
667        metadata.insert("type".into(), MetadataValue::String("concept".into()));
668        metadata.insert("maturity".into(), MetadataValue::String("emerging".into()));
669        metadata.insert(
670            "abstraction_level".into(),
671            MetadataValue::String("concrete".into()),
672        );
673        metadata.insert(
674            "created_date".into(),
675            MetadataValue::String("2026-01-15".into()),
676        );
677        metadata.insert(
678            "last_modified".into(),
679            MetadataValue::String("2026-04-12".into()),
680        );
681
682        let mut sections = IndexMap::new();
683        if with_definition {
684            sections.insert("definition".into(), "Precise definition.".into());
685        }
686        sections.insert("explanation".into(), "How it works.".into());
687
688        Entity {
689            id: EntityId::new("concepts", name),
690            title: name.into(),
691            entity_type: "concept".into(),
692            mem: "concepts".into(),
693            file_path: format!("{name}.md"),
694            metadata,
695            sections,
696            relationships: Vec::new(),
697            content_hash: String::new(),
698            stub: false,
699            stub_kind: None,
700            heading_spans: std::collections::HashMap::new(),
701        }
702    }
703
704    #[test]
705    fn health_concept_missing_definition_reports_definition_field() {
706        let schema = &type_by_name("concept").unwrap();
707        let entity = make_concept_entity("clarity", false);
708        let report = entity_health(&entity, schema);
709
710        // The missing-field issue must name the concept schema's required
711        // section ("definition"), not spec's "identity".
712        assert!(report.issues.iter().any(|i| i.field == "definition"));
713        assert!(!report.issues.iter().any(|i| i.field == "identity"));
714        assert!(!report.issues.iter().any(|i| i.field == "purpose"));
715        assert!(report.score < 1.0);
716
717        // An entity with the definition filled in has no issue for that field.
718        let healthy = make_concept_entity("clarity-ok", true);
719        let healthy_report = entity_health(&healthy, schema);
720        assert!(
721            !healthy_report
722                .issues
723                .iter()
724                .any(|i| i.field == "definition")
725        );
726    }
727
728    #[test]
729    fn health_detects_missing_sections() {
730        let schema = &type_by_name("spec").unwrap();
731        let entity = make_entity("incomplete", false);
732        let report = entity_health(&entity, schema);
733        assert!(!report.issues.is_empty());
734        assert!(report.score < 1.0);
735    }
736
737    #[test]
738    fn health_clean_entity() {
739        let schema = &type_by_name("spec").unwrap();
740        let entity = make_entity("complete", true);
741        let report = entity_health(&entity, schema);
742        // May still have issues for other required fields, but identity/purpose are covered
743        let section_issues: Vec<_> = report
744            .issues
745            .iter()
746            .filter(|i| i.field == "identity" || i.field == "purpose")
747            .collect();
748        assert!(section_issues.is_empty());
749    }
750
751    #[test]
752    fn health_summary_counts() {
753        let mut store = Store::new();
754        let e1 = make_entity("healthy", true);
755        let e2 = make_entity("unhealthy", false);
756        store.upsert(e1.id.clone(), e1);
757        store.upsert(e2.id.clone(), e2);
758
759        let schema = &type_by_name("spec").unwrap();
760        let summary = compute_health(&store, schema, &HashMap::new());
761        assert_eq!(summary.orphan_count, 2); // No edges between them
762        assert_eq!(summary.stub_count, 0);
763    }
764
765    #[test]
766    fn health_surfaces_invalid_rel_shape_on_existing_edges() {
767        // software@0.1.0 declares `source_types: [actor]` on OWNS.
768        // Seed a non-actor source with an outgoing OWNS edge — the
769        // health scan must surface `INVALID_REL_SHAPE` in the
770        // entity's issues so an agent running a sweep can identify
771        // edges to clean up via `memstead_relate remove=true`.
772        use crate::entity::Relationship;
773        use memstead_schema::SchemaRegistry;
774
775        let registry = SchemaRegistry::builtin();
776        let software = registry
777            .resolve_by_name("software")
778            .unwrap()
779            .expect("software schema ships as a builtin");
780
781        let mut store = Store::new();
782        // Source entity is `spec`, not `actor`. Add an OWNS edge to
783        // a target whose type doesn't matter for source-side shape.
784        let mut bad = make_entity("bad-owns-source", true);
785        bad.entity_type = "spec".into();
786        bad.metadata
787            .insert("level".into(), MetadataValue::String("M0".into()));
788        bad.metadata
789            .insert("stability".into(), MetadataValue::String("evolving".into()));
790        bad.relationships.push(Relationship {
791            rel_type: "OWNS".into(),
792            target: EntityId::new("specs", "victim"),
793            description: None,
794        });
795        let mut victim = make_entity("victim", true);
796        victim.entity_type = "spec".into();
797        store.upsert(bad.id.clone(), bad);
798        store.upsert(victim.id.clone(), victim);
799
800        let mut mem_schemas = HashMap::new();
801        mem_schemas.insert("specs".to_string(), software);
802
803        let schema = &type_by_name("spec").unwrap();
804        let summary = compute_health(&store, schema, &mem_schemas);
805        let report = summary
806            .missing_fields
807            .iter()
808            .find(|r| r.id.as_ref() == "specs--bad-owns-source")
809            .expect("shape-violating entity must surface");
810        let issue = report
811            .issues
812            .iter()
813            .find(|i| i.field == "relationships" && i.message.contains("INVALID_REL_SHAPE"))
814            .expect("shape violation must produce an INVALID_REL_SHAPE issue");
815        assert!(
816            issue.message.contains("OWNS"),
817            "issue must name the offending rel_type: {}",
818            issue.message
819        );
820        assert!(
821            issue.message.contains("spec"),
822            "issue must name the actual source type: {}",
823            issue.message
824        );
825        assert!(
826            issue.message.contains("actor"),
827            "issue must name the allowed source type: {}",
828            issue.message
829        );
830        assert!(
831            issue.message.contains("remove=true"),
832            "issue must surface the recovery path: {}",
833            issue.message
834        );
835    }
836
837    #[test]
838    fn health_does_not_flag_shape_compliant_edges() {
839        // Sanity counterpart: an actor source with OWNS edge satisfies
840        // `source_types: [actor]` — no INVALID_REL_SHAPE issue surfaces.
841        use crate::entity::Relationship;
842        use memstead_schema::SchemaRegistry;
843
844        let registry = SchemaRegistry::builtin();
845        let software = registry
846            .resolve_by_name("software")
847            .unwrap()
848            .expect("software schema ships as a builtin");
849
850        let mut store = Store::new();
851        let mut owner = make_entity("owner", true);
852        owner.entity_type = "actor".into();
853        owner
854            .metadata
855            .insert("kind".into(), MetadataValue::String("team".into()));
856        owner
857            .metadata
858            .insert("active".into(), MetadataValue::Bool(true));
859        owner
860            .metadata
861            .insert("handle".into(), MetadataValue::String("owner".into()));
862        owner.relationships.push(Relationship {
863            rel_type: "OWNS".into(),
864            target: EntityId::new("specs", "owned"),
865            description: None,
866        });
867        let mut owned = make_entity("owned", true);
868        owned.entity_type = "spec".into();
869        store.upsert(owner.id.clone(), owner);
870        store.upsert(owned.id.clone(), owned);
871
872        let mut mem_schemas = HashMap::new();
873        mem_schemas.insert("specs".to_string(), software);
874
875        let schema = &type_by_name("spec").unwrap();
876        let summary = compute_health(&store, schema, &mem_schemas);
877        let shape_issue = summary
878            .missing_fields
879            .iter()
880            .flat_map(|r| r.issues.iter())
881            .find(|i| i.message.contains("INVALID_REL_SHAPE"));
882        assert!(
883            shape_issue.is_none(),
884            "shape-compliant edge must not surface a shape issue, got: {shape_issue:?}"
885        );
886    }
887
888    #[test]
889    fn health_warns_on_undeclared_relationship_in_existing_entity() {
890        use crate::entity::Relationship;
891        use memstead_schema::Schema;
892
893        let mut store = Store::new();
894        let mut entity = make_entity("with-bad-rel", true);
895        // Author an edge using a name that does not exist in the default
896        // schema's vocabulary. The load-side contract per decision 3 is
897        // about unknown *types*; unknown *relationships* on an already-
898        // loaded entity land in the soft health surface instead so an
899        // agent running `memstead_health` after a schema edit sees the drift.
900        entity.relationships.push(Relationship {
901            rel_type: "CONJURES".into(),
902            target: EntityId::new("specs", "unknown"),
903            description: None,
904        });
905        store.upsert(entity.id.clone(), entity);
906
907        let mut mem_schemas = HashMap::new();
908        mem_schemas.insert("specs".to_string(), Schema::builtin_default());
909
910        let schema = &type_by_name("spec").unwrap();
911        let summary = compute_health(&store, schema, &mem_schemas);
912        let report = summary
913            .missing_fields
914            .iter()
915            .find(|r| r.id.as_ref() == "specs--with-bad-rel")
916            .expect("entity must surface in missing_fields");
917        let rel_issue = report
918            .issues
919            .iter()
920            .find(|i| i.field == "relationships")
921            .expect("undeclared relationship must produce an issue");
922        assert!(
923            rel_issue.message.contains("CONJURES"),
924            "issue message must name the offending relationship: {}",
925            rel_issue.message
926        );
927        assert!(
928            rel_issue.message.contains("default@1.0.0"),
929            "issue must name the schema pin: {}",
930            rel_issue.message
931        );
932    }
933
934    // -------------------------------------------------------------------
935    // Dangling wiki-link detection
936    // -------------------------------------------------------------------
937
938    /// Build an entity with an arbitrary section body so the test can seed
939    /// inline wiki-links at will. Mem defaults to `specs`.
940    fn make_entity_with_body(name: &str, section_key: &str, body: &str) -> Entity {
941        let mut entity = make_entity(name, true);
942        entity.sections.insert(section_key.into(), body.to_string());
943        entity
944    }
945
946    #[test]
947    fn dangling_link_detected_after_delete() {
948        use crate::entity::store_builder::make_stub;
949
950        let mut store = Store::new();
951        let a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
952        store.upsert(a.id.clone(), a.clone());
953
954        // Seed b as a stub — the signal that its markdown file is gone
955        // (post-delete, pre-recreate, or never authored).
956        let b_id = EntityId::new("specs", "b");
957        store.upsert(b_id.clone(), make_stub(b_id.clone()));
958
959        let dangling = super::collect_dangling_links(&store, None);
960        assert_eq!(dangling.len(), 1, "exactly one dangling link expected");
961        let d = &dangling[0];
962        assert_eq!(d.from, a.id);
963        assert_eq!(d.target_id, b_id);
964        assert_eq!(d.target_path, "b");
965        assert_eq!(d.section.as_deref(), Some("purpose"));
966    }
967
968    #[test]
969    fn dangling_link_does_not_flag_stub_target_of_explicit_relationship() {
970        use crate::entity::Relationship;
971        use crate::entity::store_builder::make_stub;
972
973        let mut store = Store::new();
974        // A has NO inline link in its body — only an explicit relationship
975        // edge pointing at a stub.
976        let mut a = make_entity("a", true);
977        let b_id = EntityId::new("specs", "b");
978        a.relationships.push(Relationship {
979            rel_type: "REFERENCES".into(),
980            target: b_id.clone(),
981            description: None,
982        });
983        store.upsert(a.id.clone(), a);
984        store.upsert(b_id.clone(), make_stub(b_id));
985
986        let dangling = super::collect_dangling_links(&store, None);
987        assert!(
988            dangling.is_empty(),
989            "explicit relationships to stubs are valid by design \
990             (stubs are first-class placeholders); only inline-body \
991             wiki-links to stubs must surface"
992        );
993    }
994
995    #[test]
996    fn dangling_link_does_not_flag_real_reference() {
997        use crate::entity::Relationship;
998
999        let mut store = Store::new();
1000        let mut a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
1001        // Backing relation makes the body link a valid alias.
1002        a.relationships.push(Relationship {
1003            rel_type: "REFERENCES".into(),
1004            target: EntityId::new("specs", "b"),
1005            description: None,
1006        });
1007        let b = make_entity("b", true);
1008        store.upsert(a.id.clone(), a);
1009        store.upsert(b.id.clone(), b);
1010
1011        let dangling = super::collect_dangling_links(&store, None);
1012        assert!(
1013            dangling.is_empty(),
1014            "real reference backed by relation — not dangling, not alias-orphan"
1015        );
1016    }
1017
1018    /// F12: a `## Relationships` row pointing at a fully-absent target
1019    /// (out-of-band file edit, mem-delete corruption) must surface.
1020    /// The scan covers both axes; relationship-table danglers ship
1021    /// `section: None` to mark the source axis.
1022    #[test]
1023    fn dangling_link_relationship_section_target_absent() {
1024        use crate::entity::Relationship;
1025
1026        let mut store = Store::new();
1027        let mut a = make_entity("a", true);
1028        // Note: NO stub in the store for `gone` — out-of-band edit
1029        // removed the stub but left the relationship row.
1030        a.relationships.push(Relationship {
1031            rel_type: "DEPENDS_ON".into(),
1032            target: EntityId::new("specs", "gone"),
1033            description: None,
1034        });
1035        store.upsert(a.id.clone(), a.clone());
1036
1037        let dangling = super::collect_dangling_links(&store, None);
1038        assert_eq!(
1039            dangling.len(),
1040            1,
1041            "exactly one relationship-section dangler"
1042        );
1043        let d = &dangling[0];
1044        assert_eq!(d.from, a.id);
1045        assert_eq!(d.target_id, EntityId::new("specs", "gone"));
1046        assert!(
1047            d.section.is_none(),
1048            "relationship-section danglers ship `section: None`, got {:?}",
1049            d.section
1050        );
1051    }
1052
1053    /// Relationship rows pointing at stubs are NOT flagged. Auto-stub
1054    /// is the alias machinery's forward-reference mechanism; flagging
1055    /// stubs would conflate the "engine-managed placeholder" case with
1056    /// corruption.
1057    #[test]
1058    fn dangling_link_relationship_section_stub_target_not_flagged() {
1059        use crate::entity::Relationship;
1060        use crate::entity::store_builder::make_stub;
1061
1062        let mut store = Store::new();
1063        let mut a = make_entity("a", true);
1064        let b_id = EntityId::new("specs", "b");
1065        a.relationships.push(Relationship {
1066            rel_type: "DEPENDS_ON".into(),
1067            target: b_id.clone(),
1068            description: None,
1069        });
1070        store.upsert(a.id.clone(), a);
1071        store.upsert(b_id.clone(), make_stub(b_id));
1072
1073        let dangling = super::collect_dangling_links(&store, None);
1074        assert!(
1075            dangling.is_empty(),
1076            "relationship targets that resolve to stubs are forward-references, not corruption"
1077        );
1078    }
1079
1080    /// When both the body and the relationship section point at the
1081    /// same fully-absent target, the dangler dedupes to a single entry
1082    /// on whichever axis fired first (body-scan runs
1083    /// before relationship-scan in the implementation; the body axis
1084    /// wins). Stub-shaped duplicates are not possible because the
1085    /// relationship-section scan skips stubs.
1086    #[test]
1087    fn dangling_link_dedups_across_body_and_relations() {
1088        use crate::entity::Relationship;
1089        use crate::entity::store_builder::make_stub;
1090
1091        let mut store = Store::new();
1092        let mut a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
1093        let b_id = EntityId::new("specs", "b");
1094        a.relationships.push(Relationship {
1095            rel_type: "REFERENCES".into(),
1096            target: b_id.clone(),
1097            description: None,
1098        });
1099        store.upsert(a.id.clone(), a.clone());
1100        store.upsert(b_id.clone(), make_stub(b_id.clone()));
1101
1102        let dangling = super::collect_dangling_links(&store, None);
1103        assert_eq!(
1104            dangling.len(),
1105            1,
1106            "body + relations both pointing at the same stub should dedup"
1107        );
1108        // Body scan fires first; the surviving entry carries
1109        // `section: Some(_)`.
1110        assert!(dangling[0].section.is_some(), "body axis wins the dedup");
1111    }
1112
1113    #[test]
1114    fn dangling_links_scope_to_mem_filter() {
1115        use crate::entity::store_builder::make_stub;
1116
1117        let mut store = Store::new();
1118
1119        // specs--a with body [[gone]] → dangling in specs.
1120        let a = make_entity_with_body("a", "purpose", "Refers to [[gone]] in prose.");
1121        store.upsert(a.id.clone(), a);
1122        let gone_specs = EntityId::new("specs", "gone");
1123        store.upsert(gone_specs.clone(), make_stub(gone_specs));
1124
1125        // web--x with body [[gone]] → dangling in web (different stub).
1126        let mut x = make_entity("x", true);
1127        x.id = EntityId::new("web", "x");
1128        x.mem = "web".into();
1129        x.file_path = "x.md".into();
1130        x.sections
1131            .insert("purpose".into(), "Refers to [[gone]] in prose.".into());
1132        store.upsert(x.id.clone(), x);
1133        let gone_web = EntityId::new("web", "gone");
1134        store.upsert(gone_web.clone(), make_stub(gone_web));
1135
1136        let all = super::collect_dangling_links(&store, None);
1137        assert_eq!(all.len(), 2);
1138
1139        let specs_only = super::collect_dangling_links(&store, Some("specs"));
1140        assert_eq!(specs_only.len(), 1);
1141        assert_eq!(specs_only[0].from.mem(), "specs");
1142
1143        let web_only = super::collect_dangling_links(&store, Some("web"));
1144        assert_eq!(web_only.len(), 1);
1145        assert_eq!(web_only[0].from.mem(), "web");
1146    }
1147
1148    #[test]
1149    fn parse_iso_date() {
1150        let days = parse_iso_to_days("2026-04-12").unwrap();
1151        assert!(days > 0);
1152
1153        let days_with_time = parse_iso_to_days("2026-04-12T10:00:00Z").unwrap();
1154        assert_eq!(days, days_with_time);
1155    }
1156
1157    #[test]
1158    fn ymd_roundtrip() {
1159        // 2026-01-01
1160        let days = ymd_to_days(2026, 1, 1);
1161        assert!(days > 20000); // sanity check
1162    }
1163
1164    // ---------------------------------------------------------------------
1165    // collect_tag_distribution — #18
1166    // ---------------------------------------------------------------------
1167
1168    fn make_entity_with_tags(name: &str, mem: &str, entity_type: &str, tags: &str) -> Entity {
1169        let mut e = make_entity(name, true);
1170        e.id = EntityId::new(mem, name);
1171        e.mem = mem.into();
1172        e.entity_type = entity_type.into();
1173        e.metadata
1174            .insert("tags".into(), MetadataValue::String(tags.into()));
1175        e
1176    }
1177
1178    fn make_entity_no_tags(name: &str) -> Entity {
1179        make_entity(name, true)
1180    }
1181
1182    #[test]
1183    fn tag_distribution_aggregates_across_entities() {
1184        let mut store = Store::new();
1185        let a = make_entity_with_tags("a", "specs", "spec", "decision, plan");
1186        let b = make_entity_with_tags("b", "specs", "spec", "decision, plan");
1187        let c = make_entity_with_tags("c", "specs", "spec", "plan");
1188        store.upsert(a.id.clone(), a);
1189        store.upsert(b.id.clone(), b);
1190        store.upsert(c.id.clone(), c);
1191
1192        let (dist, _folded, untagged) = collect_tag_distribution(&store, None, 10);
1193        assert_eq!(dist.len(), 2);
1194        assert_eq!(dist[0].tag, "plan");
1195        assert_eq!(dist[0].count, 3);
1196        assert_eq!(dist[0].by_entity_type.get("spec"), Some(&3));
1197        assert_eq!(dist[1].tag, "decision");
1198        assert_eq!(dist[1].count, 2);
1199        assert_eq!(untagged.total, 0);
1200    }
1201
1202    #[test]
1203    fn tag_distribution_case_sensitive() {
1204        let mut store = Store::new();
1205        let a = make_entity_with_tags("a", "specs", "spec", "Decision");
1206        let b = make_entity_with_tags("b", "specs", "spec", "decision");
1207        store.upsert(a.id.clone(), a);
1208        store.upsert(b.id.clone(), b);
1209
1210        let (dist, folded, _untagged) = collect_tag_distribution(&store, None, 10);
1211        assert_eq!(dist.len(), 2, "`decision` and `Decision` stay distinct");
1212        let tags: std::collections::HashSet<&str> = dist.iter().map(|t| t.tag.as_str()).collect();
1213        assert!(tags.contains("decision"));
1214        assert!(tags.contains("Decision"));
1215
1216        // Drift sidecar surfaces the collision.
1217        assert_eq!(folded.len(), 1);
1218        assert_eq!(folded[0].canonical, "decision");
1219        assert_eq!(folded[0].total, 2);
1220        assert_eq!(folded[0].variants.len(), 2);
1221    }
1222
1223    #[test]
1224    fn untagged_entities_counts_missing_and_empty() {
1225        let mut store = Store::new();
1226        let a = make_entity_no_tags("a"); // no `tags` metadata
1227        let b = make_entity_with_tags("b", "specs", "spec", "");
1228        let c = make_entity_with_tags("c", "specs", "spec", " , , ");
1229        store.upsert(a.id.clone(), a);
1230        store.upsert(b.id.clone(), b);
1231        store.upsert(c.id.clone(), c);
1232
1233        let (dist, _folded, untagged) = collect_tag_distribution(&store, None, 10);
1234        assert!(dist.is_empty(), "no effective tags → empty distribution");
1235        assert_eq!(untagged.total, 3);
1236        assert_eq!(untagged.by_entity_type.get("spec"), Some(&3));
1237    }
1238
1239    #[test]
1240    fn tag_distribution_respects_mem_filter() {
1241        let mut store = Store::new();
1242        let a = make_entity_with_tags("a", "specs", "spec", "decision");
1243        let b = make_entity_with_tags("b", "memos", "memo", "observation");
1244        let c = make_entity_no_tags("c");
1245        store.upsert(a.id.clone(), a);
1246        store.upsert(b.id.clone(), b);
1247        store.upsert(c.id.clone(), c);
1248
1249        let (dist, _folded, untagged) = collect_tag_distribution(&store, Some("memos"), 10);
1250        assert_eq!(dist.len(), 1);
1251        assert_eq!(dist[0].tag, "observation");
1252        assert_eq!(untagged.total, 0, "untagged scoped to filter mem");
1253    }
1254
1255    #[test]
1256    fn tag_distribution_respects_limit() {
1257        let mut store = Store::new();
1258        for (name, tag) in [
1259            ("a", "t-alpha"),
1260            ("b", "t-beta"),
1261            ("c", "t-gamma"),
1262            ("d", "t-delta"),
1263            ("e", "t-epsilon"),
1264        ] {
1265            let e = make_entity_with_tags(name, "specs", "spec", tag);
1266            store.upsert(e.id.clone(), e);
1267        }
1268
1269        let (dist, _folded, _untagged) = collect_tag_distribution(&store, None, 3);
1270        assert_eq!(dist.len(), 3);
1271        // Every tag appears once → ties across all 5; deterministic tie-break is
1272        // lex ascending: alpha, beta, delta (first 3 sorted).
1273        assert_eq!(dist[0].tag, "t-alpha");
1274        assert_eq!(dist[1].tag, "t-beta");
1275        assert_eq!(dist[2].tag, "t-delta");
1276    }
1277
1278    // ----------------------------------------------------------------------
1279    // required_outgoing health collector
1280    // ----------------------------------------------------------------------
1281
1282    /// Build a minimal schema fixture pinning `decision` with two
1283    /// `required_outgoing` blocks (CHOSEN + REJECTED), `note` with none.
1284    fn required_outgoing_fixture_schema() -> std::sync::Arc<memstead_schema::Schema> {
1285        let manifest = r#"name: tests-ro-health
1286version: 0.1.0
1287description: required_outgoing health test schema
1288when_to_use: tests
1289types:
1290  - decision
1291  - note
1292relationships:
1293  mode: strict
1294  definitions:
1295    - name: PART_OF
1296      description: Hier
1297      default_weight: 3.0
1298      acyclic: true
1299    - name: CHOSEN
1300      description: ch
1301      default_weight: 3.0
1302    - name: REJECTED
1303      description: rj
1304      default_weight: 2.0
1305    - name: REFERENCES
1306      description: ref
1307      default_weight: 0.5
1308    - name: _default
1309      description: Fallback
1310      default_weight: 1.0
1311community:
1312  resolution: 1.0
1313  seed: 42
1314"#;
1315        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";
1316        let decision_yaml = format!(
1317            "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",
1318        );
1319        let note_yaml = format!("name: note\ndescription: t\nwhen_to_use: Here\n{body_section}",);
1320        std::sync::Arc::new(
1321            memstead_schema::load_schema_from_memory(
1322                manifest,
1323                &[
1324                    ("decision".to_string(), decision_yaml),
1325                    ("note".to_string(), note_yaml),
1326                ],
1327            )
1328            .expect("ro fixture schema must parse"),
1329        )
1330    }
1331
1332    fn make_typed_entity(mem: &str, slug: &str, entity_type: &str) -> crate::entity::Entity {
1333        use crate::entity::MetadataValue;
1334        let mut metadata = IndexMap::new();
1335        metadata.insert("type".into(), MetadataValue::String(entity_type.into()));
1336        let mut sections = IndexMap::new();
1337        sections.insert("body".into(), "Body.".into());
1338        crate::entity::Entity {
1339            id: EntityId::new(mem, slug),
1340            title: slug.to_string(),
1341            entity_type: entity_type.into(),
1342            mem: mem.into(),
1343            file_path: format!("{slug}.md"),
1344            metadata,
1345            sections,
1346            relationships: Vec::new(),
1347            content_hash: String::new(),
1348            stub: false,
1349            stub_kind: None,
1350            heading_spans: std::collections::HashMap::new(),
1351        }
1352    }
1353
1354    #[test]
1355    fn missing_required_outgoing_collects_violators_only() {
1356        let schema = required_outgoing_fixture_schema();
1357        let mut store = Store::new();
1358        // Two decisions: one without any edges (violates 2 blocks), one
1359        // with both edges satisfied. One note (no requirement).
1360        let mut violator = make_typed_entity("plan", "stalled", "decision");
1361        let mut satisfied = make_typed_entity("plan", "wired", "decision");
1362        let opt_a = make_typed_entity("plan", "a", "note");
1363        let opt_b = make_typed_entity("plan", "b", "note");
1364        let happy_note = make_typed_entity("plan", "side", "note");
1365        satisfied.relationships.push(crate::entity::Relationship {
1366            rel_type: "CHOSEN".into(),
1367            target: opt_a.id.clone(),
1368            description: None,
1369        });
1370        satisfied.relationships.push(crate::entity::Relationship {
1371            rel_type: "REJECTED".into(),
1372            target: opt_b.id.clone(),
1373            description: None,
1374        });
1375        for e in [violator.clone(), satisfied, opt_a, opt_b, happy_note] {
1376            store.upsert(e.id.clone(), e);
1377        }
1378
1379        let mut mem_schemas = HashMap::new();
1380        mem_schemas.insert("plan".to_string(), schema);
1381
1382        let reports = collect_missing_required_outgoing(&store, None, &mem_schemas);
1383        assert_eq!(
1384            reports.len(),
1385            1,
1386            "exactly one violator (the empty decision); got {reports:?}"
1387        );
1388        let r = &reports[0];
1389        assert_eq!(r.id, violator.id);
1390        assert_eq!(r.entity_type, "decision");
1391        assert_eq!(r.mem, "plan");
1392        assert_eq!(r.missing.len(), 2);
1393        let names: Vec<&str> = r
1394            .missing
1395            .iter()
1396            .flat_map(|b| b.relationships.iter().map(String::as_str))
1397            .collect();
1398        assert!(names.contains(&"CHOSEN"));
1399        assert!(names.contains(&"REJECTED"));
1400
1401        // mark warning still doesn't propagate when violator is removed.
1402        violator.relationships.push(crate::entity::Relationship {
1403            rel_type: "CHOSEN".into(),
1404            target: EntityId::new("plan", "x"),
1405            description: None,
1406        });
1407    }
1408
1409    #[test]
1410    fn missing_required_outgoing_respects_mem_filter() {
1411        // Plan: "a write to mem A doesn't surface mem B's violations
1412        // in memstead_health mem=A; mem-scoped aggregation is correct."
1413        let schema = required_outgoing_fixture_schema();
1414        let mut store = Store::new();
1415        let v_a = make_typed_entity("alpha", "stalled", "decision");
1416        let v_b = make_typed_entity("beta", "stalled", "decision");
1417        store.upsert(v_a.id.clone(), v_a);
1418        store.upsert(v_b.id.clone(), v_b.clone());
1419
1420        let mut mem_schemas = HashMap::new();
1421        mem_schemas.insert("alpha".to_string(), schema.clone());
1422        mem_schemas.insert("beta".to_string(), schema);
1423
1424        let alpha_only = collect_missing_required_outgoing(&store, Some("alpha"), &mem_schemas);
1425        assert_eq!(alpha_only.len(), 1);
1426        assert_eq!(alpha_only[0].mem, "alpha");
1427
1428        let both = collect_missing_required_outgoing(&store, None, &mem_schemas);
1429        assert_eq!(both.len(), 2);
1430    }
1431
1432    #[test]
1433    fn missing_required_outgoing_skips_stubs_and_unschemaed_mems() {
1434        // Stubs have no entity_type; unschemaed mems can't be evaluated
1435        // — both must be silently skipped.
1436        let schema = required_outgoing_fixture_schema();
1437        let mut store = Store::new();
1438        let mut stub = make_typed_entity("plan", "ghost", "");
1439        stub.stub = true;
1440        stub.entity_type = String::new();
1441        let other = make_typed_entity("uncharted", "lonely", "decision");
1442        store.upsert(stub.id.clone(), stub);
1443        store.upsert(other.id.clone(), other);
1444
1445        let mut mem_schemas = HashMap::new();
1446        mem_schemas.insert("plan".to_string(), schema);
1447
1448        let reports = collect_missing_required_outgoing(&store, None, &mem_schemas);
1449        assert!(
1450            reports.is_empty(),
1451            "stub (no schema lookup) and unschemaed mem must be skipped; got {reports:?}",
1452        );
1453    }
1454}