1use std::collections::{BTreeMap, BTreeSet};
36use std::path::Path;
37
38use serde::Serialize;
39
40use crate::Engine;
41use crate::anchor::{AnchorGrain, AnchorProvenanceClass, AnchorState};
42use crate::binding::{Binding, CoverageSemantics, MediumCapabilities, medium_capabilities};
43use crate::chunking::estimate_tokens;
44
45use super::advance::read_advance_store;
46use super::cursor::{enumerate_source_artifacts_reported, source_moved};
47use super::findings::{FindingClass, FindingKey, read_findings_store};
48use super::resolve::{ChangeStrategy, ResolvedIngest, ResolvedSource, resolve_change_strategy};
49
50pub const DEFAULT_REPORT_BUDGET: usize = 8_000;
54
55pub const ALLOWED_REPORT_INCLUDE_KEYS: &[&str] =
61 &["uncovered_artifacts", "tree_fanout", "superseded_findings"];
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
74#[serde(tag = "kind", rename_all = "kebab-case")]
75pub enum DenominatorBasis {
76 Enumerated {
79 count: usize,
81 },
82 NonEnumerable {
86 reason: String,
88 },
89 Partial {
95 count: usize,
97 reason: String,
99 },
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
106pub struct TreeFanout {
107 pub entity: String,
109 pub artifact: String,
111 pub fanout: usize,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
119pub struct GrainCoverage {
120 pub denominator: DenominatorBasis,
122 pub direct_covered: usize,
124 pub tree_only_covered: usize,
127 pub uncovered: Vec<String>,
129 pub tree_anchors: Vec<TreeFanout>,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default)]
137pub struct AnchorComposition {
138 pub by_class: BTreeMap<String, usize>,
142 pub by_grain: BTreeMap<String, usize>,
144 pub authored: usize,
147 pub observed: usize,
149 pub resolves: usize,
151 pub drifted: usize,
153 pub recheck: usize,
155 pub orphaned: usize,
157 pub unobserved: usize,
160 pub counted_rows: usize,
166 pub distinct_artifacts: usize,
171 pub excluded_other_binding: usize,
174 pub excluded_out_of_scope: usize,
177 pub excluded_artifacts: Vec<String>,
181 pub counted_without_provenance: usize,
185 pub dangling: usize,
190 pub dangling_rows: Vec<String>,
194 pub unreconciled: Option<String>,
198 pub span_unvalidated: usize,
203 pub hash_from_backfill: usize,
208 pub aging: Vec<AgingAnchor>,
213}
214
215#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
217pub struct AgingAnchor {
218 pub entity: String,
219 pub artifact: String,
220 pub observed_at: String,
221 pub unobserved_for_days: u64,
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
227pub struct FacetCapability {
228 pub facet: String,
230 pub medium_type: String,
232 pub enumerable: bool,
234 pub change_signal: bool,
236 pub base_version_retrievable: bool,
238 pub anchor_namespace: String,
240 pub signal: String,
243}
244
245impl FacetCapability {
246 fn from_caps(
247 facet: String,
248 medium_type: String,
249 caps: MediumCapabilities,
250 strategy: ChangeStrategy,
251 ) -> Self {
252 FacetCapability {
253 facet,
254 medium_type,
255 enumerable: caps.enumerable,
256 change_signal: caps.change_signal,
257 base_version_retrievable: caps.base_version_retrievable
264 && strategy_retrieves_base(strategy),
265 anchor_namespace: caps.anchor_namespace.to_string(),
266 signal: signal_wire(strategy).to_string(),
267 }
268 }
269}
270
271#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
273pub struct FacetFreshness {
274 pub facet: String,
276 pub signal: String,
278 pub synced: Option<String>,
280 pub verified: Option<String>,
282 pub change_detectable: bool,
287}
288
289#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
291pub struct FidelityReport {
292 pub binding: String,
294 pub destination_mem: String,
296 pub adopt: bool,
303 pub coverage_semantics: CoverageSemantics,
307 pub coverage_semantics_declared: bool,
312 pub legacy_dialect_patterns: Vec<String>,
318 pub capabilities: Vec<FacetCapability>,
320 pub freshness: Vec<FacetFreshness>,
322 pub source_moved_past_synced: Option<bool>,
326 pub coverage: GrainCoverage,
328 pub anchors: AnchorComposition,
330 pub findings_by_class: BTreeMap<String, usize>,
332 pub backlog: usize,
334 pub superseded: Vec<String>,
337 pub disposed_excluded: usize,
340 pub disposed_excluded_rationales: Vec<(String, String)>,
346 pub degradations: Vec<String>,
348}
349
350#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
366#[serde(rename_all = "lowercase")]
367pub enum RollupVerdict {
368 Clean,
370 Drifted,
372 Inconclusive,
375}
376
377impl RollupVerdict {
378 pub fn wire(&self) -> &'static str {
380 match self {
381 RollupVerdict::Clean => "clean",
382 RollupVerdict::Drifted => "drifted",
383 RollupVerdict::Inconclusive => "inconclusive",
384 }
385 }
386}
387
388#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
391pub struct Rollup {
392 pub verdict: RollupVerdict,
394 pub findings_total: usize,
396 pub because: String,
399 pub blind_spots: Vec<String>,
404 pub actions: Vec<String>,
407}
408
409const CLASS_SEVERITY: [&str; 5] = [
414 "wrong",
415 "drifted",
416 "unresolvable-anchor",
417 "uncovered",
418 "queued-for-adjudication",
419];
420
421fn class_action(class: &str, n: usize, binding: &str) -> String {
423 match class {
424 "wrong" => format!(
425 "{n} entity/entities contradict their source — read them against the source and \
426 correct the entity (`memstead projection brief {binding}` lists them)"
427 ),
428 "drifted" => format!(
429 "{n} anchored artifact(s) moved since the entity was written — re-read the source \
430 and update the entity, then re-verify to advance the baseline"
431 ),
432 "unresolvable-anchor" => format!(
433 "{n} anchor(s) no longer resolve to anything — repoint them at the artifact's new \
434 location or unset them (`memstead_update` `anchors_unset`)"
435 ),
436 "uncovered" => format!(
437 "{n} in-scope source artifact(s) carry no anchor — cover them via \
438 `memstead projection brief {binding} --sync`, or record a disposition for the \
439 ones deliberately excluded"
440 ),
441 "queued-for-adjudication" => format!(
442 "{n} finding(s) are queued and not yet adjudicated — run \
443 `memstead projection verify {binding} --full` to work the backlog down"
444 ),
445 other => format!("{n} `{other}` finding(s) recorded"),
446 }
447}
448
449impl FidelityReport {
450 pub fn rollup(&self) -> Rollup {
457 let findings_total: usize = self.findings_by_class.values().sum();
458
459 let mut blind_spots: Vec<String> = Vec::new();
460 match &self.coverage.denominator {
461 DenominatorBasis::NonEnumerable { reason } => blind_spots.push(format!(
462 "the source scope is not enumerable ({reason}) — coverage is reported over \
463 anchors only, so an uncovered artifact cannot be detected"
464 )),
465 DenominatorBasis::Enumerated { count: 0 } => blind_spots.push(
466 "the enumerated source scope is empty (0 artifacts) — every coverage figure \
467 below is vacuous, not clean"
468 .to_string(),
469 ),
470 DenominatorBasis::Partial { count, reason } => blind_spots.push(format!(
471 "the source enumeration is INCOMPLETE ({reason}) — {count} artifact(s) \
472 survived, but their share of the population is unknown, so no coverage \
473 percentage is reported below"
474 )),
475 DenominatorBasis::Enumerated { .. } => {}
476 }
477 if !self.legacy_dialect_patterns.is_empty() {
478 blind_spots.push(format!(
479 "scope pattern(s) are still written against the workspace root rather than the \
480 source pointer and select nothing under the pointer join, so whatever they \
481 were meant to cover is absent from the denominator: {}. Rewrite them relative \
482 to the source's pointer",
483 self.legacy_dialect_patterns.join(", ")
484 ));
485 }
486 if self.anchors.observed == 0 {
487 blind_spots.push(
488 "no anchor carried a resolution state this pass — nothing was adjudicated"
489 .to_string(),
490 );
491 }
492 if self.anchors.unobserved > 0 {
504 blind_spots.push(format!(
505 "{} counted anchor(s) could not be observed at all this pass, so their state is unknown rather than clean",
506 self.anchors.unobserved
507 ));
508 }
509 if self.anchors.span_unvalidated > 0 {
510 blind_spots.push(format!(
511 "{} counted span anchor(s) were never checked against their artifact, so the span they name is unverified even where the hash resolves",
512 self.anchors.span_unvalidated
513 ));
514 }
515 if let Some(why) = &self.anchors.unreconciled {
516 blind_spots.push(format!(
517 "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"
518 ));
519 }
520 let change_blind: std::collections::BTreeSet<&str> = self
530 .freshness
531 .iter()
532 .filter(|f| !f.change_detectable)
533 .map(|f| f.facet.as_str())
534 .collect();
535 for cap in &self.capabilities {
536 if !cap.change_signal {
537 blind_spots.push(format!(
538 "facet `{}` ({}) provides no change signal — drift on it cannot be \
539 observed at all",
540 cap.facet, cap.medium_type
541 ));
542 } else if change_blind.contains(cap.facet.as_str()) {
543 blind_spots.push(format!(
544 "facet `{}` ({}) declares change-detection `{}` but this pass could \
545 not read that signal — either the binding asked for none, or the \
546 checkout cannot deliver it (a `git` source with no `.git`: an \
547 archive, a container COPY, a vendored drop). Drift on it cannot \
548 be observed",
549 cap.facet, cap.medium_type, cap.signal
550 ));
551 }
552 if !cap.enumerable {
562 blind_spots.push(format!(
563 "facet `{}` ({}) is not enumerable — an uncovered artifact under it \
564 cannot be detected, only an anchored one",
565 cap.facet, cap.medium_type
566 ));
567 }
568 }
569
570 let mut actions: Vec<String> = Vec::new();
571 for class in CLASS_SEVERITY {
572 if let Some(&n) = self.findings_by_class.get(class)
573 && n > 0
574 {
575 actions.push(class_action(class, n, &self.binding));
576 }
577 }
578 for (class, &n) in &self.findings_by_class {
581 if n > 0 && !CLASS_SEVERITY.contains(&class.as_str()) {
582 actions.push(class_action(class, n, &self.binding));
583 }
584 }
585
586 let only_uncovered = findings_total > 0
592 && self
593 .findings_by_class
594 .iter()
595 .all(|(class, &n)| n == 0 || class == "uncovered");
596
597 let (verdict, because) = if self.adopt && only_uncovered {
598 (
599 RollupVerdict::Inconclusive,
600 format!(
601 "this mem predates its binding — the {findings_total} uncovered artifact(s) \
602 are the backfill worklist, not drift"
603 ),
604 )
605 } else if findings_total > 0 {
606 let tally = self
607 .findings_by_class
608 .iter()
609 .filter(|(_, n)| **n > 0)
610 .map(|(class, n)| format!("{class}: {n}"))
611 .collect::<Vec<_>>()
612 .join(", ");
613 (
614 RollupVerdict::Drifted,
615 format!("{findings_total} finding(s) recorded over the current key ({tally})"),
616 )
617 } else if !blind_spots.is_empty() {
618 (
619 RollupVerdict::Inconclusive,
620 format!(
621 "no findings recorded, but the pass could not speak to {} axis/axes — \
622 this is not a clean bill of health",
623 blind_spots.len()
624 ),
625 )
626 } else {
627 (
628 RollupVerdict::Clean,
629 "the pass was substantive on every axis and recorded no findings".to_string(),
630 )
631 };
632
633 Rollup {
634 verdict,
635 findings_total,
636 because,
637 blind_spots,
638 actions,
639 }
640 }
641}
642
643#[derive(Debug, Clone, PartialEq, Eq)]
650pub struct RenderedFidelityReport {
651 pub markdown: String,
653 pub mode: String,
656 pub hints: Vec<(String, usize)>,
659 pub budget_used: usize,
661}
662
663fn ratio(num: usize, den: usize) -> String {
669 if den == 0 {
670 format!("{num}/{den} (n/a)")
671 } else {
672 let pct = (num as f64) * 100.0 / (den as f64);
673 format!("{num}/{den} ({pct:.1}%)")
674 }
675}
676
677fn render_hard_required(report: &FidelityReport) -> String {
681 let mut md = String::new();
682 md.push_str(&format!("# Fidelity report — `{}`\n\n", report.binding));
683
684 let rollup = report.rollup();
689 md.push_str(&format!(
690 "**Verdict: {}** — {}.\n\n",
691 rollup.verdict.wire().to_uppercase(),
692 rollup.because
693 ));
694 if !rollup.actions.is_empty() {
695 md.push_str("**Do next:**\n\n");
696 for action in &rollup.actions {
697 md.push_str(&format!("1. {action}\n"));
698 }
699 md.push('\n');
700 }
701 if !rollup.blind_spots.is_empty() {
702 md.push_str("**This pass could not see:**\n\n");
703 for spot in &rollup.blind_spots {
704 md.push_str(&format!("- {spot}\n"));
705 }
706 md.push('\n');
707 }
708
709 md.push_str(&format!(
710 "- **Destination mem:** `{}`\n- **Coverage semantics:** {}{}\n\n",
711 report.destination_mem,
712 match report.coverage_semantics {
713 CoverageSemantics::Exhaustive => "exhaustive",
714 CoverageSemantics::Curated => "curated",
715 },
716 if report.coverage_semantics_declared {
717 ""
718 } else {
719 " (resolved from the sources' media — not declared)"
720 }
721 ));
722
723 if report.adopt {
730 md.push_str("## Adopting — first verify\n\n");
731 md.push_str(
732 "This mem predates its binding: it carries no anchors and has no prior sync \
733 baseline, so **0% anchored is expected — this is onboarding, not a failure.** \
734 Do not read the coverage numbers below as drift or a red verdict; the uncovered \
735 artifacts are the backfill worklist, not defects.\n\n",
736 );
737 md.push_str(&format!(
738 "**Backfill path:** run `memstead projection brief {} --sync` to work through the in-scope \
739 source artifacts that carry no entity yet, covering the clearly-new concepts among \
740 them through the normal mutation surface. Backfilling is incremental — a partial \
741 pass is fine, and the next sync continues where you left off.\n\n",
742 report.binding
743 ));
744 }
745
746 md.push_str("## Denominator provenance\n\n");
748 match &report.coverage.denominator {
749 DenominatorBasis::Enumerated { count } => md.push_str(&format!(
750 "Coverage is reported relative to the per-medium enumeration `S(D)` = **{count}** \
751 source artifact(s) in scope (after `deny_paths`).\n\n"
752 )),
753 DenominatorBasis::NonEnumerable { reason } => md.push_str(&format!(
754 "No `S(D)` denominator: {reason}. Coverage is reported over anchors only; the \
755 per-medium enumeration is unavailable.\n\n"
756 )),
757 DenominatorBasis::Partial { count, reason } => md.push_str(&format!(
758 "`S(D)` is **partial**: {reason}. **{count}** source artifact(s) were \
759 enumerated by the patterns that did resolve, but that set is not the \
760 population, so the coverage figures below are counts and carry no \
761 percentage.\n\n"
762 )),
763 }
764
765 md.push_str("## Capability matrix\n\n");
767 if report.capabilities.is_empty() {
768 md.push_str("_(no primary sources resolved)_\n\n");
769 } else {
770 for c in &report.capabilities {
771 md.push_str(&format!("### `{}` ({})\n\n", c.facet, c.medium_type));
772 md.push_str(&format!(
773 "- enumerable: {} | change_signal: {} | base_version_retrievable: {}\n",
774 c.enumerable, c.change_signal, c.base_version_retrievable
775 ));
776 md.push_str(&format!(
777 "- anchor_namespace: `{}` | resolved signal: `{}`\n\n",
778 c.anchor_namespace, c.signal
779 ));
780 }
781 }
782
783 md.push_str("## Freshness\n\n");
785 if report.freshness.is_empty() {
786 md.push_str("_(no source facets)_\n\n");
787 } else {
788 for f in &report.freshness {
789 md.push_str(&format!("### `{}`\n\n", f.facet));
790 md.push_str(&format!("- signal: `{}`\n", f.signal));
791 if !f.change_detectable {
792 md.push_str(
796 "- **freshness unknowable** — this medium is not change-detectable \
797 (no change signal); `#synced` / `#verified` cannot be adjudicated as fresh\n",
798 );
799 } else {
800 match &f.synced {
801 Some(t) => md.push_str(&format!("- `#synced`: `{t}`\n")),
802 None => md.push_str("- `#synced`: never synced\n"),
803 }
804 match &f.verified {
805 Some(t) => md.push_str(&format!("- `#verified`: `{t}`\n")),
806 None => md.push_str("- `#verified`: never verified\n"),
807 }
808 }
809 md.push('\n');
810 }
811 match report.source_moved_past_synced {
813 Some(true) => md.push_str(
814 "**Source moved past its `#synced` baseline** — the graph is stale for the \
815 moved facet(s); a sync pass is due.\n\n",
816 ),
817 Some(false) => {
818 md.push_str("Every change-detectable source is at its `#synced` baseline.\n\n")
819 }
820 None => {}
821 }
822 }
823
824 md.push_str("## Coverage (grain-classed)\n\n");
826 let den = match &report.coverage.denominator {
830 DenominatorBasis::Enumerated { count } => *count,
831 DenominatorBasis::NonEnumerable { .. } | DenominatorBasis::Partial { .. } => 0,
832 };
833 md.push_str(&format!(
834 "- direct-covered (file / span anchors): {}\n",
835 ratio(report.coverage.direct_covered, den)
836 ));
837 let tree_files: usize = report.coverage.tree_anchors.iter().map(|t| t.fanout).sum();
840 md.push_str(&format!(
841 "- tree-anchor fan-out (separate axis): {} tree anchor(s) fanning out over {} file(s); \
842 {} file(s) covered ONLY via a tree anchor\n",
843 report.coverage.tree_anchors.len(),
844 tree_files,
845 report.coverage.tree_only_covered
846 ));
847 md.push_str(&format!(
848 "- uncovered (no anchor): {}\n\n",
849 report.coverage.uncovered.len()
850 ));
851
852 match report.coverage_semantics {
857 CoverageSemantics::Exhaustive if report.adopt => {
858 let backlog = report
859 .coverage
860 .uncovered
861 .len()
862 .saturating_sub(report.disposed_excluded);
863 md.push_str(&format!(
864 "**Exhaustive coverage (onboarding):** {backlog} in-scope artifact(s) carry no \
865 entity yet ({} disposed excluded) — the expected first-sync backfill worklist \
866 for a mem that predates its binding, not defects.\n\n",
867 report.disposed_excluded
868 ));
869 }
870 CoverageSemantics::Exhaustive => {
871 let findings = report
872 .coverage
873 .uncovered
874 .len()
875 .saturating_sub(report.disposed_excluded);
876 md.push_str(&format!(
877 "**Exhaustive coverage:** {findings} unaccounted artifact(s) — not anchored, not \
878 declared-excluded, no persisted disposition ({} disposed excluded) — are \
879 **findings**.\n\n",
880 report.disposed_excluded
881 ));
882 }
883 CoverageSemantics::Curated => {
884 md.push_str(&format!(
885 "**Curated coverage:** {} unaccounted artifact(s) are **information**, not \
886 defects — a curated binding covers a deliberate slice.\n\n",
887 report.coverage.uncovered.len()
888 ));
889 }
890 }
891
892 if !report.disposed_excluded_rationales.is_empty() {
896 md.push_str("**Excluded on purpose (persisted dispositions):**\n");
897 for (artifact, rationale) in &report.disposed_excluded_rationales {
898 if rationale.is_empty() {
899 md.push_str(&format!("- `{artifact}`\n"));
900 } else {
901 md.push_str(&format!("- `{artifact}` — {rationale}\n"));
902 }
903 }
904 md.push('\n');
905 }
906
907 md.push_str("## Anchors\n\n");
909 md.push_str(&format!(
910 "- by class: {}\n",
911 render_counts(&report.anchors.by_class)
912 ));
913 md.push_str(&format!(
914 "- by grain: {}\n",
915 render_counts(&report.anchors.by_grain)
916 ));
917 md.push_str(&format!(
918 "- `authored` bucket (excluded from coverage/accuracy denominators): {}\n",
919 report.anchors.authored
920 ));
921 md.push_str(&format!(
928 "- resolution (non-`authored`, observed): resolves {}, drifted {}, recheck {}, \
929 orphaned {}; **anchor-resolution %:** {} over {} counted row(s) on {} distinct \
930 artifact(s), with {} unobserved this pass (state unavailable, never scored as \
931 resolved)\n",
932 report.anchors.resolves,
933 report.anchors.drifted,
934 report.anchors.recheck,
935 report.anchors.orphaned,
936 ratio(report.anchors.resolves, report.anchors.observed),
937 report.anchors.counted_rows,
938 report.anchors.distinct_artifacts,
939 report.anchors.unobserved
940 ));
941 md.push_str(&format!(
947 "- the figures above count anchor ROWS: {} row(s) over {} distinct artifact(s)\n",
948 report.anchors.counted_rows, report.anchors.distinct_artifacts
949 ));
950 if !report.anchors.aging.is_empty() {
954 md.push_str(&format!(
955 "- {} counted row(s) rest on a recorded observation rather than a live one \
956 (url anchors; the engine never fetches):\n",
957 report.anchors.aging.len()
958 ));
959 const AGING_CAP: usize = 10;
960 for a in report.anchors.aging.iter().take(AGING_CAP) {
961 md.push_str(&format!(
962 " - `{}` → `{}`: unobserved for {} days (observed {})\n",
963 a.entity, a.artifact, a.unobserved_for_days, a.observed_at
964 ));
965 }
966 if report.anchors.aging.len() > AGING_CAP {
967 md.push_str(&format!(
968 " - …and {} more\n",
969 report.anchors.aging.len() - AGING_CAP
970 ));
971 }
972 }
973 if report.anchors.excluded_other_binding > 0 || report.anchors.excluded_out_of_scope > 0 {
976 md.push_str(&format!(
977 "- excluded from this binding's population: {} written by another binding, \
978 {} outside this binding's declared scope (legal, reported here, never deleted)\n",
979 report.anchors.excluded_other_binding, report.anchors.excluded_out_of_scope
980 ));
981 const NAMED_CAP: usize = 10;
988 for a in report.anchors.excluded_artifacts.iter().take(NAMED_CAP) {
989 md.push_str(&format!(" - {a}\n"));
990 }
991 if report.anchors.excluded_artifacts.len() > NAMED_CAP {
992 md.push_str(&format!(
993 " - …and {} more (counts above are complete)\n",
994 report.anchors.excluded_artifacts.len() - NAMED_CAP
995 ));
996 }
997 }
998 match (&report.anchors.unreconciled, report.anchors.dangling) {
1003 (Some(why), _) => md.push_str(&format!(
1004 "- the entity end of these anchors was NOT reconciled this pass ({why}), so \
1005 dangling sidecar rows would not have been detected\n"
1006 )),
1007 (None, 0) => {}
1008 (None, n) => {
1009 md.push_str(&format!(
1010 "- {n} sidecar row(s) name an entity this mem no longer holds. Excluded from \
1011 every figure above, reported rather than repaired: the row is the trace of a \
1012 writer that went around the engine\n"
1013 ));
1014 const NAMED_CAP: usize = 10;
1015 for r in report.anchors.dangling_rows.iter().take(NAMED_CAP) {
1016 md.push_str(&format!(" - {r}\n"));
1017 }
1018 if report.anchors.dangling_rows.len() > NAMED_CAP {
1019 md.push_str(&format!(
1020 " - …and {} more (the count above is complete)\n",
1021 report.anchors.dangling_rows.len() - NAMED_CAP
1022 ));
1023 }
1024 }
1025 }
1026 if report.anchors.span_unvalidated > 0 {
1032 md.push_str(&format!(
1033 "- {} counted span row(s) were never checked against their artifact, so their \
1034 span is unverified even where the hash resolves\n",
1035 report.anchors.span_unvalidated
1036 ));
1037 }
1038 if report.anchors.hash_from_backfill > 0 {
1039 md.push_str(&format!(
1040 "- {} counted row(s) carry a baseline the engine inferred by backfill rather than \
1041 one an author pinned\n",
1042 report.anchors.hash_from_backfill
1043 ));
1044 }
1045 if report.anchors.counted_without_provenance > 0 {
1046 md.push_str(&format!(
1047 "- {} counted anchor(s) record no producing binding and are included by the \
1048 pre-provenance fallback, so this population rests partly on that fallback \
1049 rather than wholly on provenance\n",
1050 report.anchors.counted_without_provenance
1051 ));
1052 }
1053 md.push('\n');
1054
1055 md.push_str("## Findings\n\n");
1057 md.push_str(&format!(
1058 "- by class: {}\n",
1059 render_counts(&report.findings_by_class)
1060 ));
1061 md.push_str(&format!(
1062 "- **tier-3 adjudication backlog:** {}\n",
1063 report.backlog
1064 ));
1065 md.push_str(&format!(
1066 "- superseded (prior `hash(D)`, segregated): {}\n\n",
1067 report.superseded.len()
1068 ));
1069
1070 md.push_str("## Degradations\n\n");
1072 if report.degradations.is_empty() {
1073 md.push_str("_(none)_\n\n");
1074 } else {
1075 for d in &report.degradations {
1076 md.push_str(&format!("- {d}\n"));
1077 }
1078 md.push('\n');
1079 }
1080
1081 md
1082}
1083
1084fn render_counts(counts: &BTreeMap<String, usize>) -> String {
1086 if counts.is_empty() {
1087 return "(none)".to_string();
1088 }
1089 counts
1090 .iter()
1091 .map(|(k, v)| format!("{k}={v}"))
1092 .collect::<Vec<_>>()
1093 .join(", ")
1094}
1095
1096fn heavy_sections(report: &FidelityReport) -> Vec<(&'static str, String)> {
1100 let mut out: Vec<(&'static str, String)> = Vec::new();
1101
1102 let mut s = String::new();
1104 if !report.coverage.uncovered.is_empty() {
1105 s.push_str("## Uncovered artifacts\n\n");
1106 for a in &report.coverage.uncovered {
1107 s.push_str(&format!("- `{a}`\n"));
1108 }
1109 s.push('\n');
1110 }
1111 out.push(("uncovered_artifacts", s));
1112
1113 let mut s = String::new();
1115 if !report.coverage.tree_anchors.is_empty() {
1116 s.push_str("## Tree-anchor fan-out (detail)\n\n");
1117 for t in &report.coverage.tree_anchors {
1118 s.push_str(&format!(
1119 "- `{}` → `{}` fans out over {} file(s)\n",
1120 t.entity, t.artifact, t.fanout
1121 ));
1122 }
1123 s.push('\n');
1124 }
1125 out.push(("tree_fanout", s));
1126
1127 let mut s = String::new();
1129 if !report.superseded.is_empty() {
1130 s.push_str("## Superseded findings (detail)\n\n");
1131 for f in &report.superseded {
1132 s.push_str(&format!("- {f}\n"));
1133 }
1134 s.push('\n');
1135 }
1136 out.push(("superseded_findings", s));
1137
1138 out
1139}
1140
1141pub fn render_fidelity_report(
1152 report: &FidelityReport,
1153 budget: usize,
1154 include: &[String],
1155) -> RenderedFidelityReport {
1156 let hard = render_hard_required(report);
1157 let hard_cost = estimate_tokens(&hard);
1158 let overbudget = hard_cost > budget;
1159
1160 let include_set: std::collections::BTreeSet<&str> = include
1161 .iter()
1162 .map(String::as_str)
1163 .filter(|k| ALLOWED_REPORT_INCLUDE_KEYS.contains(k))
1164 .collect();
1165 let unknown_includes: Vec<&String> = include
1166 .iter()
1167 .filter(|k| !ALLOWED_REPORT_INCLUDE_KEYS.contains(&k.as_str()))
1168 .collect();
1169
1170 let sections = heavy_sections(report);
1171 let mut emitted: Vec<String> = Vec::new();
1172 let mut hints: Vec<(String, usize)> = Vec::new();
1173 let mut used = hard_cost;
1174 let mut remaining = budget.saturating_sub(hard_cost);
1175
1176 for (key, section_md) in §ions {
1177 if section_md.is_empty() {
1178 continue; }
1180 let cost = estimate_tokens(section_md);
1181 let forced = include_set.contains(key);
1182 if forced {
1183 emitted.push(section_md.clone());
1184 used += cost;
1185 remaining = remaining.saturating_sub(cost);
1186 } else if !overbudget && remaining >= cost {
1187 emitted.push(section_md.clone());
1188 used += cost;
1189 remaining -= cost;
1190 } else {
1191 hints.push(((*key).to_string(), cost));
1192 }
1193 }
1194
1195 let mode = if overbudget {
1196 "overbudget"
1197 } else if hints.is_empty() {
1198 "complete"
1199 } else {
1200 "reduced"
1201 };
1202
1203 let mut md = String::new();
1204 md.push_str("---\n");
1205 md.push_str(&format!("_report_mode: {mode}\n"));
1206 md.push_str(&format!("_budget_requested: {budget}\n"));
1207 md.push_str(&format!("_budget_used: {used}\n"));
1208 md.push_str("---\n\n");
1209 md.push_str(&hard);
1210 for section in &emitted {
1211 md.push_str(section);
1212 }
1213
1214 if !hints.is_empty() {
1215 md.push_str("## Hints\n\n");
1216 md.push_str(
1217 "_(heavy sections omitted under the token budget — re-query with the key)_\n\n",
1218 );
1219 for (key, tokens) in &hints {
1220 md.push_str(&format!("- `{key}` — estimated_tokens: {tokens}\n"));
1221 }
1222 md.push('\n');
1223 }
1224
1225 if !unknown_includes.is_empty() {
1226 md.push_str("## Warnings\n\n");
1227 for k in &unknown_includes {
1228 md.push_str(&format!(
1229 "- unknown include key `{k}` — allowed: {}\n",
1230 ALLOWED_REPORT_INCLUDE_KEYS.join(", ")
1231 ));
1232 }
1233 md.push('\n');
1234 }
1235
1236 RenderedFidelityReport {
1237 markdown: md,
1238 mode: mode.to_string(),
1239 hints,
1240 budget_used: used,
1241 }
1242}
1243
1244pub fn compute_fidelity_report(
1259 engine: &Engine,
1260 workspace_root: &Path,
1261 binding: &Binding,
1262 resolved: &ResolvedIngest,
1263 key: &FindingKey,
1264) -> FidelityReport {
1265 let binding_id = resolved.name.clone();
1266 let dest = resolved.destination_mem.clone();
1267
1268 let sync_state = engine
1270 .mem_config_for(&dest)
1271 .map(|c| c.sync_state.clone())
1272 .unwrap_or_default();
1273 let mut capabilities: Vec<FacetCapability> = Vec::new();
1274 let mut freshness: Vec<FacetFreshness> = Vec::new();
1275 let mut any_change_detectable = false;
1276 for source in &resolved.sources {
1277 let ResolvedSource::Primary(p) = source else {
1278 continue;
1279 };
1280 let caps = medium_capabilities(p.medium_type);
1281 let medium_type = serde_json::to_value(p.medium_type)
1282 .ok()
1283 .and_then(|v| v.as_str().map(str::to_string))
1284 .unwrap_or_default();
1285 let strategy = resolve_change_strategy(p, workspace_root);
1286 let signal = signal_wire(strategy).to_string();
1287 let signal_readable = match strategy {
1297 ChangeStrategy::Git => {
1298 super::resolve::find_git_root(&super::resolve::source_base_path(p, workspace_root))
1299 .is_some()
1300 }
1301 _ => true,
1302 };
1303 let change_detectable =
1304 caps.change_signal && strategy != ChangeStrategy::None && signal_readable;
1305 any_change_detectable |= change_detectable;
1306
1307 capabilities.push(FacetCapability::from_caps(
1308 p.name.clone(),
1309 medium_type,
1310 caps,
1311 strategy,
1312 ));
1313
1314 let synced = sync_state
1315 .get(&format!("{binding_id}/{}#synced", p.name))
1316 .cloned();
1317 let verified = sync_state
1318 .get(&format!("{binding_id}/{}#verified", p.name))
1319 .cloned();
1320 freshness.push(FacetFreshness {
1321 facet: p.name.clone(),
1322 signal,
1323 synced,
1324 verified,
1325 change_detectable,
1326 });
1327 }
1328
1329 let source_moved_past_synced = if any_change_detectable {
1330 Some(source_moved(engine, resolved, workspace_root))
1331 } else {
1332 None
1333 };
1334
1335 let mut s_d: Vec<String> = Vec::new();
1337 let mut enumerable_facets = 0usize;
1338 let mut empty_enumerable_facets: BTreeSet<String> = BTreeSet::new();
1345 let mut malformed_patterns: Vec<String> = Vec::new();
1350 let mut legacy_patterns: Vec<String> = Vec::new();
1351 let mut partiality_reasons: Vec<String> = Vec::new();
1357 for source in &resolved.sources {
1358 if let ResolvedSource::Primary(p) = source {
1359 let caps = medium_capabilities(p.medium_type);
1360 if caps.enumerable {
1361 enumerable_facets += 1;
1362 }
1363 let walked = enumerate_source_artifacts_reported(
1364 engine,
1365 p,
1366 &resolved.deny_paths,
1367 workspace_root,
1368 );
1369 if caps.enumerable && walked.files.is_empty() {
1370 empty_enumerable_facets.insert(p.name.clone());
1371 }
1372 for m in &walked.malformed {
1373 malformed_patterns.push(format!("`{}` in facet `{}`", m, p.name));
1374 }
1375 for note in &walked.legacy_dialect {
1376 legacy_patterns.push(format!("`{}` in facet `{}`", note.pattern, p.name));
1377 }
1378 if let Some(reason) = walked.partiality_reason() {
1379 partiality_reasons.push(format!("facet `{}`: {reason}", p.name));
1380 }
1381 s_d.extend(walked.files);
1382 }
1383 }
1384 s_d.sort();
1385 s_d.dedup();
1386
1387 let denominator = if !partiality_reasons.is_empty() {
1388 DenominatorBasis::Partial {
1391 count: s_d.len(),
1392 reason: partiality_reasons.join("; "),
1393 }
1394 } else if !s_d.is_empty() {
1395 DenominatorBasis::Enumerated { count: s_d.len() }
1396 } else if enumerable_facets == 0 {
1397 DenominatorBasis::NonEnumerable {
1398 reason: "the medium type(s) are not enumerable this cycle".to_string(),
1399 }
1400 } else if !legacy_patterns.is_empty() {
1401 DenominatorBasis::NonEnumerable {
1406 reason: format!(
1407 "scope pattern(s) still written against the workspace root rather than the \
1408 source pointer, so they select nothing under the pointer join: {}. Rewrite \
1409 them relative to the source's pointer",
1410 legacy_patterns.join(", ")
1411 ),
1412 }
1413 } else {
1414 DenominatorBasis::NonEnumerable {
1418 reason: "no source artifacts enumerated in scope".to_string(),
1419 }
1420 };
1421
1422 let mut direct_covered = 0usize;
1423 let mut tree_only_covered = 0usize;
1424 let mut uncovered: Vec<String> = Vec::new();
1425 let mut tree_fanout: BTreeMap<(String, String), usize> = BTreeMap::new();
1426 let entity_end_reconciled = engine.entity_set_is_reconcilable(dest.as_str()).is_ok();
1427 for file in &s_d {
1428 let refs = engine.anchors_referencing_artifact(file);
1442 let mine: Vec<&(crate::EntityId, crate::anchor::Anchor)> = refs
1443 .iter()
1444 .filter(|(eid, a)| {
1445 eid.mem() == dest.as_str()
1446 && a.binding
1447 .as_deref()
1448 .map(|b| b == key.binding_hash.as_str())
1449 .unwrap_or(true)
1450 && (!entity_end_reconciled || !engine.entity_is_absent(eid))
1451 })
1452 .collect();
1453 if mine.is_empty() {
1454 uncovered.push(file.clone());
1455 continue;
1456 }
1457 let has_non_tree = mine.iter().any(|(_, a)| a.grain != AnchorGrain::Tree);
1458 if has_non_tree {
1459 direct_covered += 1;
1460 } else {
1461 tree_only_covered += 1;
1462 }
1463 for (eid, a) in &mine {
1465 if a.grain == AnchorGrain::Tree {
1466 *tree_fanout
1467 .entry((eid.as_ref().to_string(), a.artifact.clone()))
1468 .or_insert(0) += 1;
1469 }
1470 }
1471 }
1472 let tree_anchors: Vec<TreeFanout> = tree_fanout
1473 .into_iter()
1474 .map(|((entity, artifact), fanout)| TreeFanout {
1475 entity,
1476 artifact,
1477 fanout,
1478 })
1479 .collect();
1480
1481 let coverage = GrainCoverage {
1482 denominator,
1483 direct_covered,
1484 tree_only_covered,
1485 uncovered: uncovered.clone(),
1486 tree_anchors,
1487 };
1488
1489 let population = crate::ingest::anchor_population::population_for(
1493 engine,
1494 resolved,
1495 Some(key.binding_hash.as_str()),
1496 );
1497 let mut anchors = AnchorComposition {
1498 counted_rows: population.included.len(),
1499 distinct_artifacts: population.distinct_artifacts(),
1500 excluded_other_binding: population
1501 .excluded_count(crate::ingest::anchor_population::ExclusionReason::OtherBinding),
1502 excluded_out_of_scope: population
1503 .excluded_count(crate::ingest::anchor_population::ExclusionReason::OutOfScope),
1504 excluded_artifacts: population
1505 .excluded
1506 .iter()
1507 .map(|e| format!("{} ({})", e.artifact, e.reason.as_wire()))
1508 .collect(),
1509 counted_without_provenance: population.without_provenance,
1510 dangling: population.dangling.len(),
1511 dangling_rows: population
1512 .dangling
1513 .iter()
1514 .map(|d| format!("{} → {}", d.entity, d.artifact))
1515 .collect(),
1516 unreconciled: population.unreconciled.map(str::to_string),
1517 span_unvalidated: population
1518 .included
1519 .iter()
1520 .filter(|(_, r)| r.anchor.span_unvalidated)
1521 .count(),
1522 hash_from_backfill: population
1523 .included
1524 .iter()
1525 .filter(|(_, r)| {
1526 r.anchor.hash_source == Some(crate::anchor::AnchorHashSource::Backfill)
1527 })
1528 .count(),
1529 aging: {
1530 let today = crate::engine::mutation::iso_now();
1531 let mut rows: Vec<AgingAnchor> = population
1532 .included
1533 .iter()
1534 .filter_map(|(eid, r)| {
1535 let at = r.observed_at.as_deref()?;
1536 Some(AgingAnchor {
1537 entity: eid.as_ref().to_string(),
1538 artifact: r.anchor.artifact.clone(),
1539 observed_at: at.to_string(),
1540 unobserved_for_days: crate::anchor::days_between(at, &today).unwrap_or(0),
1541 })
1542 })
1543 .collect();
1544 rows.sort_by_key(|a| std::cmp::Reverse(a.unobserved_for_days));
1545 rows
1546 },
1547 ..Default::default()
1548 };
1549 for (_eid, resolved_anchor) in population.included {
1550 let a = &resolved_anchor.anchor;
1551 *anchors
1552 .by_class
1553 .entry(a.class.as_wire().to_string())
1554 .or_insert(0) += 1;
1555 *anchors
1556 .by_grain
1557 .entry(a.grain.as_wire().to_string())
1558 .or_insert(0) += 1;
1559 if a.class == AnchorProvenanceClass::Authored {
1560 anchors.authored += 1;
1561 continue; }
1563 match resolved_anchor.state {
1564 Some(AnchorState::Resolves) => {
1565 anchors.resolves += 1;
1566 anchors.observed += 1;
1567 }
1568 Some(AnchorState::Drifted) => {
1569 anchors.drifted += 1;
1570 anchors.observed += 1;
1571 }
1572 Some(AnchorState::Recheck) => {
1573 anchors.recheck += 1;
1574 anchors.observed += 1;
1575 }
1576 Some(AnchorState::Orphaned) => {
1577 anchors.orphaned += 1;
1578 anchors.observed += 1;
1579 }
1580 None => anchors.unobserved += 1,
1581 }
1582 }
1583
1584 let mut findings_by_class: BTreeMap<String, usize> = BTreeMap::new();
1586 let mut backlog = 0usize;
1587 let mut superseded: Vec<String> = Vec::new();
1588 if let Some((mem, name)) = binding_id.split_once('/')
1589 && let Ok(Some(store)) = read_findings_store(workspace_root, mem, name)
1590 {
1591 for f in store.current(key) {
1592 *findings_by_class
1593 .entry(f.class.as_wire().to_string())
1594 .or_insert(0) += 1;
1595 if f.class == FindingClass::QueuedForAdjudication {
1596 backlog += 1;
1597 }
1598 }
1599 for f in store.superseded(key) {
1600 superseded.push(format!(
1601 "[{}] {} ({})",
1602 f.class.as_wire(),
1603 finding_target_label(&f.target),
1604 f.facet
1605 ));
1606 }
1607 }
1608
1609 let mut disposed_excluded_rationales: Vec<(String, String)> = Vec::new();
1615 if let Some((mem, name)) = binding_id.split_once('/')
1616 && let Ok(Some(state)) = read_advance_store(workspace_root, mem, name)
1617 {
1618 let uncovered_set: std::collections::BTreeSet<&str> =
1619 uncovered.iter().map(String::as_str).collect();
1620 for (artifact, rationale) in &state.exclusions {
1621 if uncovered_set.contains(artifact.as_str()) {
1622 disposed_excluded_rationales.push((artifact.clone(), rationale.clone()));
1623 }
1624 }
1625 }
1626 let disposed_excluded = disposed_excluded_rationales.len();
1627
1628 let mut degradations: Vec<String> = Vec::new();
1630 for c in &capabilities {
1631 if !c.change_signal || c.signal == "none" {
1632 degradations.push(format!(
1633 "change-signal-none:`{}` — freshness is unknowable for this facet",
1634 c.facet
1635 ));
1636 }
1637 if !c.enumerable {
1638 degradations.push(format!(
1639 "enumeration-unavailable:`{}` — `S(D)` coverage denominator not computable",
1640 c.facet
1641 ));
1642 } else if empty_enumerable_facets.contains(&c.facet) {
1643 degradations.push(format!(
1651 "enumeration-empty:`{}` — the medium claims enumerability but the walk yielded \
1652 no artifacts; coverage is reported over anchors only",
1653 c.facet
1654 ));
1655 }
1656 if !c.base_version_retrievable {
1657 degradations.push(format!(
1658 "base-version-unretrievable:`{}` — prune degrades to conflict-flagging",
1659 c.facet
1660 ));
1661 }
1662 }
1663 if anchors.recheck > 0 {
1664 degradations.push(format!(
1665 "hash-adjudication-deferred — {} anchor(s) recheck (unstable medium / hash \
1666 unavailable), not asserted drift",
1667 anchors.recheck
1668 ));
1669 }
1670 if anchors.unobserved > 0 {
1671 degradations.push(format!(
1672 "anchors-unobserved — {} anchor(s) could not be observed this pass",
1673 anchors.unobserved
1674 ));
1675 }
1676
1677 let adopt = super::render::mem_predates_binding(engine, resolved);
1681 let effective_coverage = crate::binding::effective_coverage_semantics(binding);
1682
1683 FidelityReport {
1684 legacy_dialect_patterns: legacy_patterns,
1685 binding: binding_id,
1686 destination_mem: dest,
1687 adopt,
1688 coverage_semantics: effective_coverage.value,
1689 coverage_semantics_declared: effective_coverage.declared,
1690 capabilities,
1691 freshness,
1692 source_moved_past_synced,
1693 coverage,
1694 anchors,
1695 findings_by_class,
1696 backlog,
1697 superseded,
1698 disposed_excluded,
1699 disposed_excluded_rationales,
1700 degradations,
1701 }
1702}
1703
1704fn strategy_retrieves_base(strategy: ChangeStrategy) -> bool {
1713 matches!(strategy, ChangeStrategy::Git | ChangeStrategy::Graph)
1714}
1715
1716fn signal_wire(strategy: ChangeStrategy) -> &'static str {
1719 match strategy {
1720 ChangeStrategy::None => "none",
1721 ChangeStrategy::Git => "git",
1722 ChangeStrategy::Mtime => "mtime",
1723 ChangeStrategy::Graph => "graph",
1724 }
1725}
1726
1727fn finding_target_label(target: &super::findings::FindingTarget) -> String {
1729 match target {
1730 super::findings::FindingTarget::Anchor { entity, artifact } => {
1731 format!("{entity} → {artifact}")
1732 }
1733 super::findings::FindingTarget::Artifact { artifact } => artifact.clone(),
1734 }
1735}
1736
1737#[cfg(test)]
1738mod tests {
1739 use super::*;
1740
1741 fn base_report() -> FidelityReport {
1744 FidelityReport {
1745 legacy_dialect_patterns: Vec::new(),
1746 binding: "engine/graph".to_string(),
1747 destination_mem: "engine".to_string(),
1748 adopt: false,
1749 coverage_semantics: CoverageSemantics::Exhaustive,
1750 coverage_semantics_declared: true,
1751 capabilities: vec![FacetCapability {
1752 facet: "src".to_string(),
1753 medium_type: "codebase".to_string(),
1754 enumerable: true,
1755 change_signal: true,
1756 base_version_retrievable: true,
1757 anchor_namespace: "path".to_string(),
1758 signal: "git".to_string(),
1759 }],
1760 freshness: vec![FacetFreshness {
1761 facet: "src".to_string(),
1762 signal: "git".to_string(),
1763 synced: Some("deadbeef".to_string()),
1764 verified: None,
1765 change_detectable: true,
1766 }],
1767 source_moved_past_synced: Some(false),
1768 coverage: GrainCoverage {
1769 denominator: DenominatorBasis::Enumerated { count: 10 },
1770 direct_covered: 6,
1771 tree_only_covered: 3,
1772 uncovered: vec!["src/a.rs".to_string()],
1773 tree_anchors: vec![TreeFanout {
1774 entity: "engine--big".to_string(),
1775 artifact: "src/".to_string(),
1776 fanout: 3,
1777 }],
1778 },
1779 anchors: AnchorComposition {
1780 by_class: BTreeMap::from([
1781 ("anchored".to_string(), 5),
1782 ("authored".to_string(), 2),
1783 ]),
1784 by_grain: BTreeMap::from([("file".to_string(), 4), ("tree".to_string(), 1)]),
1785 authored: 2,
1786 observed: 5,
1787 resolves: 4,
1788 drifted: 0,
1789 recheck: 1,
1790 orphaned: 0,
1791 unobserved: 0,
1792 ..Default::default()
1793 },
1794 findings_by_class: BTreeMap::from([
1795 ("uncovered".to_string(), 1),
1796 ("queued-for-adjudication".to_string(), 1),
1797 ]),
1798 backlog: 1,
1799 superseded: Vec::new(),
1800 disposed_excluded: 0,
1801 disposed_excluded_rationales: Vec::new(),
1802 degradations: vec!["hash-adjudication-deferred — 1 anchor(s) recheck".to_string()],
1803 }
1804 }
1805
1806 #[test]
1809 fn aging_rows_render_with_their_age() {
1810 let mut r = base_report();
1811 r.anchors.aging = vec![AgingAnchor {
1812 entity: "engine--cites".to_string(),
1813 artifact: "https://w.test/living".to_string(),
1814 observed_at: "2026-08-03T09:00:00Z".to_string(),
1815 unobserved_for_days: 30,
1816 }];
1817 let md = render_fidelity_report(&r, 8_000, &[]).markdown;
1818 assert!(
1819 md.contains("1 counted row(s) rest on a recorded observation"),
1820 "{md}"
1821 );
1822 assert!(
1823 md.contains(
1824 "`engine--cites` → `https://w.test/living`: unobserved for 30 days (observed 2026-08-03T09:00:00Z)"
1825 ),
1826 "{md}"
1827 );
1828 let plain = render_fidelity_report(&base_report(), 8_000, &[]).markdown;
1829 assert!(!plain.contains("recorded observation"), "{plain}");
1830 }
1831
1832 #[test]
1837 fn b1_renders_all_elements_deterministically() {
1838 let r = base_report();
1839 let a = render_fidelity_report(&r, 8_000, &[]);
1840 let b = render_fidelity_report(&r, 8_000, &[]);
1841 assert_eq!(a.markdown, b.markdown, "deterministic — identical bytes");
1842
1843 let md = &a.markdown;
1844 assert!(md.contains("direct-covered (file / span anchors): 6/10"));
1846 assert!(md.contains(
1847 "tree-anchor fan-out (separate axis): 1 tree anchor(s) fanning out over 3 file(s)"
1848 ));
1849 assert!(
1851 !md.contains("9/10"),
1852 "tree fan-out must not blend into direct coverage"
1853 );
1854 assert!(md.contains("anchor-resolution %:** 4/5"));
1856 assert!(md.contains("`authored` bucket (excluded from coverage/accuracy denominators): 2"));
1858 assert!(md.contains("tier-3 adjudication backlog:** 1"));
1860 assert!(md.contains("## Capability matrix"));
1862 assert!(md.contains("## Degradations"));
1863 assert!(md.contains("hash-adjudication-deferred"));
1864 assert!(md.contains("per-medium enumeration `S(D)` = **10**"));
1866 }
1867
1868 #[test]
1871 fn b2_detectionless_medium_freshness_unknowable_never_green() {
1872 let mut r = base_report();
1873 r.capabilities = vec![FacetCapability {
1874 facet: "manual".to_string(),
1875 medium_type: "web".to_string(),
1876 enumerable: false,
1877 change_signal: false,
1878 base_version_retrievable: false,
1879 anchor_namespace: "url".to_string(),
1880 signal: "none".to_string(),
1881 }];
1882 r.freshness = vec![FacetFreshness {
1883 facet: "manual".to_string(),
1884 signal: "none".to_string(),
1885 synced: Some("should-never-render-green".to_string()),
1888 verified: Some("nor-this".to_string()),
1889 change_detectable: false,
1890 }];
1891 r.source_moved_past_synced = None;
1892 let out = render_fidelity_report(&r, 8_000, &[]);
1893 let md = &out.markdown;
1894 assert!(md.contains("signal: `none`"));
1895 assert!(md.contains("freshness unknowable"));
1896 assert!(!md.contains("should-never-render-green"));
1899 assert!(
1900 !md.contains("`#synced`: `"),
1901 "no synced token rendered for a non-detectable medium"
1902 );
1903 assert!(
1904 !md.contains("at its `#synced` baseline"),
1905 "no green 'at baseline' verdict"
1906 );
1907 }
1908
1909 #[test]
1917 fn b1_base_retrievability_follows_resolved_strategy_not_medium_ceiling() {
1918 use crate::pipeline::MediumType;
1919
1920 assert!(medium_capabilities(MediumType::Filesystem).base_version_retrievable);
1922
1923 let fs_mtime = FacetCapability::from_caps(
1925 "prose".to_string(),
1926 "filesystem".to_string(),
1927 medium_capabilities(MediumType::Filesystem),
1928 ChangeStrategy::Mtime,
1929 );
1930 assert!(
1931 !fs_mtime.base_version_retrievable,
1932 "filesystem+mtime has no retrievable base leg — degrades to conflict-flag"
1933 );
1934 assert_eq!(fs_mtime.signal, "mtime");
1935
1936 let fs_git = FacetCapability::from_caps(
1937 "prose".to_string(),
1938 "filesystem".to_string(),
1939 medium_capabilities(MediumType::Filesystem),
1940 ChangeStrategy::Git,
1941 );
1942 assert!(
1943 fs_git.base_version_retrievable,
1944 "filesystem backed by git keeps the never-clobber base leg"
1945 );
1946
1947 assert!(!strategy_retrieves_base(ChangeStrategy::None));
1949 assert!(!strategy_retrieves_base(ChangeStrategy::Mtime));
1950 assert!(strategy_retrieves_base(ChangeStrategy::Git));
1951 assert!(strategy_retrieves_base(ChangeStrategy::Graph));
1952
1953 let mut r = base_report();
1957 r.capabilities = vec![fs_mtime.clone()];
1958 r.degradations = if !fs_mtime.base_version_retrievable {
1959 vec![format!(
1960 "base-version-unretrievable:`{}` — prune degrades to conflict-flagging",
1961 fs_mtime.facet
1962 )]
1963 } else {
1964 Vec::new()
1965 };
1966 let md = render_fidelity_report(&r, 8_000, &[]).markdown;
1967 assert!(
1968 md.contains("base-version-unretrievable:`prose` — prune degrades to conflict-flagging"),
1969 "filesystem+mtime surfaces the conflict-flag degradation in the report"
1970 );
1971 }
1972
1973 #[test]
1976 fn b3_aggregates_always_ship_at_zero_budget() {
1977 let r = base_report();
1978 let out = render_fidelity_report(&r, 0, &[]);
1979 assert_eq!(out.mode, "overbudget");
1980 let md = &out.markdown;
1981 assert!(md.contains("direct-covered (file / span anchors): 6/10"));
1983 assert!(md.contains("tier-3 adjudication backlog:** 1"));
1984 assert!(md.contains("## Capability matrix"));
1985 assert!(!md.contains("## Uncovered artifacts"));
1987 assert!(md.contains("## Hints"));
1988 assert!(out.hints.iter().any(|(k, _)| k == "uncovered_artifacts"));
1989 }
1990
1991 #[test]
1995 fn b3_large_facet_list_truncates_then_include_forces() {
1996 let mut r = base_report();
1997 r.coverage.uncovered = (0..500).map(|i| format!("src/file_{i}.rs")).collect();
1999 let hard_cost = estimate_tokens(&render_hard_required(&r));
2001 let out = render_fidelity_report(&r, hard_cost + 5, &[]);
2002 assert_eq!(out.mode, "reduced");
2003 assert!(
2004 !out.markdown.contains("src/file_499.rs"),
2005 "big list not rendered unbounded"
2006 );
2007 assert!(out.markdown.contains("## Hints"));
2008 let (_, est) = out
2009 .hints
2010 .iter()
2011 .find(|(k, _)| k == "uncovered_artifacts")
2012 .expect("uncovered list hinted");
2013 assert!(*est > 5, "the hint carries a real estimated_tokens figure");
2014
2015 let forced =
2017 render_fidelity_report(&r, hard_cost + 5, &["uncovered_artifacts".to_string()]);
2018 assert!(
2019 forced.markdown.contains("src/file_499.rs"),
2020 "include forces the full list"
2021 );
2022 }
2023
2024 #[test]
2027 fn b4_curated_vs_exhaustive_framing() {
2028 let mut exhaustive = base_report();
2029 exhaustive.coverage_semantics = CoverageSemantics::Exhaustive;
2030 let ex_md = render_fidelity_report(&exhaustive, 8_000, &[]).markdown;
2031 assert!(ex_md.contains("Exhaustive coverage:"));
2032 assert!(ex_md.contains("are **findings**"));
2033
2034 let mut curated = base_report();
2035 curated.coverage_semantics = CoverageSemantics::Curated;
2036 let cur_md = render_fidelity_report(&curated, 8_000, &[]).markdown;
2037 assert!(cur_md.contains("Curated coverage:"));
2038 assert!(cur_md.contains("**information**"));
2039 assert!(
2040 !cur_md.contains("are **findings**"),
2041 "curated never frames unaccounted as findings"
2042 );
2043 }
2044
2045 #[test]
2048 fn b4_disposition_excludes_from_exhaustive_findings() {
2049 let mut r = base_report();
2050 r.coverage_semantics = CoverageSemantics::Exhaustive;
2051 r.coverage.uncovered = vec!["src/a.rs".to_string(), "src/b.rs".to_string()];
2052 r.disposed_excluded = 1;
2053 let md = render_fidelity_report(&r, 8_000, &[]).markdown;
2054 assert!(md.contains("1 unaccounted artifact(s)"));
2056 assert!(md.contains("(1 disposed excluded)"));
2057 }
2058
2059 #[test]
2062 fn b4_authored_exclusion_rationale_is_rendered() {
2063 let mut r = base_report();
2064 r.coverage_semantics = CoverageSemantics::Exhaustive;
2065 r.coverage.uncovered = vec!["src/gen.rs".to_string()];
2066 r.disposed_excluded = 1;
2067 r.disposed_excluded_rationales =
2068 vec![("src/gen.rs".to_string(), "generated; no entity".to_string())];
2069 let md = render_fidelity_report(&r, 8_000, &[]).markdown;
2070 assert!(md.contains("Excluded on purpose (persisted dispositions):"));
2071 assert!(md.contains("`src/gen.rs` — generated; no entity"));
2072 }
2073
2074 #[test]
2077 fn b5_denominator_provenance_stated() {
2078 let r = base_report();
2079 let md = render_fidelity_report(&r, 8_000, &[]).markdown;
2080 assert!(md.contains("## Denominator provenance"));
2081 assert!(md.contains("per-medium enumeration `S(D)` = **10**"));
2082
2083 let mut non = base_report();
2084 non.coverage.denominator = DenominatorBasis::NonEnumerable {
2085 reason: "the medium type(s) are not enumerable this cycle".to_string(),
2086 };
2087 let md2 = render_fidelity_report(&non, 8_000, &[]).markdown;
2088 assert!(md2.contains("No `S(D)` denominator"));
2089 assert!(md2.contains("not enumerable this cycle"));
2090 }
2091
2092 #[test]
2098 fn e1_adopt_report_renders_onboarding_no_red_verdict() {
2099 let mut r = base_report();
2100 r.adopt = true;
2101 r.coverage_semantics = CoverageSemantics::Exhaustive;
2102 r.coverage.uncovered = (0..5).map(|i| format!("src/file_{i}.rs")).collect();
2103 let md = render_fidelity_report(&r, 8_000, &[]).markdown;
2104
2105 assert!(md.contains("## Adopting — first verify"));
2107 assert!(md.contains("0% anchored is expected — this is onboarding, not a failure."));
2108 assert!(
2110 md.contains("**Backfill path:** run `memstead projection brief engine/graph --sync`")
2111 );
2112 assert!(
2115 !md.contains("are **findings**"),
2116 "pre-binding history must not produce a red findings verdict"
2117 );
2118 assert!(md.contains("Exhaustive coverage (onboarding):"));
2119 assert!(md.contains("backfill worklist"));
2120
2121 r.adopt = false;
2123 let md2 = render_fidelity_report(&r, 8_000, &[]).markdown;
2124 assert!(!md2.contains("## Adopting — first verify"));
2125 assert!(md2.contains("are **findings**"));
2126 }
2127
2128 #[test]
2130 fn unknown_include_key_warns() {
2131 let r = base_report();
2132 let out = render_fidelity_report(&r, 8_000, &["bogus".to_string()]);
2133 assert!(out.markdown.contains("unknown include key `bogus`"));
2134 }
2135
2136 use crate::anchor::{Anchor, AnchorHashStability, AnchorProvenanceClass, AnchorSidecar};
2139 use crate::binding::{
2140 BINDING_VERSION, Binding, BuildMode, BuildOperation, DEFAULT_ADJUDICATION_CAP,
2141 DEFAULT_FULL_RESYNC_EVERY, Operations, VerifyOperation,
2142 };
2143 use crate::ingest::findings::verify_binding;
2144 use crate::ingest::resolve::resolve_binding_run;
2145 use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
2146 use crate::pipeline_store::{load_pipeline_configs, write_binding};
2147 use crate::workspace::{
2148 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
2149 };
2150 use crate::workspace_store::WorkspaceStoreAdapter;
2151
2152 #[test]
2159 fn compute_report_end_to_end() {
2160 let tmp = tempfile::tempdir().unwrap();
2161 let (report, outcome, md) = end_to_end_report(tmp.path(), &["direct", "tree", "auth"]);
2162 end_to_end_body(&report, &outcome, &md);
2163 }
2164
2165 #[test]
2170 fn coverage_does_not_rest_on_an_anchor_whose_entity_is_gone() {
2171 let tmp = tempfile::tempdir().unwrap();
2172 let (report, _outcome, md) = end_to_end_report(tmp.path(), &["tree"]);
2173 assert_eq!(
2174 report.coverage.direct_covered, 0,
2175 "the only direct anchor on present.rs is dangling, so nothing covers it directly"
2176 );
2177 assert!(
2178 report
2179 .coverage
2180 .uncovered
2181 .contains(&"src/present.rs".to_string()),
2182 "and the artifact reads uncovered rather than covered by a phantom"
2183 );
2184 assert_eq!(report.anchors.dangling, 2);
2186 assert_eq!(report.anchors.counted_rows, 1);
2187 assert_eq!(report.anchors.unreconciled, None);
2188 assert!(
2189 md.contains("name an entity this mem no longer holds"),
2190 "and the report says so on the page, not only in the struct"
2191 );
2192 }
2193
2194 fn end_to_end_report(
2200 root: &std::path::Path,
2201 entity_slugs: &[&str],
2202 ) -> (
2203 FidelityReport,
2204 crate::ingest::findings::VerifyOutcome,
2205 String,
2206 ) {
2207 let mem_dir = root.join("mem");
2208 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2209 std::fs::write(
2210 mem_dir.join(".memstead").join("config.json"),
2211 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2212 )
2213 .unwrap();
2214
2215 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2216 std::fs::write(
2217 root.join(".memstead").join("workspace.toml"),
2218 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2219 )
2220 .unwrap();
2221 let mount = Mount {
2222 mem: "engine".to_string(),
2223 schema: Some("default@1.0.0".parse().unwrap()),
2224 storage: MountStorage::Folder {
2225 path: mem_dir.clone(),
2226 },
2227 capability: MountCapability::Write,
2228 lifecycle: MountLifecycle::Eager,
2229 cross_linkable: false,
2230 migration_target: None,
2231 };
2232 crate::FileWorkspaceStore::new()
2233 .save_state(
2234 root,
2235 &Workspace {
2236 mounts: vec![mount],
2237 settings: WorkspaceSettings::default(),
2238 },
2239 )
2240 .unwrap();
2241
2242 let out = std::process::Command::new("git")
2243 .args(["init", "-q"])
2244 .current_dir(root)
2245 .output()
2246 .unwrap();
2247 assert!(out.status.success());
2248 std::fs::create_dir_all(root.join("src").join("sub")).unwrap();
2249 std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
2250 std::fs::write(root.join("src").join("uncovered.rs"), "fn b() {}\n").unwrap();
2251 std::fs::write(root.join("src").join("sub").join("deep.rs"), "fn c() {}\n").unwrap();
2252
2253 let mk = |artifact: &str, grain: AnchorGrain, class: AnchorProvenanceClass| Anchor {
2254 artifact: artifact.to_string(),
2255 grain,
2256 class,
2257 at_version: None,
2258 hash: class.is_hash_bearing().then(|| "recorded".to_string()),
2259 hash_stability: AnchorHashStability::Stable,
2260 derived_from: Vec::new(),
2261 binding: None,
2262 source: None,
2263 span_unvalidated: false,
2264 hash_source: None,
2265 last_observed: None,
2266 };
2267 for slug in entity_slugs {
2271 std::fs::write(
2272 mem_dir.join(format!("{slug}.md")),
2273 "---\ntype: decision\n---\n\n# E\n\n## Decision\n\nBody.\n",
2274 )
2275 .unwrap();
2276 }
2277 let mut sidecar = AnchorSidecar::default();
2278 sidecar.set(
2279 "engine--direct",
2280 vec![mk(
2281 "src/present.rs",
2282 AnchorGrain::File,
2283 AnchorProvenanceClass::Anchored,
2284 )],
2285 );
2286 sidecar.set(
2287 "engine--tree",
2288 vec![mk(
2289 "src/sub/",
2290 AnchorGrain::Tree,
2291 AnchorProvenanceClass::Anchored,
2292 )],
2293 );
2294 sidecar.set(
2296 "engine--auth",
2297 vec![mk(
2298 "src/present.rs",
2299 AnchorGrain::File,
2300 AnchorProvenanceClass::Authored,
2301 )],
2302 );
2303 std::fs::write(
2304 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2305 sidecar.to_bytes(),
2306 )
2307 .unwrap();
2308
2309 write_binding(
2310 root,
2311 "engine",
2312 "graph",
2313 &Binding {
2314 version: BINDING_VERSION,
2315 intent: None,
2316 sources: vec![crate::pipeline::Source {
2317 name: "graph".to_string(),
2318 medium_type: MediumType::Codebase,
2319 pointer: String::new(),
2320 change_detection: Some("git".to_string()),
2321 scope: vec![PatternEntry {
2322 path: "src/**/*.rs".to_string(),
2323 mode: PatternMode::Allow,
2324 }],
2325 engagement: None,
2326 preparation: None,
2327 }],
2328 reference_mems: Vec::new(),
2329 destination_mem: "engine".to_string(),
2330 deny_paths: Vec::new(),
2331 coverage_semantics: None,
2332 rules: None,
2333 prune: None,
2334 operations: Operations {
2335 build: Some(BuildOperation {
2336 mode: BuildMode::Discovery,
2337 trigger: IngestTrigger::Loop,
2338 batch_size: 20,
2339 post_actions: None,
2340 }),
2341 sync: None,
2342 verify: Some(VerifyOperation {
2343 trigger: IngestTrigger::Manual,
2344 batch_size: 20,
2345 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2346 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2347 }),
2348 },
2349 },
2350 )
2351 .unwrap();
2352
2353 let engine = Engine::from_workspace_root(root).unwrap();
2354 let configs = load_pipeline_configs(root).unwrap();
2355 let binding = &configs.bindings[0].config;
2356 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2357
2358 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2360
2361 let report = compute_fidelity_report(&engine, root, binding, &resolved, &outcome.key);
2363 let md = render_fidelity_report(&report, 8_000, &[]).markdown;
2364 (report, outcome, md)
2365 }
2366
2367 fn end_to_end_body(
2368 report: &FidelityReport,
2369 outcome: &crate::ingest::findings::VerifyOutcome,
2370 md: &str,
2371 ) {
2372 assert_eq!(
2374 report.coverage.denominator,
2375 DenominatorBasis::Enumerated { count: 3 }
2376 );
2377 assert_eq!(report.coverage.direct_covered, 1);
2380 assert_eq!(report.coverage.tree_only_covered, 1);
2381 assert_eq!(
2382 report.coverage.uncovered,
2383 vec!["src/uncovered.rs".to_string()]
2384 );
2385 assert_eq!(report.coverage.tree_anchors.len(), 1);
2387 assert_eq!(report.coverage.tree_anchors[0].fanout, 1);
2388 assert_eq!(report.coverage.tree_anchors[0].artifact, "src/sub/");
2389 assert_eq!(report.anchors.authored, 1);
2391 assert_eq!(report.anchors.by_class.get("authored"), Some(&1));
2392 assert_eq!(report.anchors.observed, 2);
2397 assert_eq!(report.anchors.recheck, 1);
2398 assert_eq!(report.anchors.drifted, 1);
2399 assert_eq!(report.backlog, outcome.backlog);
2401 assert!(
2403 report
2404 .degradations
2405 .iter()
2406 .any(|d| d.contains("hash-adjudication-deferred"))
2407 );
2408 assert!(md.contains("per-medium enumeration `S(D)` = **3**"));
2410 assert!(!report.adopt);
2413 assert!(!md.contains("## Adopting — first verify"));
2414 }
2415
2416 #[test]
2421 fn compute_report_adopt_when_mem_predates_binding() {
2422 let tmp = tempfile::tempdir().unwrap();
2423 let root = tmp.path();
2424 let mem_dir = root.join("mem");
2425 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2426 std::fs::write(
2427 mem_dir.join(".memstead").join("config.json"),
2428 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2429 )
2430 .unwrap();
2431 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2432 std::fs::write(
2433 root.join(".memstead").join("workspace.toml"),
2434 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2435 )
2436 .unwrap();
2437 let mount = Mount {
2438 mem: "engine".to_string(),
2439 schema: Some("default@1.0.0".parse().unwrap()),
2440 storage: MountStorage::Folder {
2441 path: mem_dir.clone(),
2442 },
2443 capability: MountCapability::Write,
2444 lifecycle: MountLifecycle::Eager,
2445 cross_linkable: false,
2446 migration_target: None,
2447 };
2448 crate::FileWorkspaceStore::new()
2449 .save_state(
2450 root,
2451 &Workspace {
2452 mounts: vec![mount],
2453 settings: WorkspaceSettings::default(),
2454 },
2455 )
2456 .unwrap();
2457 let out = std::process::Command::new("git")
2458 .args(["init", "-q"])
2459 .current_dir(root)
2460 .output()
2461 .unwrap();
2462 assert!(out.status.success());
2463 std::fs::create_dir_all(root.join("src")).unwrap();
2464 std::fs::write(root.join("src").join("a.rs"), "fn a() {}\n").unwrap();
2466 std::fs::write(root.join("src").join("b.rs"), "fn b() {}\n").unwrap();
2467
2468 write_binding(
2469 root,
2470 "engine",
2471 "graph",
2472 &Binding {
2473 version: BINDING_VERSION,
2474 intent: None,
2475 sources: vec![crate::pipeline::Source {
2476 name: "graph".to_string(),
2477 medium_type: MediumType::Codebase,
2478 pointer: String::new(),
2479 change_detection: Some("git".to_string()),
2480 scope: vec![PatternEntry {
2481 path: "src/**/*.rs".to_string(),
2482 mode: PatternMode::Allow,
2483 }],
2484 engagement: None,
2485 preparation: None,
2486 }],
2487 reference_mems: Vec::new(),
2488 destination_mem: "engine".to_string(),
2489 deny_paths: Vec::new(),
2490 coverage_semantics: None,
2491 rules: None,
2492 prune: None,
2493 operations: Operations {
2494 build: Some(BuildOperation {
2495 mode: BuildMode::Discovery,
2496 trigger: IngestTrigger::Loop,
2497 batch_size: 20,
2498 post_actions: None,
2499 }),
2500 sync: None,
2501 verify: Some(VerifyOperation {
2502 trigger: IngestTrigger::Manual,
2503 batch_size: 20,
2504 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2505 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2506 }),
2507 },
2508 },
2509 )
2510 .unwrap();
2511
2512 let engine = Engine::from_workspace_root(root).unwrap();
2513 let configs = load_pipeline_configs(root).unwrap();
2514 let binding = &configs.bindings[0].config;
2515 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2516 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2517 let report = compute_fidelity_report(&engine, root, binding, &resolved, &outcome.key);
2518
2519 assert!(
2521 report.adopt,
2522 "a no-anchor, never-synced mem predates its binding"
2523 );
2524 let md = render_fidelity_report(&report, 8_000, &[]).markdown;
2525 assert!(md.contains("## Adopting — first verify"));
2526 assert!(md.contains("0% anchored is expected"));
2527 assert!(!md.contains("are **findings**"));
2529 assert!(md.contains("Exhaustive coverage (onboarding):"));
2530 }
2531
2532 #[test]
2536 fn report_marks_resolved_coverage_semantics() {
2537 let mut resolved = base_report();
2538 resolved.coverage_semantics = CoverageSemantics::Curated;
2539 resolved.coverage_semantics_declared = false;
2540 let md = render_hard_required(&resolved);
2541 assert!(
2542 md.contains("curated (resolved from the sources' media — not declared)"),
2543 "resolved value carries the marker: {md}"
2544 );
2545
2546 let declared = base_report(); let md = render_hard_required(&declared);
2548 assert!(
2549 md.contains("**Coverage semantics:** exhaustive\n"),
2550 "declared value renders bare: {md}"
2551 );
2552 assert!(
2553 !md.contains("(resolved from the sources' media"),
2554 "no resolution marker on a declared value: {md}"
2555 );
2556 }
2557}
2558
2559#[cfg(test)]
2560mod rollup_tests {
2561 use super::*;
2562
2563 fn clean_report() -> FidelityReport {
2567 FidelityReport {
2568 legacy_dialect_patterns: Vec::new(),
2569 binding: "engine/graph".to_string(),
2570 destination_mem: "engine".to_string(),
2571 adopt: false,
2572 coverage_semantics: CoverageSemantics::Exhaustive,
2573 coverage_semantics_declared: true,
2574 capabilities: vec![FacetCapability {
2575 facet: "src".to_string(),
2576 medium_type: "codebase".to_string(),
2577 enumerable: true,
2578 change_signal: true,
2579 base_version_retrievable: true,
2580 anchor_namespace: "path".to_string(),
2581 signal: "git".to_string(),
2582 }],
2583 freshness: vec![FacetFreshness {
2584 facet: "src".to_string(),
2585 signal: "git".to_string(),
2586 synced: Some("deadbeef".to_string()),
2587 verified: None,
2588 change_detectable: true,
2589 }],
2590 source_moved_past_synced: Some(false),
2591 coverage: GrainCoverage {
2592 denominator: DenominatorBasis::Enumerated { count: 4 },
2593 direct_covered: 4,
2594 tree_only_covered: 0,
2595 uncovered: Vec::new(),
2596 tree_anchors: Vec::new(),
2597 },
2598 anchors: AnchorComposition {
2599 by_class: BTreeMap::from([("anchored".to_string(), 4)]),
2600 by_grain: BTreeMap::from([("file".to_string(), 4)]),
2601 authored: 0,
2602 observed: 4,
2603 resolves: 4,
2604 drifted: 0,
2605 recheck: 0,
2606 orphaned: 0,
2607 unobserved: 0,
2608 ..Default::default()
2609 },
2610 findings_by_class: BTreeMap::new(),
2611 backlog: 0,
2612 superseded: Vec::new(),
2613 disposed_excluded: 0,
2614 disposed_excluded_rationales: Vec::new(),
2615 degradations: Vec::new(),
2616 }
2617 }
2618
2619 #[test]
2625 fn unadjudicated_rows_block_clean_but_exclusions_do_not() {
2626 let mut r = clean_report();
2627 assert_eq!(
2628 r.rollup().verdict,
2629 RollupVerdict::Clean,
2630 "the baseline is clean"
2631 );
2632
2633 r.anchors.excluded_out_of_scope = 3;
2635 r.anchors.excluded_other_binding = 2;
2636 r.anchors.excluded_artifacts = vec!["src/a.rs (out-of-scope)".into()];
2637 assert_eq!(
2638 r.rollup().verdict,
2639 RollupVerdict::Clean,
2640 "excluding a row this binding does not answer for is an ANSWER, not a blind spot"
2641 );
2642
2643 let mut unobserved = r.clone();
2645 unobserved.anchors.unobserved = 1;
2646 let roll = unobserved.rollup();
2647 assert_eq!(roll.verdict, RollupVerdict::Inconclusive);
2648 assert!(
2649 roll.blind_spots
2650 .iter()
2651 .any(|b| b.contains("could not be observed")),
2652 "and it names itself: {:?}",
2653 roll.blind_spots
2654 );
2655
2656 let mut span = r.clone();
2658 span.anchors.span_unvalidated = 2;
2659 assert_eq!(span.rollup().verdict, RollupVerdict::Inconclusive);
2660
2661 let mut ent = r.clone();
2663 ent.anchors.unreconciled = Some("the mem's lazy entity load has not run".into());
2664 assert_eq!(ent.rollup().verdict, RollupVerdict::Inconclusive);
2665 }
2666
2667 #[test]
2672 fn all_five_conditions_are_reachable_in_the_report() {
2673 let mut r = clean_report();
2674 r.anchors.excluded_out_of_scope = 1;
2675 r.anchors.excluded_other_binding = 1;
2676 r.anchors.excluded_artifacts = vec![
2677 "src/a.rs (out-of-scope)".into(),
2678 "src/b.rs (other-binding)".into(),
2679 ];
2680 r.anchors.dangling = 1;
2681 r.anchors.dangling_rows = vec!["engine--gone → src/c.rs".into()];
2682 r.anchors.span_unvalidated = 1;
2683 r.anchors.hash_from_backfill = 1;
2684
2685 let md = render_fidelity_report(&r, 8_000, &[]).markdown;
2686 for (needle, condition) in [
2687 ("outside this binding's declared scope", "scope-excluded"),
2688 ("written by another binding", "other-binding"),
2689 ("no longer holds", "dangling entity"),
2690 ("never checked against their artifact", "span not validated"),
2691 ("inferred by backfill", "baseline established by backfill"),
2692 ] {
2693 assert!(
2694 md.contains(needle),
2695 "{condition} is not reachable in the report; looked for {needle:?} in:\n{md}"
2696 );
2697 }
2698 }
2699
2700 #[test]
2702 fn clean_requires_a_substantive_pass_and_no_findings() {
2703 let mut r = clean_report();
2704 assert_eq!(r.rollup().verdict, RollupVerdict::Clean);
2705 assert!(r.rollup().blind_spots.is_empty());
2706 assert!(r.rollup().actions.is_empty());
2707
2708 r.findings_by_class.insert("drifted".to_string(), 2);
2709 let roll = r.rollup();
2710 assert_eq!(roll.verdict, RollupVerdict::Drifted);
2711 assert_eq!(roll.findings_total, 2);
2712 assert!(
2713 roll.actions[0].contains("moved since the entity was written"),
2714 "the top action is the concrete next step: {:?}",
2715 roll.actions
2716 );
2717 }
2718
2719 #[test]
2724 fn a_vacuous_zero_over_zero_is_inconclusive_not_clean() {
2725 let mut r = clean_report();
2726 r.coverage.denominator = DenominatorBasis::Enumerated { count: 0 };
2727 let roll = r.rollup();
2728 assert_eq!(
2729 roll.verdict,
2730 RollupVerdict::Inconclusive,
2731 "0/0 is not a clean bill of health"
2732 );
2733 assert!(
2734 roll.blind_spots.iter().any(|s| s.contains("vacuous")),
2735 "the blindness is named, not implied: {:?}",
2736 roll.blind_spots
2737 );
2738 }
2739
2740 #[test]
2744 fn a_non_enumerable_facet_blocks_green_even_in_a_mixed_binding() {
2745 let mut r = clean_report();
2746 r.capabilities.push(FacetCapability {
2747 facet: "site".to_string(),
2748 medium_type: "web".to_string(),
2749 enumerable: false,
2750 change_signal: true,
2754 base_version_retrievable: false,
2755 anchor_namespace: "url".to_string(),
2756 signal: "none".to_string(),
2757 });
2758 assert!(matches!(
2760 r.coverage.denominator,
2761 DenominatorBasis::Enumerated { count } if count > 0
2762 ));
2763 let roll = r.rollup();
2764 assert_eq!(
2765 roll.verdict,
2766 RollupVerdict::Inconclusive,
2767 "one enumerable facet must not launder a non-enumerable one: {roll:?}"
2768 );
2769 assert!(
2770 roll.blind_spots
2771 .iter()
2772 .any(|s| s.contains("not enumerable")),
2773 "{:?}",
2774 roll.blind_spots
2775 );
2776 }
2777
2778 #[test]
2784 fn a_resolved_signal_of_none_blocks_green_even_when_the_medium_could_signal() {
2785 let mut r = clean_report();
2786 r.capabilities[0].change_signal = true;
2789 r.capabilities[0].signal = "none".to_string();
2790 r.freshness[0].change_detectable = false;
2791 r.freshness[0].signal = "none".to_string();
2792 let roll = r.rollup();
2793 assert_eq!(
2794 roll.verdict,
2795 RollupVerdict::Inconclusive,
2796 "a change-blind binding is not a clean bill of health: {roll:?}"
2797 );
2798 assert!(
2799 roll.blind_spots
2800 .iter()
2801 .any(|s| s.contains("could not read that signal")),
2802 "the blind spot names the unreadable signal: {:?}",
2803 roll.blind_spots
2804 );
2805 }
2806
2807 #[test]
2811 fn a_facet_without_a_change_signal_blocks_green() {
2812 let mut r = clean_report();
2813 r.capabilities[0].change_signal = false;
2814 let roll = r.rollup();
2815 assert_eq!(roll.verdict, RollupVerdict::Inconclusive);
2816 assert!(
2817 roll.blind_spots
2818 .iter()
2819 .any(|s| s.contains("no change signal")),
2820 "{:?}",
2821 roll.blind_spots
2822 );
2823 }
2824
2825 #[test]
2828 fn a_non_enumerable_scope_blocks_green() {
2829 let mut r = clean_report();
2830 r.coverage.denominator = DenominatorBasis::NonEnumerable {
2831 reason: "web medium".to_string(),
2832 };
2833 assert_eq!(r.rollup().verdict, RollupVerdict::Inconclusive);
2834 }
2835
2836 #[test]
2838 fn zero_observed_anchors_blocks_green() {
2839 let mut r = clean_report();
2840 r.anchors.observed = 0;
2841 r.anchors.resolves = 0;
2842 assert_eq!(r.rollup().verdict, RollupVerdict::Inconclusive);
2843 }
2844
2845 #[test]
2849 fn adopt_with_only_uncovered_is_never_red() {
2850 let mut r = clean_report();
2851 r.adopt = true;
2852 r.findings_by_class.insert("uncovered".to_string(), 12);
2853 let roll = r.rollup();
2854 assert_eq!(
2855 roll.verdict,
2856 RollupVerdict::Inconclusive,
2857 "onboarding is neither drift nor a clean bill: {roll:?}"
2858 );
2859 assert!(
2860 roll.because.contains("backfill worklist"),
2861 "the reason states the onboarding framing: {}",
2862 roll.because
2863 );
2864
2865 r.findings_by_class.insert("drifted".to_string(), 1);
2868 assert_eq!(r.rollup().verdict, RollupVerdict::Drifted);
2869 }
2870
2871 #[test]
2874 fn findings_outrank_blind_spots() {
2875 let mut r = clean_report();
2876 r.capabilities[0].change_signal = false;
2877 r.findings_by_class.insert("wrong".to_string(), 1);
2878 let roll = r.rollup();
2879 assert_eq!(roll.verdict, RollupVerdict::Drifted);
2880 assert!(
2881 !roll.blind_spots.is_empty(),
2882 "the blindness is still reported alongside the verdict"
2883 );
2884 }
2885
2886 #[test]
2889 fn actions_are_severity_ordered_and_never_drop_a_class() {
2890 let mut r = clean_report();
2891 r.findings_by_class.insert("uncovered".to_string(), 3);
2892 r.findings_by_class.insert("wrong".to_string(), 1);
2893 r.findings_by_class
2894 .insert("some-future-class".to_string(), 2);
2895 let roll = r.rollup();
2896 assert!(
2897 roll.actions[0].contains("contradict their source"),
2898 "{roll:?}"
2899 );
2900 assert_eq!(roll.actions.len(), 3, "{roll:?}");
2901 assert!(
2902 roll.actions.iter().any(|a| a.contains("some-future-class")),
2903 "an unranked class still surfaces: {roll:?}"
2904 );
2905 }
2906
2907 #[test]
2909 fn verdict_wire_strings_are_stable() {
2910 assert_eq!(RollupVerdict::Clean.wire(), "clean");
2911 assert_eq!(RollupVerdict::Drifted.wire(), "drifted");
2912 assert_eq!(RollupVerdict::Inconclusive.wire(), "inconclusive");
2913 let json = serde_json::to_string(&RollupVerdict::Inconclusive).unwrap();
2914 assert_eq!(json, "\"inconclusive\"");
2915 }
2916}