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_reported, 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 Partial {
94 count: usize,
96 reason: String,
98 },
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
105pub struct TreeFanout {
106 pub entity: String,
108 pub artifact: String,
110 pub fanout: usize,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
118pub struct GrainCoverage {
119 pub denominator: DenominatorBasis,
121 pub direct_covered: usize,
123 pub tree_only_covered: usize,
126 pub uncovered: Vec<String>,
128 pub tree_anchors: Vec<TreeFanout>,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default)]
136pub struct AnchorComposition {
137 pub by_class: BTreeMap<String, usize>,
141 pub by_grain: BTreeMap<String, usize>,
143 pub authored: usize,
146 pub observed: usize,
148 pub resolves: usize,
150 pub drifted: usize,
152 pub recheck: usize,
154 pub orphaned: usize,
156 pub unobserved: usize,
159 pub counted_rows: usize,
165 pub distinct_artifacts: usize,
170 pub excluded_other_binding: usize,
173 pub excluded_out_of_scope: usize,
176 pub excluded_artifacts: Vec<String>,
180 pub counted_without_provenance: usize,
184 pub dangling: usize,
189 pub dangling_rows: Vec<String>,
193 pub unreconciled: Option<String>,
197 pub span_unvalidated: usize,
202 pub hash_from_backfill: usize,
207}
208
209#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
212pub struct FacetCapability {
213 pub facet: String,
215 pub medium_type: String,
217 pub enumerable: bool,
219 pub change_signal: bool,
221 pub base_version_retrievable: bool,
223 pub anchor_namespace: String,
225 pub signal: String,
228}
229
230impl FacetCapability {
231 fn from_caps(
232 facet: String,
233 medium_type: String,
234 caps: MediumCapabilities,
235 strategy: ChangeStrategy,
236 ) -> Self {
237 FacetCapability {
238 facet,
239 medium_type,
240 enumerable: caps.enumerable,
241 change_signal: caps.change_signal,
242 base_version_retrievable: caps.base_version_retrievable
249 && strategy_retrieves_base(strategy),
250 anchor_namespace: caps.anchor_namespace.to_string(),
251 signal: signal_wire(strategy).to_string(),
252 }
253 }
254}
255
256#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
258pub struct FacetFreshness {
259 pub facet: String,
261 pub signal: String,
263 pub synced: Option<String>,
265 pub verified: Option<String>,
267 pub change_detectable: bool,
272}
273
274#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
276pub struct FidelityReport {
277 pub binding: String,
279 pub destination_mem: String,
281 pub adopt: bool,
288 pub coverage_semantics: CoverageSemantics,
292 pub coverage_semantics_declared: bool,
297 pub legacy_dialect_patterns: Vec<String>,
303 pub capabilities: Vec<FacetCapability>,
305 pub freshness: Vec<FacetFreshness>,
307 pub source_moved_past_synced: Option<bool>,
311 pub coverage: GrainCoverage,
313 pub anchors: AnchorComposition,
315 pub findings_by_class: BTreeMap<String, usize>,
317 pub backlog: usize,
319 pub superseded: Vec<String>,
322 pub disposed_excluded: usize,
325 pub disposed_excluded_rationales: Vec<(String, String)>,
331 pub degradations: Vec<String>,
333}
334
335#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
351#[serde(rename_all = "lowercase")]
352pub enum RollupVerdict {
353 Clean,
355 Drifted,
357 Inconclusive,
360}
361
362impl RollupVerdict {
363 pub fn wire(&self) -> &'static str {
365 match self {
366 RollupVerdict::Clean => "clean",
367 RollupVerdict::Drifted => "drifted",
368 RollupVerdict::Inconclusive => "inconclusive",
369 }
370 }
371}
372
373#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
376pub struct Rollup {
377 pub verdict: RollupVerdict,
379 pub findings_total: usize,
381 pub because: String,
384 pub blind_spots: Vec<String>,
389 pub actions: Vec<String>,
392}
393
394const CLASS_SEVERITY: [&str; 5] = [
399 "wrong",
400 "drifted",
401 "unresolvable-anchor",
402 "uncovered",
403 "queued-for-adjudication",
404];
405
406fn class_action(class: &str, n: usize, binding: &str) -> String {
408 match class {
409 "wrong" => format!(
410 "{n} entity/entities contradict their source — read them against the source and \
411 correct the entity (`memstead projection brief {binding}` lists them)"
412 ),
413 "drifted" => format!(
414 "{n} anchored artifact(s) moved since the entity was written — re-read the source \
415 and update the entity, then re-verify to advance the baseline"
416 ),
417 "unresolvable-anchor" => format!(
418 "{n} anchor(s) no longer resolve to anything — repoint them at the artifact's new \
419 location or unset them (`memstead_update` `anchors_unset`)"
420 ),
421 "uncovered" => format!(
422 "{n} in-scope source artifact(s) carry no anchor — cover them via \
423 `memstead projection brief {binding} --sync`, or record a disposition for the \
424 ones deliberately excluded"
425 ),
426 "queued-for-adjudication" => format!(
427 "{n} finding(s) are queued and not yet adjudicated — run \
428 `memstead projection verify {binding} --full` to work the backlog down"
429 ),
430 other => format!("{n} `{other}` finding(s) recorded"),
431 }
432}
433
434impl FidelityReport {
435 pub fn rollup(&self) -> Rollup {
442 let findings_total: usize = self.findings_by_class.values().sum();
443
444 let mut blind_spots: Vec<String> = Vec::new();
445 match &self.coverage.denominator {
446 DenominatorBasis::NonEnumerable { reason } => blind_spots.push(format!(
447 "the source scope is not enumerable ({reason}) — coverage is reported over \
448 anchors only, so an uncovered artifact cannot be detected"
449 )),
450 DenominatorBasis::Enumerated { count: 0 } => blind_spots.push(
451 "the enumerated source scope is empty (0 artifacts) — every coverage figure \
452 below is vacuous, not clean"
453 .to_string(),
454 ),
455 DenominatorBasis::Partial { count, reason } => blind_spots.push(format!(
456 "the source enumeration is INCOMPLETE ({reason}) — {count} artifact(s) \
457 survived, but their share of the population is unknown, so no coverage \
458 percentage is reported below"
459 )),
460 DenominatorBasis::Enumerated { .. } => {}
461 }
462 if !self.legacy_dialect_patterns.is_empty() {
463 blind_spots.push(format!(
464 "scope pattern(s) are still written against the workspace root rather than the \
465 source pointer and select nothing under the pointer join, so whatever they \
466 were meant to cover is absent from the denominator: {}. Rewrite them relative \
467 to the source's pointer",
468 self.legacy_dialect_patterns.join(", ")
469 ));
470 }
471 if self.anchors.observed == 0 {
472 blind_spots.push(
473 "no anchor carried a resolution state this pass — nothing was adjudicated"
474 .to_string(),
475 );
476 }
477 if self.anchors.unobserved > 0 {
489 blind_spots.push(format!(
490 "{} counted anchor(s) could not be observed at all this pass, so their state is unknown rather than clean",
491 self.anchors.unobserved
492 ));
493 }
494 if self.anchors.span_unvalidated > 0 {
495 blind_spots.push(format!(
496 "{} counted span anchor(s) were never checked against their artifact, so the span they name is unverified even where the hash resolves",
497 self.anchors.span_unvalidated
498 ));
499 }
500 if let Some(why) = &self.anchors.unreconciled {
501 blind_spots.push(format!(
502 "the entity end of these anchors was not reconciled ({why}), so a row naming an entity the mem no longer holds would not have been detected"
503 ));
504 }
505 let change_blind: std::collections::BTreeSet<&str> = self
515 .freshness
516 .iter()
517 .filter(|f| !f.change_detectable)
518 .map(|f| f.facet.as_str())
519 .collect();
520 for cap in &self.capabilities {
521 if !cap.change_signal {
522 blind_spots.push(format!(
523 "facet `{}` ({}) provides no change signal — drift on it cannot be \
524 observed at all",
525 cap.facet, cap.medium_type
526 ));
527 } else if change_blind.contains(cap.facet.as_str()) {
528 blind_spots.push(format!(
529 "facet `{}` ({}) declares change-detection `{}` but this pass could \
530 not read that signal — either the binding asked for none, or the \
531 checkout cannot deliver it (a `git` source with no `.git`: an \
532 archive, a container COPY, a vendored drop). Drift on it cannot \
533 be observed",
534 cap.facet, cap.medium_type, cap.signal
535 ));
536 }
537 if !cap.enumerable {
547 blind_spots.push(format!(
548 "facet `{}` ({}) is not enumerable — an uncovered artifact under it \
549 cannot be detected, only an anchored one",
550 cap.facet, cap.medium_type
551 ));
552 }
553 }
554
555 let mut actions: Vec<String> = Vec::new();
556 for class in CLASS_SEVERITY {
557 if let Some(&n) = self.findings_by_class.get(class)
558 && n > 0
559 {
560 actions.push(class_action(class, n, &self.binding));
561 }
562 }
563 for (class, &n) in &self.findings_by_class {
566 if n > 0 && !CLASS_SEVERITY.contains(&class.as_str()) {
567 actions.push(class_action(class, n, &self.binding));
568 }
569 }
570
571 let only_uncovered = findings_total > 0
577 && self
578 .findings_by_class
579 .iter()
580 .all(|(class, &n)| n == 0 || class == "uncovered");
581
582 let (verdict, because) = if self.adopt && only_uncovered {
583 (
584 RollupVerdict::Inconclusive,
585 format!(
586 "this mem predates its binding — the {findings_total} uncovered artifact(s) \
587 are the backfill worklist, not drift"
588 ),
589 )
590 } else if findings_total > 0 {
591 let tally = self
592 .findings_by_class
593 .iter()
594 .filter(|(_, n)| **n > 0)
595 .map(|(class, n)| format!("{class}: {n}"))
596 .collect::<Vec<_>>()
597 .join(", ");
598 (
599 RollupVerdict::Drifted,
600 format!("{findings_total} finding(s) recorded over the current key ({tally})"),
601 )
602 } else if !blind_spots.is_empty() {
603 (
604 RollupVerdict::Inconclusive,
605 format!(
606 "no findings recorded, but the pass could not speak to {} axis/axes — \
607 this is not a clean bill of health",
608 blind_spots.len()
609 ),
610 )
611 } else {
612 (
613 RollupVerdict::Clean,
614 "the pass was substantive on every axis and recorded no findings".to_string(),
615 )
616 };
617
618 Rollup {
619 verdict,
620 findings_total,
621 because,
622 blind_spots,
623 actions,
624 }
625 }
626}
627
628#[derive(Debug, Clone, PartialEq, Eq)]
635pub struct RenderedFidelityReport {
636 pub markdown: String,
638 pub mode: String,
641 pub hints: Vec<(String, usize)>,
644 pub budget_used: usize,
646}
647
648fn ratio(num: usize, den: usize) -> String {
654 if den == 0 {
655 format!("{num}/{den} (n/a)")
656 } else {
657 let pct = (num as f64) * 100.0 / (den as f64);
658 format!("{num}/{den} ({pct:.1}%)")
659 }
660}
661
662fn render_hard_required(report: &FidelityReport) -> String {
666 let mut md = String::new();
667 md.push_str(&format!("# Fidelity report — `{}`\n\n", report.binding));
668
669 let rollup = report.rollup();
674 md.push_str(&format!(
675 "**Verdict: {}** — {}.\n\n",
676 rollup.verdict.wire().to_uppercase(),
677 rollup.because
678 ));
679 if !rollup.actions.is_empty() {
680 md.push_str("**Do next:**\n\n");
681 for action in &rollup.actions {
682 md.push_str(&format!("1. {action}\n"));
683 }
684 md.push('\n');
685 }
686 if !rollup.blind_spots.is_empty() {
687 md.push_str("**This pass could not see:**\n\n");
688 for spot in &rollup.blind_spots {
689 md.push_str(&format!("- {spot}\n"));
690 }
691 md.push('\n');
692 }
693
694 md.push_str(&format!(
695 "- **Destination mem:** `{}`\n- **Coverage semantics:** {}{}\n\n",
696 report.destination_mem,
697 match report.coverage_semantics {
698 CoverageSemantics::Exhaustive => "exhaustive",
699 CoverageSemantics::Curated => "curated",
700 },
701 if report.coverage_semantics_declared {
702 ""
703 } else {
704 " (resolved from the sources' media — not declared)"
705 }
706 ));
707
708 if report.adopt {
715 md.push_str("## Adopting — first verify\n\n");
716 md.push_str(
717 "This mem predates its binding: it carries no anchors and has no prior sync \
718 baseline, so **0% anchored is expected — this is onboarding, not a failure.** \
719 Do not read the coverage numbers below as drift or a red verdict; the uncovered \
720 artifacts are the backfill worklist, not defects.\n\n",
721 );
722 md.push_str(&format!(
723 "**Backfill path:** run `memstead projection brief {} --sync` to work through the in-scope \
724 source artifacts that carry no entity yet, covering the clearly-new concepts among \
725 them through the normal mutation surface. Backfilling is incremental — a partial \
726 pass is fine, and the next sync continues where you left off.\n\n",
727 report.binding
728 ));
729 }
730
731 md.push_str("## Denominator provenance\n\n");
733 match &report.coverage.denominator {
734 DenominatorBasis::Enumerated { count } => md.push_str(&format!(
735 "Coverage is reported relative to the per-medium enumeration `S(D)` = **{count}** \
736 source artifact(s) in scope (after `deny_paths`).\n\n"
737 )),
738 DenominatorBasis::NonEnumerable { reason } => md.push_str(&format!(
739 "No `S(D)` denominator: {reason}. Coverage is reported over anchors only; the \
740 per-medium enumeration is unavailable.\n\n"
741 )),
742 DenominatorBasis::Partial { count, reason } => md.push_str(&format!(
743 "`S(D)` is **partial**: {reason}. **{count}** source artifact(s) were \
744 enumerated by the patterns that did resolve, but that set is not the \
745 population, so the coverage figures below are counts and carry no \
746 percentage.\n\n"
747 )),
748 }
749
750 md.push_str("## Capability matrix\n\n");
752 if report.capabilities.is_empty() {
753 md.push_str("_(no primary sources resolved)_\n\n");
754 } else {
755 for c in &report.capabilities {
756 md.push_str(&format!("### `{}` ({})\n\n", c.facet, c.medium_type));
757 md.push_str(&format!(
758 "- enumerable: {} | change_signal: {} | base_version_retrievable: {}\n",
759 c.enumerable, c.change_signal, c.base_version_retrievable
760 ));
761 md.push_str(&format!(
762 "- anchor_namespace: `{}` | resolved signal: `{}`\n\n",
763 c.anchor_namespace, c.signal
764 ));
765 }
766 }
767
768 md.push_str("## Freshness\n\n");
770 if report.freshness.is_empty() {
771 md.push_str("_(no source facets)_\n\n");
772 } else {
773 for f in &report.freshness {
774 md.push_str(&format!("### `{}`\n\n", f.facet));
775 md.push_str(&format!("- signal: `{}`\n", f.signal));
776 if !f.change_detectable {
777 md.push_str(
781 "- **freshness unknowable** — this medium is not change-detectable \
782 (no change signal); `#synced` / `#verified` cannot be adjudicated as fresh\n",
783 );
784 } else {
785 match &f.synced {
786 Some(t) => md.push_str(&format!("- `#synced`: `{t}`\n")),
787 None => md.push_str("- `#synced`: never synced\n"),
788 }
789 match &f.verified {
790 Some(t) => md.push_str(&format!("- `#verified`: `{t}`\n")),
791 None => md.push_str("- `#verified`: never verified\n"),
792 }
793 }
794 md.push('\n');
795 }
796 match report.source_moved_past_synced {
798 Some(true) => md.push_str(
799 "**Source moved past its `#synced` baseline** — the graph is stale for the \
800 moved facet(s); a sync pass is due.\n\n",
801 ),
802 Some(false) => {
803 md.push_str("Every change-detectable source is at its `#synced` baseline.\n\n")
804 }
805 None => {}
806 }
807 }
808
809 md.push_str("## Coverage (grain-classed)\n\n");
811 let den = match &report.coverage.denominator {
815 DenominatorBasis::Enumerated { count } => *count,
816 DenominatorBasis::NonEnumerable { .. } | DenominatorBasis::Partial { .. } => 0,
817 };
818 md.push_str(&format!(
819 "- direct-covered (file / span anchors): {}\n",
820 ratio(report.coverage.direct_covered, den)
821 ));
822 let tree_files: usize = report.coverage.tree_anchors.iter().map(|t| t.fanout).sum();
825 md.push_str(&format!(
826 "- tree-anchor fan-out (separate axis): {} tree anchor(s) fanning out over {} file(s); \
827 {} file(s) covered ONLY via a tree anchor\n",
828 report.coverage.tree_anchors.len(),
829 tree_files,
830 report.coverage.tree_only_covered
831 ));
832 md.push_str(&format!(
833 "- uncovered (no anchor): {}\n\n",
834 report.coverage.uncovered.len()
835 ));
836
837 match report.coverage_semantics {
842 CoverageSemantics::Exhaustive if report.adopt => {
843 let backlog = report
844 .coverage
845 .uncovered
846 .len()
847 .saturating_sub(report.disposed_excluded);
848 md.push_str(&format!(
849 "**Exhaustive coverage (onboarding):** {backlog} in-scope artifact(s) carry no \
850 entity yet ({} disposed excluded) — the expected first-sync backfill worklist \
851 for a mem that predates its binding, not defects.\n\n",
852 report.disposed_excluded
853 ));
854 }
855 CoverageSemantics::Exhaustive => {
856 let findings = report
857 .coverage
858 .uncovered
859 .len()
860 .saturating_sub(report.disposed_excluded);
861 md.push_str(&format!(
862 "**Exhaustive coverage:** {findings} unaccounted artifact(s) — not anchored, not \
863 declared-excluded, no persisted disposition ({} disposed excluded) — are \
864 **findings**.\n\n",
865 report.disposed_excluded
866 ));
867 }
868 CoverageSemantics::Curated => {
869 md.push_str(&format!(
870 "**Curated coverage:** {} unaccounted artifact(s) are **information**, not \
871 defects — a curated binding covers a deliberate slice.\n\n",
872 report.coverage.uncovered.len()
873 ));
874 }
875 }
876
877 if !report.disposed_excluded_rationales.is_empty() {
881 md.push_str("**Excluded on purpose (persisted dispositions):**\n");
882 for (artifact, rationale) in &report.disposed_excluded_rationales {
883 if rationale.is_empty() {
884 md.push_str(&format!("- `{artifact}`\n"));
885 } else {
886 md.push_str(&format!("- `{artifact}` — {rationale}\n"));
887 }
888 }
889 md.push('\n');
890 }
891
892 md.push_str("## Anchors\n\n");
894 md.push_str(&format!(
895 "- by class: {}\n",
896 render_counts(&report.anchors.by_class)
897 ));
898 md.push_str(&format!(
899 "- by grain: {}\n",
900 render_counts(&report.anchors.by_grain)
901 ));
902 md.push_str(&format!(
903 "- `authored` bucket (excluded from coverage/accuracy denominators): {}\n",
904 report.anchors.authored
905 ));
906 md.push_str(&format!(
913 "- resolution (non-`authored`, observed): resolves {}, drifted {}, recheck {}, \
914 orphaned {}; **anchor-resolution %:** {} over {} counted row(s) on {} distinct \
915 artifact(s), with {} unobserved this pass (state unavailable, never scored as \
916 resolved)\n",
917 report.anchors.resolves,
918 report.anchors.drifted,
919 report.anchors.recheck,
920 report.anchors.orphaned,
921 ratio(report.anchors.resolves, report.anchors.observed),
922 report.anchors.counted_rows,
923 report.anchors.distinct_artifacts,
924 report.anchors.unobserved
925 ));
926 md.push_str(&format!(
932 "- the figures above count anchor ROWS: {} row(s) over {} distinct artifact(s)\n",
933 report.anchors.counted_rows, report.anchors.distinct_artifacts
934 ));
935 if report.anchors.excluded_other_binding > 0 || report.anchors.excluded_out_of_scope > 0 {
938 md.push_str(&format!(
939 "- excluded from this binding's population: {} written by another binding, \
940 {} outside this binding's declared scope (legal, reported here, never deleted)\n",
941 report.anchors.excluded_other_binding, report.anchors.excluded_out_of_scope
942 ));
943 const NAMED_CAP: usize = 10;
950 for a in report.anchors.excluded_artifacts.iter().take(NAMED_CAP) {
951 md.push_str(&format!(" - {a}\n"));
952 }
953 if report.anchors.excluded_artifacts.len() > NAMED_CAP {
954 md.push_str(&format!(
955 " - …and {} more (counts above are complete)\n",
956 report.anchors.excluded_artifacts.len() - NAMED_CAP
957 ));
958 }
959 }
960 match (&report.anchors.unreconciled, report.anchors.dangling) {
965 (Some(why), _) => md.push_str(&format!(
966 "- the entity end of these anchors was NOT reconciled this pass ({why}), so \
967 dangling sidecar rows would not have been detected\n"
968 )),
969 (None, 0) => {}
970 (None, n) => {
971 md.push_str(&format!(
972 "- {n} sidecar row(s) name an entity this mem no longer holds. Excluded from \
973 every figure above, reported rather than repaired: the row is the trace of a \
974 writer that went around the engine\n"
975 ));
976 const NAMED_CAP: usize = 10;
977 for r in report.anchors.dangling_rows.iter().take(NAMED_CAP) {
978 md.push_str(&format!(" - {r}\n"));
979 }
980 if report.anchors.dangling_rows.len() > NAMED_CAP {
981 md.push_str(&format!(
982 " - …and {} more (the count above is complete)\n",
983 report.anchors.dangling_rows.len() - NAMED_CAP
984 ));
985 }
986 }
987 }
988 if report.anchors.span_unvalidated > 0 {
994 md.push_str(&format!(
995 "- {} counted span row(s) were never checked against their artifact, so their \
996 span is unverified even where the hash resolves\n",
997 report.anchors.span_unvalidated
998 ));
999 }
1000 if report.anchors.hash_from_backfill > 0 {
1001 md.push_str(&format!(
1002 "- {} counted row(s) carry a baseline the engine inferred by backfill rather than \
1003 one an author pinned\n",
1004 report.anchors.hash_from_backfill
1005 ));
1006 }
1007 if report.anchors.counted_without_provenance > 0 {
1008 md.push_str(&format!(
1009 "- {} counted anchor(s) record no producing binding and are included by the \
1010 pre-provenance fallback, so this population rests partly on that fallback \
1011 rather than wholly on provenance\n",
1012 report.anchors.counted_without_provenance
1013 ));
1014 }
1015 md.push('\n');
1016
1017 md.push_str("## Findings\n\n");
1019 md.push_str(&format!(
1020 "- by class: {}\n",
1021 render_counts(&report.findings_by_class)
1022 ));
1023 md.push_str(&format!(
1024 "- **tier-3 adjudication backlog:** {}\n",
1025 report.backlog
1026 ));
1027 md.push_str(&format!(
1028 "- superseded (prior `(hash(D), source_head)` key, segregated): {}\n\n",
1029 report.superseded.len()
1030 ));
1031
1032 md.push_str("## Degradations\n\n");
1034 if report.degradations.is_empty() {
1035 md.push_str("_(none)_\n\n");
1036 } else {
1037 for d in &report.degradations {
1038 md.push_str(&format!("- {d}\n"));
1039 }
1040 md.push('\n');
1041 }
1042
1043 md
1044}
1045
1046fn render_counts(counts: &BTreeMap<String, usize>) -> String {
1048 if counts.is_empty() {
1049 return "(none)".to_string();
1050 }
1051 counts
1052 .iter()
1053 .map(|(k, v)| format!("{k}={v}"))
1054 .collect::<Vec<_>>()
1055 .join(", ")
1056}
1057
1058fn heavy_sections(report: &FidelityReport) -> Vec<(&'static str, String)> {
1062 let mut out: Vec<(&'static str, String)> = Vec::new();
1063
1064 let mut s = String::new();
1066 if !report.coverage.uncovered.is_empty() {
1067 s.push_str("## Uncovered artifacts\n\n");
1068 for a in &report.coverage.uncovered {
1069 s.push_str(&format!("- `{a}`\n"));
1070 }
1071 s.push('\n');
1072 }
1073 out.push(("uncovered_artifacts", s));
1074
1075 let mut s = String::new();
1077 if !report.coverage.tree_anchors.is_empty() {
1078 s.push_str("## Tree-anchor fan-out (detail)\n\n");
1079 for t in &report.coverage.tree_anchors {
1080 s.push_str(&format!(
1081 "- `{}` → `{}` fans out over {} file(s)\n",
1082 t.entity, t.artifact, t.fanout
1083 ));
1084 }
1085 s.push('\n');
1086 }
1087 out.push(("tree_fanout", s));
1088
1089 let mut s = String::new();
1091 if !report.superseded.is_empty() {
1092 s.push_str("## Superseded findings (detail)\n\n");
1093 for f in &report.superseded {
1094 s.push_str(&format!("- {f}\n"));
1095 }
1096 s.push('\n');
1097 }
1098 out.push(("superseded_findings", s));
1099
1100 out
1101}
1102
1103pub fn render_fidelity_report(
1114 report: &FidelityReport,
1115 budget: usize,
1116 include: &[String],
1117) -> RenderedFidelityReport {
1118 let hard = render_hard_required(report);
1119 let hard_cost = estimate_tokens(&hard);
1120 let overbudget = hard_cost > budget;
1121
1122 let include_set: std::collections::BTreeSet<&str> = include
1123 .iter()
1124 .map(String::as_str)
1125 .filter(|k| ALLOWED_REPORT_INCLUDE_KEYS.contains(k))
1126 .collect();
1127 let unknown_includes: Vec<&String> = include
1128 .iter()
1129 .filter(|k| !ALLOWED_REPORT_INCLUDE_KEYS.contains(&k.as_str()))
1130 .collect();
1131
1132 let sections = heavy_sections(report);
1133 let mut emitted: Vec<String> = Vec::new();
1134 let mut hints: Vec<(String, usize)> = Vec::new();
1135 let mut used = hard_cost;
1136 let mut remaining = budget.saturating_sub(hard_cost);
1137
1138 for (key, section_md) in §ions {
1139 if section_md.is_empty() {
1140 continue; }
1142 let cost = estimate_tokens(section_md);
1143 let forced = include_set.contains(key);
1144 if forced {
1145 emitted.push(section_md.clone());
1146 used += cost;
1147 remaining = remaining.saturating_sub(cost);
1148 } else if !overbudget && remaining >= cost {
1149 emitted.push(section_md.clone());
1150 used += cost;
1151 remaining -= cost;
1152 } else {
1153 hints.push(((*key).to_string(), cost));
1154 }
1155 }
1156
1157 let mode = if overbudget {
1158 "overbudget"
1159 } else if hints.is_empty() {
1160 "complete"
1161 } else {
1162 "reduced"
1163 };
1164
1165 let mut md = String::new();
1166 md.push_str("---\n");
1167 md.push_str(&format!("_report_mode: {mode}\n"));
1168 md.push_str(&format!("_budget_requested: {budget}\n"));
1169 md.push_str(&format!("_budget_used: {used}\n"));
1170 md.push_str("---\n\n");
1171 md.push_str(&hard);
1172 for section in &emitted {
1173 md.push_str(section);
1174 }
1175
1176 if !hints.is_empty() {
1177 md.push_str("## Hints\n\n");
1178 md.push_str(
1179 "_(heavy sections omitted under the token budget — re-query with the key)_\n\n",
1180 );
1181 for (key, tokens) in &hints {
1182 md.push_str(&format!("- `{key}` — estimated_tokens: {tokens}\n"));
1183 }
1184 md.push('\n');
1185 }
1186
1187 if !unknown_includes.is_empty() {
1188 md.push_str("## Warnings\n\n");
1189 for k in &unknown_includes {
1190 md.push_str(&format!(
1191 "- unknown include key `{k}` — allowed: {}\n",
1192 ALLOWED_REPORT_INCLUDE_KEYS.join(", ")
1193 ));
1194 }
1195 md.push('\n');
1196 }
1197
1198 RenderedFidelityReport {
1199 markdown: md,
1200 mode: mode.to_string(),
1201 hints,
1202 budget_used: used,
1203 }
1204}
1205
1206pub fn compute_fidelity_report(
1221 engine: &Engine,
1222 workspace_root: &Path,
1223 binding: &Binding,
1224 resolved: &ResolvedIngest,
1225 key: &FindingKey,
1226) -> FidelityReport {
1227 let binding_id = resolved.name.clone();
1228 let dest = resolved.destination_mem.clone();
1229
1230 let sync_state = engine
1232 .mem_config_for(&dest)
1233 .map(|c| c.sync_state.clone())
1234 .unwrap_or_default();
1235 let mut capabilities: Vec<FacetCapability> = Vec::new();
1236 let mut freshness: Vec<FacetFreshness> = Vec::new();
1237 let mut any_change_detectable = false;
1238 for source in &resolved.sources {
1239 let ResolvedSource::Primary(p) = source else {
1240 continue;
1241 };
1242 let caps = medium_capabilities(p.medium_type);
1243 let medium_type = serde_json::to_value(p.medium_type)
1244 .ok()
1245 .and_then(|v| v.as_str().map(str::to_string))
1246 .unwrap_or_default();
1247 let strategy = resolve_change_strategy(p, workspace_root);
1248 let signal = signal_wire(strategy).to_string();
1249 let signal_readable = match strategy {
1259 ChangeStrategy::Git => {
1260 super::resolve::find_git_root(&super::resolve::source_base_path(p, workspace_root))
1261 .is_some()
1262 }
1263 _ => true,
1264 };
1265 let change_detectable =
1266 caps.change_signal && strategy != ChangeStrategy::None && signal_readable;
1267 any_change_detectable |= change_detectable;
1268
1269 capabilities.push(FacetCapability::from_caps(
1270 p.name.clone(),
1271 medium_type,
1272 caps,
1273 strategy,
1274 ));
1275
1276 let synced = sync_state
1277 .get(&format!("{binding_id}/{}#synced", p.name))
1278 .cloned();
1279 let verified = sync_state
1280 .get(&format!("{binding_id}/{}#verified", p.name))
1281 .cloned();
1282 freshness.push(FacetFreshness {
1283 facet: p.name.clone(),
1284 signal,
1285 synced,
1286 verified,
1287 change_detectable,
1288 });
1289 }
1290
1291 let source_moved_past_synced = if any_change_detectable {
1292 Some(source_moved(engine, resolved, workspace_root))
1293 } else {
1294 None
1295 };
1296
1297 let mut s_d: Vec<String> = Vec::new();
1299 let mut enumerable_facets = 0usize;
1300 let mut empty_enumerable_facets: BTreeSet<String> = BTreeSet::new();
1307 let mut malformed_patterns: Vec<String> = Vec::new();
1312 let mut legacy_patterns: Vec<String> = Vec::new();
1313 let mut partiality_reasons: Vec<String> = Vec::new();
1319 for source in &resolved.sources {
1320 if let ResolvedSource::Primary(p) = source {
1321 let caps = medium_capabilities(p.medium_type);
1322 if caps.enumerable {
1323 enumerable_facets += 1;
1324 }
1325 let walked = enumerate_source_artifacts_reported(
1326 engine,
1327 p,
1328 &resolved.deny_paths,
1329 workspace_root,
1330 );
1331 if caps.enumerable && walked.files.is_empty() {
1332 empty_enumerable_facets.insert(p.name.clone());
1333 }
1334 for m in &walked.malformed {
1335 malformed_patterns.push(format!("`{}` in facet `{}`", m, p.name));
1336 }
1337 for note in &walked.legacy_dialect {
1338 legacy_patterns.push(format!("`{}` in facet `{}`", note.pattern, p.name));
1339 }
1340 if let Some(reason) = walked.partiality_reason() {
1341 partiality_reasons.push(format!("facet `{}`: {reason}", p.name));
1342 }
1343 s_d.extend(walked.files);
1344 }
1345 }
1346 s_d.sort();
1347 s_d.dedup();
1348
1349 let denominator = if !partiality_reasons.is_empty() {
1350 DenominatorBasis::Partial {
1353 count: s_d.len(),
1354 reason: partiality_reasons.join("; "),
1355 }
1356 } else if !s_d.is_empty() {
1357 DenominatorBasis::Enumerated { count: s_d.len() }
1358 } else if enumerable_facets == 0 {
1359 DenominatorBasis::NonEnumerable {
1360 reason: "the medium type(s) are not enumerable this cycle".to_string(),
1361 }
1362 } else if !legacy_patterns.is_empty() {
1363 DenominatorBasis::NonEnumerable {
1368 reason: format!(
1369 "scope pattern(s) still written against the workspace root rather than the \
1370 source pointer, so they select nothing under the pointer join: {}. Rewrite \
1371 them relative to the source's pointer",
1372 legacy_patterns.join(", ")
1373 ),
1374 }
1375 } else {
1376 DenominatorBasis::NonEnumerable {
1380 reason: "no source artifacts enumerated in scope".to_string(),
1381 }
1382 };
1383
1384 let mut direct_covered = 0usize;
1385 let mut tree_only_covered = 0usize;
1386 let mut uncovered: Vec<String> = Vec::new();
1387 let mut tree_fanout: BTreeMap<(String, String), usize> = BTreeMap::new();
1388 let entity_end_reconciled = engine.entity_set_is_reconcilable(dest.as_str()).is_ok();
1389 for file in &s_d {
1390 let refs = engine.anchors_referencing_artifact(file);
1404 let mine: Vec<&(crate::EntityId, crate::anchor::Anchor)> = refs
1405 .iter()
1406 .filter(|(eid, a)| {
1407 eid.mem() == dest.as_str()
1408 && a.binding
1409 .as_deref()
1410 .map(|b| b == key.binding_hash.as_str())
1411 .unwrap_or(true)
1412 && (!entity_end_reconciled || !engine.entity_is_absent(eid))
1413 })
1414 .collect();
1415 if mine.is_empty() {
1416 uncovered.push(file.clone());
1417 continue;
1418 }
1419 let has_non_tree = mine.iter().any(|(_, a)| a.grain != AnchorGrain::Tree);
1420 if has_non_tree {
1421 direct_covered += 1;
1422 } else {
1423 tree_only_covered += 1;
1424 }
1425 for (eid, a) in &mine {
1427 if a.grain == AnchorGrain::Tree {
1428 *tree_fanout
1429 .entry((eid.as_ref().to_string(), a.artifact.clone()))
1430 .or_insert(0) += 1;
1431 }
1432 }
1433 }
1434 let tree_anchors: Vec<TreeFanout> = tree_fanout
1435 .into_iter()
1436 .map(|((entity, artifact), fanout)| TreeFanout {
1437 entity,
1438 artifact,
1439 fanout,
1440 })
1441 .collect();
1442
1443 let coverage = GrainCoverage {
1444 denominator,
1445 direct_covered,
1446 tree_only_covered,
1447 uncovered: uncovered.clone(),
1448 tree_anchors,
1449 };
1450
1451 let population = crate::ingest::anchor_population::population_for(
1455 engine,
1456 resolved,
1457 Some(key.binding_hash.as_str()),
1458 );
1459 let mut anchors = AnchorComposition {
1460 counted_rows: population.included.len(),
1461 distinct_artifacts: population.distinct_artifacts(),
1462 excluded_other_binding: population
1463 .excluded_count(crate::ingest::anchor_population::ExclusionReason::OtherBinding),
1464 excluded_out_of_scope: population
1465 .excluded_count(crate::ingest::anchor_population::ExclusionReason::OutOfScope),
1466 excluded_artifacts: population
1467 .excluded
1468 .iter()
1469 .map(|e| format!("{} ({})", e.artifact, e.reason.as_wire()))
1470 .collect(),
1471 counted_without_provenance: population.without_provenance,
1472 dangling: population.dangling.len(),
1473 dangling_rows: population
1474 .dangling
1475 .iter()
1476 .map(|d| format!("{} → {}", d.entity, d.artifact))
1477 .collect(),
1478 unreconciled: population.unreconciled.map(str::to_string),
1479 span_unvalidated: population
1480 .included
1481 .iter()
1482 .filter(|(_, r)| r.anchor.span_unvalidated)
1483 .count(),
1484 hash_from_backfill: population
1485 .included
1486 .iter()
1487 .filter(|(_, r)| {
1488 r.anchor.hash_source == Some(crate::anchor::AnchorHashSource::Backfill)
1489 })
1490 .count(),
1491 ..Default::default()
1492 };
1493 for (_eid, resolved_anchor) in population.included {
1494 let a = &resolved_anchor.anchor;
1495 *anchors
1496 .by_class
1497 .entry(a.class.as_wire().to_string())
1498 .or_insert(0) += 1;
1499 *anchors
1500 .by_grain
1501 .entry(a.grain.as_wire().to_string())
1502 .or_insert(0) += 1;
1503 if a.class == AnchorProvenanceClass::Authored {
1504 anchors.authored += 1;
1505 continue; }
1507 match resolved_anchor.state {
1508 Some(AnchorState::Resolves) => {
1509 anchors.resolves += 1;
1510 anchors.observed += 1;
1511 }
1512 Some(AnchorState::Drifted) => {
1513 anchors.drifted += 1;
1514 anchors.observed += 1;
1515 }
1516 Some(AnchorState::Recheck) => {
1517 anchors.recheck += 1;
1518 anchors.observed += 1;
1519 }
1520 Some(AnchorState::Orphaned) => {
1521 anchors.orphaned += 1;
1522 anchors.observed += 1;
1523 }
1524 None => anchors.unobserved += 1,
1525 }
1526 }
1527
1528 let mut findings_by_class: BTreeMap<String, usize> = BTreeMap::new();
1530 let mut backlog = 0usize;
1531 let mut superseded: Vec<String> = Vec::new();
1532 if let Some((mem, name)) = binding_id.split_once('/')
1533 && let Ok(Some(store)) = read_findings_store(workspace_root, mem, name)
1534 {
1535 for f in store.current(key) {
1536 *findings_by_class
1537 .entry(f.class.as_wire().to_string())
1538 .or_insert(0) += 1;
1539 if f.class == FindingClass::QueuedForAdjudication {
1540 backlog += 1;
1541 }
1542 }
1543 for f in store.superseded(key) {
1544 superseded.push(format!(
1545 "[{}] {} ({})",
1546 f.class.as_wire(),
1547 finding_target_label(&f.target),
1548 f.facet
1549 ));
1550 }
1551 }
1552
1553 let mut disposed_excluded_rationales: Vec<(String, String)> = Vec::new();
1559 if let Some((mem, name)) = binding_id.split_once('/')
1560 && let Ok(Some(state)) = read_advance_store(workspace_root, mem, name)
1561 {
1562 let uncovered_set: std::collections::BTreeSet<&str> =
1563 uncovered.iter().map(String::as_str).collect();
1564 for (artifact, rationale) in &state.exclusions {
1565 if uncovered_set.contains(artifact.as_str()) {
1566 disposed_excluded_rationales.push((artifact.clone(), rationale.clone()));
1567 }
1568 }
1569 }
1570 let disposed_excluded = disposed_excluded_rationales.len();
1571
1572 let mut degradations: Vec<String> = Vec::new();
1574 for c in &capabilities {
1575 if !c.change_signal || c.signal == "none" {
1576 degradations.push(format!(
1577 "change-signal-none:`{}` — freshness is unknowable for this facet",
1578 c.facet
1579 ));
1580 }
1581 if !c.enumerable {
1582 degradations.push(format!(
1583 "enumeration-unavailable:`{}` — `S(D)` coverage denominator not computable",
1584 c.facet
1585 ));
1586 } else if empty_enumerable_facets.contains(&c.facet) {
1587 degradations.push(format!(
1595 "enumeration-empty:`{}` — the medium claims enumerability but the walk yielded \
1596 no artifacts; coverage is reported over anchors only",
1597 c.facet
1598 ));
1599 }
1600 if !c.base_version_retrievable {
1601 degradations.push(format!(
1602 "base-version-unretrievable:`{}` — prune degrades to conflict-flagging",
1603 c.facet
1604 ));
1605 }
1606 }
1607 if anchors.recheck > 0 {
1608 degradations.push(format!(
1609 "hash-adjudication-deferred — {} anchor(s) recheck (unstable medium / hash \
1610 unavailable), not asserted drift",
1611 anchors.recheck
1612 ));
1613 }
1614 if anchors.unobserved > 0 {
1615 degradations.push(format!(
1616 "anchors-unobserved — {} anchor(s) could not be observed this pass",
1617 anchors.unobserved
1618 ));
1619 }
1620
1621 let adopt = super::render::mem_predates_binding(engine, resolved);
1625 let effective_coverage = crate::binding::effective_coverage_semantics(binding);
1626
1627 FidelityReport {
1628 legacy_dialect_patterns: legacy_patterns,
1629 binding: binding_id,
1630 destination_mem: dest,
1631 adopt,
1632 coverage_semantics: effective_coverage.value,
1633 coverage_semantics_declared: effective_coverage.declared,
1634 capabilities,
1635 freshness,
1636 source_moved_past_synced,
1637 coverage,
1638 anchors,
1639 findings_by_class,
1640 backlog,
1641 superseded,
1642 disposed_excluded,
1643 disposed_excluded_rationales,
1644 degradations,
1645 }
1646}
1647
1648fn strategy_retrieves_base(strategy: ChangeStrategy) -> bool {
1657 matches!(strategy, ChangeStrategy::Git | ChangeStrategy::Graph)
1658}
1659
1660fn signal_wire(strategy: ChangeStrategy) -> &'static str {
1663 match strategy {
1664 ChangeStrategy::None => "none",
1665 ChangeStrategy::Git => "git",
1666 ChangeStrategy::Mtime => "mtime",
1667 ChangeStrategy::Graph => "graph",
1668 }
1669}
1670
1671fn finding_target_label(target: &super::findings::FindingTarget) -> String {
1673 match target {
1674 super::findings::FindingTarget::Anchor { entity, artifact } => {
1675 format!("{entity} → {artifact}")
1676 }
1677 super::findings::FindingTarget::Artifact { artifact } => artifact.clone(),
1678 }
1679}
1680
1681#[cfg(test)]
1682mod tests {
1683 use super::*;
1684
1685 fn base_report() -> FidelityReport {
1688 FidelityReport {
1689 legacy_dialect_patterns: Vec::new(),
1690 binding: "engine/graph".to_string(),
1691 destination_mem: "engine".to_string(),
1692 adopt: false,
1693 coverage_semantics: CoverageSemantics::Exhaustive,
1694 coverage_semantics_declared: true,
1695 capabilities: vec![FacetCapability {
1696 facet: "src".to_string(),
1697 medium_type: "codebase".to_string(),
1698 enumerable: true,
1699 change_signal: true,
1700 base_version_retrievable: true,
1701 anchor_namespace: "path".to_string(),
1702 signal: "git".to_string(),
1703 }],
1704 freshness: vec![FacetFreshness {
1705 facet: "src".to_string(),
1706 signal: "git".to_string(),
1707 synced: Some("deadbeef".to_string()),
1708 verified: None,
1709 change_detectable: true,
1710 }],
1711 source_moved_past_synced: Some(false),
1712 coverage: GrainCoverage {
1713 denominator: DenominatorBasis::Enumerated { count: 10 },
1714 direct_covered: 6,
1715 tree_only_covered: 3,
1716 uncovered: vec!["src/a.rs".to_string()],
1717 tree_anchors: vec![TreeFanout {
1718 entity: "engine--big".to_string(),
1719 artifact: "src/".to_string(),
1720 fanout: 3,
1721 }],
1722 },
1723 anchors: AnchorComposition {
1724 by_class: BTreeMap::from([
1725 ("anchored".to_string(), 5),
1726 ("authored".to_string(), 2),
1727 ]),
1728 by_grain: BTreeMap::from([("file".to_string(), 4), ("tree".to_string(), 1)]),
1729 authored: 2,
1730 observed: 5,
1731 resolves: 4,
1732 drifted: 0,
1733 recheck: 1,
1734 orphaned: 0,
1735 unobserved: 0,
1736 ..Default::default()
1737 },
1738 findings_by_class: BTreeMap::from([
1739 ("uncovered".to_string(), 1),
1740 ("queued-for-adjudication".to_string(), 1),
1741 ]),
1742 backlog: 1,
1743 superseded: Vec::new(),
1744 disposed_excluded: 0,
1745 disposed_excluded_rationales: Vec::new(),
1746 degradations: vec!["hash-adjudication-deferred — 1 anchor(s) recheck".to_string()],
1747 }
1748 }
1749
1750 #[test]
1755 fn b1_renders_all_elements_deterministically() {
1756 let r = base_report();
1757 let a = render_fidelity_report(&r, 8_000, &[]);
1758 let b = render_fidelity_report(&r, 8_000, &[]);
1759 assert_eq!(a.markdown, b.markdown, "deterministic — identical bytes");
1760
1761 let md = &a.markdown;
1762 assert!(md.contains("direct-covered (file / span anchors): 6/10"));
1764 assert!(md.contains(
1765 "tree-anchor fan-out (separate axis): 1 tree anchor(s) fanning out over 3 file(s)"
1766 ));
1767 assert!(
1769 !md.contains("9/10"),
1770 "tree fan-out must not blend into direct coverage"
1771 );
1772 assert!(md.contains("anchor-resolution %:** 4/5"));
1774 assert!(md.contains("`authored` bucket (excluded from coverage/accuracy denominators): 2"));
1776 assert!(md.contains("tier-3 adjudication backlog:** 1"));
1778 assert!(md.contains("## Capability matrix"));
1780 assert!(md.contains("## Degradations"));
1781 assert!(md.contains("hash-adjudication-deferred"));
1782 assert!(md.contains("per-medium enumeration `S(D)` = **10**"));
1784 }
1785
1786 #[test]
1789 fn b2_detectionless_medium_freshness_unknowable_never_green() {
1790 let mut r = base_report();
1791 r.capabilities = vec![FacetCapability {
1792 facet: "manual".to_string(),
1793 medium_type: "web".to_string(),
1794 enumerable: false,
1795 change_signal: false,
1796 base_version_retrievable: false,
1797 anchor_namespace: "url".to_string(),
1798 signal: "none".to_string(),
1799 }];
1800 r.freshness = vec![FacetFreshness {
1801 facet: "manual".to_string(),
1802 signal: "none".to_string(),
1803 synced: Some("should-never-render-green".to_string()),
1806 verified: Some("nor-this".to_string()),
1807 change_detectable: false,
1808 }];
1809 r.source_moved_past_synced = None;
1810 let out = render_fidelity_report(&r, 8_000, &[]);
1811 let md = &out.markdown;
1812 assert!(md.contains("signal: `none`"));
1813 assert!(md.contains("freshness unknowable"));
1814 assert!(!md.contains("should-never-render-green"));
1817 assert!(
1818 !md.contains("`#synced`: `"),
1819 "no synced token rendered for a non-detectable medium"
1820 );
1821 assert!(
1822 !md.contains("at its `#synced` baseline"),
1823 "no green 'at baseline' verdict"
1824 );
1825 }
1826
1827 #[test]
1835 fn b1_base_retrievability_follows_resolved_strategy_not_medium_ceiling() {
1836 use crate::pipeline::MediumType;
1837
1838 assert!(medium_capabilities(MediumType::Filesystem).base_version_retrievable);
1840
1841 let fs_mtime = FacetCapability::from_caps(
1843 "prose".to_string(),
1844 "filesystem".to_string(),
1845 medium_capabilities(MediumType::Filesystem),
1846 ChangeStrategy::Mtime,
1847 );
1848 assert!(
1849 !fs_mtime.base_version_retrievable,
1850 "filesystem+mtime has no retrievable base leg — degrades to conflict-flag"
1851 );
1852 assert_eq!(fs_mtime.signal, "mtime");
1853
1854 let fs_git = FacetCapability::from_caps(
1855 "prose".to_string(),
1856 "filesystem".to_string(),
1857 medium_capabilities(MediumType::Filesystem),
1858 ChangeStrategy::Git,
1859 );
1860 assert!(
1861 fs_git.base_version_retrievable,
1862 "filesystem backed by git keeps the never-clobber base leg"
1863 );
1864
1865 assert!(!strategy_retrieves_base(ChangeStrategy::None));
1867 assert!(!strategy_retrieves_base(ChangeStrategy::Mtime));
1868 assert!(strategy_retrieves_base(ChangeStrategy::Git));
1869 assert!(strategy_retrieves_base(ChangeStrategy::Graph));
1870
1871 let mut r = base_report();
1875 r.capabilities = vec![fs_mtime.clone()];
1876 r.degradations = if !fs_mtime.base_version_retrievable {
1877 vec![format!(
1878 "base-version-unretrievable:`{}` — prune degrades to conflict-flagging",
1879 fs_mtime.facet
1880 )]
1881 } else {
1882 Vec::new()
1883 };
1884 let md = render_fidelity_report(&r, 8_000, &[]).markdown;
1885 assert!(
1886 md.contains("base-version-unretrievable:`prose` — prune degrades to conflict-flagging"),
1887 "filesystem+mtime surfaces the conflict-flag degradation in the report"
1888 );
1889 }
1890
1891 #[test]
1894 fn b3_aggregates_always_ship_at_zero_budget() {
1895 let r = base_report();
1896 let out = render_fidelity_report(&r, 0, &[]);
1897 assert_eq!(out.mode, "overbudget");
1898 let md = &out.markdown;
1899 assert!(md.contains("direct-covered (file / span anchors): 6/10"));
1901 assert!(md.contains("tier-3 adjudication backlog:** 1"));
1902 assert!(md.contains("## Capability matrix"));
1903 assert!(!md.contains("## Uncovered artifacts"));
1905 assert!(md.contains("## Hints"));
1906 assert!(out.hints.iter().any(|(k, _)| k == "uncovered_artifacts"));
1907 }
1908
1909 #[test]
1913 fn b3_large_facet_list_truncates_then_include_forces() {
1914 let mut r = base_report();
1915 r.coverage.uncovered = (0..500).map(|i| format!("src/file_{i}.rs")).collect();
1917 let hard_cost = estimate_tokens(&render_hard_required(&r));
1919 let out = render_fidelity_report(&r, hard_cost + 5, &[]);
1920 assert_eq!(out.mode, "reduced");
1921 assert!(
1922 !out.markdown.contains("src/file_499.rs"),
1923 "big list not rendered unbounded"
1924 );
1925 assert!(out.markdown.contains("## Hints"));
1926 let (_, est) = out
1927 .hints
1928 .iter()
1929 .find(|(k, _)| k == "uncovered_artifacts")
1930 .expect("uncovered list hinted");
1931 assert!(*est > 5, "the hint carries a real estimated_tokens figure");
1932
1933 let forced =
1935 render_fidelity_report(&r, hard_cost + 5, &["uncovered_artifacts".to_string()]);
1936 assert!(
1937 forced.markdown.contains("src/file_499.rs"),
1938 "include forces the full list"
1939 );
1940 }
1941
1942 #[test]
1945 fn b4_curated_vs_exhaustive_framing() {
1946 let mut exhaustive = base_report();
1947 exhaustive.coverage_semantics = CoverageSemantics::Exhaustive;
1948 let ex_md = render_fidelity_report(&exhaustive, 8_000, &[]).markdown;
1949 assert!(ex_md.contains("Exhaustive coverage:"));
1950 assert!(ex_md.contains("are **findings**"));
1951
1952 let mut curated = base_report();
1953 curated.coverage_semantics = CoverageSemantics::Curated;
1954 let cur_md = render_fidelity_report(&curated, 8_000, &[]).markdown;
1955 assert!(cur_md.contains("Curated coverage:"));
1956 assert!(cur_md.contains("**information**"));
1957 assert!(
1958 !cur_md.contains("are **findings**"),
1959 "curated never frames unaccounted as findings"
1960 );
1961 }
1962
1963 #[test]
1966 fn b4_disposition_excludes_from_exhaustive_findings() {
1967 let mut r = base_report();
1968 r.coverage_semantics = CoverageSemantics::Exhaustive;
1969 r.coverage.uncovered = vec!["src/a.rs".to_string(), "src/b.rs".to_string()];
1970 r.disposed_excluded = 1;
1971 let md = render_fidelity_report(&r, 8_000, &[]).markdown;
1972 assert!(md.contains("1 unaccounted artifact(s)"));
1974 assert!(md.contains("(1 disposed excluded)"));
1975 }
1976
1977 #[test]
1980 fn b4_authored_exclusion_rationale_is_rendered() {
1981 let mut r = base_report();
1982 r.coverage_semantics = CoverageSemantics::Exhaustive;
1983 r.coverage.uncovered = vec!["src/gen.rs".to_string()];
1984 r.disposed_excluded = 1;
1985 r.disposed_excluded_rationales =
1986 vec![("src/gen.rs".to_string(), "generated; no entity".to_string())];
1987 let md = render_fidelity_report(&r, 8_000, &[]).markdown;
1988 assert!(md.contains("Excluded on purpose (persisted dispositions):"));
1989 assert!(md.contains("`src/gen.rs` — generated; no entity"));
1990 }
1991
1992 #[test]
1995 fn b5_denominator_provenance_stated() {
1996 let r = base_report();
1997 let md = render_fidelity_report(&r, 8_000, &[]).markdown;
1998 assert!(md.contains("## Denominator provenance"));
1999 assert!(md.contains("per-medium enumeration `S(D)` = **10**"));
2000
2001 let mut non = base_report();
2002 non.coverage.denominator = DenominatorBasis::NonEnumerable {
2003 reason: "the medium type(s) are not enumerable this cycle".to_string(),
2004 };
2005 let md2 = render_fidelity_report(&non, 8_000, &[]).markdown;
2006 assert!(md2.contains("No `S(D)` denominator"));
2007 assert!(md2.contains("not enumerable this cycle"));
2008 }
2009
2010 #[test]
2016 fn e1_adopt_report_renders_onboarding_no_red_verdict() {
2017 let mut r = base_report();
2018 r.adopt = true;
2019 r.coverage_semantics = CoverageSemantics::Exhaustive;
2020 r.coverage.uncovered = (0..5).map(|i| format!("src/file_{i}.rs")).collect();
2021 let md = render_fidelity_report(&r, 8_000, &[]).markdown;
2022
2023 assert!(md.contains("## Adopting — first verify"));
2025 assert!(md.contains("0% anchored is expected — this is onboarding, not a failure."));
2026 assert!(
2028 md.contains("**Backfill path:** run `memstead projection brief engine/graph --sync`")
2029 );
2030 assert!(
2033 !md.contains("are **findings**"),
2034 "pre-binding history must not produce a red findings verdict"
2035 );
2036 assert!(md.contains("Exhaustive coverage (onboarding):"));
2037 assert!(md.contains("backfill worklist"));
2038
2039 r.adopt = false;
2041 let md2 = render_fidelity_report(&r, 8_000, &[]).markdown;
2042 assert!(!md2.contains("## Adopting — first verify"));
2043 assert!(md2.contains("are **findings**"));
2044 }
2045
2046 #[test]
2048 fn unknown_include_key_warns() {
2049 let r = base_report();
2050 let out = render_fidelity_report(&r, 8_000, &["bogus".to_string()]);
2051 assert!(out.markdown.contains("unknown include key `bogus`"));
2052 }
2053
2054 use crate::anchor::{Anchor, AnchorHashStability, AnchorProvenanceClass, AnchorSidecar};
2057 use crate::binding::{
2058 BINDING_VERSION, Binding, BuildMode, BuildOperation, DEFAULT_ADJUDICATION_CAP,
2059 DEFAULT_FULL_RESYNC_EVERY, Operations, VerifyOperation,
2060 };
2061 use crate::ingest::findings::verify_binding;
2062 use crate::ingest::resolve::resolve_binding_run;
2063 use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
2064 use crate::pipeline_store::{load_pipeline_configs, write_binding};
2065 use crate::workspace::{
2066 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
2067 };
2068 use crate::workspace_store::WorkspaceStoreAdapter;
2069
2070 #[test]
2077 fn compute_report_end_to_end() {
2078 let tmp = tempfile::tempdir().unwrap();
2079 let (report, outcome, md) = end_to_end_report(tmp.path(), &["direct", "tree", "auth"]);
2080 end_to_end_body(&report, &outcome, &md);
2081 }
2082
2083 #[test]
2088 fn coverage_does_not_rest_on_an_anchor_whose_entity_is_gone() {
2089 let tmp = tempfile::tempdir().unwrap();
2090 let (report, _outcome, md) = end_to_end_report(tmp.path(), &["tree"]);
2091 assert_eq!(
2092 report.coverage.direct_covered, 0,
2093 "the only direct anchor on present.rs is dangling, so nothing covers it directly"
2094 );
2095 assert!(
2096 report
2097 .coverage
2098 .uncovered
2099 .contains(&"src/present.rs".to_string()),
2100 "and the artifact reads uncovered rather than covered by a phantom"
2101 );
2102 assert_eq!(report.anchors.dangling, 2);
2104 assert_eq!(report.anchors.counted_rows, 1);
2105 assert_eq!(report.anchors.unreconciled, None);
2106 assert!(
2107 md.contains("name an entity this mem no longer holds"),
2108 "and the report says so on the page, not only in the struct"
2109 );
2110 }
2111
2112 fn end_to_end_report(
2118 root: &std::path::Path,
2119 entity_slugs: &[&str],
2120 ) -> (
2121 FidelityReport,
2122 crate::ingest::findings::VerifyOutcome,
2123 String,
2124 ) {
2125 let mem_dir = root.join("mem");
2126 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2127 std::fs::write(
2128 mem_dir.join(".memstead").join("config.json"),
2129 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2130 )
2131 .unwrap();
2132
2133 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2134 std::fs::write(
2135 root.join(".memstead").join("workspace.toml"),
2136 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2137 )
2138 .unwrap();
2139 let mount = Mount {
2140 mem: "engine".to_string(),
2141 schema: Some("default@1.0.0".parse().unwrap()),
2142 storage: MountStorage::Folder {
2143 path: mem_dir.clone(),
2144 },
2145 capability: MountCapability::Write,
2146 lifecycle: MountLifecycle::Eager,
2147 cross_linkable: false,
2148 migration_target: None,
2149 };
2150 crate::FileWorkspaceStore::new()
2151 .save_state(
2152 root,
2153 &Workspace {
2154 mounts: vec![mount],
2155 settings: WorkspaceSettings::default(),
2156 },
2157 )
2158 .unwrap();
2159
2160 let out = std::process::Command::new("git")
2161 .args(["init", "-q"])
2162 .current_dir(root)
2163 .output()
2164 .unwrap();
2165 assert!(out.status.success());
2166 std::fs::create_dir_all(root.join("src").join("sub")).unwrap();
2167 std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
2168 std::fs::write(root.join("src").join("uncovered.rs"), "fn b() {}\n").unwrap();
2169 std::fs::write(root.join("src").join("sub").join("deep.rs"), "fn c() {}\n").unwrap();
2170
2171 let mk = |artifact: &str, grain: AnchorGrain, class: AnchorProvenanceClass| Anchor {
2172 artifact: artifact.to_string(),
2173 grain,
2174 class,
2175 at_version: None,
2176 hash: class.is_hash_bearing().then(|| "recorded".to_string()),
2177 hash_stability: AnchorHashStability::Stable,
2178 derived_from: Vec::new(),
2179 binding: None,
2180 source: None,
2181 span_unvalidated: false,
2182 hash_source: None,
2183 };
2184 for slug in entity_slugs {
2188 std::fs::write(
2189 mem_dir.join(format!("{slug}.md")),
2190 "---\ntype: decision\n---\n\n# E\n\n## Decision\n\nBody.\n",
2191 )
2192 .unwrap();
2193 }
2194 let mut sidecar = AnchorSidecar::default();
2195 sidecar.set(
2196 "engine--direct",
2197 vec![mk(
2198 "src/present.rs",
2199 AnchorGrain::File,
2200 AnchorProvenanceClass::Anchored,
2201 )],
2202 );
2203 sidecar.set(
2204 "engine--tree",
2205 vec![mk(
2206 "src/sub/",
2207 AnchorGrain::Tree,
2208 AnchorProvenanceClass::Anchored,
2209 )],
2210 );
2211 sidecar.set(
2213 "engine--auth",
2214 vec![mk(
2215 "src/present.rs",
2216 AnchorGrain::File,
2217 AnchorProvenanceClass::Authored,
2218 )],
2219 );
2220 std::fs::write(
2221 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2222 sidecar.to_bytes(),
2223 )
2224 .unwrap();
2225
2226 write_binding(
2227 root,
2228 "engine",
2229 "graph",
2230 &Binding {
2231 version: BINDING_VERSION,
2232 intent: None,
2233 sources: vec![crate::pipeline::Source {
2234 name: "graph".to_string(),
2235 medium_type: MediumType::Codebase,
2236 pointer: String::new(),
2237 change_detection: Some("git".to_string()),
2238 scope: vec![PatternEntry {
2239 path: "src/**/*.rs".to_string(),
2240 mode: PatternMode::Allow,
2241 }],
2242 engagement: None,
2243 preparation: None,
2244 }],
2245 reference_mems: Vec::new(),
2246 destination_mem: "engine".to_string(),
2247 deny_paths: Vec::new(),
2248 coverage_semantics: None,
2249 rules: None,
2250 prune: None,
2251 operations: Operations {
2252 build: Some(BuildOperation {
2253 mode: BuildMode::Discovery,
2254 trigger: IngestTrigger::Loop,
2255 batch_size: 20,
2256 post_actions: None,
2257 }),
2258 sync: None,
2259 verify: Some(VerifyOperation {
2260 trigger: IngestTrigger::Manual,
2261 batch_size: 20,
2262 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2263 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2264 }),
2265 },
2266 },
2267 )
2268 .unwrap();
2269
2270 let engine = Engine::from_workspace_root(root).unwrap();
2271 let configs = load_pipeline_configs(root).unwrap();
2272 let binding = &configs.bindings[0].config;
2273 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2274
2275 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2277
2278 let report = compute_fidelity_report(&engine, root, binding, &resolved, &outcome.key);
2280 let md = render_fidelity_report(&report, 8_000, &[]).markdown;
2281 (report, outcome, md)
2282 }
2283
2284 fn end_to_end_body(
2285 report: &FidelityReport,
2286 outcome: &crate::ingest::findings::VerifyOutcome,
2287 md: &str,
2288 ) {
2289 assert_eq!(
2291 report.coverage.denominator,
2292 DenominatorBasis::Enumerated { count: 3 }
2293 );
2294 assert_eq!(report.coverage.direct_covered, 1);
2297 assert_eq!(report.coverage.tree_only_covered, 1);
2298 assert_eq!(
2299 report.coverage.uncovered,
2300 vec!["src/uncovered.rs".to_string()]
2301 );
2302 assert_eq!(report.coverage.tree_anchors.len(), 1);
2304 assert_eq!(report.coverage.tree_anchors[0].fanout, 1);
2305 assert_eq!(report.coverage.tree_anchors[0].artifact, "src/sub/");
2306 assert_eq!(report.anchors.authored, 1);
2308 assert_eq!(report.anchors.by_class.get("authored"), Some(&1));
2309 assert_eq!(report.anchors.observed, 2);
2314 assert_eq!(report.anchors.recheck, 1);
2315 assert_eq!(report.anchors.drifted, 1);
2316 assert_eq!(report.backlog, outcome.backlog);
2318 assert!(
2320 report
2321 .degradations
2322 .iter()
2323 .any(|d| d.contains("hash-adjudication-deferred"))
2324 );
2325 assert!(md.contains("per-medium enumeration `S(D)` = **3**"));
2327 assert!(!report.adopt);
2330 assert!(!md.contains("## Adopting — first verify"));
2331 }
2332
2333 #[test]
2338 fn compute_report_adopt_when_mem_predates_binding() {
2339 let tmp = tempfile::tempdir().unwrap();
2340 let root = tmp.path();
2341 let mem_dir = root.join("mem");
2342 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2343 std::fs::write(
2344 mem_dir.join(".memstead").join("config.json"),
2345 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2346 )
2347 .unwrap();
2348 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2349 std::fs::write(
2350 root.join(".memstead").join("workspace.toml"),
2351 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2352 )
2353 .unwrap();
2354 let mount = Mount {
2355 mem: "engine".to_string(),
2356 schema: Some("default@1.0.0".parse().unwrap()),
2357 storage: MountStorage::Folder {
2358 path: mem_dir.clone(),
2359 },
2360 capability: MountCapability::Write,
2361 lifecycle: MountLifecycle::Eager,
2362 cross_linkable: false,
2363 migration_target: None,
2364 };
2365 crate::FileWorkspaceStore::new()
2366 .save_state(
2367 root,
2368 &Workspace {
2369 mounts: vec![mount],
2370 settings: WorkspaceSettings::default(),
2371 },
2372 )
2373 .unwrap();
2374 let out = std::process::Command::new("git")
2375 .args(["init", "-q"])
2376 .current_dir(root)
2377 .output()
2378 .unwrap();
2379 assert!(out.status.success());
2380 std::fs::create_dir_all(root.join("src")).unwrap();
2381 std::fs::write(root.join("src").join("a.rs"), "fn a() {}\n").unwrap();
2383 std::fs::write(root.join("src").join("b.rs"), "fn b() {}\n").unwrap();
2384
2385 write_binding(
2386 root,
2387 "engine",
2388 "graph",
2389 &Binding {
2390 version: BINDING_VERSION,
2391 intent: None,
2392 sources: vec![crate::pipeline::Source {
2393 name: "graph".to_string(),
2394 medium_type: MediumType::Codebase,
2395 pointer: String::new(),
2396 change_detection: Some("git".to_string()),
2397 scope: vec![PatternEntry {
2398 path: "src/**/*.rs".to_string(),
2399 mode: PatternMode::Allow,
2400 }],
2401 engagement: None,
2402 preparation: None,
2403 }],
2404 reference_mems: Vec::new(),
2405 destination_mem: "engine".to_string(),
2406 deny_paths: Vec::new(),
2407 coverage_semantics: None,
2408 rules: None,
2409 prune: None,
2410 operations: Operations {
2411 build: Some(BuildOperation {
2412 mode: BuildMode::Discovery,
2413 trigger: IngestTrigger::Loop,
2414 batch_size: 20,
2415 post_actions: None,
2416 }),
2417 sync: None,
2418 verify: Some(VerifyOperation {
2419 trigger: IngestTrigger::Manual,
2420 batch_size: 20,
2421 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2422 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2423 }),
2424 },
2425 },
2426 )
2427 .unwrap();
2428
2429 let engine = Engine::from_workspace_root(root).unwrap();
2430 let configs = load_pipeline_configs(root).unwrap();
2431 let binding = &configs.bindings[0].config;
2432 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2433 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2434 let report = compute_fidelity_report(&engine, root, binding, &resolved, &outcome.key);
2435
2436 assert!(
2438 report.adopt,
2439 "a no-anchor, never-synced mem predates its binding"
2440 );
2441 let md = render_fidelity_report(&report, 8_000, &[]).markdown;
2442 assert!(md.contains("## Adopting — first verify"));
2443 assert!(md.contains("0% anchored is expected"));
2444 assert!(!md.contains("are **findings**"));
2446 assert!(md.contains("Exhaustive coverage (onboarding):"));
2447 }
2448
2449 #[test]
2453 fn report_marks_resolved_coverage_semantics() {
2454 let mut resolved = base_report();
2455 resolved.coverage_semantics = CoverageSemantics::Curated;
2456 resolved.coverage_semantics_declared = false;
2457 let md = render_hard_required(&resolved);
2458 assert!(
2459 md.contains("curated (resolved from the sources' media — not declared)"),
2460 "resolved value carries the marker: {md}"
2461 );
2462
2463 let declared = base_report(); let md = render_hard_required(&declared);
2465 assert!(
2466 md.contains("**Coverage semantics:** exhaustive\n"),
2467 "declared value renders bare: {md}"
2468 );
2469 assert!(
2470 !md.contains("(resolved from the sources' media"),
2471 "no resolution marker on a declared value: {md}"
2472 );
2473 }
2474}
2475
2476#[cfg(test)]
2477mod rollup_tests {
2478 use super::*;
2479
2480 fn clean_report() -> FidelityReport {
2484 FidelityReport {
2485 legacy_dialect_patterns: Vec::new(),
2486 binding: "engine/graph".to_string(),
2487 destination_mem: "engine".to_string(),
2488 adopt: false,
2489 coverage_semantics: CoverageSemantics::Exhaustive,
2490 coverage_semantics_declared: true,
2491 capabilities: vec![FacetCapability {
2492 facet: "src".to_string(),
2493 medium_type: "codebase".to_string(),
2494 enumerable: true,
2495 change_signal: true,
2496 base_version_retrievable: true,
2497 anchor_namespace: "path".to_string(),
2498 signal: "git".to_string(),
2499 }],
2500 freshness: vec![FacetFreshness {
2501 facet: "src".to_string(),
2502 signal: "git".to_string(),
2503 synced: Some("deadbeef".to_string()),
2504 verified: None,
2505 change_detectable: true,
2506 }],
2507 source_moved_past_synced: Some(false),
2508 coverage: GrainCoverage {
2509 denominator: DenominatorBasis::Enumerated { count: 4 },
2510 direct_covered: 4,
2511 tree_only_covered: 0,
2512 uncovered: Vec::new(),
2513 tree_anchors: Vec::new(),
2514 },
2515 anchors: AnchorComposition {
2516 by_class: BTreeMap::from([("anchored".to_string(), 4)]),
2517 by_grain: BTreeMap::from([("file".to_string(), 4)]),
2518 authored: 0,
2519 observed: 4,
2520 resolves: 4,
2521 drifted: 0,
2522 recheck: 0,
2523 orphaned: 0,
2524 unobserved: 0,
2525 ..Default::default()
2526 },
2527 findings_by_class: BTreeMap::new(),
2528 backlog: 0,
2529 superseded: Vec::new(),
2530 disposed_excluded: 0,
2531 disposed_excluded_rationales: Vec::new(),
2532 degradations: Vec::new(),
2533 }
2534 }
2535
2536 #[test]
2542 fn unadjudicated_rows_block_clean_but_exclusions_do_not() {
2543 let mut r = clean_report();
2544 assert_eq!(
2545 r.rollup().verdict,
2546 RollupVerdict::Clean,
2547 "the baseline is clean"
2548 );
2549
2550 r.anchors.excluded_out_of_scope = 3;
2552 r.anchors.excluded_other_binding = 2;
2553 r.anchors.excluded_artifacts = vec!["src/a.rs (out-of-scope)".into()];
2554 assert_eq!(
2555 r.rollup().verdict,
2556 RollupVerdict::Clean,
2557 "excluding a row this binding does not answer for is an ANSWER, not a blind spot"
2558 );
2559
2560 let mut unobserved = r.clone();
2562 unobserved.anchors.unobserved = 1;
2563 let roll = unobserved.rollup();
2564 assert_eq!(roll.verdict, RollupVerdict::Inconclusive);
2565 assert!(
2566 roll.blind_spots
2567 .iter()
2568 .any(|b| b.contains("could not be observed")),
2569 "and it names itself: {:?}",
2570 roll.blind_spots
2571 );
2572
2573 let mut span = r.clone();
2575 span.anchors.span_unvalidated = 2;
2576 assert_eq!(span.rollup().verdict, RollupVerdict::Inconclusive);
2577
2578 let mut ent = r.clone();
2580 ent.anchors.unreconciled = Some("the mem's lazy entity load has not run".into());
2581 assert_eq!(ent.rollup().verdict, RollupVerdict::Inconclusive);
2582 }
2583
2584 #[test]
2589 fn all_five_conditions_are_reachable_in_the_report() {
2590 let mut r = clean_report();
2591 r.anchors.excluded_out_of_scope = 1;
2592 r.anchors.excluded_other_binding = 1;
2593 r.anchors.excluded_artifacts = vec![
2594 "src/a.rs (out-of-scope)".into(),
2595 "src/b.rs (other-binding)".into(),
2596 ];
2597 r.anchors.dangling = 1;
2598 r.anchors.dangling_rows = vec!["engine--gone → src/c.rs".into()];
2599 r.anchors.span_unvalidated = 1;
2600 r.anchors.hash_from_backfill = 1;
2601
2602 let md = render_fidelity_report(&r, 8_000, &[]).markdown;
2603 for (needle, condition) in [
2604 ("outside this binding's declared scope", "scope-excluded"),
2605 ("written by another binding", "other-binding"),
2606 ("no longer holds", "dangling entity"),
2607 ("never checked against their artifact", "span not validated"),
2608 ("inferred by backfill", "baseline established by backfill"),
2609 ] {
2610 assert!(
2611 md.contains(needle),
2612 "{condition} is not reachable in the report; looked for {needle:?} in:\n{md}"
2613 );
2614 }
2615 }
2616
2617 #[test]
2619 fn clean_requires_a_substantive_pass_and_no_findings() {
2620 let mut r = clean_report();
2621 assert_eq!(r.rollup().verdict, RollupVerdict::Clean);
2622 assert!(r.rollup().blind_spots.is_empty());
2623 assert!(r.rollup().actions.is_empty());
2624
2625 r.findings_by_class.insert("drifted".to_string(), 2);
2626 let roll = r.rollup();
2627 assert_eq!(roll.verdict, RollupVerdict::Drifted);
2628 assert_eq!(roll.findings_total, 2);
2629 assert!(
2630 roll.actions[0].contains("moved since the entity was written"),
2631 "the top action is the concrete next step: {:?}",
2632 roll.actions
2633 );
2634 }
2635
2636 #[test]
2641 fn a_vacuous_zero_over_zero_is_inconclusive_not_clean() {
2642 let mut r = clean_report();
2643 r.coverage.denominator = DenominatorBasis::Enumerated { count: 0 };
2644 let roll = r.rollup();
2645 assert_eq!(
2646 roll.verdict,
2647 RollupVerdict::Inconclusive,
2648 "0/0 is not a clean bill of health"
2649 );
2650 assert!(
2651 roll.blind_spots.iter().any(|s| s.contains("vacuous")),
2652 "the blindness is named, not implied: {:?}",
2653 roll.blind_spots
2654 );
2655 }
2656
2657 #[test]
2661 fn a_non_enumerable_facet_blocks_green_even_in_a_mixed_binding() {
2662 let mut r = clean_report();
2663 r.capabilities.push(FacetCapability {
2664 facet: "site".to_string(),
2665 medium_type: "web".to_string(),
2666 enumerable: false,
2667 change_signal: true,
2671 base_version_retrievable: false,
2672 anchor_namespace: "url".to_string(),
2673 signal: "none".to_string(),
2674 });
2675 assert!(matches!(
2677 r.coverage.denominator,
2678 DenominatorBasis::Enumerated { count } if count > 0
2679 ));
2680 let roll = r.rollup();
2681 assert_eq!(
2682 roll.verdict,
2683 RollupVerdict::Inconclusive,
2684 "one enumerable facet must not launder a non-enumerable one: {roll:?}"
2685 );
2686 assert!(
2687 roll.blind_spots
2688 .iter()
2689 .any(|s| s.contains("not enumerable")),
2690 "{:?}",
2691 roll.blind_spots
2692 );
2693 }
2694
2695 #[test]
2701 fn a_resolved_signal_of_none_blocks_green_even_when_the_medium_could_signal() {
2702 let mut r = clean_report();
2703 r.capabilities[0].change_signal = true;
2706 r.capabilities[0].signal = "none".to_string();
2707 r.freshness[0].change_detectable = false;
2708 r.freshness[0].signal = "none".to_string();
2709 let roll = r.rollup();
2710 assert_eq!(
2711 roll.verdict,
2712 RollupVerdict::Inconclusive,
2713 "a change-blind binding is not a clean bill of health: {roll:?}"
2714 );
2715 assert!(
2716 roll.blind_spots
2717 .iter()
2718 .any(|s| s.contains("could not read that signal")),
2719 "the blind spot names the unreadable signal: {:?}",
2720 roll.blind_spots
2721 );
2722 }
2723
2724 #[test]
2728 fn a_facet_without_a_change_signal_blocks_green() {
2729 let mut r = clean_report();
2730 r.capabilities[0].change_signal = false;
2731 let roll = r.rollup();
2732 assert_eq!(roll.verdict, RollupVerdict::Inconclusive);
2733 assert!(
2734 roll.blind_spots
2735 .iter()
2736 .any(|s| s.contains("no change signal")),
2737 "{:?}",
2738 roll.blind_spots
2739 );
2740 }
2741
2742 #[test]
2745 fn a_non_enumerable_scope_blocks_green() {
2746 let mut r = clean_report();
2747 r.coverage.denominator = DenominatorBasis::NonEnumerable {
2748 reason: "web medium".to_string(),
2749 };
2750 assert_eq!(r.rollup().verdict, RollupVerdict::Inconclusive);
2751 }
2752
2753 #[test]
2755 fn zero_observed_anchors_blocks_green() {
2756 let mut r = clean_report();
2757 r.anchors.observed = 0;
2758 r.anchors.resolves = 0;
2759 assert_eq!(r.rollup().verdict, RollupVerdict::Inconclusive);
2760 }
2761
2762 #[test]
2766 fn adopt_with_only_uncovered_is_never_red() {
2767 let mut r = clean_report();
2768 r.adopt = true;
2769 r.findings_by_class.insert("uncovered".to_string(), 12);
2770 let roll = r.rollup();
2771 assert_eq!(
2772 roll.verdict,
2773 RollupVerdict::Inconclusive,
2774 "onboarding is neither drift nor a clean bill: {roll:?}"
2775 );
2776 assert!(
2777 roll.because.contains("backfill worklist"),
2778 "the reason states the onboarding framing: {}",
2779 roll.because
2780 );
2781
2782 r.findings_by_class.insert("drifted".to_string(), 1);
2785 assert_eq!(r.rollup().verdict, RollupVerdict::Drifted);
2786 }
2787
2788 #[test]
2791 fn findings_outrank_blind_spots() {
2792 let mut r = clean_report();
2793 r.capabilities[0].change_signal = false;
2794 r.findings_by_class.insert("wrong".to_string(), 1);
2795 let roll = r.rollup();
2796 assert_eq!(roll.verdict, RollupVerdict::Drifted);
2797 assert!(
2798 !roll.blind_spots.is_empty(),
2799 "the blindness is still reported alongside the verdict"
2800 );
2801 }
2802
2803 #[test]
2806 fn actions_are_severity_ordered_and_never_drop_a_class() {
2807 let mut r = clean_report();
2808 r.findings_by_class.insert("uncovered".to_string(), 3);
2809 r.findings_by_class.insert("wrong".to_string(), 1);
2810 r.findings_by_class
2811 .insert("some-future-class".to_string(), 2);
2812 let roll = r.rollup();
2813 assert!(
2814 roll.actions[0].contains("contradict their source"),
2815 "{roll:?}"
2816 );
2817 assert_eq!(roll.actions.len(), 3, "{roll:?}");
2818 assert!(
2819 roll.actions.iter().any(|a| a.contains("some-future-class")),
2820 "an unranked class still surfaces: {roll:?}"
2821 );
2822 }
2823
2824 #[test]
2826 fn verdict_wire_strings_are_stable() {
2827 assert_eq!(RollupVerdict::Clean.wire(), "clean");
2828 assert_eq!(RollupVerdict::Drifted.wire(), "drifted");
2829 assert_eq!(RollupVerdict::Inconclusive.wire(), "inconclusive");
2830 let json = serde_json::to_string(&RollupVerdict::Inconclusive).unwrap();
2831 assert_eq!(json, "\"inconclusive\"");
2832 }
2833}