Skip to main content

memstead_base/ingest/
report.rs

1//! The **tier-1 fidelity report** (bundle plan `05-verify-sync-engine`, group
2//! B) — deterministic, engine-rendered, token-budgeted.
3//!
4//! Verify (group A) records durable findings; this module *renders* a
5//! measurement over them plus the live anchor / capability / freshness state.
6//! It performs **no LLM call** and **no destination-mem mutation** — it reads
7//! the engine, the findings store, the advance store, and the capability
8//! matrix, and formats a report. Any repair instruction is the sync brief's job
9//! (group C), never this report's.
10//!
11//! ## What the report states honestly (B1–B5)
12//!
13//! - **Grain-classed coverage** with tree-anchor fan-out kept on its **own
14//!   axis** — a 1-entity/200-file tree anchor shows as one anchor fanning out
15//!   over 200 files, never laundered into a blended coverage percentage (B1).
16//! - **Anchor-resolution %** over the mem's observed anchors, with `authored`
17//!   provenance **excluded** from the coverage/accuracy denominators and shown
18//!   as its own bucket (B1).
19//! - **Freshness** vs. both `sync_state` tokens (`#synced` / `#verified`). A
20//!   detection-less medium (the capability matrix marks it non-change-
21//!   detectable) renders `signal: none` → *"freshness unknowable"*; a green
22//!   freshness verdict is **structurally unreachable** for such a medium (B2).
23//! - **Token-budgeted** in the house envelope shape shared with
24//!   [`crate::overview`]: aggregates are hard-required and always ship; heavy
25//!   per-artifact lists greedy-fill by priority and, when they do not fit,
26//!   drop to `## Hints` with an `estimated_tokens` figure — never rendered
27//!   unbounded (B3).
28//! - **Coverage semantics** branch: under `curated`, the unaccounted share is
29//!   information; under `exhaustive`, unaccounted artifacts (not anchored, not
30//!   declared-excluded, no persisted disposition) are findings (B4).
31//! - **Denominator provenance** is stated: coverage is relative to the
32//!   per-medium enumeration `S(D)` (B5).
33
34use std::collections::{BTreeMap, BTreeSet};
35use std::path::Path;
36
37use serde::Serialize;
38
39use crate::Engine;
40use crate::anchor::{AnchorGrain, AnchorProvenanceClass, AnchorState};
41use crate::binding::{Binding, CoverageSemantics, MediumCapabilities, medium_capabilities};
42use crate::chunking::estimate_tokens;
43
44use super::advance::read_advance_store;
45use super::cursor::{enumerate_source_artifacts, source_moved};
46use super::findings::{FindingClass, FindingKey, read_findings_store};
47use super::resolve::{ChangeStrategy, ResolvedIngest, ResolvedSource, resolve_change_strategy};
48
49/// Default token budget for the report's heavy content. Mirrors
50/// [`crate::overview::DEFAULT_OVERVIEW_BUDGET`] — one house envelope, one
51/// default.
52pub const DEFAULT_REPORT_BUDGET: usize = 8_000;
53
54/// Heavy-content include keys the renderer recognises, in **greedy-fill
55/// priority order**. A key listed in `include` forces its section in past the
56/// budget (mirroring the overview envelope); an unlisted key greedy-fills until
57/// the budget is exhausted, then surfaces as a hint. An unknown key is ignored
58/// with a warning line.
59pub const ALLOWED_REPORT_INCLUDE_KEYS: &[&str] =
60    &["uncovered_artifacts", "tree_fanout", "superseded_findings"];
61
62// ---------------------------------------------------------------------------
63// Structured report — the deterministic, pre-computed data the pure renderer
64// formats. Assembling it (`compute_fidelity_report`) reads the engine; the
65// renderer (`render_fidelity_report`) is a pure function over this data, so
66// every B1–B5 assertion tests against a hand-built value with no IO.
67// ---------------------------------------------------------------------------
68
69/// The denominator basis for coverage (B5): coverage is reported relative to
70/// the per-medium enumeration `S(D)`, or — when the medium cannot be
71/// enumerated — the report says so rather than inventing a denominator.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
73#[serde(tag = "kind", rename_all = "kebab-case")]
74pub enum DenominatorBasis {
75    /// `S(D)` was enumerated: `count` source artifacts in scope (after
76    /// `deny_paths`), the coverage denominator.
77    Enumerated {
78        /// `|S(D)|` — the enumerated source-artifact count.
79        count: usize,
80    },
81    /// The medium is non-enumerable (or its type is not enumerated this cycle):
82    /// no `S(D)`, so coverage is reported over anchors only and the denominator
83    /// is stated unavailable.
84    NonEnumerable {
85        /// Why no `S(D)` could be computed.
86        reason: String,
87    },
88}
89
90/// One tree-grain anchor's fan-out over `S(D)` (B1). A tree anchor is one row
91/// here whatever its fan-out — the per-file count is an observation, never a
92/// per-file coverage credit.
93#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
94pub struct TreeFanout {
95    /// The entity id carrying the tree anchor.
96    pub entity: String,
97    /// The tree artifact reference.
98    pub artifact: String,
99    /// How many `S(D)` files fall under this tree.
100    pub fanout: usize,
101}
102
103/// Grain-classed coverage over `S(D)` (B1). Tree-anchor fan-out is a **separate
104/// axis** — `direct_covered` and `tree_only_covered` are never summed into one
105/// blended percentage.
106#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
107pub struct GrainCoverage {
108    /// The denominator basis (B5).
109    pub denominator: DenominatorBasis,
110    /// `S(D)` files directly covered by a non-tree (file / span) anchor.
111    pub direct_covered: usize,
112    /// `S(D)` files covered **only** via a tree-grain anchor (the fan-out axis,
113    /// kept distinct from `direct_covered`).
114    pub tree_only_covered: usize,
115    /// `S(D)` files with no anchor at all (the heavy artifact list).
116    pub uncovered: Vec<String>,
117    /// Per tree anchor, its fan-out over `S(D)` (the heavy detail list).
118    pub tree_anchors: Vec<TreeFanout>,
119}
120
121/// Anchor composition + resolution tally over the destination mem's anchors
122/// (B1). `authored` provenance is pulled into its own bucket and **excluded**
123/// from the resolution (coverage/accuracy) tally.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default)]
125pub struct AnchorComposition {
126    /// Count per provenance-class wire string across **all** the mem's anchors
127    /// (the full transparency breakdown, including `authored`).
128    pub by_class: BTreeMap<String, usize>,
129    /// Count per grain wire string across all the mem's anchors.
130    pub by_grain: BTreeMap<String, usize>,
131    /// `authored`-class anchors — the own bucket, excluded from the resolution
132    /// denominator below.
133    pub authored: usize,
134    /// Non-`authored` anchors that carry a resolution state this pass.
135    pub observed: usize,
136    /// Non-`authored` anchors that resolved clean.
137    pub resolves: usize,
138    /// Non-`authored` anchors that drifted (stable-medium hash break).
139    pub drifted: usize,
140    /// Non-`authored` anchors deferred for re-examination (unstable / no hash).
141    pub recheck: usize,
142    /// Non-`authored` anchors whose artifact is gone.
143    pub orphaned: usize,
144    /// Non-`authored` anchors that could **not** be observed this pass (state
145    /// `None`) — reported honestly, never counted as resolved.
146    pub unobserved: usize,
147}
148
149/// One facet's capability-matrix row + resolved change signal (B1 capability
150/// block; B2 change-detectability; B5 enumeration provenance).
151#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
152pub struct FacetCapability {
153    /// The source facet.
154    pub facet: String,
155    /// The medium type wire string.
156    pub medium_type: String,
157    /// Whether the medium's scope is enumerable (`S(D)` computable).
158    pub enumerable: bool,
159    /// Whether the medium provides a change signal.
160    pub change_signal: bool,
161    /// Whether a base version is retrievable (three-way-merge feasibility).
162    pub base_version_retrievable: bool,
163    /// The anchor namespace (`path` / `path+commit` / `entity` / `url`).
164    pub anchor_namespace: String,
165    /// The resolved change-detection signal (`git` / `mtime` / `graph` /
166    /// `none`).
167    pub signal: String,
168}
169
170impl FacetCapability {
171    fn from_caps(
172        facet: String,
173        medium_type: String,
174        caps: MediumCapabilities,
175        strategy: ChangeStrategy,
176    ) -> Self {
177        FacetCapability {
178            facet,
179            medium_type,
180            enumerable: caps.enumerable,
181            change_signal: caps.change_signal,
182            // Effective, not the static ceiling: a base version is retrievable
183            // only when the *resolved* strategy actually holds prior content.
184            // `mtime` reports that an artifact changed, not its previous bytes,
185            // and `none` detects nothing — either degrades prune to
186            // conflict-flagging even on a medium whose type-level capability
187            // row (e.g. filesystem) advertises base retrievability.
188            base_version_retrievable: caps.base_version_retrievable
189                && strategy_retrieves_base(strategy),
190            anchor_namespace: caps.anchor_namespace.to_string(),
191            signal: signal_wire(strategy).to_string(),
192        }
193    }
194}
195
196/// One facet's freshness state vs. both `sync_state` tokens (B1/B2).
197#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
198pub struct FacetFreshness {
199    /// The source facet.
200    pub facet: String,
201    /// The resolved change signal (`git` / `mtime` / `graph` / `none`).
202    pub signal: String,
203    /// The `#synced` baseline token, or `None` when never synced.
204    pub synced: Option<String>,
205    /// The `#verified` baseline token, or `None` when never verified.
206    pub verified: Option<String>,
207    /// Whether the medium is change-detectable at all: the capability matrix
208    /// marks a change signal **and** a strategy resolved (signal ≠ `none`).
209    /// When `false`, freshness is **unknowable** and the renderer is
210    /// structurally incapable of printing a green verdict for this facet (B2).
211    pub change_detectable: bool,
212}
213
214/// The tier-1 fidelity report — fully computed, deterministic data.
215#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
216pub struct FidelityReport {
217    /// The canonical binding id `<mem>/<stem>`.
218    pub binding: String,
219    /// The destination mem.
220    pub destination_mem: String,
221    /// Whether the destination mem predates its binding — the adopt / onboarding
222    /// case (E1). When `true`, the report leads with the expected-0%-anchored
223    /// onboarding framing and the concrete backfill path, and the coverage
224    /// section frames uncovered artifacts as the backfill worklist rather than
225    /// as defects: no failure/error framing and no red verdict is produced
226    /// **solely** by pre-binding history.
227    pub adopt: bool,
228    /// The binding's EFFECTIVE coverage (B4) — declared when the author
229    /// wrote the field, otherwise resolved per medium
230    /// ([`crate::binding::effective_coverage_semantics`]).
231    pub coverage_semantics: CoverageSemantics,
232    /// `true` when the binding declared the field; `false` when the
233    /// effective value was resolved from the sources' media. The render
234    /// marks the resolved case so a reader never mistakes a resolution
235    /// for an author's assertion.
236    pub coverage_semantics_declared: bool,
237    /// Per-facet capability rows (B1 capability block).
238    pub capabilities: Vec<FacetCapability>,
239    /// Per-facet freshness (B1/B2).
240    pub freshness: Vec<FacetFreshness>,
241    /// Binding-level: has any change-detectable source moved past its `#synced`
242    /// baseline this pass? `None` when no source is change-detectable (nothing
243    /// to compare) — never a fabricated `false`.
244    pub source_moved_past_synced: Option<bool>,
245    /// Grain-classed coverage over `S(D)` (B1/B5).
246    pub coverage: GrainCoverage,
247    /// Anchor composition + resolution (B1).
248    pub anchors: AnchorComposition,
249    /// Findings tally by class over the current key.
250    pub findings_by_class: BTreeMap<String, usize>,
251    /// Tier-3 backlog depth — findings queued for adjudication (B1).
252    pub backlog: usize,
253    /// Findings recorded under a **prior** `(hash(D), source_head)` key,
254    /// segregated as superseded (the heavy detail list is the count's backing).
255    pub superseded: Vec<String>,
256    /// Persisted dispositions that exclude an otherwise-uncovered artifact from
257    /// the exhaustive findings set (B4) — the count (`= disposed_excluded_rationales.len()`).
258    pub disposed_excluded: usize,
259    /// The durable authored-exclusion ledger consulted under exhaustive coverage
260    /// (B4): `(artifact, rationale)` for each uncovered artifact a persisted
261    /// disposition marks deliberately excluded. Removed from the findings /
262    /// backfill denominator and rendered with its reasoning so the editorial
263    /// decision stays visible.
264    pub disposed_excluded_rationales: Vec<(String, String)>,
265    /// Degradation flags (B1) — typed, human/agent-readable strings.
266    pub degradations: Vec<String>,
267}
268
269// ---------------------------------------------------------------------------
270// Rollup verdict
271// ---------------------------------------------------------------------------
272
273/// The one-word answer a CI gate and a human reader branch on, derived from
274/// an assembled [`FidelityReport`] — never measured separately, so it cannot
275/// disagree with the figures under it.
276///
277/// Three values, because there are three honest answers and the third is the
278/// one that matters: a measurement can complete without being able to support
279/// a green claim. A medium with no change signal cannot observe drift; an
280/// empty enumerated scope makes coverage vacuous; a pass that adjudicated no
281/// anchor observed nothing. Summarizing any of those as "clean" would be the
282/// report asserting more than it measured, so they resolve to
283/// [`RollupVerdict::Inconclusive`] with the blindness named.
284#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
285#[serde(rename_all = "lowercase")]
286pub enum RollupVerdict {
287    /// The pass was substantive on every axis and recorded no findings.
288    Clean,
289    /// Findings were recorded over the current key.
290    Drifted,
291    /// The pass completed but cannot support a green claim — see
292    /// [`Rollup::because`] and [`Rollup::blind_spots`].
293    Inconclusive,
294}
295
296impl RollupVerdict {
297    /// The stable wire string (`clean` / `drifted` / `inconclusive`).
298    pub fn wire(&self) -> &'static str {
299        match self {
300            RollupVerdict::Clean => "clean",
301            RollupVerdict::Drifted => "drifted",
302            RollupVerdict::Inconclusive => "inconclusive",
303        }
304    }
305}
306
307/// The rollup block: the verdict, the tally behind it, why it is what it is,
308/// and the concrete next actions.
309#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
310pub struct Rollup {
311    /// The verdict.
312    pub verdict: RollupVerdict,
313    /// Total findings over the current key, summed across every class.
314    pub findings_total: usize,
315    /// One sentence explaining the verdict. Always populated — a verdict
316    /// without a reason is a number a reader has to re-derive.
317    pub because: String,
318    /// Axes this measurement could not speak to, each named concretely.
319    /// Empty on a substantive pass. Non-empty forces `Inconclusive` unless
320    /// findings were actually recorded (an observed finding is real whatever
321    /// else the pass could not see).
322    pub blind_spots: Vec<String>,
323    /// Top concrete actions, most severe class first. Empty when there is
324    /// nothing to act on.
325    pub actions: Vec<String>,
326}
327
328/// Finding classes in the order a reader should act on them: a wrong
329/// projection misleads, drift is stale, an unresolvable anchor is broken
330/// bookkeeping, uncovered is unwritten work, and a queued item is not yet
331/// adjudicated at all.
332const CLASS_SEVERITY: [&str; 5] = [
333    "wrong",
334    "drifted",
335    "unresolvable-anchor",
336    "uncovered",
337    "queued-for-adjudication",
338];
339
340/// The concrete action for one finding class.
341fn class_action(class: &str, n: usize, binding: &str) -> String {
342    match class {
343        "wrong" => format!(
344            "{n} entity/entities contradict their source — read them against the source and \
345             correct the entity (`memstead projection brief {binding}` lists them)"
346        ),
347        "drifted" => format!(
348            "{n} anchored artifact(s) moved since the entity was written — re-read the source \
349             and update the entity, then re-verify to advance the baseline"
350        ),
351        "unresolvable-anchor" => format!(
352            "{n} anchor(s) no longer resolve to anything — repoint them at the artifact's new \
353             location or unset them (`memstead_update` `anchors_unset`)"
354        ),
355        "uncovered" => format!(
356            "{n} in-scope source artifact(s) carry no anchor — cover them via \
357             `memstead projection brief {binding} --sync`, or record a disposition for the \
358             ones deliberately excluded"
359        ),
360        "queued-for-adjudication" => format!(
361            "{n} finding(s) are queued and not yet adjudicated — run \
362             `memstead projection verify {binding} --full` to work the backlog down"
363        ),
364        other => format!("{n} `{other}` finding(s) recorded"),
365    }
366}
367
368impl FidelityReport {
369    /// Derive the [`Rollup`] from this report's own figures.
370    ///
371    /// Pure and total — same report, same verdict, no engine access. The
372    /// derivation is deliberately conservative in one direction only: it will
373    /// downgrade a green claim it cannot support, and it will never upgrade a
374    /// recorded finding away.
375    pub fn rollup(&self) -> Rollup {
376        let findings_total: usize = self.findings_by_class.values().sum();
377
378        let mut blind_spots: Vec<String> = Vec::new();
379        match &self.coverage.denominator {
380            DenominatorBasis::NonEnumerable { reason } => blind_spots.push(format!(
381                "the source scope is not enumerable ({reason}) — coverage is reported over \
382                 anchors only, so an uncovered artifact cannot be detected"
383            )),
384            DenominatorBasis::Enumerated { count: 0 } => blind_spots.push(
385                "the enumerated source scope is empty (0 artifacts) — every coverage figure \
386                 below is vacuous, not clean"
387                    .to_string(),
388            ),
389            DenominatorBasis::Enumerated { .. } => {}
390        }
391        if self.anchors.observed == 0 {
392            blind_spots.push(
393                "no anchor carried a resolution state this pass — nothing was adjudicated"
394                    .to_string(),
395            );
396        }
397        // A facet is change-blind if EITHER its medium cannot signal change
398        // or the binding resolved that medium to no strategy. The two are
399        // different: a `codebase` medium reports `change_signal: true` while
400        // a binding declaring `change_detection: "none"` resolves it to
401        // `ChangeStrategy::None`, which is exactly the freshness row's
402        // `change_detectable`. Reading only the capability row let such a
403        // binding render CLEAN while the report body two screens down said
404        // "freshness unknowable" — the headline disagreeing with its own
405        // evidence, which is the one thing this derivation exists to prevent.
406        let change_blind: std::collections::BTreeSet<&str> = self
407            .freshness
408            .iter()
409            .filter(|f| !f.change_detectable)
410            .map(|f| f.facet.as_str())
411            .collect();
412        for cap in &self.capabilities {
413            if !cap.change_signal {
414                blind_spots.push(format!(
415                    "facet `{}` ({}) provides no change signal — drift on it cannot be \
416                     observed at all",
417                    cap.facet, cap.medium_type
418                ));
419            } else if change_blind.contains(cap.facet.as_str()) {
420                blind_spots.push(format!(
421                    "facet `{}` ({}) declares change-detection `{}` but this pass could \
422                     not read that signal — either the binding asked for none, or the \
423                     checkout cannot deliver it (a `git` source with no `.git`: an \
424                     archive, a container COPY, a vendored drop). Drift on it cannot \
425                     be observed",
426                    cap.facet, cap.medium_type, cap.signal
427                ));
428            }
429            // Checked per facet, not only on the binding-level denominator:
430            // in a MIXED binding one enumerable facet makes `S(D)` non-empty,
431            // so the denominator reads `Enumerated` and the binding-level
432            // blind spot above never fires — while the non-enumerable facet's
433            // coverage stays unmeasurable. Every medium that is non-enumerable
434            // today also lacks a change signal, so this adds no blind spot
435            // under the current matrix; it is here so a future
436            // non-enumerable-but-change-detectable medium cannot silently
437            // render a mixed binding green.
438            if !cap.enumerable {
439                blind_spots.push(format!(
440                    "facet `{}` ({}) is not enumerable — an uncovered artifact under it \
441                     cannot be detected, only an anchored one",
442                    cap.facet, cap.medium_type
443                ));
444            }
445        }
446
447        let mut actions: Vec<String> = Vec::new();
448        for class in CLASS_SEVERITY {
449            if let Some(&n) = self.findings_by_class.get(class)
450                && n > 0
451            {
452                actions.push(class_action(class, n, &self.binding));
453            }
454        }
455        // Any class the vocabulary grew past this list still surfaces, after
456        // the ranked ones — an unknown class is never silently dropped.
457        for (class, &n) in &self.findings_by_class {
458            if n > 0 && !CLASS_SEVERITY.contains(&class.as_str()) {
459                actions.push(class_action(class, n, &self.binding));
460            }
461        }
462
463        // The adopt case (E1): a mem that predates its binding is expected to
464        // be 0% anchored, so uncovered findings there are the backfill
465        // worklist, not drift. A red verdict must never be produced SOLELY by
466        // pre-binding history — but the pass is not clean either, so it lands
467        // inconclusive with the onboarding reason.
468        let only_uncovered = findings_total > 0
469            && self
470                .findings_by_class
471                .iter()
472                .all(|(class, &n)| n == 0 || class == "uncovered");
473
474        let (verdict, because) = if self.adopt && only_uncovered {
475            (
476                RollupVerdict::Inconclusive,
477                format!(
478                    "this mem predates its binding — the {findings_total} uncovered artifact(s) \
479                     are the backfill worklist, not drift"
480                ),
481            )
482        } else if findings_total > 0 {
483            let tally = self
484                .findings_by_class
485                .iter()
486                .filter(|(_, n)| **n > 0)
487                .map(|(class, n)| format!("{class}: {n}"))
488                .collect::<Vec<_>>()
489                .join(", ");
490            (
491                RollupVerdict::Drifted,
492                format!("{findings_total} finding(s) recorded over the current key ({tally})"),
493            )
494        } else if !blind_spots.is_empty() {
495            (
496                RollupVerdict::Inconclusive,
497                format!(
498                    "no findings recorded, but the pass could not speak to {} axis/axes — \
499                     this is not a clean bill of health",
500                    blind_spots.len()
501                ),
502            )
503        } else {
504            (
505                RollupVerdict::Clean,
506                "the pass was substantive on every axis and recorded no findings".to_string(),
507            )
508        };
509
510        Rollup {
511            verdict,
512            findings_total,
513            because,
514            blind_spots,
515            actions,
516        }
517    }
518}
519
520// ---------------------------------------------------------------------------
521// Rendered output
522// ---------------------------------------------------------------------------
523
524/// The rendered report: markdown plus the structured envelope bits (mode,
525/// hints) mirroring [`crate::overview::OverviewOutput`].
526#[derive(Debug, Clone, PartialEq, Eq)]
527pub struct RenderedFidelityReport {
528    /// The rendered markdown.
529    pub markdown: String,
530    /// `"complete"` / `"reduced"` / `"overbudget"` — the same tri-state the
531    /// overview envelope uses.
532    pub mode: String,
533    /// Drill-in hints for heavy sections omitted under the budget:
534    /// `(key, estimated_tokens)`.
535    pub hints: Vec<(String, usize)>,
536    /// The budget actually consumed by hard-required + emitted heavy content.
537    pub budget_used: usize,
538}
539
540// ---------------------------------------------------------------------------
541// Pure renderer
542// ---------------------------------------------------------------------------
543
544/// Render `N/D (P%)`, or `N/D (n/a)` when the denominator is zero.
545fn ratio(num: usize, den: usize) -> String {
546    if den == 0 {
547        format!("{num}/{den} (n/a)")
548    } else {
549        let pct = (num as f64) * 100.0 / (den as f64);
550        format!("{num}/{den} ({pct:.1}%)")
551    }
552}
553
554/// Render the hard-required (always-ships) aggregate markdown for a report.
555/// This is the content B3's "aggregated counts always ship" rests on — it is
556/// concatenated whatever the budget.
557fn render_hard_required(report: &FidelityReport) -> String {
558    let mut md = String::new();
559    md.push_str(&format!("# Fidelity report — `{}`\n\n", report.binding));
560
561    // --- Rollup verdict (opens the report) ---
562    // A reader gets the answer before the provenance. Derived from the
563    // figures below, never measured separately, so the headline cannot
564    // disagree with its own body.
565    let rollup = report.rollup();
566    md.push_str(&format!(
567        "**Verdict: {}** — {}.\n\n",
568        rollup.verdict.wire().to_uppercase(),
569        rollup.because
570    ));
571    if !rollup.actions.is_empty() {
572        md.push_str("**Do next:**\n\n");
573        for action in &rollup.actions {
574            md.push_str(&format!("1. {action}\n"));
575        }
576        md.push('\n');
577    }
578    if !rollup.blind_spots.is_empty() {
579        md.push_str("**This pass could not see:**\n\n");
580        for spot in &rollup.blind_spots {
581            md.push_str(&format!("- {spot}\n"));
582        }
583        md.push('\n');
584    }
585
586    md.push_str(&format!(
587        "- **Destination mem:** `{}`\n- **Coverage semantics:** {}{}\n\n",
588        report.destination_mem,
589        match report.coverage_semantics {
590            CoverageSemantics::Exhaustive => "exhaustive",
591            CoverageSemantics::Curated => "curated",
592        },
593        if report.coverage_semantics_declared {
594            ""
595        } else {
596            " (resolved from the sources' media — not declared)"
597        }
598    ));
599
600    // --- Adopt / onboarding framing (E1) ---
601    // When the mem predates its binding, the report LEADS with onboarding
602    // framing: the expected-0%-anchored statement plus the concrete backfill
603    // path. REFUSAL: this is never a failure/error framing and the report never
604    // produces a red verdict solely from pre-binding history — the coverage
605    // section below reframes uncovered artifacts as the backfill worklist.
606    if report.adopt {
607        md.push_str("## Adopting — first verify\n\n");
608        md.push_str(
609            "This mem predates its binding: it carries no anchors and has no prior sync \
610             baseline, so **0% anchored is expected — this is onboarding, not a failure.** \
611             Do not read the coverage numbers below as drift or a red verdict; the uncovered \
612             artifacts are the backfill worklist, not defects.\n\n",
613        );
614        md.push_str(&format!(
615            "**Backfill path:** run `memstead projection brief {} --sync` to work through the in-scope \
616             source artifacts that carry no entity yet, covering the clearly-new concepts among \
617             them through the normal mutation surface. Backfilling is incremental — a partial \
618             pass is fine, and the next sync continues where you left off.\n\n",
619            report.binding
620        ));
621    }
622
623    // --- Denominator provenance (B5) ---
624    md.push_str("## Denominator provenance\n\n");
625    match &report.coverage.denominator {
626        DenominatorBasis::Enumerated { count } => md.push_str(&format!(
627            "Coverage is reported relative to the per-medium enumeration `S(D)` = **{count}** \
628             source artifact(s) in scope (after `deny_paths`).\n\n"
629        )),
630        DenominatorBasis::NonEnumerable { reason } => md.push_str(&format!(
631            "No `S(D)` denominator: {reason}. Coverage is reported over anchors only; the \
632             per-medium enumeration is unavailable.\n\n"
633        )),
634    }
635
636    // --- Capability matrix (B1) ---
637    md.push_str("## Capability matrix\n\n");
638    if report.capabilities.is_empty() {
639        md.push_str("_(no primary sources resolved)_\n\n");
640    } else {
641        for c in &report.capabilities {
642            md.push_str(&format!("### `{}` ({})\n\n", c.facet, c.medium_type));
643            md.push_str(&format!(
644                "- enumerable: {} | change_signal: {} | base_version_retrievable: {}\n",
645                c.enumerable, c.change_signal, c.base_version_retrievable
646            ));
647            md.push_str(&format!(
648                "- anchor_namespace: `{}` | resolved signal: `{}`\n\n",
649                c.anchor_namespace, c.signal
650            ));
651        }
652    }
653
654    // --- Freshness (B1/B2) ---
655    md.push_str("## Freshness\n\n");
656    if report.freshness.is_empty() {
657        md.push_str("_(no source facets)_\n\n");
658    } else {
659        for f in &report.freshness {
660            md.push_str(&format!("### `{}`\n\n", f.facet));
661            md.push_str(&format!("- signal: `{}`\n", f.signal));
662            if !f.change_detectable {
663                // B2 REFUSAL: a non-change-detectable medium NEVER prints a
664                // green freshness verdict — only "unknowable". This branch is
665                // the only place `signal: none` freshness is rendered.
666                md.push_str(
667                    "- **freshness unknowable** — this medium is not change-detectable \
668                     (no change signal); `#synced` / `#verified` cannot be adjudicated as fresh\n",
669                );
670            } else {
671                match &f.synced {
672                    Some(t) => md.push_str(&format!("- `#synced`: `{t}`\n")),
673                    None => md.push_str("- `#synced`: never synced\n"),
674                }
675                match &f.verified {
676                    Some(t) => md.push_str(&format!("- `#verified`: `{t}`\n")),
677                    None => md.push_str("- `#verified`: never verified\n"),
678                }
679            }
680            md.push('\n');
681        }
682        // Binding-level move verdict — only when something is change-detectable.
683        match report.source_moved_past_synced {
684            Some(true) => md.push_str(
685                "**Source moved past its `#synced` baseline** — the graph is stale for the \
686                 moved facet(s); a sync pass is due.\n\n",
687            ),
688            Some(false) => {
689                md.push_str("Every change-detectable source is at its `#synced` baseline.\n\n")
690            }
691            None => {}
692        }
693    }
694
695    // --- Coverage (B1, B4) ---
696    md.push_str("## Coverage (grain-classed)\n\n");
697    let den = match &report.coverage.denominator {
698        DenominatorBasis::Enumerated { count } => *count,
699        DenominatorBasis::NonEnumerable { .. } => 0,
700    };
701    md.push_str(&format!(
702        "- direct-covered (file / span anchors): {}\n",
703        ratio(report.coverage.direct_covered, den)
704    ));
705    // Tree fan-out is a DISTINCT axis — reported separately, never blended into
706    // the direct-covered percentage (B1).
707    let tree_files: usize = report.coverage.tree_anchors.iter().map(|t| t.fanout).sum();
708    md.push_str(&format!(
709        "- tree-anchor fan-out (separate axis): {} tree anchor(s) fanning out over {} file(s); \
710         {} file(s) covered ONLY via a tree anchor\n",
711        report.coverage.tree_anchors.len(),
712        tree_files,
713        report.coverage.tree_only_covered
714    ));
715    md.push_str(&format!(
716        "- uncovered (no anchor): {}\n\n",
717        report.coverage.uncovered.len()
718    ));
719
720    // Coverage-semantics framing (B4). REFUSAL (E1): under adopt, the exhaustive
721    // branch must NOT frame the uncovered artifacts as defect findings — they are
722    // the expected backfill worklist of a mem that predates its binding, never a
723    // red verdict caused solely by pre-binding history.
724    match report.coverage_semantics {
725        CoverageSemantics::Exhaustive if report.adopt => {
726            let backlog = report
727                .coverage
728                .uncovered
729                .len()
730                .saturating_sub(report.disposed_excluded);
731            md.push_str(&format!(
732                "**Exhaustive coverage (onboarding):** {backlog} in-scope artifact(s) carry no \
733                 entity yet ({} disposed excluded) — the expected first-sync backfill worklist \
734                 for a mem that predates its binding, not defects.\n\n",
735                report.disposed_excluded
736            ));
737        }
738        CoverageSemantics::Exhaustive => {
739            let findings = report
740                .coverage
741                .uncovered
742                .len()
743                .saturating_sub(report.disposed_excluded);
744            md.push_str(&format!(
745                "**Exhaustive coverage:** {findings} unaccounted artifact(s) — not anchored, not \
746                 declared-excluded, no persisted disposition ({} disposed excluded) — are \
747                 **findings**.\n\n",
748                report.disposed_excluded
749            ));
750        }
751        CoverageSemantics::Curated => {
752            md.push_str(&format!(
753                "**Curated coverage:** {} unaccounted artifact(s) are **information**, not \
754                 defects — a curated binding covers a deliberate slice.\n\n",
755                report.coverage.uncovered.len()
756            ));
757        }
758    }
759
760    // Authored exclusion ledger (B4) — surface the reasoning behind each
761    // deliberately-excluded artifact so an editorial decision stays visible and
762    // auditable, not just subtracted from a denominator.
763    if !report.disposed_excluded_rationales.is_empty() {
764        md.push_str("**Excluded on purpose (persisted dispositions):**\n");
765        for (artifact, rationale) in &report.disposed_excluded_rationales {
766            if rationale.is_empty() {
767                md.push_str(&format!("- `{artifact}`\n"));
768            } else {
769                md.push_str(&format!("- `{artifact}` — {rationale}\n"));
770            }
771        }
772        md.push('\n');
773    }
774
775    // --- Anchors (B1) ---
776    md.push_str("## Anchors\n\n");
777    md.push_str(&format!(
778        "- by class: {}\n",
779        render_counts(&report.anchors.by_class)
780    ));
781    md.push_str(&format!(
782        "- by grain: {}\n",
783        render_counts(&report.anchors.by_grain)
784    ));
785    md.push_str(&format!(
786        "- `authored` bucket (excluded from coverage/accuracy denominators): {}\n",
787        report.anchors.authored
788    ));
789    md.push_str(&format!(
790        "- resolution (non-`authored`, observed): resolves {}, drifted {}, recheck {}, orphaned {}\n",
791        report.anchors.resolves,
792        report.anchors.drifted,
793        report.anchors.recheck,
794        report.anchors.orphaned
795    ));
796    md.push_str(&format!(
797        "- **anchor-resolution %:** {}\n",
798        ratio(report.anchors.resolves, report.anchors.observed)
799    ));
800    md.push_str(&format!(
801        "- unobserved this pass (state unavailable, never scored as resolved): {}\n\n",
802        report.anchors.unobserved
803    ));
804
805    // --- Findings + backlog (B1) ---
806    md.push_str("## Findings\n\n");
807    md.push_str(&format!(
808        "- by class: {}\n",
809        render_counts(&report.findings_by_class)
810    ));
811    md.push_str(&format!(
812        "- **tier-3 adjudication backlog:** {}\n",
813        report.backlog
814    ));
815    md.push_str(&format!(
816        "- superseded (prior `(hash(D), source_head)` key, segregated): {}\n\n",
817        report.superseded.len()
818    ));
819
820    // --- Degradations (B1) ---
821    md.push_str("## Degradations\n\n");
822    if report.degradations.is_empty() {
823        md.push_str("_(none)_\n\n");
824    } else {
825        for d in &report.degradations {
826            md.push_str(&format!("- {d}\n"));
827        }
828        md.push('\n');
829    }
830
831    md
832}
833
834/// Render a `BTreeMap<String, usize>` as `k=v, k=v` (or `(none)`).
835fn render_counts(counts: &BTreeMap<String, usize>) -> String {
836    if counts.is_empty() {
837        return "(none)".to_string();
838    }
839    counts
840        .iter()
841        .map(|(k, v)| format!("{k}={v}"))
842        .collect::<Vec<_>>()
843        .join(", ")
844}
845
846/// The three heavy sections, in greedy-fill priority order — each a
847/// `(key, markdown)` pair whose markdown is empty when the section has nothing
848/// to show (an empty section is emitted free, never hinted).
849fn heavy_sections(report: &FidelityReport) -> Vec<(&'static str, String)> {
850    let mut out: Vec<(&'static str, String)> = Vec::new();
851
852    // uncovered_artifacts
853    let mut s = String::new();
854    if !report.coverage.uncovered.is_empty() {
855        s.push_str("## Uncovered artifacts\n\n");
856        for a in &report.coverage.uncovered {
857            s.push_str(&format!("- `{a}`\n"));
858        }
859        s.push('\n');
860    }
861    out.push(("uncovered_artifacts", s));
862
863    // tree_fanout
864    let mut s = String::new();
865    if !report.coverage.tree_anchors.is_empty() {
866        s.push_str("## Tree-anchor fan-out (detail)\n\n");
867        for t in &report.coverage.tree_anchors {
868            s.push_str(&format!(
869                "- `{}` → `{}` fans out over {} file(s)\n",
870                t.entity, t.artifact, t.fanout
871            ));
872        }
873        s.push('\n');
874    }
875    out.push(("tree_fanout", s));
876
877    // superseded_findings
878    let mut s = String::new();
879    if !report.superseded.is_empty() {
880        s.push_str("## Superseded findings (detail)\n\n");
881        for f in &report.superseded {
882            s.push_str(&format!("- {f}\n"));
883        }
884        s.push('\n');
885    }
886    out.push(("superseded_findings", s));
887
888    out
889}
890
891/// Render the tier-1 fidelity report into markdown, token-budgeted in the house
892/// envelope shape (B3). Aggregated counts (the hard-required block) always ship;
893/// heavy per-artifact lists greedy-fill by priority and drop to `## Hints` when
894/// they do not fit — `include`-listed keys force their section in past the
895/// budget, exactly as the overview envelope does.
896///
897/// - `budget` — the target token budget for **heavy** content (the aggregates
898///   ship in addition, so total output exceeds this when the report is large).
899/// - `include` — keys forced in regardless of budget; an unknown key adds a
900///   warning line, mirroring the overview composer.
901pub fn render_fidelity_report(
902    report: &FidelityReport,
903    budget: usize,
904    include: &[String],
905) -> RenderedFidelityReport {
906    let hard = render_hard_required(report);
907    let hard_cost = estimate_tokens(&hard);
908    let overbudget = hard_cost > budget;
909
910    let include_set: std::collections::BTreeSet<&str> = include
911        .iter()
912        .map(String::as_str)
913        .filter(|k| ALLOWED_REPORT_INCLUDE_KEYS.contains(k))
914        .collect();
915    let unknown_includes: Vec<&String> = include
916        .iter()
917        .filter(|k| !ALLOWED_REPORT_INCLUDE_KEYS.contains(&k.as_str()))
918        .collect();
919
920    let sections = heavy_sections(report);
921    let mut emitted: Vec<String> = Vec::new();
922    let mut hints: Vec<(String, usize)> = Vec::new();
923    let mut used = hard_cost;
924    let mut remaining = budget.saturating_sub(hard_cost);
925
926    for (key, section_md) in &sections {
927        if section_md.is_empty() {
928            continue; // nothing to show — never hinted, never charged
929        }
930        let cost = estimate_tokens(section_md);
931        let forced = include_set.contains(key);
932        if forced {
933            emitted.push(section_md.clone());
934            used += cost;
935            remaining = remaining.saturating_sub(cost);
936        } else if !overbudget && remaining >= cost {
937            emitted.push(section_md.clone());
938            used += cost;
939            remaining -= cost;
940        } else {
941            hints.push(((*key).to_string(), cost));
942        }
943    }
944
945    let mode = if overbudget {
946        "overbudget"
947    } else if hints.is_empty() {
948        "complete"
949    } else {
950        "reduced"
951    };
952
953    let mut md = String::new();
954    md.push_str("---\n");
955    md.push_str(&format!("_report_mode: {mode}\n"));
956    md.push_str(&format!("_budget_requested: {budget}\n"));
957    md.push_str(&format!("_budget_used: {used}\n"));
958    md.push_str("---\n\n");
959    md.push_str(&hard);
960    for section in &emitted {
961        md.push_str(section);
962    }
963
964    if !hints.is_empty() {
965        md.push_str("## Hints\n\n");
966        md.push_str(
967            "_(heavy sections omitted under the token budget — re-query with the key)_\n\n",
968        );
969        for (key, tokens) in &hints {
970            md.push_str(&format!("- `{key}` — estimated_tokens: {tokens}\n"));
971        }
972        md.push('\n');
973    }
974
975    if !unknown_includes.is_empty() {
976        md.push_str("## Warnings\n\n");
977        for k in &unknown_includes {
978            md.push_str(&format!(
979                "- unknown include key `{k}` — allowed: {}\n",
980                ALLOWED_REPORT_INCLUDE_KEYS.join(", ")
981            ));
982        }
983        md.push('\n');
984    }
985
986    RenderedFidelityReport {
987        markdown: md,
988        mode: mode.to_string(),
989        hints,
990        budget_used: used,
991    }
992}
993
994// ---------------------------------------------------------------------------
995// Assembly — reads the engine, findings store, advance store, capability matrix
996// ---------------------------------------------------------------------------
997
998/// Assemble the tier-1 [`FidelityReport`] for a binding (B1–B5). Read-only on
999/// the destination mem — it borrows `&Engine` (shared), reads the durable
1000/// findings store under `key`, the advance store, and the live anchor /
1001/// enumeration / freshness state. It performs no mutation and no LLM call.
1002///
1003/// `key` is the current `(hash(D), source_head)` the verify pass recorded
1004/// under (from [`super::findings::VerifyOutcome::key`]); the report's findings
1005/// tally is the store's `current(key)` slice — all open findings under the
1006/// key's `hash(D)`, regardless of the head each was observed at — and the
1007/// superseded count is everything under prior binding hashes.
1008pub fn compute_fidelity_report(
1009    engine: &Engine,
1010    workspace_root: &Path,
1011    binding: &Binding,
1012    resolved: &ResolvedIngest,
1013    key: &FindingKey,
1014) -> FidelityReport {
1015    let binding_id = resolved.name.clone();
1016    let dest = resolved.destination_mem.clone();
1017
1018    // --- Capabilities + freshness, per primary facet ---
1019    let sync_state = engine
1020        .mem_config_for(&dest)
1021        .map(|c| c.sync_state.clone())
1022        .unwrap_or_default();
1023    let mut capabilities: Vec<FacetCapability> = Vec::new();
1024    let mut freshness: Vec<FacetFreshness> = Vec::new();
1025    let mut any_change_detectable = false;
1026    for source in &resolved.sources {
1027        let ResolvedSource::Primary(p) = source else {
1028            continue;
1029        };
1030        let caps = medium_capabilities(p.medium_type);
1031        let medium_type = serde_json::to_value(p.medium_type)
1032            .ok()
1033            .and_then(|v| v.as_str().map(str::to_string))
1034            .unwrap_or_default();
1035        let strategy = resolve_change_strategy(p, workspace_root);
1036        let signal = signal_wire(strategy).to_string();
1037        // Detectable means THIS PASS could read the signal, not that the
1038        // binding declared one. A `git` strategy over a tree with no `.git`
1039        // — a `git archive`, a Docker `COPY`, a vendored drop — declares a
1040        // signal the checkout cannot deliver: the head resolves empty and no
1041        // baseline is written. Reporting `change_detectable: true` there let
1042        // the rollup call such a pass "substantive on every axis" and render
1043        // CLEAN, which is the worst failure a gate can have. The declaration
1044        // is not second-guessed (that is the resolver's job); what the run
1045        // could observe is reported honestly.
1046        let signal_readable = match strategy {
1047            ChangeStrategy::Git => {
1048                super::resolve::find_git_root(&super::resolve::source_base_path(p, workspace_root))
1049                    .is_some()
1050            }
1051            _ => true,
1052        };
1053        let change_detectable =
1054            caps.change_signal && strategy != ChangeStrategy::None && signal_readable;
1055        any_change_detectable |= change_detectable;
1056
1057        capabilities.push(FacetCapability::from_caps(
1058            p.name.clone(),
1059            medium_type,
1060            caps,
1061            strategy,
1062        ));
1063
1064        let synced = sync_state
1065            .get(&format!("{binding_id}/{}#synced", p.name))
1066            .cloned();
1067        let verified = sync_state
1068            .get(&format!("{binding_id}/{}#verified", p.name))
1069            .cloned();
1070        freshness.push(FacetFreshness {
1071            facet: p.name.clone(),
1072            signal,
1073            synced,
1074            verified,
1075            change_detectable,
1076        });
1077    }
1078
1079    let source_moved_past_synced = if any_change_detectable {
1080        Some(source_moved(engine, resolved, workspace_root))
1081    } else {
1082        None
1083    };
1084
1085    // --- S(D) enumeration + grain-classed coverage ---
1086    let mut s_d: Vec<String> = Vec::new();
1087    let mut enumerable_facets = 0usize;
1088    // Facets whose medium the matrix marks enumerable and whose OWN walk came
1089    // back empty. Tracked per facet, not over the union: in a mixed binding one
1090    // facet that walks makes `S(D)` non-empty, so a binding-level flag reads
1091    // "something was enumerated" while the empty facet's coverage stays
1092    // unmeasured — and the degradation below, which names a facet, could not
1093    // honestly speak for it. Same reasoning as the per-facet blind spot above.
1094    let mut empty_enumerable_facets: BTreeSet<String> = BTreeSet::new();
1095    for source in &resolved.sources {
1096        if let ResolvedSource::Primary(p) = source {
1097            let caps = medium_capabilities(p.medium_type);
1098            if caps.enumerable {
1099                enumerable_facets += 1;
1100            }
1101            let walked =
1102                enumerate_source_artifacts(engine, p, &resolved.deny_paths, workspace_root);
1103            if caps.enumerable && walked.is_empty() {
1104                empty_enumerable_facets.insert(p.name.clone());
1105            }
1106            s_d.extend(walked);
1107        }
1108    }
1109    s_d.sort();
1110    s_d.dedup();
1111
1112    let denominator = if !s_d.is_empty() {
1113        DenominatorBasis::Enumerated { count: s_d.len() }
1114    } else if enumerable_facets == 0 {
1115        DenominatorBasis::NonEnumerable {
1116            reason: "the medium type(s) are not enumerable this cycle".to_string(),
1117        }
1118    } else {
1119        // Enumerable per the matrix but the walk yielded nothing — an empty
1120        // or over-narrow scope. The degradation block below says so out loud;
1121        // `--full` refuses this case outright rather than measuring it.
1122        DenominatorBasis::NonEnumerable {
1123            reason: "no source artifacts enumerated in scope".to_string(),
1124        }
1125    };
1126
1127    let mut direct_covered = 0usize;
1128    let mut tree_only_covered = 0usize;
1129    let mut uncovered: Vec<String> = Vec::new();
1130    let mut tree_fanout: BTreeMap<(String, String), usize> = BTreeMap::new();
1131    for file in &s_d {
1132        let refs = engine.anchors_referencing_artifact(file);
1133        let mine: Vec<&(crate::EntityId, crate::anchor::Anchor)> = refs
1134            .iter()
1135            .filter(|(eid, _)| eid.mem() == dest.as_str())
1136            .collect();
1137        if mine.is_empty() {
1138            uncovered.push(file.clone());
1139            continue;
1140        }
1141        let has_non_tree = mine.iter().any(|(_, a)| a.grain != AnchorGrain::Tree);
1142        if has_non_tree {
1143            direct_covered += 1;
1144        } else {
1145            tree_only_covered += 1;
1146        }
1147        // Attribute tree fan-out (separate axis) for every covering tree anchor.
1148        for (eid, a) in &mine {
1149            if a.grain == AnchorGrain::Tree {
1150                *tree_fanout
1151                    .entry((eid.as_ref().to_string(), a.artifact.clone()))
1152                    .or_insert(0) += 1;
1153            }
1154        }
1155    }
1156    let tree_anchors: Vec<TreeFanout> = tree_fanout
1157        .into_iter()
1158        .map(|((entity, artifact), fanout)| TreeFanout {
1159            entity,
1160            artifact,
1161            fanout,
1162        })
1163        .collect();
1164
1165    let coverage = GrainCoverage {
1166        denominator,
1167        direct_covered,
1168        tree_only_covered,
1169        uncovered: uncovered.clone(),
1170        tree_anchors,
1171    };
1172
1173    // --- Anchor composition + resolution over the mem's anchors ---
1174    let mut anchors = AnchorComposition::default();
1175    for (_eid, resolved_anchor) in engine.mem_anchors_resolved(&dest) {
1176        let a = &resolved_anchor.anchor;
1177        *anchors
1178            .by_class
1179            .entry(a.class.as_wire().to_string())
1180            .or_insert(0) += 1;
1181        *anchors
1182            .by_grain
1183            .entry(a.grain.as_wire().to_string())
1184            .or_insert(0) += 1;
1185        if a.class == AnchorProvenanceClass::Authored {
1186            anchors.authored += 1;
1187            continue; // own bucket — excluded from the resolution denominator
1188        }
1189        match resolved_anchor.state {
1190            Some(AnchorState::Resolves) => {
1191                anchors.resolves += 1;
1192                anchors.observed += 1;
1193            }
1194            Some(AnchorState::Drifted) => {
1195                anchors.drifted += 1;
1196                anchors.observed += 1;
1197            }
1198            Some(AnchorState::Recheck) => {
1199                anchors.recheck += 1;
1200                anchors.observed += 1;
1201            }
1202            Some(AnchorState::Orphaned) => {
1203                anchors.orphaned += 1;
1204                anchors.observed += 1;
1205            }
1206            None => anchors.unobserved += 1,
1207        }
1208    }
1209
1210    // --- Findings tally + backlog + superseded, from the durable store ---
1211    let mut findings_by_class: BTreeMap<String, usize> = BTreeMap::new();
1212    let mut backlog = 0usize;
1213    let mut superseded: Vec<String> = Vec::new();
1214    if let Some((mem, name)) = binding_id.split_once('/')
1215        && let Ok(Some(store)) = read_findings_store(workspace_root, mem, name)
1216    {
1217        for f in store.current(key) {
1218            *findings_by_class
1219                .entry(f.class.as_wire().to_string())
1220                .or_insert(0) += 1;
1221            if f.class == FindingClass::QueuedForAdjudication {
1222                backlog += 1;
1223            }
1224        }
1225        for f in store.superseded(key) {
1226            superseded.push(format!(
1227                "[{}] {} ({})",
1228                f.class.as_wire(),
1229                finding_target_label(&f.target),
1230                f.facet
1231            ));
1232        }
1233    }
1234
1235    // --- Durable authored-exclusion ledger (B4) ---
1236    // The advance store's `exclusions` map survives advance completion (unlike
1237    // its transient `dispositions`), so an artifact mined-and-deliberately-
1238    // excluded no longer re-surfaces as `uncovered` on every verify — and keeps
1239    // its reasoning. Consult it for every uncovered artifact.
1240    let mut disposed_excluded_rationales: Vec<(String, String)> = Vec::new();
1241    if let Some((mem, name)) = binding_id.split_once('/')
1242        && let Ok(Some(state)) = read_advance_store(workspace_root, mem, name)
1243    {
1244        let uncovered_set: std::collections::BTreeSet<&str> =
1245            uncovered.iter().map(String::as_str).collect();
1246        for (artifact, rationale) in &state.exclusions {
1247            if uncovered_set.contains(artifact.as_str()) {
1248                disposed_excluded_rationales.push((artifact.clone(), rationale.clone()));
1249            }
1250        }
1251    }
1252    let disposed_excluded = disposed_excluded_rationales.len();
1253
1254    // --- Degradation flags (B1) ---
1255    let mut degradations: Vec<String> = Vec::new();
1256    for c in &capabilities {
1257        if !c.change_signal || c.signal == "none" {
1258            degradations.push(format!(
1259                "change-signal-none:`{}` — freshness is unknowable for this facet",
1260                c.facet
1261            ));
1262        }
1263        if !c.enumerable {
1264            degradations.push(format!(
1265                "enumeration-unavailable:`{}` — `S(D)` coverage denominator not computable",
1266                c.facet
1267            ));
1268        } else if empty_enumerable_facets.contains(&c.facet) {
1269            // The matrix CLAIMS this medium enumerates and the walk produced
1270            // nothing. That is a capability unavailable in this pass, and the
1271            // block above only ever spoke for media the matrix already marks
1272            // non-enumerable — so the honest case rendered `Degradations:
1273            // (none)` beside a report with no denominator. `--full` refuses
1274            // this outright; a plain pass measures what it can and must say
1275            // what it could not.
1276            degradations.push(format!(
1277                "enumeration-empty:`{}` — the medium claims enumerability but the walk yielded \
1278                 no artifacts; coverage is reported over anchors only",
1279                c.facet
1280            ));
1281        }
1282        if !c.base_version_retrievable {
1283            degradations.push(format!(
1284                "base-version-unretrievable:`{}` — prune degrades to conflict-flagging",
1285                c.facet
1286            ));
1287        }
1288    }
1289    if anchors.recheck > 0 {
1290        degradations.push(format!(
1291            "hash-adjudication-deferred — {} anchor(s) recheck (unstable medium / hash \
1292             unavailable), not asserted drift",
1293            anchors.recheck
1294        ));
1295    }
1296    if anchors.unobserved > 0 {
1297        degradations.push(format!(
1298            "anchors-unobserved — {} anchor(s) could not be observed this pass",
1299            anchors.unobserved
1300        ));
1301    }
1302
1303    // Adopt / onboarding signal (E1) — the single canonical predicate shared with
1304    // the sync brief and the status rollup: a mem with no anchors and no recorded
1305    // `#synced` baseline predates its binding, so 0% anchored is expected.
1306    let adopt = super::render::mem_predates_binding(engine, resolved);
1307    let effective_coverage = crate::binding::effective_coverage_semantics(binding);
1308
1309    FidelityReport {
1310        binding: binding_id,
1311        destination_mem: dest,
1312        adopt,
1313        coverage_semantics: effective_coverage.value,
1314        coverage_semantics_declared: effective_coverage.declared,
1315        capabilities,
1316        freshness,
1317        source_moved_past_synced,
1318        coverage,
1319        anchors,
1320        findings_by_class,
1321        backlog,
1322        superseded,
1323        disposed_excluded,
1324        disposed_excluded_rationales,
1325        degradations,
1326    }
1327}
1328
1329/// Whether a resolved change-detection strategy can retrieve a prior base
1330/// version for a three-way merge (B1). Only git-backed strategies (`git`,
1331/// `graph`) hold prior content; `mtime` reports *that* an artifact changed but
1332/// not its previous bytes, and `none` detects nothing — both leave prune with
1333/// no base leg, so it degrades to conflict-flagging regardless of the medium
1334/// type's static base-retrievability ceiling. This is why filesystem+mtime —
1335/// a common non-git dogfood binding — must surface the conflict-flag
1336/// degradation even though `MediumType::Filesystem` advertises retrievability.
1337fn strategy_retrieves_base(strategy: ChangeStrategy) -> bool {
1338    matches!(strategy, ChangeStrategy::Git | ChangeStrategy::Graph)
1339}
1340
1341/// The `signal` wire string for a [`ChangeStrategy`] — `none` for detection-less
1342/// (never a fabricated token, B2).
1343fn signal_wire(strategy: ChangeStrategy) -> &'static str {
1344    match strategy {
1345        ChangeStrategy::None => "none",
1346        ChangeStrategy::Git => "git",
1347        ChangeStrategy::Mtime => "mtime",
1348        ChangeStrategy::Graph => "graph",
1349    }
1350}
1351
1352/// A compact label for a finding target (superseded detail).
1353fn finding_target_label(target: &super::findings::FindingTarget) -> String {
1354    match target {
1355        super::findings::FindingTarget::Anchor { entity, artifact } => {
1356            format!("{entity} → {artifact}")
1357        }
1358        super::findings::FindingTarget::Artifact { artifact } => artifact.clone(),
1359    }
1360}
1361
1362#[cfg(test)]
1363mod tests {
1364    use super::*;
1365
1366    // ---- pure-renderer fixtures ------------------------------------------
1367
1368    fn base_report() -> FidelityReport {
1369        FidelityReport {
1370            binding: "engine/graph".to_string(),
1371            destination_mem: "engine".to_string(),
1372            adopt: false,
1373            coverage_semantics: CoverageSemantics::Exhaustive,
1374            coverage_semantics_declared: true,
1375            capabilities: vec![FacetCapability {
1376                facet: "src".to_string(),
1377                medium_type: "codebase".to_string(),
1378                enumerable: true,
1379                change_signal: true,
1380                base_version_retrievable: true,
1381                anchor_namespace: "path".to_string(),
1382                signal: "git".to_string(),
1383            }],
1384            freshness: vec![FacetFreshness {
1385                facet: "src".to_string(),
1386                signal: "git".to_string(),
1387                synced: Some("deadbeef".to_string()),
1388                verified: None,
1389                change_detectable: true,
1390            }],
1391            source_moved_past_synced: Some(false),
1392            coverage: GrainCoverage {
1393                denominator: DenominatorBasis::Enumerated { count: 10 },
1394                direct_covered: 6,
1395                tree_only_covered: 3,
1396                uncovered: vec!["src/a.rs".to_string()],
1397                tree_anchors: vec![TreeFanout {
1398                    entity: "engine--big".to_string(),
1399                    artifact: "src/".to_string(),
1400                    fanout: 3,
1401                }],
1402            },
1403            anchors: AnchorComposition {
1404                by_class: BTreeMap::from([
1405                    ("anchored".to_string(), 5),
1406                    ("authored".to_string(), 2),
1407                ]),
1408                by_grain: BTreeMap::from([("file".to_string(), 4), ("tree".to_string(), 1)]),
1409                authored: 2,
1410                observed: 5,
1411                resolves: 4,
1412                drifted: 0,
1413                recheck: 1,
1414                orphaned: 0,
1415                unobserved: 0,
1416            },
1417            findings_by_class: BTreeMap::from([
1418                ("uncovered".to_string(), 1),
1419                ("queued-for-adjudication".to_string(), 1),
1420            ]),
1421            backlog: 1,
1422            superseded: Vec::new(),
1423            disposed_excluded: 0,
1424            disposed_excluded_rationales: Vec::new(),
1425            degradations: vec!["hash-adjudication-deferred — 1 anchor(s) recheck".to_string()],
1426        }
1427    }
1428
1429    /// B1 — the report renders every required element deterministically, with
1430    /// tree fan-out on its own axis, `authored` as its own excluded bucket, and
1431    /// the backlog depth. Two renders of the same input are byte-identical (no
1432    /// LLM, no clock).
1433    #[test]
1434    fn b1_renders_all_elements_deterministically() {
1435        let r = base_report();
1436        let a = render_fidelity_report(&r, 8_000, &[]);
1437        let b = render_fidelity_report(&r, 8_000, &[]);
1438        assert_eq!(a.markdown, b.markdown, "deterministic — identical bytes");
1439
1440        let md = &a.markdown;
1441        // Grain-classed coverage with tree fan-out SEPARATE, never blended.
1442        assert!(md.contains("direct-covered (file / span anchors): 6/10"));
1443        assert!(md.contains(
1444            "tree-anchor fan-out (separate axis): 1 tree anchor(s) fanning out over 3 file(s)"
1445        ));
1446        // The direct % is NOT (6+3)/10 — the tree fan-out is not folded in.
1447        assert!(
1448            !md.contains("9/10"),
1449            "tree fan-out must not blend into direct coverage"
1450        );
1451        // anchor-resolution % over non-authored observed.
1452        assert!(md.contains("anchor-resolution %:** 4/5"));
1453        // authored is its own excluded bucket.
1454        assert!(md.contains("`authored` bucket (excluded from coverage/accuracy denominators): 2"));
1455        // tier-3 backlog depth from the store tally.
1456        assert!(md.contains("tier-3 adjudication backlog:** 1"));
1457        // capability-matrix block + degradation flags.
1458        assert!(md.contains("## Capability matrix"));
1459        assert!(md.contains("## Degradations"));
1460        assert!(md.contains("hash-adjudication-deferred"));
1461        // B5 denominator provenance.
1462        assert!(md.contains("per-medium enumeration `S(D)` = **10**"));
1463    }
1464
1465    /// B2 — a detection-less medium renders `signal: none` → "freshness
1466    /// unknowable", and NO green freshness verdict appears for it.
1467    #[test]
1468    fn b2_detectionless_medium_freshness_unknowable_never_green() {
1469        let mut r = base_report();
1470        r.capabilities = vec![FacetCapability {
1471            facet: "manual".to_string(),
1472            medium_type: "web".to_string(),
1473            enumerable: false,
1474            change_signal: false,
1475            base_version_retrievable: false,
1476            anchor_namespace: "url".to_string(),
1477            signal: "none".to_string(),
1478        }];
1479        r.freshness = vec![FacetFreshness {
1480            facet: "manual".to_string(),
1481            signal: "none".to_string(),
1482            // Even if a stale token were somehow present, it must never be
1483            // rendered as a fresh/green verdict.
1484            synced: Some("should-never-render-green".to_string()),
1485            verified: Some("nor-this".to_string()),
1486            change_detectable: false,
1487        }];
1488        r.source_moved_past_synced = None;
1489        let out = render_fidelity_report(&r, 8_000, &[]);
1490        let md = &out.markdown;
1491        assert!(md.contains("signal: `none`"));
1492        assert!(md.contains("freshness unknowable"));
1493        // REFUSAL: no fabricated green token, no fresh verdict, no baseline
1494        // token laundered as fresh.
1495        assert!(!md.contains("should-never-render-green"));
1496        assert!(
1497            !md.contains("`#synced`: `"),
1498            "no synced token rendered for a non-detectable medium"
1499        );
1500        assert!(
1501            !md.contains("at its `#synced` baseline"),
1502            "no green 'at baseline' verdict"
1503        );
1504    }
1505
1506    /// B1 — base retrievability is *effective*, keyed on the resolved
1507    /// change-detection strategy, not the medium type's static ceiling. A
1508    /// filesystem binding that resolves to `mtime` (no prior content, only a
1509    /// mod-time signal) has no retrievable base leg, so its facet capability
1510    /// reports `base_version_retrievable: false` — which is exactly what the
1511    /// degradation loop keys on to surface the conflict-flag posture. The same
1512    /// filesystem medium backed by `git` keeps the full never-clobber base leg.
1513    #[test]
1514    fn b1_base_retrievability_follows_resolved_strategy_not_medium_ceiling() {
1515        use crate::pipeline::MediumType;
1516
1517        // The medium type's static ceiling advertises retrievability…
1518        assert!(medium_capabilities(MediumType::Filesystem).base_version_retrievable);
1519
1520        // …but the effective capability derives from the resolved strategy.
1521        let fs_mtime = FacetCapability::from_caps(
1522            "prose".to_string(),
1523            "filesystem".to_string(),
1524            medium_capabilities(MediumType::Filesystem),
1525            ChangeStrategy::Mtime,
1526        );
1527        assert!(
1528            !fs_mtime.base_version_retrievable,
1529            "filesystem+mtime has no retrievable base leg — degrades to conflict-flag"
1530        );
1531        assert_eq!(fs_mtime.signal, "mtime");
1532
1533        let fs_git = FacetCapability::from_caps(
1534            "prose".to_string(),
1535            "filesystem".to_string(),
1536            medium_capabilities(MediumType::Filesystem),
1537            ChangeStrategy::Git,
1538        );
1539        assert!(
1540            fs_git.base_version_retrievable,
1541            "filesystem backed by git keeps the never-clobber base leg"
1542        );
1543
1544        // A detection-less strategy also has no base leg.
1545        assert!(!strategy_retrieves_base(ChangeStrategy::None));
1546        assert!(!strategy_retrieves_base(ChangeStrategy::Mtime));
1547        assert!(strategy_retrieves_base(ChangeStrategy::Git));
1548        assert!(strategy_retrieves_base(ChangeStrategy::Graph));
1549
1550        // The linkage the fix restores: a false effective flag drives the
1551        // conflict-flag degradation the report renders (mirrors the derivation
1552        // in compute_fidelity_report's degradation loop).
1553        let mut r = base_report();
1554        r.capabilities = vec![fs_mtime.clone()];
1555        r.degradations = if !fs_mtime.base_version_retrievable {
1556            vec![format!(
1557                "base-version-unretrievable:`{}` — prune degrades to conflict-flagging",
1558                fs_mtime.facet
1559            )]
1560        } else {
1561            Vec::new()
1562        };
1563        let md = render_fidelity_report(&r, 8_000, &[]).markdown;
1564        assert!(
1565            md.contains("base-version-unretrievable:`prose` — prune degrades to conflict-flagging"),
1566            "filesystem+mtime surfaces the conflict-flag degradation in the report"
1567        );
1568    }
1569
1570    /// B3 — aggregates always ship at budget 0 (mode overbudget, every heavy
1571    /// list dropped to hints).
1572    #[test]
1573    fn b3_aggregates_always_ship_at_zero_budget() {
1574        let r = base_report();
1575        let out = render_fidelity_report(&r, 0, &[]);
1576        assert_eq!(out.mode, "overbudget");
1577        let md = &out.markdown;
1578        // Aggregated counts still ship.
1579        assert!(md.contains("direct-covered (file / span anchors): 6/10"));
1580        assert!(md.contains("tier-3 adjudication backlog:** 1"));
1581        assert!(md.contains("## Capability matrix"));
1582        // The per-artifact list did NOT render inline; it is a hint.
1583        assert!(!md.contains("## Uncovered artifacts"));
1584        assert!(md.contains("## Hints"));
1585        assert!(out.hints.iter().any(|(k, _)| k == "uncovered_artifacts"));
1586    }
1587
1588    /// B3 — a large facet's per-artifact list never renders unbounded under a
1589    /// small budget: it is dropped to a hint with an estimated_tokens figure.
1590    /// The complement: `include` forces it in past the budget.
1591    #[test]
1592    fn b3_large_facet_list_truncates_then_include_forces() {
1593        let mut r = base_report();
1594        // A large uncovered facet — 500 artifacts.
1595        r.coverage.uncovered = (0..500).map(|i| format!("src/file_{i}.rs")).collect();
1596        // A budget large enough for the aggregates but not the huge list.
1597        let hard_cost = estimate_tokens(&render_hard_required(&r));
1598        let out = render_fidelity_report(&r, hard_cost + 5, &[]);
1599        assert_eq!(out.mode, "reduced");
1600        assert!(
1601            !out.markdown.contains("src/file_499.rs"),
1602            "big list not rendered unbounded"
1603        );
1604        assert!(out.markdown.contains("## Hints"));
1605        let (_, est) = out
1606            .hints
1607            .iter()
1608            .find(|(k, _)| k == "uncovered_artifacts")
1609            .expect("uncovered list hinted");
1610        assert!(*est > 5, "the hint carries a real estimated_tokens figure");
1611
1612        // Complement: include forces the section in past the budget.
1613        let forced =
1614            render_fidelity_report(&r, hard_cost + 5, &["uncovered_artifacts".to_string()]);
1615        assert!(
1616            forced.markdown.contains("src/file_499.rs"),
1617            "include forces the full list"
1618        );
1619    }
1620
1621    /// B4 — exhaustive vs curated framing differs: exhaustive calls unaccounted
1622    /// artifacts findings; curated calls them information.
1623    #[test]
1624    fn b4_curated_vs_exhaustive_framing() {
1625        let mut exhaustive = base_report();
1626        exhaustive.coverage_semantics = CoverageSemantics::Exhaustive;
1627        let ex_md = render_fidelity_report(&exhaustive, 8_000, &[]).markdown;
1628        assert!(ex_md.contains("Exhaustive coverage:"));
1629        assert!(ex_md.contains("are **findings**"));
1630
1631        let mut curated = base_report();
1632        curated.coverage_semantics = CoverageSemantics::Curated;
1633        let cur_md = render_fidelity_report(&curated, 8_000, &[]).markdown;
1634        assert!(cur_md.contains("Curated coverage:"));
1635        assert!(cur_md.contains("**information**"));
1636        assert!(
1637            !cur_md.contains("are **findings**"),
1638            "curated never frames unaccounted as findings"
1639        );
1640    }
1641
1642    /// B4 — a persisted disposition removes an uncovered artifact from the
1643    /// exhaustive findings count.
1644    #[test]
1645    fn b4_disposition_excludes_from_exhaustive_findings() {
1646        let mut r = base_report();
1647        r.coverage_semantics = CoverageSemantics::Exhaustive;
1648        r.coverage.uncovered = vec!["src/a.rs".to_string(), "src/b.rs".to_string()];
1649        r.disposed_excluded = 1;
1650        let md = render_fidelity_report(&r, 8_000, &[]).markdown;
1651        // 2 uncovered − 1 disposed = 1 finding.
1652        assert!(md.contains("1 unaccounted artifact(s)"));
1653        assert!(md.contains("(1 disposed excluded)"));
1654    }
1655
1656    /// B4 — the authored-exclusion ledger renders each excluded artifact with
1657    /// its reasoning, so the editorial decision stays visible (not just counted).
1658    #[test]
1659    fn b4_authored_exclusion_rationale_is_rendered() {
1660        let mut r = base_report();
1661        r.coverage_semantics = CoverageSemantics::Exhaustive;
1662        r.coverage.uncovered = vec!["src/gen.rs".to_string()];
1663        r.disposed_excluded = 1;
1664        r.disposed_excluded_rationales =
1665            vec![("src/gen.rs".to_string(), "generated; no entity".to_string())];
1666        let md = render_fidelity_report(&r, 8_000, &[]).markdown;
1667        assert!(md.contains("Excluded on purpose (persisted dispositions):"));
1668        assert!(md.contains("`src/gen.rs` — generated; no entity"));
1669    }
1670
1671    /// B5 — the denominator provenance is stated; a non-enumerable medium says
1672    /// so rather than inventing a denominator.
1673    #[test]
1674    fn b5_denominator_provenance_stated() {
1675        let r = base_report();
1676        let md = render_fidelity_report(&r, 8_000, &[]).markdown;
1677        assert!(md.contains("## Denominator provenance"));
1678        assert!(md.contains("per-medium enumeration `S(D)` = **10**"));
1679
1680        let mut non = base_report();
1681        non.coverage.denominator = DenominatorBasis::NonEnumerable {
1682            reason: "the medium type(s) are not enumerable this cycle".to_string(),
1683        };
1684        let md2 = render_fidelity_report(&non, 8_000, &[]).markdown;
1685        assert!(md2.contains("No `S(D)` denominator"));
1686        assert!(md2.contains("not enumerable this cycle"));
1687    }
1688
1689    /// E1 (report half) — a mem that predates its binding renders the onboarding
1690    /// framing: the expected-0%-anchored statement plus the concrete backfill
1691    /// path. REFUSAL: no failure/error framing and no red "are findings" verdict
1692    /// is produced solely by pre-binding history — the uncovered artifacts are
1693    /// reframed as the backfill worklist.
1694    #[test]
1695    fn e1_adopt_report_renders_onboarding_no_red_verdict() {
1696        let mut r = base_report();
1697        r.adopt = true;
1698        r.coverage_semantics = CoverageSemantics::Exhaustive;
1699        r.coverage.uncovered = (0..5).map(|i| format!("src/file_{i}.rs")).collect();
1700        let md = render_fidelity_report(&r, 8_000, &[]).markdown;
1701
1702        // Onboarding framing leads, with the expected-0% statement …
1703        assert!(md.contains("## Adopting — first verify"));
1704        assert!(md.contains("0% anchored is expected — this is onboarding, not a failure."));
1705        // … and the concrete backfill path.
1706        assert!(
1707            md.contains("**Backfill path:** run `memstead projection brief engine/graph --sync`")
1708        );
1709        // REFUSAL: the exhaustive branch never frames uncovered as red defect
1710        // "findings" under adopt — it is the onboarding backfill worklist.
1711        assert!(
1712            !md.contains("are **findings**"),
1713            "pre-binding history must not produce a red findings verdict"
1714        );
1715        assert!(md.contains("Exhaustive coverage (onboarding):"));
1716        assert!(md.contains("backfill worklist"));
1717
1718        // Complement: without adopt, the same uncovered set IS framed as findings.
1719        r.adopt = false;
1720        let md2 = render_fidelity_report(&r, 8_000, &[]).markdown;
1721        assert!(!md2.contains("## Adopting — first verify"));
1722        assert!(md2.contains("are **findings**"));
1723    }
1724
1725    /// An unknown include key is surfaced as a warning, not silently dropped.
1726    #[test]
1727    fn unknown_include_key_warns() {
1728        let r = base_report();
1729        let out = render_fidelity_report(&r, 8_000, &["bogus".to_string()]);
1730        assert!(out.markdown.contains("unknown include key `bogus`"));
1731    }
1732
1733    // ---- assembly (impure) end-to-end ------------------------------------
1734
1735    use crate::anchor::{Anchor, AnchorHashStability, AnchorProvenanceClass, AnchorSidecar};
1736    use crate::binding::{
1737        BINDING_VERSION, Binding, BuildMode, BuildOperation, DEFAULT_ADJUDICATION_CAP,
1738        DEFAULT_FULL_RESYNC_EVERY, Operations, VerifyOperation,
1739    };
1740    use crate::ingest::findings::verify_binding;
1741    use crate::ingest::resolve::resolve_binding_run;
1742    use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
1743    use crate::pipeline_store::{load_pipeline_configs, write_binding};
1744    use crate::workspace::{
1745        Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
1746    };
1747    use crate::workspace_store::WorkspaceStoreAdapter;
1748
1749    /// The assembly reads the engine, findings store, and enumeration end to
1750    /// end: coverage is classed over `S(D)` with a direct-covered file, a
1751    /// tree-only file, and an uncovered file; the tree fan-out is on its own
1752    /// axis; the `authored` anchor is its own excluded bucket; the tier-3
1753    /// backlog reads from the store the verify pass populated. Read-only on the
1754    /// mem throughout (`&Engine`).
1755    #[test]
1756    fn compute_report_end_to_end() {
1757        let tmp = tempfile::tempdir().unwrap();
1758        let root = tmp.path();
1759        let mem_dir = root.join("mem");
1760        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1761        std::fs::write(
1762            mem_dir.join(".memstead").join("config.json"),
1763            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1764        )
1765        .unwrap();
1766
1767        std::fs::create_dir_all(root.join(".memstead")).unwrap();
1768        std::fs::write(
1769            root.join(".memstead").join("workspace.toml"),
1770            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1771        )
1772        .unwrap();
1773        let mount = Mount {
1774            mem: "engine".to_string(),
1775            schema: Some("default@1.0.0".parse().unwrap()),
1776            storage: MountStorage::Folder {
1777                path: mem_dir.clone(),
1778            },
1779            capability: MountCapability::Write,
1780            lifecycle: MountLifecycle::Eager,
1781            cross_linkable: false,
1782            migration_target: None,
1783        };
1784        crate::FileWorkspaceStore::new()
1785            .save_state(
1786                root,
1787                &Workspace {
1788                    mounts: vec![mount],
1789                    settings: WorkspaceSettings::default(),
1790                },
1791            )
1792            .unwrap();
1793
1794        let out = std::process::Command::new("git")
1795            .args(["init", "-q"])
1796            .current_dir(root)
1797            .output()
1798            .unwrap();
1799        assert!(out.status.success());
1800        std::fs::create_dir_all(root.join("src").join("sub")).unwrap();
1801        std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
1802        std::fs::write(root.join("src").join("uncovered.rs"), "fn b() {}\n").unwrap();
1803        std::fs::write(root.join("src").join("sub").join("deep.rs"), "fn c() {}\n").unwrap();
1804
1805        let mk = |artifact: &str, grain: AnchorGrain, class: AnchorProvenanceClass| Anchor {
1806            artifact: artifact.to_string(),
1807            grain,
1808            class,
1809            at_version: None,
1810            hash: class.is_hash_bearing().then(|| "recorded".to_string()),
1811            hash_stability: AnchorHashStability::Stable,
1812            derived_from: Vec::new(),
1813            binding: None,
1814            source: None,
1815        };
1816        let mut sidecar = AnchorSidecar::default();
1817        sidecar.set(
1818            "engine--direct",
1819            vec![mk(
1820                "src/present.rs",
1821                AnchorGrain::File,
1822                AnchorProvenanceClass::Anchored,
1823            )],
1824        );
1825        sidecar.set(
1826            "engine--tree",
1827            vec![mk(
1828                "src/sub/",
1829                AnchorGrain::Tree,
1830                AnchorProvenanceClass::Anchored,
1831            )],
1832        );
1833        // An authored anchor — its own excluded bucket, never scored.
1834        sidecar.set(
1835            "engine--auth",
1836            vec![mk(
1837                "src/present.rs",
1838                AnchorGrain::File,
1839                AnchorProvenanceClass::Authored,
1840            )],
1841        );
1842        std::fs::write(
1843            mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
1844            sidecar.to_bytes(),
1845        )
1846        .unwrap();
1847
1848        write_binding(
1849            root,
1850            "engine",
1851            "graph",
1852            &Binding {
1853                version: BINDING_VERSION,
1854                intent: None,
1855                sources: vec![crate::pipeline::Source {
1856                    name: "graph".to_string(),
1857                    medium_type: MediumType::Codebase,
1858                    pointer: String::new(),
1859                    change_detection: Some("git".to_string()),
1860                    scope: vec![PatternEntry {
1861                        path: "src/**/*.rs".to_string(),
1862                        mode: PatternMode::Allow,
1863                    }],
1864                    engagement: None,
1865                    preparation: None,
1866                }],
1867                reference_mems: Vec::new(),
1868                destination_mem: "engine".to_string(),
1869                deny_paths: Vec::new(),
1870                coverage_semantics: None,
1871                rules: None,
1872                prune: None,
1873                operations: Operations {
1874                    build: Some(BuildOperation {
1875                        mode: BuildMode::Discovery,
1876                        trigger: IngestTrigger::Loop,
1877                        batch_size: 20,
1878                        post_actions: None,
1879                    }),
1880                    sync: None,
1881                    verify: Some(VerifyOperation {
1882                        trigger: IngestTrigger::Manual,
1883                        batch_size: 20,
1884                        adjudication_cap: DEFAULT_ADJUDICATION_CAP,
1885                        full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
1886                    }),
1887                },
1888            },
1889        )
1890        .unwrap();
1891
1892        let engine = Engine::from_workspace_root(root).unwrap();
1893        let configs = load_pipeline_configs(root).unwrap();
1894        let binding = &configs.bindings[0].config;
1895        let resolved = resolve_binding_run("engine/graph", binding).unwrap();
1896
1897        // Populate the durable findings store (group A) — read-only on the mem.
1898        let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
1899
1900        // Assemble the tier-1 report (group B) under the same key.
1901        let report = compute_fidelity_report(&engine, root, binding, &resolved, &outcome.key);
1902
1903        // S(D) = the three .rs files under src/.
1904        assert_eq!(
1905            report.coverage.denominator,
1906            DenominatorBasis::Enumerated { count: 3 }
1907        );
1908        // present.rs is directly covered; sub/deep.rs is tree-only; uncovered.rs
1909        // is uncovered.
1910        assert_eq!(report.coverage.direct_covered, 1);
1911        assert_eq!(report.coverage.tree_only_covered, 1);
1912        assert_eq!(
1913            report.coverage.uncovered,
1914            vec!["src/uncovered.rs".to_string()]
1915        );
1916        // The tree anchor's fan-out is on its own axis — one anchor over one file.
1917        assert_eq!(report.coverage.tree_anchors.len(), 1);
1918        assert_eq!(report.coverage.tree_anchors[0].fanout, 1);
1919        assert_eq!(report.coverage.tree_anchors[0].artifact, "src/sub/");
1920        // `authored` is its own excluded bucket, never in the resolution tally.
1921        assert_eq!(report.anchors.authored, 1);
1922        assert_eq!(report.anchors.by_class.get("authored"), Some(&1));
1923        // Two hash-bearing anchors present: the file anchor's recorded hash
1924        // mismatches the observed prepared form → deterministic drift; the
1925        // tree anchor has no prepared form without a code map → recheck (honest
1926        // deferral, never fabricated drift). Observed excludes authored.
1927        assert_eq!(report.anchors.observed, 2);
1928        assert_eq!(report.anchors.recheck, 1);
1929        assert_eq!(report.anchors.drifted, 1);
1930        // Backlog reads from the store the verify pass populated.
1931        assert_eq!(report.backlog, outcome.backlog);
1932        // A degradation flag for the deferred hash adjudication.
1933        assert!(
1934            report
1935                .degradations
1936                .iter()
1937                .any(|d| d.contains("hash-adjudication-deferred"))
1938        );
1939        // The rendered report is deterministic and carries the S(D) statement.
1940        let md = render_fidelity_report(&report, 8_000, &[]).markdown;
1941        assert!(md.contains("per-medium enumeration `S(D)` = **3**"));
1942        // This mem carries anchors, so it does NOT predate its binding — no
1943        // onboarding framing (the E1 complement).
1944        assert!(!report.adopt);
1945        assert!(!md.contains("## Adopting — first verify"));
1946    }
1947
1948    /// E1 (report half) end-to-end — a mem with **no** anchors and no `#synced`
1949    /// baseline predates its binding: `compute_fidelity_report` sets `adopt` from
1950    /// the live engine, and the rendered report leads with onboarding framing
1951    /// with no red findings verdict. Read-only on the mem (`&Engine`).
1952    #[test]
1953    fn compute_report_adopt_when_mem_predates_binding() {
1954        let tmp = tempfile::tempdir().unwrap();
1955        let root = tmp.path();
1956        let mem_dir = root.join("mem");
1957        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1958        std::fs::write(
1959            mem_dir.join(".memstead").join("config.json"),
1960            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1961        )
1962        .unwrap();
1963        std::fs::create_dir_all(root.join(".memstead")).unwrap();
1964        std::fs::write(
1965            root.join(".memstead").join("workspace.toml"),
1966            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1967        )
1968        .unwrap();
1969        let mount = Mount {
1970            mem: "engine".to_string(),
1971            schema: Some("default@1.0.0".parse().unwrap()),
1972            storage: MountStorage::Folder {
1973                path: mem_dir.clone(),
1974            },
1975            capability: MountCapability::Write,
1976            lifecycle: MountLifecycle::Eager,
1977            cross_linkable: false,
1978            migration_target: None,
1979        };
1980        crate::FileWorkspaceStore::new()
1981            .save_state(
1982                root,
1983                &Workspace {
1984                    mounts: vec![mount],
1985                    settings: WorkspaceSettings::default(),
1986                },
1987            )
1988            .unwrap();
1989        let out = std::process::Command::new("git")
1990            .args(["init", "-q"])
1991            .current_dir(root)
1992            .output()
1993            .unwrap();
1994        assert!(out.status.success());
1995        std::fs::create_dir_all(root.join("src")).unwrap();
1996        // In-scope source with no anchor yet — the backfill worklist.
1997        std::fs::write(root.join("src").join("a.rs"), "fn a() {}\n").unwrap();
1998        std::fs::write(root.join("src").join("b.rs"), "fn b() {}\n").unwrap();
1999
2000        write_binding(
2001            root,
2002            "engine",
2003            "graph",
2004            &Binding {
2005                version: BINDING_VERSION,
2006                intent: None,
2007                sources: vec![crate::pipeline::Source {
2008                    name: "graph".to_string(),
2009                    medium_type: MediumType::Codebase,
2010                    pointer: String::new(),
2011                    change_detection: Some("git".to_string()),
2012                    scope: vec![PatternEntry {
2013                        path: "src/**/*.rs".to_string(),
2014                        mode: PatternMode::Allow,
2015                    }],
2016                    engagement: None,
2017                    preparation: None,
2018                }],
2019                reference_mems: Vec::new(),
2020                destination_mem: "engine".to_string(),
2021                deny_paths: Vec::new(),
2022                coverage_semantics: None,
2023                rules: None,
2024                prune: None,
2025                operations: Operations {
2026                    build: Some(BuildOperation {
2027                        mode: BuildMode::Discovery,
2028                        trigger: IngestTrigger::Loop,
2029                        batch_size: 20,
2030                        post_actions: None,
2031                    }),
2032                    sync: None,
2033                    verify: Some(VerifyOperation {
2034                        trigger: IngestTrigger::Manual,
2035                        batch_size: 20,
2036                        adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2037                        full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2038                    }),
2039                },
2040            },
2041        )
2042        .unwrap();
2043
2044        let engine = Engine::from_workspace_root(root).unwrap();
2045        let configs = load_pipeline_configs(root).unwrap();
2046        let binding = &configs.bindings[0].config;
2047        let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2048        let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2049        let report = compute_fidelity_report(&engine, root, binding, &resolved, &outcome.key);
2050
2051        // No anchors + no baseline → the mem predates its binding (E1).
2052        assert!(
2053            report.adopt,
2054            "a no-anchor, never-synced mem predates its binding"
2055        );
2056        let md = render_fidelity_report(&report, 8_000, &[]).markdown;
2057        assert!(md.contains("## Adopting — first verify"));
2058        assert!(md.contains("0% anchored is expected"));
2059        // REFUSAL: the uncovered source is NOT a red findings verdict here.
2060        assert!(!md.contains("are **findings**"));
2061        assert!(md.contains("Exhaustive coverage (onboarding):"));
2062    }
2063
2064    /// The report renders the EFFECTIVE coverage and marks the case
2065    /// where it was resolved from the media rather than declared —
2066    /// a reader never mistakes a resolution for an author's assertion.
2067    #[test]
2068    fn report_marks_resolved_coverage_semantics() {
2069        let mut resolved = base_report();
2070        resolved.coverage_semantics = CoverageSemantics::Curated;
2071        resolved.coverage_semantics_declared = false;
2072        let md = render_hard_required(&resolved);
2073        assert!(
2074            md.contains("curated (resolved from the sources' media — not declared)"),
2075            "resolved value carries the marker: {md}"
2076        );
2077
2078        let declared = base_report(); // declared: true in the fixture
2079        let md = render_hard_required(&declared);
2080        assert!(
2081            md.contains("**Coverage semantics:** exhaustive\n"),
2082            "declared value renders bare: {md}"
2083        );
2084        assert!(
2085            !md.contains("(resolved from the sources' media"),
2086            "no resolution marker on a declared value: {md}"
2087        );
2088    }
2089}
2090
2091#[cfg(test)]
2092mod rollup_tests {
2093    use super::*;
2094
2095    /// A report whose every axis is substantive and whose findings are empty
2096    /// — the only shape that may verdict `clean`. Each test degrades exactly
2097    /// one axis from here, so a failure names the axis that moved.
2098    fn clean_report() -> FidelityReport {
2099        FidelityReport {
2100            binding: "engine/graph".to_string(),
2101            destination_mem: "engine".to_string(),
2102            adopt: false,
2103            coverage_semantics: CoverageSemantics::Exhaustive,
2104            coverage_semantics_declared: true,
2105            capabilities: vec![FacetCapability {
2106                facet: "src".to_string(),
2107                medium_type: "codebase".to_string(),
2108                enumerable: true,
2109                change_signal: true,
2110                base_version_retrievable: true,
2111                anchor_namespace: "path".to_string(),
2112                signal: "git".to_string(),
2113            }],
2114            freshness: vec![FacetFreshness {
2115                facet: "src".to_string(),
2116                signal: "git".to_string(),
2117                synced: Some("deadbeef".to_string()),
2118                verified: None,
2119                change_detectable: true,
2120            }],
2121            source_moved_past_synced: Some(false),
2122            coverage: GrainCoverage {
2123                denominator: DenominatorBasis::Enumerated { count: 4 },
2124                direct_covered: 4,
2125                tree_only_covered: 0,
2126                uncovered: Vec::new(),
2127                tree_anchors: Vec::new(),
2128            },
2129            anchors: AnchorComposition {
2130                by_class: BTreeMap::from([("anchored".to_string(), 4)]),
2131                by_grain: BTreeMap::from([("file".to_string(), 4)]),
2132                authored: 0,
2133                observed: 4,
2134                resolves: 4,
2135                drifted: 0,
2136                recheck: 0,
2137                orphaned: 0,
2138                unobserved: 0,
2139            },
2140            findings_by_class: BTreeMap::new(),
2141            backlog: 0,
2142            superseded: Vec::new(),
2143            disposed_excluded: 0,
2144            disposed_excluded_rationales: Vec::new(),
2145            degradations: Vec::new(),
2146        }
2147    }
2148
2149    /// A substantive pass with nothing recorded is the only way to green.
2150    #[test]
2151    fn clean_requires_a_substantive_pass_and_no_findings() {
2152        let mut r = clean_report();
2153        assert_eq!(r.rollup().verdict, RollupVerdict::Clean);
2154        assert!(r.rollup().blind_spots.is_empty());
2155        assert!(r.rollup().actions.is_empty());
2156
2157        r.findings_by_class.insert("drifted".to_string(), 2);
2158        let roll = r.rollup();
2159        assert_eq!(roll.verdict, RollupVerdict::Drifted);
2160        assert_eq!(roll.findings_total, 2);
2161        assert!(
2162            roll.actions[0].contains("moved since the entity was written"),
2163            "the top action is the concrete next step: {:?}",
2164            roll.actions
2165        );
2166    }
2167
2168    /// Criterion 4's complement: a vacuous measurement is never summarized as
2169    /// clean. The graph medium's `0/0` case reports `enumerable: true` and
2170    /// enumerates nothing, which is exactly how a "0 findings" run could look
2171    /// green while having observed no source at all.
2172    #[test]
2173    fn a_vacuous_zero_over_zero_is_inconclusive_not_clean() {
2174        let mut r = clean_report();
2175        r.coverage.denominator = DenominatorBasis::Enumerated { count: 0 };
2176        let roll = r.rollup();
2177        assert_eq!(
2178            roll.verdict,
2179            RollupVerdict::Inconclusive,
2180            "0/0 is not a clean bill of health"
2181        );
2182        assert!(
2183            roll.blind_spots.iter().any(|s| s.contains("vacuous")),
2184            "the blindness is named, not implied: {:?}",
2185            roll.blind_spots
2186        );
2187    }
2188
2189    /// A facet that cannot be enumerated blocks green on its own, even when
2190    /// a sibling facet makes the binding-level denominator `Enumerated`. The
2191    /// mixed-binding case is exactly where a per-binding check would miss it.
2192    #[test]
2193    fn a_non_enumerable_facet_blocks_green_even_in_a_mixed_binding() {
2194        let mut r = clean_report();
2195        r.capabilities.push(FacetCapability {
2196            facet: "site".to_string(),
2197            medium_type: "web".to_string(),
2198            enumerable: false,
2199            // Deliberately TRUE: isolates the enumerability axis from the
2200            // change-signal one, so this test fails if only the latter is
2201            // checked.
2202            change_signal: true,
2203            base_version_retrievable: false,
2204            anchor_namespace: "url".to_string(),
2205            signal: "none".to_string(),
2206        });
2207        // The enumerable sibling keeps the denominator populated.
2208        assert!(matches!(
2209            r.coverage.denominator,
2210            DenominatorBasis::Enumerated { count } if count > 0
2211        ));
2212        let roll = r.rollup();
2213        assert_eq!(
2214            roll.verdict,
2215            RollupVerdict::Inconclusive,
2216            "one enumerable facet must not launder a non-enumerable one: {roll:?}"
2217        );
2218        assert!(
2219            roll.blind_spots
2220                .iter()
2221                .any(|s| s.contains("not enumerable")),
2222            "{:?}",
2223            roll.blind_spots
2224        );
2225    }
2226
2227    /// A binding that declares `change_detection: "none"` over a medium that
2228    /// COULD signal change is change-blind all the same. The capability row
2229    /// still reads `change_signal: true` — only the resolved signal and the
2230    /// freshness row know — so a rollup reading capabilities alone renders
2231    /// this green while its own body prints "freshness unknowable".
2232    #[test]
2233    fn a_resolved_signal_of_none_blocks_green_even_when_the_medium_could_signal() {
2234        let mut r = clean_report();
2235        // Exactly the shape `change_detection: "none"` over a codebase
2236        // produces: the MEDIUM can signal, the BINDING declined to.
2237        r.capabilities[0].change_signal = true;
2238        r.capabilities[0].signal = "none".to_string();
2239        r.freshness[0].change_detectable = false;
2240        r.freshness[0].signal = "none".to_string();
2241        let roll = r.rollup();
2242        assert_eq!(
2243            roll.verdict,
2244            RollupVerdict::Inconclusive,
2245            "a change-blind binding is not a clean bill of health: {roll:?}"
2246        );
2247        assert!(
2248            roll.blind_spots
2249                .iter()
2250                .any(|s| s.contains("could not read that signal")),
2251            "the blind spot names the unreadable signal: {:?}",
2252            roll.blind_spots
2253        );
2254    }
2255
2256    /// A medium with no change signal cannot observe drift, so it cannot
2257    /// support a green verdict on that axis — the capability row decides,
2258    /// not the finding count.
2259    #[test]
2260    fn a_facet_without_a_change_signal_blocks_green() {
2261        let mut r = clean_report();
2262        r.capabilities[0].change_signal = false;
2263        let roll = r.rollup();
2264        assert_eq!(roll.verdict, RollupVerdict::Inconclusive);
2265        assert!(
2266            roll.blind_spots
2267                .iter()
2268                .any(|s| s.contains("no change signal")),
2269            "{:?}",
2270            roll.blind_spots
2271        );
2272    }
2273
2274    /// A non-enumerable scope means an uncovered artifact is undetectable —
2275    /// silence there is absence of evidence, not evidence of absence.
2276    #[test]
2277    fn a_non_enumerable_scope_blocks_green() {
2278        let mut r = clean_report();
2279        r.coverage.denominator = DenominatorBasis::NonEnumerable {
2280            reason: "web medium".to_string(),
2281        };
2282        assert_eq!(r.rollup().verdict, RollupVerdict::Inconclusive);
2283    }
2284
2285    /// A pass that adjudicated nothing observed nothing.
2286    #[test]
2287    fn zero_observed_anchors_blocks_green() {
2288        let mut r = clean_report();
2289        r.anchors.observed = 0;
2290        r.anchors.resolves = 0;
2291        assert_eq!(r.rollup().verdict, RollupVerdict::Inconclusive);
2292    }
2293
2294    /// E1: a mem that predates its binding is expected to be 0% anchored, so
2295    /// uncovered findings there are the backfill worklist. No red verdict may
2296    /// be produced SOLELY by pre-binding history — but it is not clean either.
2297    #[test]
2298    fn adopt_with_only_uncovered_is_never_red() {
2299        let mut r = clean_report();
2300        r.adopt = true;
2301        r.findings_by_class.insert("uncovered".to_string(), 12);
2302        let roll = r.rollup();
2303        assert_eq!(
2304            roll.verdict,
2305            RollupVerdict::Inconclusive,
2306            "onboarding is neither drift nor a clean bill: {roll:?}"
2307        );
2308        assert!(
2309            roll.because.contains("backfill worklist"),
2310            "the reason states the onboarding framing: {}",
2311            roll.because
2312        );
2313
2314        // Real drift on an adopting mem is still drift — the E1 framing
2315        // covers pre-binding history, not everything that follows it.
2316        r.findings_by_class.insert("drifted".to_string(), 1);
2317        assert_eq!(r.rollup().verdict, RollupVerdict::Drifted);
2318    }
2319
2320    /// An observed finding outranks a blind spot: the pass could not see
2321    /// everything, but what it did see is real.
2322    #[test]
2323    fn findings_outrank_blind_spots() {
2324        let mut r = clean_report();
2325        r.capabilities[0].change_signal = false;
2326        r.findings_by_class.insert("wrong".to_string(), 1);
2327        let roll = r.rollup();
2328        assert_eq!(roll.verdict, RollupVerdict::Drifted);
2329        assert!(
2330            !roll.blind_spots.is_empty(),
2331            "the blindness is still reported alongside the verdict"
2332        );
2333    }
2334
2335    /// Actions are ordered by what a reader should fix first, and a class the
2336    /// vocabulary grows past the ranked list is never silently dropped.
2337    #[test]
2338    fn actions_are_severity_ordered_and_never_drop_a_class() {
2339        let mut r = clean_report();
2340        r.findings_by_class.insert("uncovered".to_string(), 3);
2341        r.findings_by_class.insert("wrong".to_string(), 1);
2342        r.findings_by_class
2343            .insert("some-future-class".to_string(), 2);
2344        let roll = r.rollup();
2345        assert!(
2346            roll.actions[0].contains("contradict their source"),
2347            "{roll:?}"
2348        );
2349        assert_eq!(roll.actions.len(), 3, "{roll:?}");
2350        assert!(
2351            roll.actions.iter().any(|a| a.contains("some-future-class")),
2352            "an unranked class still surfaces: {roll:?}"
2353        );
2354    }
2355
2356    /// The wire vocabulary is closed and stable — consumers branch on it.
2357    #[test]
2358    fn verdict_wire_strings_are_stable() {
2359        assert_eq!(RollupVerdict::Clean.wire(), "clean");
2360        assert_eq!(RollupVerdict::Drifted.wire(), "drifted");
2361        assert_eq!(RollupVerdict::Inconclusive.wire(), "inconclusive");
2362        let json = serde_json::to_string(&RollupVerdict::Inconclusive).unwrap();
2363        assert_eq!(json, "\"inconclusive\"");
2364    }
2365}