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