Skip to main content

memstead_base/ingest/
report.rs

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