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