1use std::collections::{BTreeMap, BTreeSet};
35use std::path::Path;
36
37use serde::Serialize;
38
39use crate::Engine;
40use crate::anchor::{AnchorGrain, AnchorProvenanceClass, AnchorState};
41use crate::binding::{Binding, CoverageSemantics, MediumCapabilities, medium_capabilities};
42use crate::chunking::estimate_tokens;
43
44use super::advance::read_advance_store;
45use super::cursor::{enumerate_source_artifacts, source_moved};
46use super::findings::{FindingClass, FindingKey, read_findings_store};
47use super::resolve::{ChangeStrategy, ResolvedIngest, ResolvedSource, resolve_change_strategy};
48
49pub const DEFAULT_REPORT_BUDGET: usize = 8_000;
53
54pub const ALLOWED_REPORT_INCLUDE_KEYS: &[&str] =
60 &["uncovered_artifacts", "tree_fanout", "superseded_findings"];
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
73#[serde(tag = "kind", rename_all = "kebab-case")]
74pub enum DenominatorBasis {
75 Enumerated {
78 count: usize,
80 },
81 NonEnumerable {
85 reason: String,
87 },
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
94pub struct TreeFanout {
95 pub entity: String,
97 pub artifact: String,
99 pub fanout: usize,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
107pub struct GrainCoverage {
108 pub denominator: DenominatorBasis,
110 pub direct_covered: usize,
112 pub tree_only_covered: usize,
115 pub uncovered: Vec<String>,
117 pub tree_anchors: Vec<TreeFanout>,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default)]
125pub struct AnchorComposition {
126 pub by_class: BTreeMap<String, usize>,
129 pub by_grain: BTreeMap<String, usize>,
131 pub authored: usize,
134 pub observed: usize,
136 pub resolves: usize,
138 pub drifted: usize,
140 pub recheck: usize,
142 pub orphaned: usize,
144 pub unobserved: usize,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
152pub struct FacetCapability {
153 pub facet: String,
155 pub medium_type: String,
157 pub enumerable: bool,
159 pub change_signal: bool,
161 pub base_version_retrievable: bool,
163 pub anchor_namespace: String,
165 pub signal: String,
168}
169
170impl FacetCapability {
171 fn from_caps(
172 facet: String,
173 medium_type: String,
174 caps: MediumCapabilities,
175 strategy: ChangeStrategy,
176 ) -> Self {
177 FacetCapability {
178 facet,
179 medium_type,
180 enumerable: caps.enumerable,
181 change_signal: caps.change_signal,
182 base_version_retrievable: caps.base_version_retrievable
189 && strategy_retrieves_base(strategy),
190 anchor_namespace: caps.anchor_namespace.to_string(),
191 signal: signal_wire(strategy).to_string(),
192 }
193 }
194}
195
196#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
198pub struct FacetFreshness {
199 pub facet: String,
201 pub signal: String,
203 pub synced: Option<String>,
205 pub verified: Option<String>,
207 pub change_detectable: bool,
212}
213
214#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
216pub struct FidelityReport {
217 pub binding: String,
219 pub destination_mem: String,
221 pub adopt: bool,
228 pub coverage_semantics: CoverageSemantics,
232 pub coverage_semantics_declared: bool,
237 pub capabilities: Vec<FacetCapability>,
239 pub freshness: Vec<FacetFreshness>,
241 pub source_moved_past_synced: Option<bool>,
245 pub coverage: GrainCoverage,
247 pub anchors: AnchorComposition,
249 pub findings_by_class: BTreeMap<String, usize>,
251 pub backlog: usize,
253 pub superseded: Vec<String>,
256 pub disposed_excluded: usize,
259 pub disposed_excluded_rationales: Vec<(String, String)>,
265 pub degradations: Vec<String>,
267}
268
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
285#[serde(rename_all = "lowercase")]
286pub enum RollupVerdict {
287 Clean,
289 Drifted,
291 Inconclusive,
294}
295
296impl RollupVerdict {
297 pub fn wire(&self) -> &'static str {
299 match self {
300 RollupVerdict::Clean => "clean",
301 RollupVerdict::Drifted => "drifted",
302 RollupVerdict::Inconclusive => "inconclusive",
303 }
304 }
305}
306
307#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
310pub struct Rollup {
311 pub verdict: RollupVerdict,
313 pub findings_total: usize,
315 pub because: String,
318 pub blind_spots: Vec<String>,
323 pub actions: Vec<String>,
326}
327
328const CLASS_SEVERITY: [&str; 5] = [
333 "wrong",
334 "drifted",
335 "unresolvable-anchor",
336 "uncovered",
337 "queued-for-adjudication",
338];
339
340fn class_action(class: &str, n: usize, binding: &str) -> String {
342 match class {
343 "wrong" => format!(
344 "{n} entity/entities contradict their source — read them against the source and \
345 correct the entity (`memstead projection brief {binding}` lists them)"
346 ),
347 "drifted" => format!(
348 "{n} anchored artifact(s) moved since the entity was written — re-read the source \
349 and update the entity, then re-verify to advance the baseline"
350 ),
351 "unresolvable-anchor" => format!(
352 "{n} anchor(s) no longer resolve to anything — repoint them at the artifact's new \
353 location or unset them (`memstead_update` `anchors_unset`)"
354 ),
355 "uncovered" => format!(
356 "{n} in-scope source artifact(s) carry no anchor — cover them via \
357 `memstead projection brief {binding} --sync`, or record a disposition for the \
358 ones deliberately excluded"
359 ),
360 "queued-for-adjudication" => format!(
361 "{n} finding(s) are queued and not yet adjudicated — run \
362 `memstead projection verify {binding} --full` to work the backlog down"
363 ),
364 other => format!("{n} `{other}` finding(s) recorded"),
365 }
366}
367
368impl FidelityReport {
369 pub fn rollup(&self) -> Rollup {
376 let findings_total: usize = self.findings_by_class.values().sum();
377
378 let mut blind_spots: Vec<String> = Vec::new();
379 match &self.coverage.denominator {
380 DenominatorBasis::NonEnumerable { reason } => blind_spots.push(format!(
381 "the source scope is not enumerable ({reason}) — coverage is reported over \
382 anchors only, so an uncovered artifact cannot be detected"
383 )),
384 DenominatorBasis::Enumerated { count: 0 } => blind_spots.push(
385 "the enumerated source scope is empty (0 artifacts) — every coverage figure \
386 below is vacuous, not clean"
387 .to_string(),
388 ),
389 DenominatorBasis::Enumerated { .. } => {}
390 }
391 if self.anchors.observed == 0 {
392 blind_spots.push(
393 "no anchor carried a resolution state this pass — nothing was adjudicated"
394 .to_string(),
395 );
396 }
397 let change_blind: std::collections::BTreeSet<&str> = self
407 .freshness
408 .iter()
409 .filter(|f| !f.change_detectable)
410 .map(|f| f.facet.as_str())
411 .collect();
412 for cap in &self.capabilities {
413 if !cap.change_signal {
414 blind_spots.push(format!(
415 "facet `{}` ({}) provides no change signal — drift on it cannot be \
416 observed at all",
417 cap.facet, cap.medium_type
418 ));
419 } else if change_blind.contains(cap.facet.as_str()) {
420 blind_spots.push(format!(
421 "facet `{}` ({}) declares change-detection `{}` but this pass could \
422 not read that signal — either the binding asked for none, or the \
423 checkout cannot deliver it (a `git` source with no `.git`: an \
424 archive, a container COPY, a vendored drop). Drift on it cannot \
425 be observed",
426 cap.facet, cap.medium_type, cap.signal
427 ));
428 }
429 if !cap.enumerable {
439 blind_spots.push(format!(
440 "facet `{}` ({}) is not enumerable — an uncovered artifact under it \
441 cannot be detected, only an anchored one",
442 cap.facet, cap.medium_type
443 ));
444 }
445 }
446
447 let mut actions: Vec<String> = Vec::new();
448 for class in CLASS_SEVERITY {
449 if let Some(&n) = self.findings_by_class.get(class)
450 && n > 0
451 {
452 actions.push(class_action(class, n, &self.binding));
453 }
454 }
455 for (class, &n) in &self.findings_by_class {
458 if n > 0 && !CLASS_SEVERITY.contains(&class.as_str()) {
459 actions.push(class_action(class, n, &self.binding));
460 }
461 }
462
463 let only_uncovered = findings_total > 0
469 && self
470 .findings_by_class
471 .iter()
472 .all(|(class, &n)| n == 0 || class == "uncovered");
473
474 let (verdict, because) = if self.adopt && only_uncovered {
475 (
476 RollupVerdict::Inconclusive,
477 format!(
478 "this mem predates its binding — the {findings_total} uncovered artifact(s) \
479 are the backfill worklist, not drift"
480 ),
481 )
482 } else if findings_total > 0 {
483 let tally = self
484 .findings_by_class
485 .iter()
486 .filter(|(_, n)| **n > 0)
487 .map(|(class, n)| format!("{class}: {n}"))
488 .collect::<Vec<_>>()
489 .join(", ");
490 (
491 RollupVerdict::Drifted,
492 format!("{findings_total} finding(s) recorded over the current key ({tally})"),
493 )
494 } else if !blind_spots.is_empty() {
495 (
496 RollupVerdict::Inconclusive,
497 format!(
498 "no findings recorded, but the pass could not speak to {} axis/axes — \
499 this is not a clean bill of health",
500 blind_spots.len()
501 ),
502 )
503 } else {
504 (
505 RollupVerdict::Clean,
506 "the pass was substantive on every axis and recorded no findings".to_string(),
507 )
508 };
509
510 Rollup {
511 verdict,
512 findings_total,
513 because,
514 blind_spots,
515 actions,
516 }
517 }
518}
519
520#[derive(Debug, Clone, PartialEq, Eq)]
527pub struct RenderedFidelityReport {
528 pub markdown: String,
530 pub mode: String,
533 pub hints: Vec<(String, usize)>,
536 pub budget_used: usize,
538}
539
540fn ratio(num: usize, den: usize) -> String {
546 if den == 0 {
547 format!("{num}/{den} (n/a)")
548 } else {
549 let pct = (num as f64) * 100.0 / (den as f64);
550 format!("{num}/{den} ({pct:.1}%)")
551 }
552}
553
554fn render_hard_required(report: &FidelityReport) -> String {
558 let mut md = String::new();
559 md.push_str(&format!("# Fidelity report — `{}`\n\n", report.binding));
560
561 let rollup = report.rollup();
566 md.push_str(&format!(
567 "**Verdict: {}** — {}.\n\n",
568 rollup.verdict.wire().to_uppercase(),
569 rollup.because
570 ));
571 if !rollup.actions.is_empty() {
572 md.push_str("**Do next:**\n\n");
573 for action in &rollup.actions {
574 md.push_str(&format!("1. {action}\n"));
575 }
576 md.push('\n');
577 }
578 if !rollup.blind_spots.is_empty() {
579 md.push_str("**This pass could not see:**\n\n");
580 for spot in &rollup.blind_spots {
581 md.push_str(&format!("- {spot}\n"));
582 }
583 md.push('\n');
584 }
585
586 md.push_str(&format!(
587 "- **Destination mem:** `{}`\n- **Coverage semantics:** {}{}\n\n",
588 report.destination_mem,
589 match report.coverage_semantics {
590 CoverageSemantics::Exhaustive => "exhaustive",
591 CoverageSemantics::Curated => "curated",
592 },
593 if report.coverage_semantics_declared {
594 ""
595 } else {
596 " (resolved from the sources' media — not declared)"
597 }
598 ));
599
600 if report.adopt {
607 md.push_str("## Adopting — first verify\n\n");
608 md.push_str(
609 "This mem predates its binding: it carries no anchors and has no prior sync \
610 baseline, so **0% anchored is expected — this is onboarding, not a failure.** \
611 Do not read the coverage numbers below as drift or a red verdict; the uncovered \
612 artifacts are the backfill worklist, not defects.\n\n",
613 );
614 md.push_str(&format!(
615 "**Backfill path:** run `memstead projection brief {} --sync` to work through the in-scope \
616 source artifacts that carry no entity yet, covering the clearly-new concepts among \
617 them through the normal mutation surface. Backfilling is incremental — a partial \
618 pass is fine, and the next sync continues where you left off.\n\n",
619 report.binding
620 ));
621 }
622
623 md.push_str("## Denominator provenance\n\n");
625 match &report.coverage.denominator {
626 DenominatorBasis::Enumerated { count } => md.push_str(&format!(
627 "Coverage is reported relative to the per-medium enumeration `S(D)` = **{count}** \
628 source artifact(s) in scope (after `deny_paths`).\n\n"
629 )),
630 DenominatorBasis::NonEnumerable { reason } => md.push_str(&format!(
631 "No `S(D)` denominator: {reason}. Coverage is reported over anchors only; the \
632 per-medium enumeration is unavailable.\n\n"
633 )),
634 }
635
636 md.push_str("## Capability matrix\n\n");
638 if report.capabilities.is_empty() {
639 md.push_str("_(no primary sources resolved)_\n\n");
640 } else {
641 for c in &report.capabilities {
642 md.push_str(&format!("### `{}` ({})\n\n", c.facet, c.medium_type));
643 md.push_str(&format!(
644 "- enumerable: {} | change_signal: {} | base_version_retrievable: {}\n",
645 c.enumerable, c.change_signal, c.base_version_retrievable
646 ));
647 md.push_str(&format!(
648 "- anchor_namespace: `{}` | resolved signal: `{}`\n\n",
649 c.anchor_namespace, c.signal
650 ));
651 }
652 }
653
654 md.push_str("## Freshness\n\n");
656 if report.freshness.is_empty() {
657 md.push_str("_(no source facets)_\n\n");
658 } else {
659 for f in &report.freshness {
660 md.push_str(&format!("### `{}`\n\n", f.facet));
661 md.push_str(&format!("- signal: `{}`\n", f.signal));
662 if !f.change_detectable {
663 md.push_str(
667 "- **freshness unknowable** — this medium is not change-detectable \
668 (no change signal); `#synced` / `#verified` cannot be adjudicated as fresh\n",
669 );
670 } else {
671 match &f.synced {
672 Some(t) => md.push_str(&format!("- `#synced`: `{t}`\n")),
673 None => md.push_str("- `#synced`: never synced\n"),
674 }
675 match &f.verified {
676 Some(t) => md.push_str(&format!("- `#verified`: `{t}`\n")),
677 None => md.push_str("- `#verified`: never verified\n"),
678 }
679 }
680 md.push('\n');
681 }
682 match report.source_moved_past_synced {
684 Some(true) => md.push_str(
685 "**Source moved past its `#synced` baseline** — the graph is stale for the \
686 moved facet(s); a sync pass is due.\n\n",
687 ),
688 Some(false) => {
689 md.push_str("Every change-detectable source is at its `#synced` baseline.\n\n")
690 }
691 None => {}
692 }
693 }
694
695 md.push_str("## Coverage (grain-classed)\n\n");
697 let den = match &report.coverage.denominator {
698 DenominatorBasis::Enumerated { count } => *count,
699 DenominatorBasis::NonEnumerable { .. } => 0,
700 };
701 md.push_str(&format!(
702 "- direct-covered (file / span anchors): {}\n",
703 ratio(report.coverage.direct_covered, den)
704 ));
705 let tree_files: usize = report.coverage.tree_anchors.iter().map(|t| t.fanout).sum();
708 md.push_str(&format!(
709 "- tree-anchor fan-out (separate axis): {} tree anchor(s) fanning out over {} file(s); \
710 {} file(s) covered ONLY via a tree anchor\n",
711 report.coverage.tree_anchors.len(),
712 tree_files,
713 report.coverage.tree_only_covered
714 ));
715 md.push_str(&format!(
716 "- uncovered (no anchor): {}\n\n",
717 report.coverage.uncovered.len()
718 ));
719
720 match report.coverage_semantics {
725 CoverageSemantics::Exhaustive if report.adopt => {
726 let backlog = report
727 .coverage
728 .uncovered
729 .len()
730 .saturating_sub(report.disposed_excluded);
731 md.push_str(&format!(
732 "**Exhaustive coverage (onboarding):** {backlog} in-scope artifact(s) carry no \
733 entity yet ({} disposed excluded) — the expected first-sync backfill worklist \
734 for a mem that predates its binding, not defects.\n\n",
735 report.disposed_excluded
736 ));
737 }
738 CoverageSemantics::Exhaustive => {
739 let findings = report
740 .coverage
741 .uncovered
742 .len()
743 .saturating_sub(report.disposed_excluded);
744 md.push_str(&format!(
745 "**Exhaustive coverage:** {findings} unaccounted artifact(s) — not anchored, not \
746 declared-excluded, no persisted disposition ({} disposed excluded) — are \
747 **findings**.\n\n",
748 report.disposed_excluded
749 ));
750 }
751 CoverageSemantics::Curated => {
752 md.push_str(&format!(
753 "**Curated coverage:** {} unaccounted artifact(s) are **information**, not \
754 defects — a curated binding covers a deliberate slice.\n\n",
755 report.coverage.uncovered.len()
756 ));
757 }
758 }
759
760 if !report.disposed_excluded_rationales.is_empty() {
764 md.push_str("**Excluded on purpose (persisted dispositions):**\n");
765 for (artifact, rationale) in &report.disposed_excluded_rationales {
766 if rationale.is_empty() {
767 md.push_str(&format!("- `{artifact}`\n"));
768 } else {
769 md.push_str(&format!("- `{artifact}` — {rationale}\n"));
770 }
771 }
772 md.push('\n');
773 }
774
775 md.push_str("## Anchors\n\n");
777 md.push_str(&format!(
778 "- by class: {}\n",
779 render_counts(&report.anchors.by_class)
780 ));
781 md.push_str(&format!(
782 "- by grain: {}\n",
783 render_counts(&report.anchors.by_grain)
784 ));
785 md.push_str(&format!(
786 "- `authored` bucket (excluded from coverage/accuracy denominators): {}\n",
787 report.anchors.authored
788 ));
789 md.push_str(&format!(
790 "- resolution (non-`authored`, observed): resolves {}, drifted {}, recheck {}, orphaned {}\n",
791 report.anchors.resolves,
792 report.anchors.drifted,
793 report.anchors.recheck,
794 report.anchors.orphaned
795 ));
796 md.push_str(&format!(
797 "- **anchor-resolution %:** {}\n",
798 ratio(report.anchors.resolves, report.anchors.observed)
799 ));
800 md.push_str(&format!(
801 "- unobserved this pass (state unavailable, never scored as resolved): {}\n\n",
802 report.anchors.unobserved
803 ));
804
805 md.push_str("## Findings\n\n");
807 md.push_str(&format!(
808 "- by class: {}\n",
809 render_counts(&report.findings_by_class)
810 ));
811 md.push_str(&format!(
812 "- **tier-3 adjudication backlog:** {}\n",
813 report.backlog
814 ));
815 md.push_str(&format!(
816 "- superseded (prior `(hash(D), source_head)` key, segregated): {}\n\n",
817 report.superseded.len()
818 ));
819
820 md.push_str("## Degradations\n\n");
822 if report.degradations.is_empty() {
823 md.push_str("_(none)_\n\n");
824 } else {
825 for d in &report.degradations {
826 md.push_str(&format!("- {d}\n"));
827 }
828 md.push('\n');
829 }
830
831 md
832}
833
834fn render_counts(counts: &BTreeMap<String, usize>) -> String {
836 if counts.is_empty() {
837 return "(none)".to_string();
838 }
839 counts
840 .iter()
841 .map(|(k, v)| format!("{k}={v}"))
842 .collect::<Vec<_>>()
843 .join(", ")
844}
845
846fn heavy_sections(report: &FidelityReport) -> Vec<(&'static str, String)> {
850 let mut out: Vec<(&'static str, String)> = Vec::new();
851
852 let mut s = String::new();
854 if !report.coverage.uncovered.is_empty() {
855 s.push_str("## Uncovered artifacts\n\n");
856 for a in &report.coverage.uncovered {
857 s.push_str(&format!("- `{a}`\n"));
858 }
859 s.push('\n');
860 }
861 out.push(("uncovered_artifacts", s));
862
863 let mut s = String::new();
865 if !report.coverage.tree_anchors.is_empty() {
866 s.push_str("## Tree-anchor fan-out (detail)\n\n");
867 for t in &report.coverage.tree_anchors {
868 s.push_str(&format!(
869 "- `{}` → `{}` fans out over {} file(s)\n",
870 t.entity, t.artifact, t.fanout
871 ));
872 }
873 s.push('\n');
874 }
875 out.push(("tree_fanout", s));
876
877 let mut s = String::new();
879 if !report.superseded.is_empty() {
880 s.push_str("## Superseded findings (detail)\n\n");
881 for f in &report.superseded {
882 s.push_str(&format!("- {f}\n"));
883 }
884 s.push('\n');
885 }
886 out.push(("superseded_findings", s));
887
888 out
889}
890
891pub fn render_fidelity_report(
902 report: &FidelityReport,
903 budget: usize,
904 include: &[String],
905) -> RenderedFidelityReport {
906 let hard = render_hard_required(report);
907 let hard_cost = estimate_tokens(&hard);
908 let overbudget = hard_cost > budget;
909
910 let include_set: std::collections::BTreeSet<&str> = include
911 .iter()
912 .map(String::as_str)
913 .filter(|k| ALLOWED_REPORT_INCLUDE_KEYS.contains(k))
914 .collect();
915 let unknown_includes: Vec<&String> = include
916 .iter()
917 .filter(|k| !ALLOWED_REPORT_INCLUDE_KEYS.contains(&k.as_str()))
918 .collect();
919
920 let sections = heavy_sections(report);
921 let mut emitted: Vec<String> = Vec::new();
922 let mut hints: Vec<(String, usize)> = Vec::new();
923 let mut used = hard_cost;
924 let mut remaining = budget.saturating_sub(hard_cost);
925
926 for (key, section_md) in §ions {
927 if section_md.is_empty() {
928 continue; }
930 let cost = estimate_tokens(section_md);
931 let forced = include_set.contains(key);
932 if forced {
933 emitted.push(section_md.clone());
934 used += cost;
935 remaining = remaining.saturating_sub(cost);
936 } else if !overbudget && remaining >= cost {
937 emitted.push(section_md.clone());
938 used += cost;
939 remaining -= cost;
940 } else {
941 hints.push(((*key).to_string(), cost));
942 }
943 }
944
945 let mode = if overbudget {
946 "overbudget"
947 } else if hints.is_empty() {
948 "complete"
949 } else {
950 "reduced"
951 };
952
953 let mut md = String::new();
954 md.push_str("---\n");
955 md.push_str(&format!("_report_mode: {mode}\n"));
956 md.push_str(&format!("_budget_requested: {budget}\n"));
957 md.push_str(&format!("_budget_used: {used}\n"));
958 md.push_str("---\n\n");
959 md.push_str(&hard);
960 for section in &emitted {
961 md.push_str(section);
962 }
963
964 if !hints.is_empty() {
965 md.push_str("## Hints\n\n");
966 md.push_str(
967 "_(heavy sections omitted under the token budget — re-query with the key)_\n\n",
968 );
969 for (key, tokens) in &hints {
970 md.push_str(&format!("- `{key}` — estimated_tokens: {tokens}\n"));
971 }
972 md.push('\n');
973 }
974
975 if !unknown_includes.is_empty() {
976 md.push_str("## Warnings\n\n");
977 for k in &unknown_includes {
978 md.push_str(&format!(
979 "- unknown include key `{k}` — allowed: {}\n",
980 ALLOWED_REPORT_INCLUDE_KEYS.join(", ")
981 ));
982 }
983 md.push('\n');
984 }
985
986 RenderedFidelityReport {
987 markdown: md,
988 mode: mode.to_string(),
989 hints,
990 budget_used: used,
991 }
992}
993
994pub fn compute_fidelity_report(
1009 engine: &Engine,
1010 workspace_root: &Path,
1011 binding: &Binding,
1012 resolved: &ResolvedIngest,
1013 key: &FindingKey,
1014) -> FidelityReport {
1015 let binding_id = resolved.name.clone();
1016 let dest = resolved.destination_mem.clone();
1017
1018 let sync_state = engine
1020 .mem_config_for(&dest)
1021 .map(|c| c.sync_state.clone())
1022 .unwrap_or_default();
1023 let mut capabilities: Vec<FacetCapability> = Vec::new();
1024 let mut freshness: Vec<FacetFreshness> = Vec::new();
1025 let mut any_change_detectable = false;
1026 for source in &resolved.sources {
1027 let ResolvedSource::Primary(p) = source else {
1028 continue;
1029 };
1030 let caps = medium_capabilities(p.medium_type);
1031 let medium_type = serde_json::to_value(p.medium_type)
1032 .ok()
1033 .and_then(|v| v.as_str().map(str::to_string))
1034 .unwrap_or_default();
1035 let strategy = resolve_change_strategy(p, workspace_root);
1036 let signal = signal_wire(strategy).to_string();
1037 let signal_readable = match strategy {
1047 ChangeStrategy::Git => {
1048 super::resolve::find_git_root(&super::resolve::source_base_path(p, workspace_root))
1049 .is_some()
1050 }
1051 _ => true,
1052 };
1053 let change_detectable =
1054 caps.change_signal && strategy != ChangeStrategy::None && signal_readable;
1055 any_change_detectable |= change_detectable;
1056
1057 capabilities.push(FacetCapability::from_caps(
1058 p.name.clone(),
1059 medium_type,
1060 caps,
1061 strategy,
1062 ));
1063
1064 let synced = sync_state
1065 .get(&format!("{binding_id}/{}#synced", p.name))
1066 .cloned();
1067 let verified = sync_state
1068 .get(&format!("{binding_id}/{}#verified", p.name))
1069 .cloned();
1070 freshness.push(FacetFreshness {
1071 facet: p.name.clone(),
1072 signal,
1073 synced,
1074 verified,
1075 change_detectable,
1076 });
1077 }
1078
1079 let source_moved_past_synced = if any_change_detectable {
1080 Some(source_moved(engine, resolved, workspace_root))
1081 } else {
1082 None
1083 };
1084
1085 let mut s_d: Vec<String> = Vec::new();
1087 let mut enumerable_facets = 0usize;
1088 let mut empty_enumerable_facets: BTreeSet<String> = BTreeSet::new();
1095 for source in &resolved.sources {
1096 if let ResolvedSource::Primary(p) = source {
1097 let caps = medium_capabilities(p.medium_type);
1098 if caps.enumerable {
1099 enumerable_facets += 1;
1100 }
1101 let walked =
1102 enumerate_source_artifacts(engine, p, &resolved.deny_paths, workspace_root);
1103 if caps.enumerable && walked.is_empty() {
1104 empty_enumerable_facets.insert(p.name.clone());
1105 }
1106 s_d.extend(walked);
1107 }
1108 }
1109 s_d.sort();
1110 s_d.dedup();
1111
1112 let denominator = if !s_d.is_empty() {
1113 DenominatorBasis::Enumerated { count: s_d.len() }
1114 } else if enumerable_facets == 0 {
1115 DenominatorBasis::NonEnumerable {
1116 reason: "the medium type(s) are not enumerable this cycle".to_string(),
1117 }
1118 } else {
1119 DenominatorBasis::NonEnumerable {
1123 reason: "no source artifacts enumerated in scope".to_string(),
1124 }
1125 };
1126
1127 let mut direct_covered = 0usize;
1128 let mut tree_only_covered = 0usize;
1129 let mut uncovered: Vec<String> = Vec::new();
1130 let mut tree_fanout: BTreeMap<(String, String), usize> = BTreeMap::new();
1131 for file in &s_d {
1132 let refs = engine.anchors_referencing_artifact(file);
1133 let mine: Vec<&(crate::EntityId, crate::anchor::Anchor)> = refs
1134 .iter()
1135 .filter(|(eid, _)| eid.mem() == dest.as_str())
1136 .collect();
1137 if mine.is_empty() {
1138 uncovered.push(file.clone());
1139 continue;
1140 }
1141 let has_non_tree = mine.iter().any(|(_, a)| a.grain != AnchorGrain::Tree);
1142 if has_non_tree {
1143 direct_covered += 1;
1144 } else {
1145 tree_only_covered += 1;
1146 }
1147 for (eid, a) in &mine {
1149 if a.grain == AnchorGrain::Tree {
1150 *tree_fanout
1151 .entry((eid.as_ref().to_string(), a.artifact.clone()))
1152 .or_insert(0) += 1;
1153 }
1154 }
1155 }
1156 let tree_anchors: Vec<TreeFanout> = tree_fanout
1157 .into_iter()
1158 .map(|((entity, artifact), fanout)| TreeFanout {
1159 entity,
1160 artifact,
1161 fanout,
1162 })
1163 .collect();
1164
1165 let coverage = GrainCoverage {
1166 denominator,
1167 direct_covered,
1168 tree_only_covered,
1169 uncovered: uncovered.clone(),
1170 tree_anchors,
1171 };
1172
1173 let mut anchors = AnchorComposition::default();
1175 for (_eid, resolved_anchor) in engine.mem_anchors_resolved(&dest) {
1176 let a = &resolved_anchor.anchor;
1177 *anchors
1178 .by_class
1179 .entry(a.class.as_wire().to_string())
1180 .or_insert(0) += 1;
1181 *anchors
1182 .by_grain
1183 .entry(a.grain.as_wire().to_string())
1184 .or_insert(0) += 1;
1185 if a.class == AnchorProvenanceClass::Authored {
1186 anchors.authored += 1;
1187 continue; }
1189 match resolved_anchor.state {
1190 Some(AnchorState::Resolves) => {
1191 anchors.resolves += 1;
1192 anchors.observed += 1;
1193 }
1194 Some(AnchorState::Drifted) => {
1195 anchors.drifted += 1;
1196 anchors.observed += 1;
1197 }
1198 Some(AnchorState::Recheck) => {
1199 anchors.recheck += 1;
1200 anchors.observed += 1;
1201 }
1202 Some(AnchorState::Orphaned) => {
1203 anchors.orphaned += 1;
1204 anchors.observed += 1;
1205 }
1206 None => anchors.unobserved += 1,
1207 }
1208 }
1209
1210 let mut findings_by_class: BTreeMap<String, usize> = BTreeMap::new();
1212 let mut backlog = 0usize;
1213 let mut superseded: Vec<String> = Vec::new();
1214 if let Some((mem, name)) = binding_id.split_once('/')
1215 && let Ok(Some(store)) = read_findings_store(workspace_root, mem, name)
1216 {
1217 for f in store.current(key) {
1218 *findings_by_class
1219 .entry(f.class.as_wire().to_string())
1220 .or_insert(0) += 1;
1221 if f.class == FindingClass::QueuedForAdjudication {
1222 backlog += 1;
1223 }
1224 }
1225 for f in store.superseded(key) {
1226 superseded.push(format!(
1227 "[{}] {} ({})",
1228 f.class.as_wire(),
1229 finding_target_label(&f.target),
1230 f.facet
1231 ));
1232 }
1233 }
1234
1235 let mut disposed_excluded_rationales: Vec<(String, String)> = Vec::new();
1241 if let Some((mem, name)) = binding_id.split_once('/')
1242 && let Ok(Some(state)) = read_advance_store(workspace_root, mem, name)
1243 {
1244 let uncovered_set: std::collections::BTreeSet<&str> =
1245 uncovered.iter().map(String::as_str).collect();
1246 for (artifact, rationale) in &state.exclusions {
1247 if uncovered_set.contains(artifact.as_str()) {
1248 disposed_excluded_rationales.push((artifact.clone(), rationale.clone()));
1249 }
1250 }
1251 }
1252 let disposed_excluded = disposed_excluded_rationales.len();
1253
1254 let mut degradations: Vec<String> = Vec::new();
1256 for c in &capabilities {
1257 if !c.change_signal || c.signal == "none" {
1258 degradations.push(format!(
1259 "change-signal-none:`{}` — freshness is unknowable for this facet",
1260 c.facet
1261 ));
1262 }
1263 if !c.enumerable {
1264 degradations.push(format!(
1265 "enumeration-unavailable:`{}` — `S(D)` coverage denominator not computable",
1266 c.facet
1267 ));
1268 } else if empty_enumerable_facets.contains(&c.facet) {
1269 degradations.push(format!(
1277 "enumeration-empty:`{}` — the medium claims enumerability but the walk yielded \
1278 no artifacts; coverage is reported over anchors only",
1279 c.facet
1280 ));
1281 }
1282 if !c.base_version_retrievable {
1283 degradations.push(format!(
1284 "base-version-unretrievable:`{}` — prune degrades to conflict-flagging",
1285 c.facet
1286 ));
1287 }
1288 }
1289 if anchors.recheck > 0 {
1290 degradations.push(format!(
1291 "hash-adjudication-deferred — {} anchor(s) recheck (unstable medium / hash \
1292 unavailable), not asserted drift",
1293 anchors.recheck
1294 ));
1295 }
1296 if anchors.unobserved > 0 {
1297 degradations.push(format!(
1298 "anchors-unobserved — {} anchor(s) could not be observed this pass",
1299 anchors.unobserved
1300 ));
1301 }
1302
1303 let adopt = super::render::mem_predates_binding(engine, resolved);
1307 let effective_coverage = crate::binding::effective_coverage_semantics(binding);
1308
1309 FidelityReport {
1310 binding: binding_id,
1311 destination_mem: dest,
1312 adopt,
1313 coverage_semantics: effective_coverage.value,
1314 coverage_semantics_declared: effective_coverage.declared,
1315 capabilities,
1316 freshness,
1317 source_moved_past_synced,
1318 coverage,
1319 anchors,
1320 findings_by_class,
1321 backlog,
1322 superseded,
1323 disposed_excluded,
1324 disposed_excluded_rationales,
1325 degradations,
1326 }
1327}
1328
1329fn strategy_retrieves_base(strategy: ChangeStrategy) -> bool {
1338 matches!(strategy, ChangeStrategy::Git | ChangeStrategy::Graph)
1339}
1340
1341fn signal_wire(strategy: ChangeStrategy) -> &'static str {
1344 match strategy {
1345 ChangeStrategy::None => "none",
1346 ChangeStrategy::Git => "git",
1347 ChangeStrategy::Mtime => "mtime",
1348 ChangeStrategy::Graph => "graph",
1349 }
1350}
1351
1352fn finding_target_label(target: &super::findings::FindingTarget) -> String {
1354 match target {
1355 super::findings::FindingTarget::Anchor { entity, artifact } => {
1356 format!("{entity} → {artifact}")
1357 }
1358 super::findings::FindingTarget::Artifact { artifact } => artifact.clone(),
1359 }
1360}
1361
1362#[cfg(test)]
1363mod tests {
1364 use super::*;
1365
1366 fn base_report() -> FidelityReport {
1369 FidelityReport {
1370 binding: "engine/graph".to_string(),
1371 destination_mem: "engine".to_string(),
1372 adopt: false,
1373 coverage_semantics: CoverageSemantics::Exhaustive,
1374 coverage_semantics_declared: true,
1375 capabilities: vec![FacetCapability {
1376 facet: "src".to_string(),
1377 medium_type: "codebase".to_string(),
1378 enumerable: true,
1379 change_signal: true,
1380 base_version_retrievable: true,
1381 anchor_namespace: "path".to_string(),
1382 signal: "git".to_string(),
1383 }],
1384 freshness: vec![FacetFreshness {
1385 facet: "src".to_string(),
1386 signal: "git".to_string(),
1387 synced: Some("deadbeef".to_string()),
1388 verified: None,
1389 change_detectable: true,
1390 }],
1391 source_moved_past_synced: Some(false),
1392 coverage: GrainCoverage {
1393 denominator: DenominatorBasis::Enumerated { count: 10 },
1394 direct_covered: 6,
1395 tree_only_covered: 3,
1396 uncovered: vec!["src/a.rs".to_string()],
1397 tree_anchors: vec![TreeFanout {
1398 entity: "engine--big".to_string(),
1399 artifact: "src/".to_string(),
1400 fanout: 3,
1401 }],
1402 },
1403 anchors: AnchorComposition {
1404 by_class: BTreeMap::from([
1405 ("anchored".to_string(), 5),
1406 ("authored".to_string(), 2),
1407 ]),
1408 by_grain: BTreeMap::from([("file".to_string(), 4), ("tree".to_string(), 1)]),
1409 authored: 2,
1410 observed: 5,
1411 resolves: 4,
1412 drifted: 0,
1413 recheck: 1,
1414 orphaned: 0,
1415 unobserved: 0,
1416 },
1417 findings_by_class: BTreeMap::from([
1418 ("uncovered".to_string(), 1),
1419 ("queued-for-adjudication".to_string(), 1),
1420 ]),
1421 backlog: 1,
1422 superseded: Vec::new(),
1423 disposed_excluded: 0,
1424 disposed_excluded_rationales: Vec::new(),
1425 degradations: vec!["hash-adjudication-deferred — 1 anchor(s) recheck".to_string()],
1426 }
1427 }
1428
1429 #[test]
1434 fn b1_renders_all_elements_deterministically() {
1435 let r = base_report();
1436 let a = render_fidelity_report(&r, 8_000, &[]);
1437 let b = render_fidelity_report(&r, 8_000, &[]);
1438 assert_eq!(a.markdown, b.markdown, "deterministic — identical bytes");
1439
1440 let md = &a.markdown;
1441 assert!(md.contains("direct-covered (file / span anchors): 6/10"));
1443 assert!(md.contains(
1444 "tree-anchor fan-out (separate axis): 1 tree anchor(s) fanning out over 3 file(s)"
1445 ));
1446 assert!(
1448 !md.contains("9/10"),
1449 "tree fan-out must not blend into direct coverage"
1450 );
1451 assert!(md.contains("anchor-resolution %:** 4/5"));
1453 assert!(md.contains("`authored` bucket (excluded from coverage/accuracy denominators): 2"));
1455 assert!(md.contains("tier-3 adjudication backlog:** 1"));
1457 assert!(md.contains("## Capability matrix"));
1459 assert!(md.contains("## Degradations"));
1460 assert!(md.contains("hash-adjudication-deferred"));
1461 assert!(md.contains("per-medium enumeration `S(D)` = **10**"));
1463 }
1464
1465 #[test]
1468 fn b2_detectionless_medium_freshness_unknowable_never_green() {
1469 let mut r = base_report();
1470 r.capabilities = vec![FacetCapability {
1471 facet: "manual".to_string(),
1472 medium_type: "web".to_string(),
1473 enumerable: false,
1474 change_signal: false,
1475 base_version_retrievable: false,
1476 anchor_namespace: "url".to_string(),
1477 signal: "none".to_string(),
1478 }];
1479 r.freshness = vec![FacetFreshness {
1480 facet: "manual".to_string(),
1481 signal: "none".to_string(),
1482 synced: Some("should-never-render-green".to_string()),
1485 verified: Some("nor-this".to_string()),
1486 change_detectable: false,
1487 }];
1488 r.source_moved_past_synced = None;
1489 let out = render_fidelity_report(&r, 8_000, &[]);
1490 let md = &out.markdown;
1491 assert!(md.contains("signal: `none`"));
1492 assert!(md.contains("freshness unknowable"));
1493 assert!(!md.contains("should-never-render-green"));
1496 assert!(
1497 !md.contains("`#synced`: `"),
1498 "no synced token rendered for a non-detectable medium"
1499 );
1500 assert!(
1501 !md.contains("at its `#synced` baseline"),
1502 "no green 'at baseline' verdict"
1503 );
1504 }
1505
1506 #[test]
1514 fn b1_base_retrievability_follows_resolved_strategy_not_medium_ceiling() {
1515 use crate::pipeline::MediumType;
1516
1517 assert!(medium_capabilities(MediumType::Filesystem).base_version_retrievable);
1519
1520 let fs_mtime = FacetCapability::from_caps(
1522 "prose".to_string(),
1523 "filesystem".to_string(),
1524 medium_capabilities(MediumType::Filesystem),
1525 ChangeStrategy::Mtime,
1526 );
1527 assert!(
1528 !fs_mtime.base_version_retrievable,
1529 "filesystem+mtime has no retrievable base leg — degrades to conflict-flag"
1530 );
1531 assert_eq!(fs_mtime.signal, "mtime");
1532
1533 let fs_git = FacetCapability::from_caps(
1534 "prose".to_string(),
1535 "filesystem".to_string(),
1536 medium_capabilities(MediumType::Filesystem),
1537 ChangeStrategy::Git,
1538 );
1539 assert!(
1540 fs_git.base_version_retrievable,
1541 "filesystem backed by git keeps the never-clobber base leg"
1542 );
1543
1544 assert!(!strategy_retrieves_base(ChangeStrategy::None));
1546 assert!(!strategy_retrieves_base(ChangeStrategy::Mtime));
1547 assert!(strategy_retrieves_base(ChangeStrategy::Git));
1548 assert!(strategy_retrieves_base(ChangeStrategy::Graph));
1549
1550 let mut r = base_report();
1554 r.capabilities = vec![fs_mtime.clone()];
1555 r.degradations = if !fs_mtime.base_version_retrievable {
1556 vec![format!(
1557 "base-version-unretrievable:`{}` — prune degrades to conflict-flagging",
1558 fs_mtime.facet
1559 )]
1560 } else {
1561 Vec::new()
1562 };
1563 let md = render_fidelity_report(&r, 8_000, &[]).markdown;
1564 assert!(
1565 md.contains("base-version-unretrievable:`prose` — prune degrades to conflict-flagging"),
1566 "filesystem+mtime surfaces the conflict-flag degradation in the report"
1567 );
1568 }
1569
1570 #[test]
1573 fn b3_aggregates_always_ship_at_zero_budget() {
1574 let r = base_report();
1575 let out = render_fidelity_report(&r, 0, &[]);
1576 assert_eq!(out.mode, "overbudget");
1577 let md = &out.markdown;
1578 assert!(md.contains("direct-covered (file / span anchors): 6/10"));
1580 assert!(md.contains("tier-3 adjudication backlog:** 1"));
1581 assert!(md.contains("## Capability matrix"));
1582 assert!(!md.contains("## Uncovered artifacts"));
1584 assert!(md.contains("## Hints"));
1585 assert!(out.hints.iter().any(|(k, _)| k == "uncovered_artifacts"));
1586 }
1587
1588 #[test]
1592 fn b3_large_facet_list_truncates_then_include_forces() {
1593 let mut r = base_report();
1594 r.coverage.uncovered = (0..500).map(|i| format!("src/file_{i}.rs")).collect();
1596 let hard_cost = estimate_tokens(&render_hard_required(&r));
1598 let out = render_fidelity_report(&r, hard_cost + 5, &[]);
1599 assert_eq!(out.mode, "reduced");
1600 assert!(
1601 !out.markdown.contains("src/file_499.rs"),
1602 "big list not rendered unbounded"
1603 );
1604 assert!(out.markdown.contains("## Hints"));
1605 let (_, est) = out
1606 .hints
1607 .iter()
1608 .find(|(k, _)| k == "uncovered_artifacts")
1609 .expect("uncovered list hinted");
1610 assert!(*est > 5, "the hint carries a real estimated_tokens figure");
1611
1612 let forced =
1614 render_fidelity_report(&r, hard_cost + 5, &["uncovered_artifacts".to_string()]);
1615 assert!(
1616 forced.markdown.contains("src/file_499.rs"),
1617 "include forces the full list"
1618 );
1619 }
1620
1621 #[test]
1624 fn b4_curated_vs_exhaustive_framing() {
1625 let mut exhaustive = base_report();
1626 exhaustive.coverage_semantics = CoverageSemantics::Exhaustive;
1627 let ex_md = render_fidelity_report(&exhaustive, 8_000, &[]).markdown;
1628 assert!(ex_md.contains("Exhaustive coverage:"));
1629 assert!(ex_md.contains("are **findings**"));
1630
1631 let mut curated = base_report();
1632 curated.coverage_semantics = CoverageSemantics::Curated;
1633 let cur_md = render_fidelity_report(&curated, 8_000, &[]).markdown;
1634 assert!(cur_md.contains("Curated coverage:"));
1635 assert!(cur_md.contains("**information**"));
1636 assert!(
1637 !cur_md.contains("are **findings**"),
1638 "curated never frames unaccounted as findings"
1639 );
1640 }
1641
1642 #[test]
1645 fn b4_disposition_excludes_from_exhaustive_findings() {
1646 let mut r = base_report();
1647 r.coverage_semantics = CoverageSemantics::Exhaustive;
1648 r.coverage.uncovered = vec!["src/a.rs".to_string(), "src/b.rs".to_string()];
1649 r.disposed_excluded = 1;
1650 let md = render_fidelity_report(&r, 8_000, &[]).markdown;
1651 assert!(md.contains("1 unaccounted artifact(s)"));
1653 assert!(md.contains("(1 disposed excluded)"));
1654 }
1655
1656 #[test]
1659 fn b4_authored_exclusion_rationale_is_rendered() {
1660 let mut r = base_report();
1661 r.coverage_semantics = CoverageSemantics::Exhaustive;
1662 r.coverage.uncovered = vec!["src/gen.rs".to_string()];
1663 r.disposed_excluded = 1;
1664 r.disposed_excluded_rationales =
1665 vec![("src/gen.rs".to_string(), "generated; no entity".to_string())];
1666 let md = render_fidelity_report(&r, 8_000, &[]).markdown;
1667 assert!(md.contains("Excluded on purpose (persisted dispositions):"));
1668 assert!(md.contains("`src/gen.rs` — generated; no entity"));
1669 }
1670
1671 #[test]
1674 fn b5_denominator_provenance_stated() {
1675 let r = base_report();
1676 let md = render_fidelity_report(&r, 8_000, &[]).markdown;
1677 assert!(md.contains("## Denominator provenance"));
1678 assert!(md.contains("per-medium enumeration `S(D)` = **10**"));
1679
1680 let mut non = base_report();
1681 non.coverage.denominator = DenominatorBasis::NonEnumerable {
1682 reason: "the medium type(s) are not enumerable this cycle".to_string(),
1683 };
1684 let md2 = render_fidelity_report(&non, 8_000, &[]).markdown;
1685 assert!(md2.contains("No `S(D)` denominator"));
1686 assert!(md2.contains("not enumerable this cycle"));
1687 }
1688
1689 #[test]
1695 fn e1_adopt_report_renders_onboarding_no_red_verdict() {
1696 let mut r = base_report();
1697 r.adopt = true;
1698 r.coverage_semantics = CoverageSemantics::Exhaustive;
1699 r.coverage.uncovered = (0..5).map(|i| format!("src/file_{i}.rs")).collect();
1700 let md = render_fidelity_report(&r, 8_000, &[]).markdown;
1701
1702 assert!(md.contains("## Adopting — first verify"));
1704 assert!(md.contains("0% anchored is expected — this is onboarding, not a failure."));
1705 assert!(
1707 md.contains("**Backfill path:** run `memstead projection brief engine/graph --sync`")
1708 );
1709 assert!(
1712 !md.contains("are **findings**"),
1713 "pre-binding history must not produce a red findings verdict"
1714 );
1715 assert!(md.contains("Exhaustive coverage (onboarding):"));
1716 assert!(md.contains("backfill worklist"));
1717
1718 r.adopt = false;
1720 let md2 = render_fidelity_report(&r, 8_000, &[]).markdown;
1721 assert!(!md2.contains("## Adopting — first verify"));
1722 assert!(md2.contains("are **findings**"));
1723 }
1724
1725 #[test]
1727 fn unknown_include_key_warns() {
1728 let r = base_report();
1729 let out = render_fidelity_report(&r, 8_000, &["bogus".to_string()]);
1730 assert!(out.markdown.contains("unknown include key `bogus`"));
1731 }
1732
1733 use crate::anchor::{Anchor, AnchorHashStability, AnchorProvenanceClass, AnchorSidecar};
1736 use crate::binding::{
1737 BINDING_VERSION, Binding, BuildMode, BuildOperation, DEFAULT_ADJUDICATION_CAP,
1738 DEFAULT_FULL_RESYNC_EVERY, Operations, VerifyOperation,
1739 };
1740 use crate::ingest::findings::verify_binding;
1741 use crate::ingest::resolve::resolve_binding_run;
1742 use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
1743 use crate::pipeline_store::{load_pipeline_configs, write_binding};
1744 use crate::workspace::{
1745 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
1746 };
1747 use crate::workspace_store::WorkspaceStoreAdapter;
1748
1749 #[test]
1756 fn compute_report_end_to_end() {
1757 let tmp = tempfile::tempdir().unwrap();
1758 let root = tmp.path();
1759 let mem_dir = root.join("mem");
1760 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1761 std::fs::write(
1762 mem_dir.join(".memstead").join("config.json"),
1763 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1764 )
1765 .unwrap();
1766
1767 std::fs::create_dir_all(root.join(".memstead")).unwrap();
1768 std::fs::write(
1769 root.join(".memstead").join("workspace.toml"),
1770 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1771 )
1772 .unwrap();
1773 let mount = Mount {
1774 mem: "engine".to_string(),
1775 schema: Some("default@1.0.0".parse().unwrap()),
1776 storage: MountStorage::Folder {
1777 path: mem_dir.clone(),
1778 },
1779 capability: MountCapability::Write,
1780 lifecycle: MountLifecycle::Eager,
1781 cross_linkable: false,
1782 migration_target: None,
1783 };
1784 crate::FileWorkspaceStore::new()
1785 .save_state(
1786 root,
1787 &Workspace {
1788 mounts: vec![mount],
1789 settings: WorkspaceSettings::default(),
1790 },
1791 )
1792 .unwrap();
1793
1794 let out = std::process::Command::new("git")
1795 .args(["init", "-q"])
1796 .current_dir(root)
1797 .output()
1798 .unwrap();
1799 assert!(out.status.success());
1800 std::fs::create_dir_all(root.join("src").join("sub")).unwrap();
1801 std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
1802 std::fs::write(root.join("src").join("uncovered.rs"), "fn b() {}\n").unwrap();
1803 std::fs::write(root.join("src").join("sub").join("deep.rs"), "fn c() {}\n").unwrap();
1804
1805 let mk = |artifact: &str, grain: AnchorGrain, class: AnchorProvenanceClass| Anchor {
1806 artifact: artifact.to_string(),
1807 grain,
1808 class,
1809 at_version: None,
1810 hash: class.is_hash_bearing().then(|| "recorded".to_string()),
1811 hash_stability: AnchorHashStability::Stable,
1812 derived_from: Vec::new(),
1813 binding: None,
1814 source: None,
1815 };
1816 let mut sidecar = AnchorSidecar::default();
1817 sidecar.set(
1818 "engine--direct",
1819 vec![mk(
1820 "src/present.rs",
1821 AnchorGrain::File,
1822 AnchorProvenanceClass::Anchored,
1823 )],
1824 );
1825 sidecar.set(
1826 "engine--tree",
1827 vec![mk(
1828 "src/sub/",
1829 AnchorGrain::Tree,
1830 AnchorProvenanceClass::Anchored,
1831 )],
1832 );
1833 sidecar.set(
1835 "engine--auth",
1836 vec![mk(
1837 "src/present.rs",
1838 AnchorGrain::File,
1839 AnchorProvenanceClass::Authored,
1840 )],
1841 );
1842 std::fs::write(
1843 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
1844 sidecar.to_bytes(),
1845 )
1846 .unwrap();
1847
1848 write_binding(
1849 root,
1850 "engine",
1851 "graph",
1852 &Binding {
1853 version: BINDING_VERSION,
1854 intent: None,
1855 sources: vec![crate::pipeline::Source {
1856 name: "graph".to_string(),
1857 medium_type: MediumType::Codebase,
1858 pointer: String::new(),
1859 change_detection: Some("git".to_string()),
1860 scope: vec![PatternEntry {
1861 path: "src/**/*.rs".to_string(),
1862 mode: PatternMode::Allow,
1863 }],
1864 engagement: None,
1865 preparation: None,
1866 }],
1867 reference_mems: Vec::new(),
1868 destination_mem: "engine".to_string(),
1869 deny_paths: Vec::new(),
1870 coverage_semantics: None,
1871 rules: None,
1872 prune: None,
1873 operations: Operations {
1874 build: Some(BuildOperation {
1875 mode: BuildMode::Discovery,
1876 trigger: IngestTrigger::Loop,
1877 batch_size: 20,
1878 post_actions: None,
1879 }),
1880 sync: None,
1881 verify: Some(VerifyOperation {
1882 trigger: IngestTrigger::Manual,
1883 batch_size: 20,
1884 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
1885 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
1886 }),
1887 },
1888 },
1889 )
1890 .unwrap();
1891
1892 let engine = Engine::from_workspace_root(root).unwrap();
1893 let configs = load_pipeline_configs(root).unwrap();
1894 let binding = &configs.bindings[0].config;
1895 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
1896
1897 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
1899
1900 let report = compute_fidelity_report(&engine, root, binding, &resolved, &outcome.key);
1902
1903 assert_eq!(
1905 report.coverage.denominator,
1906 DenominatorBasis::Enumerated { count: 3 }
1907 );
1908 assert_eq!(report.coverage.direct_covered, 1);
1911 assert_eq!(report.coverage.tree_only_covered, 1);
1912 assert_eq!(
1913 report.coverage.uncovered,
1914 vec!["src/uncovered.rs".to_string()]
1915 );
1916 assert_eq!(report.coverage.tree_anchors.len(), 1);
1918 assert_eq!(report.coverage.tree_anchors[0].fanout, 1);
1919 assert_eq!(report.coverage.tree_anchors[0].artifact, "src/sub/");
1920 assert_eq!(report.anchors.authored, 1);
1922 assert_eq!(report.anchors.by_class.get("authored"), Some(&1));
1923 assert_eq!(report.anchors.observed, 2);
1928 assert_eq!(report.anchors.recheck, 1);
1929 assert_eq!(report.anchors.drifted, 1);
1930 assert_eq!(report.backlog, outcome.backlog);
1932 assert!(
1934 report
1935 .degradations
1936 .iter()
1937 .any(|d| d.contains("hash-adjudication-deferred"))
1938 );
1939 let md = render_fidelity_report(&report, 8_000, &[]).markdown;
1941 assert!(md.contains("per-medium enumeration `S(D)` = **3**"));
1942 assert!(!report.adopt);
1945 assert!(!md.contains("## Adopting — first verify"));
1946 }
1947
1948 #[test]
1953 fn compute_report_adopt_when_mem_predates_binding() {
1954 let tmp = tempfile::tempdir().unwrap();
1955 let root = tmp.path();
1956 let mem_dir = root.join("mem");
1957 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1958 std::fs::write(
1959 mem_dir.join(".memstead").join("config.json"),
1960 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1961 )
1962 .unwrap();
1963 std::fs::create_dir_all(root.join(".memstead")).unwrap();
1964 std::fs::write(
1965 root.join(".memstead").join("workspace.toml"),
1966 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1967 )
1968 .unwrap();
1969 let mount = Mount {
1970 mem: "engine".to_string(),
1971 schema: Some("default@1.0.0".parse().unwrap()),
1972 storage: MountStorage::Folder {
1973 path: mem_dir.clone(),
1974 },
1975 capability: MountCapability::Write,
1976 lifecycle: MountLifecycle::Eager,
1977 cross_linkable: false,
1978 migration_target: None,
1979 };
1980 crate::FileWorkspaceStore::new()
1981 .save_state(
1982 root,
1983 &Workspace {
1984 mounts: vec![mount],
1985 settings: WorkspaceSettings::default(),
1986 },
1987 )
1988 .unwrap();
1989 let out = std::process::Command::new("git")
1990 .args(["init", "-q"])
1991 .current_dir(root)
1992 .output()
1993 .unwrap();
1994 assert!(out.status.success());
1995 std::fs::create_dir_all(root.join("src")).unwrap();
1996 std::fs::write(root.join("src").join("a.rs"), "fn a() {}\n").unwrap();
1998 std::fs::write(root.join("src").join("b.rs"), "fn b() {}\n").unwrap();
1999
2000 write_binding(
2001 root,
2002 "engine",
2003 "graph",
2004 &Binding {
2005 version: BINDING_VERSION,
2006 intent: None,
2007 sources: vec![crate::pipeline::Source {
2008 name: "graph".to_string(),
2009 medium_type: MediumType::Codebase,
2010 pointer: String::new(),
2011 change_detection: Some("git".to_string()),
2012 scope: vec![PatternEntry {
2013 path: "src/**/*.rs".to_string(),
2014 mode: PatternMode::Allow,
2015 }],
2016 engagement: None,
2017 preparation: None,
2018 }],
2019 reference_mems: Vec::new(),
2020 destination_mem: "engine".to_string(),
2021 deny_paths: Vec::new(),
2022 coverage_semantics: None,
2023 rules: None,
2024 prune: None,
2025 operations: Operations {
2026 build: Some(BuildOperation {
2027 mode: BuildMode::Discovery,
2028 trigger: IngestTrigger::Loop,
2029 batch_size: 20,
2030 post_actions: None,
2031 }),
2032 sync: None,
2033 verify: Some(VerifyOperation {
2034 trigger: IngestTrigger::Manual,
2035 batch_size: 20,
2036 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2037 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2038 }),
2039 },
2040 },
2041 )
2042 .unwrap();
2043
2044 let engine = Engine::from_workspace_root(root).unwrap();
2045 let configs = load_pipeline_configs(root).unwrap();
2046 let binding = &configs.bindings[0].config;
2047 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2048 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2049 let report = compute_fidelity_report(&engine, root, binding, &resolved, &outcome.key);
2050
2051 assert!(
2053 report.adopt,
2054 "a no-anchor, never-synced mem predates its binding"
2055 );
2056 let md = render_fidelity_report(&report, 8_000, &[]).markdown;
2057 assert!(md.contains("## Adopting — first verify"));
2058 assert!(md.contains("0% anchored is expected"));
2059 assert!(!md.contains("are **findings**"));
2061 assert!(md.contains("Exhaustive coverage (onboarding):"));
2062 }
2063
2064 #[test]
2068 fn report_marks_resolved_coverage_semantics() {
2069 let mut resolved = base_report();
2070 resolved.coverage_semantics = CoverageSemantics::Curated;
2071 resolved.coverage_semantics_declared = false;
2072 let md = render_hard_required(&resolved);
2073 assert!(
2074 md.contains("curated (resolved from the sources' media — not declared)"),
2075 "resolved value carries the marker: {md}"
2076 );
2077
2078 let declared = base_report(); let md = render_hard_required(&declared);
2080 assert!(
2081 md.contains("**Coverage semantics:** exhaustive\n"),
2082 "declared value renders bare: {md}"
2083 );
2084 assert!(
2085 !md.contains("(resolved from the sources' media"),
2086 "no resolution marker on a declared value: {md}"
2087 );
2088 }
2089}
2090
2091#[cfg(test)]
2092mod rollup_tests {
2093 use super::*;
2094
2095 fn clean_report() -> FidelityReport {
2099 FidelityReport {
2100 binding: "engine/graph".to_string(),
2101 destination_mem: "engine".to_string(),
2102 adopt: false,
2103 coverage_semantics: CoverageSemantics::Exhaustive,
2104 coverage_semantics_declared: true,
2105 capabilities: vec![FacetCapability {
2106 facet: "src".to_string(),
2107 medium_type: "codebase".to_string(),
2108 enumerable: true,
2109 change_signal: true,
2110 base_version_retrievable: true,
2111 anchor_namespace: "path".to_string(),
2112 signal: "git".to_string(),
2113 }],
2114 freshness: vec![FacetFreshness {
2115 facet: "src".to_string(),
2116 signal: "git".to_string(),
2117 synced: Some("deadbeef".to_string()),
2118 verified: None,
2119 change_detectable: true,
2120 }],
2121 source_moved_past_synced: Some(false),
2122 coverage: GrainCoverage {
2123 denominator: DenominatorBasis::Enumerated { count: 4 },
2124 direct_covered: 4,
2125 tree_only_covered: 0,
2126 uncovered: Vec::new(),
2127 tree_anchors: Vec::new(),
2128 },
2129 anchors: AnchorComposition {
2130 by_class: BTreeMap::from([("anchored".to_string(), 4)]),
2131 by_grain: BTreeMap::from([("file".to_string(), 4)]),
2132 authored: 0,
2133 observed: 4,
2134 resolves: 4,
2135 drifted: 0,
2136 recheck: 0,
2137 orphaned: 0,
2138 unobserved: 0,
2139 },
2140 findings_by_class: BTreeMap::new(),
2141 backlog: 0,
2142 superseded: Vec::new(),
2143 disposed_excluded: 0,
2144 disposed_excluded_rationales: Vec::new(),
2145 degradations: Vec::new(),
2146 }
2147 }
2148
2149 #[test]
2151 fn clean_requires_a_substantive_pass_and_no_findings() {
2152 let mut r = clean_report();
2153 assert_eq!(r.rollup().verdict, RollupVerdict::Clean);
2154 assert!(r.rollup().blind_spots.is_empty());
2155 assert!(r.rollup().actions.is_empty());
2156
2157 r.findings_by_class.insert("drifted".to_string(), 2);
2158 let roll = r.rollup();
2159 assert_eq!(roll.verdict, RollupVerdict::Drifted);
2160 assert_eq!(roll.findings_total, 2);
2161 assert!(
2162 roll.actions[0].contains("moved since the entity was written"),
2163 "the top action is the concrete next step: {:?}",
2164 roll.actions
2165 );
2166 }
2167
2168 #[test]
2173 fn a_vacuous_zero_over_zero_is_inconclusive_not_clean() {
2174 let mut r = clean_report();
2175 r.coverage.denominator = DenominatorBasis::Enumerated { count: 0 };
2176 let roll = r.rollup();
2177 assert_eq!(
2178 roll.verdict,
2179 RollupVerdict::Inconclusive,
2180 "0/0 is not a clean bill of health"
2181 );
2182 assert!(
2183 roll.blind_spots.iter().any(|s| s.contains("vacuous")),
2184 "the blindness is named, not implied: {:?}",
2185 roll.blind_spots
2186 );
2187 }
2188
2189 #[test]
2193 fn a_non_enumerable_facet_blocks_green_even_in_a_mixed_binding() {
2194 let mut r = clean_report();
2195 r.capabilities.push(FacetCapability {
2196 facet: "site".to_string(),
2197 medium_type: "web".to_string(),
2198 enumerable: false,
2199 change_signal: true,
2203 base_version_retrievable: false,
2204 anchor_namespace: "url".to_string(),
2205 signal: "none".to_string(),
2206 });
2207 assert!(matches!(
2209 r.coverage.denominator,
2210 DenominatorBasis::Enumerated { count } if count > 0
2211 ));
2212 let roll = r.rollup();
2213 assert_eq!(
2214 roll.verdict,
2215 RollupVerdict::Inconclusive,
2216 "one enumerable facet must not launder a non-enumerable one: {roll:?}"
2217 );
2218 assert!(
2219 roll.blind_spots
2220 .iter()
2221 .any(|s| s.contains("not enumerable")),
2222 "{:?}",
2223 roll.blind_spots
2224 );
2225 }
2226
2227 #[test]
2233 fn a_resolved_signal_of_none_blocks_green_even_when_the_medium_could_signal() {
2234 let mut r = clean_report();
2235 r.capabilities[0].change_signal = true;
2238 r.capabilities[0].signal = "none".to_string();
2239 r.freshness[0].change_detectable = false;
2240 r.freshness[0].signal = "none".to_string();
2241 let roll = r.rollup();
2242 assert_eq!(
2243 roll.verdict,
2244 RollupVerdict::Inconclusive,
2245 "a change-blind binding is not a clean bill of health: {roll:?}"
2246 );
2247 assert!(
2248 roll.blind_spots
2249 .iter()
2250 .any(|s| s.contains("could not read that signal")),
2251 "the blind spot names the unreadable signal: {:?}",
2252 roll.blind_spots
2253 );
2254 }
2255
2256 #[test]
2260 fn a_facet_without_a_change_signal_blocks_green() {
2261 let mut r = clean_report();
2262 r.capabilities[0].change_signal = false;
2263 let roll = r.rollup();
2264 assert_eq!(roll.verdict, RollupVerdict::Inconclusive);
2265 assert!(
2266 roll.blind_spots
2267 .iter()
2268 .any(|s| s.contains("no change signal")),
2269 "{:?}",
2270 roll.blind_spots
2271 );
2272 }
2273
2274 #[test]
2277 fn a_non_enumerable_scope_blocks_green() {
2278 let mut r = clean_report();
2279 r.coverage.denominator = DenominatorBasis::NonEnumerable {
2280 reason: "web medium".to_string(),
2281 };
2282 assert_eq!(r.rollup().verdict, RollupVerdict::Inconclusive);
2283 }
2284
2285 #[test]
2287 fn zero_observed_anchors_blocks_green() {
2288 let mut r = clean_report();
2289 r.anchors.observed = 0;
2290 r.anchors.resolves = 0;
2291 assert_eq!(r.rollup().verdict, RollupVerdict::Inconclusive);
2292 }
2293
2294 #[test]
2298 fn adopt_with_only_uncovered_is_never_red() {
2299 let mut r = clean_report();
2300 r.adopt = true;
2301 r.findings_by_class.insert("uncovered".to_string(), 12);
2302 let roll = r.rollup();
2303 assert_eq!(
2304 roll.verdict,
2305 RollupVerdict::Inconclusive,
2306 "onboarding is neither drift nor a clean bill: {roll:?}"
2307 );
2308 assert!(
2309 roll.because.contains("backfill worklist"),
2310 "the reason states the onboarding framing: {}",
2311 roll.because
2312 );
2313
2314 r.findings_by_class.insert("drifted".to_string(), 1);
2317 assert_eq!(r.rollup().verdict, RollupVerdict::Drifted);
2318 }
2319
2320 #[test]
2323 fn findings_outrank_blind_spots() {
2324 let mut r = clean_report();
2325 r.capabilities[0].change_signal = false;
2326 r.findings_by_class.insert("wrong".to_string(), 1);
2327 let roll = r.rollup();
2328 assert_eq!(roll.verdict, RollupVerdict::Drifted);
2329 assert!(
2330 !roll.blind_spots.is_empty(),
2331 "the blindness is still reported alongside the verdict"
2332 );
2333 }
2334
2335 #[test]
2338 fn actions_are_severity_ordered_and_never_drop_a_class() {
2339 let mut r = clean_report();
2340 r.findings_by_class.insert("uncovered".to_string(), 3);
2341 r.findings_by_class.insert("wrong".to_string(), 1);
2342 r.findings_by_class
2343 .insert("some-future-class".to_string(), 2);
2344 let roll = r.rollup();
2345 assert!(
2346 roll.actions[0].contains("contradict their source"),
2347 "{roll:?}"
2348 );
2349 assert_eq!(roll.actions.len(), 3, "{roll:?}");
2350 assert!(
2351 roll.actions.iter().any(|a| a.contains("some-future-class")),
2352 "an unranked class still surfaces: {roll:?}"
2353 );
2354 }
2355
2356 #[test]
2358 fn verdict_wire_strings_are_stable() {
2359 assert_eq!(RollupVerdict::Clean.wire(), "clean");
2360 assert_eq!(RollupVerdict::Drifted.wire(), "drifted");
2361 assert_eq!(RollupVerdict::Inconclusive.wire(), "inconclusive");
2362 let json = serde_json::to_string(&RollupVerdict::Inconclusive).unwrap();
2363 assert_eq!(json, "\"inconclusive\"");
2364 }
2365}