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