Skip to main content

memstead_base/ingest/
report.rs

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