Skip to main content

memstead_base/ingest/
report.rs

1//! The **tier-1 fidelity report** (bundle plan `05-verify-sync-engine`, group
2//! B) — deterministic, engine-rendered, token-budgeted.
3//!
4//! Verify (group A) records durable findings; this module *renders* a
5//! measurement over them plus the live anchor / capability / freshness state.
6//! It performs **no LLM call** and **no destination-mem mutation** — it reads
7//! the engine, the findings store, the advance store, and the capability
8//! matrix, and formats a report. Any repair instruction is the sync brief's job
9//! (group C), never this report's.
10//!
11//! ## What the report states honestly (B1–B5)
12//!
13//! - **Grain-classed coverage** with tree-anchor fan-out kept on its **own
14//!   axis** — a 1-entity/200-file tree anchor shows as one anchor fanning out
15//!   over 200 files, never laundered into a blended coverage percentage (B1).
16//! - **Anchor-resolution %** over the mem's observed anchors, with `authored`
17//!   provenance **excluded** from the coverage/accuracy denominators and shown
18//!   as its own bucket (B1).
19//! - **Freshness** vs. both `sync_state` tokens (`#synced` / `#verified`). A
20//!   detection-less medium (the capability matrix marks it non-change-
21//!   detectable) renders `signal: none` → *"freshness unknowable"*; a green
22//!   freshness verdict is **structurally unreachable** for such a medium (B2).
23//! - **Token-budgeted** in the house envelope shape shared with
24//!   [`crate::overview`]: aggregates are hard-required and always ship; heavy
25//!   per-artifact lists greedy-fill by priority and, when they do not fit,
26//!   drop to `## Hints` with an `estimated_tokens` figure — never rendered
27//!   unbounded (B3).
28//! - **Coverage semantics** branch: under `curated`, the unaccounted share is
29//!   information; under `exhaustive`, unaccounted artifacts (not anchored, not
30//!   declared-excluded, no persisted disposition) are findings (B4).
31//! - **Denominator provenance** is stated: coverage is relative to the
32//!   per-medium enumeration `S(D)` (B5).
33
34use std::collections::BTreeMap;
35use std::path::Path;
36
37use serde::Serialize;
38
39use crate::Engine;
40use crate::anchor::{AnchorGrain, AnchorProvenanceClass, AnchorState};
41use crate::binding::{BindingV1, CoverageSemantics, MediumCapabilities, medium_capabilities};
42use crate::chunking::estimate_tokens;
43
44use super::advance::read_advance_store;
45use super::cursor::{enumerate_facet_files, source_moved};
46use super::findings::{FindingClass, FindingKey, read_findings_store};
47use super::resolve::{ChangeStrategy, ResolvedIngest, ResolvedSource, resolve_change_strategy};
48
49/// Default token budget for the report's heavy content. Mirrors
50/// [`crate::overview::DEFAULT_OVERVIEW_BUDGET`] — one house envelope, one
51/// default.
52pub const DEFAULT_REPORT_BUDGET: usize = 8_000;
53
54/// Heavy-content include keys the renderer recognises, in **greedy-fill
55/// priority order**. A key listed in `include` forces its section in past the
56/// budget (mirroring the overview envelope); an unlisted key greedy-fills until
57/// the budget is exhausted, then surfaces as a hint. An unknown key is ignored
58/// with a warning line.
59pub const ALLOWED_REPORT_INCLUDE_KEYS: &[&str] =
60    &["uncovered_artifacts", "tree_fanout", "superseded_findings"];
61
62// ---------------------------------------------------------------------------
63// Structured report — the deterministic, pre-computed data the pure renderer
64// formats. Assembling it (`compute_fidelity_report`) reads the engine; the
65// renderer (`render_fidelity_report`) is a pure function over this data, so
66// every B1–B5 assertion tests against a hand-built value with no IO.
67// ---------------------------------------------------------------------------
68
69/// The denominator basis for coverage (B5): coverage is reported relative to
70/// the per-medium enumeration `S(D)`, or — when the medium cannot be
71/// enumerated — the report says so rather than inventing a denominator.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
73#[serde(tag = "kind", rename_all = "kebab-case")]
74pub enum DenominatorBasis {
75    /// `S(D)` was enumerated: `count` source artifacts in scope (after
76    /// `deny_paths`), the coverage denominator.
77    Enumerated {
78        /// `|S(D)|` — the enumerated source-artifact count.
79        count: usize,
80    },
81    /// The medium is non-enumerable (or its type is not enumerated this cycle):
82    /// no `S(D)`, so coverage is reported over anchors only and the denominator
83    /// is stated unavailable.
84    NonEnumerable {
85        /// Why no `S(D)` could be computed.
86        reason: String,
87    },
88}
89
90/// One tree-grain anchor's fan-out over `S(D)` (B1). A tree anchor is one row
91/// here whatever its fan-out — the per-file count is an observation, never a
92/// per-file coverage credit.
93#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
94pub struct TreeFanout {
95    /// The entity id carrying the tree anchor.
96    pub entity: String,
97    /// The tree artifact reference.
98    pub artifact: String,
99    /// How many `S(D)` files fall under this tree.
100    pub fanout: usize,
101}
102
103/// Grain-classed coverage over `S(D)` (B1). Tree-anchor fan-out is a **separate
104/// axis** — `direct_covered` and `tree_only_covered` are never summed into one
105/// blended percentage.
106#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
107pub struct GrainCoverage {
108    /// The denominator basis (B5).
109    pub denominator: DenominatorBasis,
110    /// `S(D)` files directly covered by a non-tree (file / span) anchor.
111    pub direct_covered: usize,
112    /// `S(D)` files covered **only** via a tree-grain anchor (the fan-out axis,
113    /// kept distinct from `direct_covered`).
114    pub tree_only_covered: usize,
115    /// `S(D)` files with no anchor at all (the heavy artifact list).
116    pub uncovered: Vec<String>,
117    /// Per tree anchor, its fan-out over `S(D)` (the heavy detail list).
118    pub tree_anchors: Vec<TreeFanout>,
119}
120
121/// Anchor composition + resolution tally over the destination mem's anchors
122/// (B1). `authored` provenance is pulled into its own bucket and **excluded**
123/// from the resolution (coverage/accuracy) tally.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default)]
125pub struct AnchorComposition {
126    /// Count per provenance-class wire string across **all** the mem's anchors
127    /// (the full transparency breakdown, including `authored`).
128    pub by_class: BTreeMap<String, usize>,
129    /// Count per grain wire string across all the mem's anchors.
130    pub by_grain: BTreeMap<String, usize>,
131    /// `authored`-class anchors — the own bucket, excluded from the resolution
132    /// denominator below.
133    pub authored: usize,
134    /// Non-`authored` anchors that carry a resolution state this pass.
135    pub observed: usize,
136    /// Non-`authored` anchors that resolved clean.
137    pub resolves: usize,
138    /// Non-`authored` anchors that drifted (stable-medium hash break).
139    pub drifted: usize,
140    /// Non-`authored` anchors deferred for re-examination (unstable / no hash).
141    pub recheck: usize,
142    /// Non-`authored` anchors whose artifact is gone.
143    pub orphaned: usize,
144    /// Non-`authored` anchors that could **not** be observed this pass (state
145    /// `None`) — reported honestly, never counted as resolved.
146    pub unobserved: usize,
147}
148
149/// One facet's capability-matrix row + resolved change signal (B1 capability
150/// block; B2 change-detectability; B5 enumeration provenance).
151#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
152pub struct FacetCapability {
153    /// The source facet.
154    pub facet: String,
155    /// The medium type wire string.
156    pub medium_type: String,
157    /// Whether the medium's scope is enumerable (`S(D)` computable).
158    pub enumerable: bool,
159    /// Whether the medium provides a change signal.
160    pub change_signal: bool,
161    /// Whether a base version is retrievable (three-way-merge feasibility).
162    pub base_version_retrievable: bool,
163    /// The anchor namespace (`path` / `path+commit` / `entity` / `url`).
164    pub anchor_namespace: String,
165    /// The resolved change-detection signal (`git` / `mtime` / `graph` /
166    /// `none`).
167    pub signal: String,
168}
169
170impl FacetCapability {
171    fn from_caps(
172        facet: String,
173        medium_type: String,
174        caps: MediumCapabilities,
175        strategy: ChangeStrategy,
176    ) -> Self {
177        FacetCapability {
178            facet,
179            medium_type,
180            enumerable: caps.enumerable,
181            change_signal: caps.change_signal,
182            // Effective, not the static ceiling: a base version is retrievable
183            // only when the *resolved* strategy actually holds prior content.
184            // `mtime` reports that an artifact changed, not its previous bytes,
185            // and `none` detects nothing — either degrades prune to
186            // conflict-flagging even on a medium whose type-level capability
187            // row (e.g. filesystem) advertises base retrievability.
188            base_version_retrievable: caps.base_version_retrievable
189                && strategy_retrieves_base(strategy),
190            anchor_namespace: caps.anchor_namespace.to_string(),
191            signal: signal_wire(strategy).to_string(),
192        }
193    }
194}
195
196/// One facet's freshness state vs. both `sync_state` tokens (B1/B2).
197#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
198pub struct FacetFreshness {
199    /// The source facet.
200    pub facet: String,
201    /// The resolved change signal (`git` / `mtime` / `graph` / `none`).
202    pub signal: String,
203    /// The `#synced` baseline token, or `None` when never synced.
204    pub synced: Option<String>,
205    /// The `#verified` baseline token, or `None` when never verified.
206    pub verified: Option<String>,
207    /// Whether the medium is change-detectable at all: the capability matrix
208    /// marks a change signal **and** a strategy resolved (signal ≠ `none`).
209    /// When `false`, freshness is **unknowable** and the renderer is
210    /// structurally incapable of printing a green verdict for this facet (B2).
211    pub change_detectable: bool,
212}
213
214/// The tier-1 fidelity report — fully computed, deterministic data.
215#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
216pub struct FidelityReport {
217    /// The canonical binding id `<mem>/<stem>`.
218    pub binding: String,
219    /// The destination mem.
220    pub destination_mem: String,
221    /// Whether the destination mem predates its binding — the adopt / onboarding
222    /// case (E1). When `true`, the report leads with the expected-0%-anchored
223    /// onboarding framing and the concrete backfill path, and the coverage
224    /// section frames uncovered artifacts as the backfill worklist rather than
225    /// as defects: no failure/error framing and no red verdict is produced
226    /// **solely** by pre-binding history.
227    pub adopt: bool,
228    /// Whether the binding claims exhaustive or curated coverage (B4).
229    pub coverage_semantics: CoverageSemantics,
230    /// Per-facet capability rows (B1 capability block).
231    pub capabilities: Vec<FacetCapability>,
232    /// Per-facet freshness (B1/B2).
233    pub freshness: Vec<FacetFreshness>,
234    /// Binding-level: has any change-detectable source moved past its `#synced`
235    /// baseline this pass? `None` when no source is change-detectable (nothing
236    /// to compare) — never a fabricated `false`.
237    pub source_moved_past_synced: Option<bool>,
238    /// Grain-classed coverage over `S(D)` (B1/B5).
239    pub coverage: GrainCoverage,
240    /// Anchor composition + resolution (B1).
241    pub anchors: AnchorComposition,
242    /// Findings tally by class over the current key.
243    pub findings_by_class: BTreeMap<String, usize>,
244    /// Tier-3 backlog depth — findings queued for adjudication (B1).
245    pub backlog: usize,
246    /// Findings recorded under a **prior** `(hash(D), source_head)` key,
247    /// segregated as superseded (the heavy detail list is the count's backing).
248    pub superseded: Vec<String>,
249    /// Persisted dispositions that exclude an otherwise-uncovered artifact from
250    /// the exhaustive findings set (B4) — the count (`= disposed_excluded_rationales.len()`).
251    pub disposed_excluded: usize,
252    /// The durable authored-exclusion ledger consulted under exhaustive coverage
253    /// (B4): `(artifact, rationale)` for each uncovered artifact a persisted
254    /// disposition marks deliberately excluded. Removed from the findings /
255    /// backfill denominator and rendered with its reasoning so the editorial
256    /// decision stays visible.
257    pub disposed_excluded_rationales: Vec<(String, String)>,
258    /// Degradation flags (B1) — typed, human/agent-readable strings.
259    pub degradations: Vec<String>,
260}
261
262// ---------------------------------------------------------------------------
263// Rendered output
264// ---------------------------------------------------------------------------
265
266/// The rendered report: markdown plus the structured envelope bits (mode,
267/// hints) mirroring [`crate::overview::OverviewOutput`].
268#[derive(Debug, Clone, PartialEq, Eq)]
269pub struct RenderedFidelityReport {
270    /// The rendered markdown.
271    pub markdown: String,
272    /// `"complete"` / `"reduced"` / `"overbudget"` — the same tri-state the
273    /// overview envelope uses.
274    pub mode: String,
275    /// Drill-in hints for heavy sections omitted under the budget:
276    /// `(key, estimated_tokens)`.
277    pub hints: Vec<(String, usize)>,
278    /// The budget actually consumed by hard-required + emitted heavy content.
279    pub budget_used: usize,
280}
281
282// ---------------------------------------------------------------------------
283// Pure renderer
284// ---------------------------------------------------------------------------
285
286/// Render `N/D (P%)`, or `N/D (n/a)` when the denominator is zero.
287fn ratio(num: usize, den: usize) -> String {
288    if den == 0 {
289        format!("{num}/{den} (n/a)")
290    } else {
291        let pct = (num as f64) * 100.0 / (den as f64);
292        format!("{num}/{den} ({pct:.1}%)")
293    }
294}
295
296/// Render the hard-required (always-ships) aggregate markdown for a report.
297/// This is the content B3's "aggregated counts always ship" rests on — it is
298/// concatenated whatever the budget.
299fn render_hard_required(report: &FidelityReport) -> String {
300    let mut md = String::new();
301    md.push_str(&format!("# Fidelity report — `{}`\n\n", report.binding));
302    md.push_str(&format!(
303        "- **Destination mem:** `{}`\n- **Coverage semantics:** {}\n\n",
304        report.destination_mem,
305        match report.coverage_semantics {
306            CoverageSemantics::Exhaustive => "exhaustive",
307            CoverageSemantics::Curated => "curated",
308        }
309    ));
310
311    // --- Adopt / onboarding framing (E1) ---
312    // When the mem predates its binding, the report LEADS with onboarding
313    // framing: the expected-0%-anchored statement plus the concrete backfill
314    // path. REFUSAL: this is never a failure/error framing and the report never
315    // produces a red verdict solely from pre-binding history — the coverage
316    // section below reframes uncovered artifacts as the backfill worklist.
317    if report.adopt {
318        md.push_str("## Adopting — first verify\n\n");
319        md.push_str(
320            "This mem predates its binding: it carries no anchors and has no prior sync \
321             baseline, so **0% anchored is expected — this is onboarding, not a failure.** \
322             Do not read the coverage numbers below as drift or a red verdict; the uncovered \
323             artifacts are the backfill worklist, not defects.\n\n",
324        );
325        md.push_str(&format!(
326            "**Backfill path:** run `memstead projection sync {}` to work through the in-scope \
327             source artifacts that carry no entity yet, covering the clearly-new concepts among \
328             them through the normal mutation surface. Backfilling is incremental — a partial \
329             pass is fine, and the next sync continues where you left off.\n\n",
330            report.binding
331        ));
332    }
333
334    // --- Denominator provenance (B5) ---
335    md.push_str("## Denominator provenance\n\n");
336    match &report.coverage.denominator {
337        DenominatorBasis::Enumerated { count } => md.push_str(&format!(
338            "Coverage is reported relative to the per-medium enumeration `S(D)` = **{count}** \
339             source artifact(s) in scope (after `deny_paths`).\n\n"
340        )),
341        DenominatorBasis::NonEnumerable { reason } => md.push_str(&format!(
342            "No `S(D)` denominator: {reason}. Coverage is reported over anchors only; the \
343             per-medium enumeration is unavailable.\n\n"
344        )),
345    }
346
347    // --- Capability matrix (B1) ---
348    md.push_str("## Capability matrix\n\n");
349    if report.capabilities.is_empty() {
350        md.push_str("_(no primary sources resolved)_\n\n");
351    } else {
352        for c in &report.capabilities {
353            md.push_str(&format!("### `{}` ({})\n\n", c.facet, c.medium_type));
354            md.push_str(&format!(
355                "- enumerable: {} | change_signal: {} | base_version_retrievable: {}\n",
356                c.enumerable, c.change_signal, c.base_version_retrievable
357            ));
358            md.push_str(&format!(
359                "- anchor_namespace: `{}` | resolved signal: `{}`\n\n",
360                c.anchor_namespace, c.signal
361            ));
362        }
363    }
364
365    // --- Freshness (B1/B2) ---
366    md.push_str("## Freshness\n\n");
367    if report.freshness.is_empty() {
368        md.push_str("_(no source facets)_\n\n");
369    } else {
370        for f in &report.freshness {
371            md.push_str(&format!("### `{}`\n\n", f.facet));
372            md.push_str(&format!("- signal: `{}`\n", f.signal));
373            if !f.change_detectable {
374                // B2 REFUSAL: a non-change-detectable medium NEVER prints a
375                // green freshness verdict — only "unknowable". This branch is
376                // the only place `signal: none` freshness is rendered.
377                md.push_str(
378                    "- **freshness unknowable** — this medium is not change-detectable \
379                     (no change signal); `#synced` / `#verified` cannot be adjudicated as fresh\n",
380                );
381            } else {
382                match &f.synced {
383                    Some(t) => md.push_str(&format!("- `#synced`: `{t}`\n")),
384                    None => md.push_str("- `#synced`: never synced\n"),
385                }
386                match &f.verified {
387                    Some(t) => md.push_str(&format!("- `#verified`: `{t}`\n")),
388                    None => md.push_str("- `#verified`: never verified\n"),
389                }
390            }
391            md.push('\n');
392        }
393        // Binding-level move verdict — only when something is change-detectable.
394        match report.source_moved_past_synced {
395            Some(true) => md.push_str(
396                "**Source moved past its `#synced` baseline** — the graph is stale for the \
397                 moved facet(s); a sync pass is due.\n\n",
398            ),
399            Some(false) => {
400                md.push_str("Every change-detectable source is at its `#synced` baseline.\n\n")
401            }
402            None => {}
403        }
404    }
405
406    // --- Coverage (B1, B4) ---
407    md.push_str("## Coverage (grain-classed)\n\n");
408    let den = match &report.coverage.denominator {
409        DenominatorBasis::Enumerated { count } => *count,
410        DenominatorBasis::NonEnumerable { .. } => 0,
411    };
412    md.push_str(&format!(
413        "- direct-covered (file / span anchors): {}\n",
414        ratio(report.coverage.direct_covered, den)
415    ));
416    // Tree fan-out is a DISTINCT axis — reported separately, never blended into
417    // the direct-covered percentage (B1).
418    let tree_files: usize = report.coverage.tree_anchors.iter().map(|t| t.fanout).sum();
419    md.push_str(&format!(
420        "- tree-anchor fan-out (separate axis): {} tree anchor(s) fanning out over {} file(s); \
421         {} file(s) covered ONLY via a tree anchor\n",
422        report.coverage.tree_anchors.len(),
423        tree_files,
424        report.coverage.tree_only_covered
425    ));
426    md.push_str(&format!(
427        "- uncovered (no anchor): {}\n\n",
428        report.coverage.uncovered.len()
429    ));
430
431    // Coverage-semantics framing (B4). REFUSAL (E1): under adopt, the exhaustive
432    // branch must NOT frame the uncovered artifacts as defect findings — they are
433    // the expected backfill worklist of a mem that predates its binding, never a
434    // red verdict caused solely by pre-binding history.
435    match report.coverage_semantics {
436        CoverageSemantics::Exhaustive if report.adopt => {
437            let backlog = report
438                .coverage
439                .uncovered
440                .len()
441                .saturating_sub(report.disposed_excluded);
442            md.push_str(&format!(
443                "**Exhaustive coverage (onboarding):** {backlog} in-scope artifact(s) carry no \
444                 entity yet ({} disposed excluded) — the expected first-sync backfill worklist \
445                 for a mem that predates its binding, not defects.\n\n",
446                report.disposed_excluded
447            ));
448        }
449        CoverageSemantics::Exhaustive => {
450            let findings = report
451                .coverage
452                .uncovered
453                .len()
454                .saturating_sub(report.disposed_excluded);
455            md.push_str(&format!(
456                "**Exhaustive coverage:** {findings} unaccounted artifact(s) — not anchored, not \
457                 declared-excluded, no persisted disposition ({} disposed excluded) — are \
458                 **findings**.\n\n",
459                report.disposed_excluded
460            ));
461        }
462        CoverageSemantics::Curated => {
463            md.push_str(&format!(
464                "**Curated coverage:** {} unaccounted artifact(s) are **information**, not \
465                 defects — a curated binding covers a deliberate slice.\n\n",
466                report.coverage.uncovered.len()
467            ));
468        }
469    }
470
471    // Authored exclusion ledger (B4) — surface the reasoning behind each
472    // deliberately-excluded artifact so an editorial decision stays visible and
473    // auditable, not just subtracted from a denominator.
474    if !report.disposed_excluded_rationales.is_empty() {
475        md.push_str("**Excluded on purpose (persisted dispositions):**\n");
476        for (artifact, rationale) in &report.disposed_excluded_rationales {
477            if rationale.is_empty() {
478                md.push_str(&format!("- `{artifact}`\n"));
479            } else {
480                md.push_str(&format!("- `{artifact}` — {rationale}\n"));
481            }
482        }
483        md.push('\n');
484    }
485
486    // --- Anchors (B1) ---
487    md.push_str("## Anchors\n\n");
488    md.push_str(&format!(
489        "- by class: {}\n",
490        render_counts(&report.anchors.by_class)
491    ));
492    md.push_str(&format!(
493        "- by grain: {}\n",
494        render_counts(&report.anchors.by_grain)
495    ));
496    md.push_str(&format!(
497        "- `authored` bucket (excluded from coverage/accuracy denominators): {}\n",
498        report.anchors.authored
499    ));
500    md.push_str(&format!(
501        "- resolution (non-`authored`, observed): resolves {}, drifted {}, recheck {}, orphaned {}\n",
502        report.anchors.resolves,
503        report.anchors.drifted,
504        report.anchors.recheck,
505        report.anchors.orphaned
506    ));
507    md.push_str(&format!(
508        "- **anchor-resolution %:** {}\n",
509        ratio(report.anchors.resolves, report.anchors.observed)
510    ));
511    md.push_str(&format!(
512        "- unobserved this pass (state unavailable, never scored as resolved): {}\n\n",
513        report.anchors.unobserved
514    ));
515
516    // --- Findings + backlog (B1) ---
517    md.push_str("## Findings\n\n");
518    md.push_str(&format!(
519        "- by class: {}\n",
520        render_counts(&report.findings_by_class)
521    ));
522    md.push_str(&format!(
523        "- **tier-3 adjudication backlog:** {}\n",
524        report.backlog
525    ));
526    md.push_str(&format!(
527        "- superseded (prior `(hash(D), source_head)` key, segregated): {}\n\n",
528        report.superseded.len()
529    ));
530
531    // --- Degradations (B1) ---
532    md.push_str("## Degradations\n\n");
533    if report.degradations.is_empty() {
534        md.push_str("_(none)_\n\n");
535    } else {
536        for d in &report.degradations {
537            md.push_str(&format!("- {d}\n"));
538        }
539        md.push('\n');
540    }
541
542    md
543}
544
545/// Render a `BTreeMap<String, usize>` as `k=v, k=v` (or `(none)`).
546fn render_counts(counts: &BTreeMap<String, usize>) -> String {
547    if counts.is_empty() {
548        return "(none)".to_string();
549    }
550    counts
551        .iter()
552        .map(|(k, v)| format!("{k}={v}"))
553        .collect::<Vec<_>>()
554        .join(", ")
555}
556
557/// The three heavy sections, in greedy-fill priority order — each a
558/// `(key, markdown)` pair whose markdown is empty when the section has nothing
559/// to show (an empty section is emitted free, never hinted).
560fn heavy_sections(report: &FidelityReport) -> Vec<(&'static str, String)> {
561    let mut out: Vec<(&'static str, String)> = Vec::new();
562
563    // uncovered_artifacts
564    let mut s = String::new();
565    if !report.coverage.uncovered.is_empty() {
566        s.push_str("## Uncovered artifacts\n\n");
567        for a in &report.coverage.uncovered {
568            s.push_str(&format!("- `{a}`\n"));
569        }
570        s.push('\n');
571    }
572    out.push(("uncovered_artifacts", s));
573
574    // tree_fanout
575    let mut s = String::new();
576    if !report.coverage.tree_anchors.is_empty() {
577        s.push_str("## Tree-anchor fan-out (detail)\n\n");
578        for t in &report.coverage.tree_anchors {
579            s.push_str(&format!(
580                "- `{}` → `{}` fans out over {} file(s)\n",
581                t.entity, t.artifact, t.fanout
582            ));
583        }
584        s.push('\n');
585    }
586    out.push(("tree_fanout", s));
587
588    // superseded_findings
589    let mut s = String::new();
590    if !report.superseded.is_empty() {
591        s.push_str("## Superseded findings (detail)\n\n");
592        for f in &report.superseded {
593            s.push_str(&format!("- {f}\n"));
594        }
595        s.push('\n');
596    }
597    out.push(("superseded_findings", s));
598
599    out
600}
601
602/// Render the tier-1 fidelity report into markdown, token-budgeted in the house
603/// envelope shape (B3). Aggregated counts (the hard-required block) always ship;
604/// heavy per-artifact lists greedy-fill by priority and drop to `## Hints` when
605/// they do not fit — `include`-listed keys force their section in past the
606/// budget, exactly as the overview envelope does.
607///
608/// - `budget` — the target token budget for **heavy** content (the aggregates
609///   ship in addition, so total output exceeds this when the report is large).
610/// - `include` — keys forced in regardless of budget; an unknown key adds a
611///   warning line, mirroring the overview composer.
612pub fn render_fidelity_report(
613    report: &FidelityReport,
614    budget: usize,
615    include: &[String],
616) -> RenderedFidelityReport {
617    let hard = render_hard_required(report);
618    let hard_cost = estimate_tokens(&hard);
619    let overbudget = hard_cost > budget;
620
621    let include_set: std::collections::BTreeSet<&str> = include
622        .iter()
623        .map(String::as_str)
624        .filter(|k| ALLOWED_REPORT_INCLUDE_KEYS.contains(k))
625        .collect();
626    let unknown_includes: Vec<&String> = include
627        .iter()
628        .filter(|k| !ALLOWED_REPORT_INCLUDE_KEYS.contains(&k.as_str()))
629        .collect();
630
631    let sections = heavy_sections(report);
632    let mut emitted: Vec<String> = Vec::new();
633    let mut hints: Vec<(String, usize)> = Vec::new();
634    let mut used = hard_cost;
635    let mut remaining = budget.saturating_sub(hard_cost);
636
637    for (key, section_md) in &sections {
638        if section_md.is_empty() {
639            continue; // nothing to show — never hinted, never charged
640        }
641        let cost = estimate_tokens(section_md);
642        let forced = include_set.contains(key);
643        if forced {
644            emitted.push(section_md.clone());
645            used += cost;
646            remaining = remaining.saturating_sub(cost);
647        } else if !overbudget && remaining >= cost {
648            emitted.push(section_md.clone());
649            used += cost;
650            remaining -= cost;
651        } else {
652            hints.push(((*key).to_string(), cost));
653        }
654    }
655
656    let mode = if overbudget {
657        "overbudget"
658    } else if hints.is_empty() {
659        "complete"
660    } else {
661        "reduced"
662    };
663
664    let mut md = String::new();
665    md.push_str("---\n");
666    md.push_str(&format!("_report_mode: {mode}\n"));
667    md.push_str(&format!("_budget_requested: {budget}\n"));
668    md.push_str(&format!("_budget_used: {used}\n"));
669    md.push_str("---\n\n");
670    md.push_str(&hard);
671    for section in &emitted {
672        md.push_str(section);
673    }
674
675    if !hints.is_empty() {
676        md.push_str("## Hints\n\n");
677        md.push_str(
678            "_(heavy sections omitted under the token budget — re-query with the key)_\n\n",
679        );
680        for (key, tokens) in &hints {
681            md.push_str(&format!("- `{key}` — estimated_tokens: {tokens}\n"));
682        }
683        md.push('\n');
684    }
685
686    if !unknown_includes.is_empty() {
687        md.push_str("## Warnings\n\n");
688        for k in &unknown_includes {
689            md.push_str(&format!(
690                "- unknown include key `{k}` — allowed: {}\n",
691                ALLOWED_REPORT_INCLUDE_KEYS.join(", ")
692            ));
693        }
694        md.push('\n');
695    }
696
697    RenderedFidelityReport {
698        markdown: md,
699        mode: mode.to_string(),
700        hints,
701        budget_used: used,
702    }
703}
704
705// ---------------------------------------------------------------------------
706// Assembly — reads the engine, findings store, advance store, capability matrix
707// ---------------------------------------------------------------------------
708
709/// Assemble the tier-1 [`FidelityReport`] for a binding (B1–B5). Read-only on
710/// the destination mem — it borrows `&Engine` (shared), reads the durable
711/// findings store under `key`, the advance store, and the live anchor /
712/// enumeration / freshness state. It performs no mutation and no LLM call.
713///
714/// `key` is the current `(hash(D), source_head)` the verify pass recorded
715/// under (from [`super::findings::VerifyOutcome::key`]); the report's findings
716/// tally is the store's `current(key)` slice, and the superseded count is
717/// everything under prior keys.
718pub fn compute_fidelity_report(
719    engine: &Engine,
720    workspace_root: &Path,
721    binding: &BindingV1,
722    resolved: &ResolvedIngest,
723    key: &FindingKey,
724) -> FidelityReport {
725    let binding_id = resolved.name.clone();
726    let dest = resolved.destination_mem.clone();
727
728    // --- Capabilities + freshness, per primary facet ---
729    let sync_state = engine
730        .mem_config_for(&dest)
731        .map(|c| c.sync_state.clone())
732        .unwrap_or_default();
733    let mut capabilities: Vec<FacetCapability> = Vec::new();
734    let mut freshness: Vec<FacetFreshness> = Vec::new();
735    let mut any_change_detectable = false;
736    for source in &resolved.sources {
737        let ResolvedSource::Primary(p) = source else {
738            continue;
739        };
740        let caps = medium_capabilities(p.medium_type);
741        let medium_type = serde_json::to_value(p.medium_type)
742            .ok()
743            .and_then(|v| v.as_str().map(str::to_string))
744            .unwrap_or_default();
745        let strategy = resolve_change_strategy(p, workspace_root);
746        let signal = signal_wire(strategy).to_string();
747        let change_detectable = caps.change_signal && strategy != ChangeStrategy::None;
748        any_change_detectable |= change_detectable;
749
750        capabilities.push(FacetCapability::from_caps(
751            p.facet_ref.clone(),
752            medium_type,
753            caps,
754            strategy,
755        ));
756
757        let synced = sync_state
758            .get(&format!("{binding_id}/{}#synced", p.facet_ref))
759            .cloned();
760        let verified = sync_state
761            .get(&format!("{binding_id}/{}#verified", p.facet_ref))
762            .cloned();
763        freshness.push(FacetFreshness {
764            facet: p.facet_ref.clone(),
765            signal,
766            synced,
767            verified,
768            change_detectable,
769        });
770    }
771
772    let source_moved_past_synced = if any_change_detectable {
773        Some(source_moved(engine, resolved, workspace_root))
774    } else {
775        None
776    };
777
778    // --- S(D) enumeration + grain-classed coverage ---
779    let mut s_d: Vec<String> = Vec::new();
780    let mut enumerable_facets = 0usize;
781    for source in &resolved.sources {
782        if let ResolvedSource::Primary(p) = source {
783            let caps = medium_capabilities(p.medium_type);
784            if caps.enumerable {
785                enumerable_facets += 1;
786            }
787            s_d.extend(enumerate_facet_files(
788                p,
789                &resolved.deny_paths,
790                workspace_root,
791            ));
792        }
793    }
794    s_d.sort();
795    s_d.dedup();
796
797    let denominator = if !s_d.is_empty() {
798        DenominatorBasis::Enumerated { count: s_d.len() }
799    } else if enumerable_facets == 0 {
800        DenominatorBasis::NonEnumerable {
801            reason: "the medium type(s) are not enumerable this cycle".to_string(),
802        }
803    } else {
804        // Enumerable per the matrix but the walk yielded nothing (empty scope /
805        // non-path medium type not walked this cycle).
806        DenominatorBasis::NonEnumerable {
807            reason: "no source artifacts enumerated in scope".to_string(),
808        }
809    };
810
811    let mut direct_covered = 0usize;
812    let mut tree_only_covered = 0usize;
813    let mut uncovered: Vec<String> = Vec::new();
814    let mut tree_fanout: BTreeMap<(String, String), usize> = BTreeMap::new();
815    for file in &s_d {
816        let refs = engine.anchors_referencing_artifact(file);
817        let mine: Vec<&(crate::EntityId, crate::anchor::Anchor)> = refs
818            .iter()
819            .filter(|(eid, _)| eid.mem() == dest.as_str())
820            .collect();
821        if mine.is_empty() {
822            uncovered.push(file.clone());
823            continue;
824        }
825        let has_non_tree = mine.iter().any(|(_, a)| a.grain != AnchorGrain::Tree);
826        if has_non_tree {
827            direct_covered += 1;
828        } else {
829            tree_only_covered += 1;
830        }
831        // Attribute tree fan-out (separate axis) for every covering tree anchor.
832        for (eid, a) in &mine {
833            if a.grain == AnchorGrain::Tree {
834                *tree_fanout
835                    .entry((eid.as_ref().to_string(), a.artifact.clone()))
836                    .or_insert(0) += 1;
837            }
838        }
839    }
840    let tree_anchors: Vec<TreeFanout> = tree_fanout
841        .into_iter()
842        .map(|((entity, artifact), fanout)| TreeFanout {
843            entity,
844            artifact,
845            fanout,
846        })
847        .collect();
848
849    let coverage = GrainCoverage {
850        denominator,
851        direct_covered,
852        tree_only_covered,
853        uncovered: uncovered.clone(),
854        tree_anchors,
855    };
856
857    // --- Anchor composition + resolution over the mem's anchors ---
858    let mut anchors = AnchorComposition::default();
859    for (_eid, resolved_anchor) in engine.mem_anchors_resolved(&dest) {
860        let a = &resolved_anchor.anchor;
861        *anchors
862            .by_class
863            .entry(a.class.as_wire().to_string())
864            .or_insert(0) += 1;
865        *anchors
866            .by_grain
867            .entry(a.grain.as_wire().to_string())
868            .or_insert(0) += 1;
869        if a.class == AnchorProvenanceClass::Authored {
870            anchors.authored += 1;
871            continue; // own bucket — excluded from the resolution denominator
872        }
873        match resolved_anchor.state {
874            Some(AnchorState::Resolves) => {
875                anchors.resolves += 1;
876                anchors.observed += 1;
877            }
878            Some(AnchorState::Drifted) => {
879                anchors.drifted += 1;
880                anchors.observed += 1;
881            }
882            Some(AnchorState::Recheck) => {
883                anchors.recheck += 1;
884                anchors.observed += 1;
885            }
886            Some(AnchorState::Orphaned) => {
887                anchors.orphaned += 1;
888                anchors.observed += 1;
889            }
890            None => anchors.unobserved += 1,
891        }
892    }
893
894    // --- Findings tally + backlog + superseded, from the durable store ---
895    let mut findings_by_class: BTreeMap<String, usize> = BTreeMap::new();
896    let mut backlog = 0usize;
897    let mut superseded: Vec<String> = Vec::new();
898    if let Some((mem, name)) = binding_id.split_once('/')
899        && let Ok(Some(store)) = read_findings_store(workspace_root, mem, name)
900    {
901        for f in store.current(key) {
902            *findings_by_class
903                .entry(f.class.as_wire().to_string())
904                .or_insert(0) += 1;
905            if f.class == FindingClass::QueuedForAdjudication {
906                backlog += 1;
907            }
908        }
909        for f in store.superseded(key) {
910            superseded.push(format!(
911                "[{}] {} ({})",
912                f.class.as_wire(),
913                finding_target_label(&f.target),
914                f.facet
915            ));
916        }
917    }
918
919    // --- Durable authored-exclusion ledger (B4) ---
920    // The advance store's `exclusions` map survives advance completion (unlike
921    // its transient `dispositions`), so an artifact mined-and-deliberately-
922    // excluded no longer re-surfaces as `uncovered` on every verify — and keeps
923    // its reasoning. Consult it for every uncovered artifact.
924    let mut disposed_excluded_rationales: Vec<(String, String)> = Vec::new();
925    if let Some((mem, name)) = binding_id.split_once('/')
926        && let Ok(Some(state)) = read_advance_store(workspace_root, mem, name)
927    {
928        let uncovered_set: std::collections::BTreeSet<&str> =
929            uncovered.iter().map(String::as_str).collect();
930        for (artifact, rationale) in &state.exclusions {
931            if uncovered_set.contains(artifact.as_str()) {
932                disposed_excluded_rationales.push((artifact.clone(), rationale.clone()));
933            }
934        }
935    }
936    let disposed_excluded = disposed_excluded_rationales.len();
937
938    // --- Degradation flags (B1) ---
939    let mut degradations: Vec<String> = Vec::new();
940    for c in &capabilities {
941        if !c.change_signal || c.signal == "none" {
942            degradations.push(format!(
943                "change-signal-none:`{}` — freshness is unknowable for this facet",
944                c.facet
945            ));
946        }
947        if !c.enumerable {
948            degradations.push(format!(
949                "enumeration-unavailable:`{}` — `S(D)` coverage denominator not computable",
950                c.facet
951            ));
952        }
953        if !c.base_version_retrievable {
954            degradations.push(format!(
955                "base-version-unretrievable:`{}` — prune degrades to conflict-flagging",
956                c.facet
957            ));
958        }
959    }
960    if anchors.recheck > 0 {
961        degradations.push(format!(
962            "hash-adjudication-deferred — {} anchor(s) recheck (unstable medium / hash \
963             unavailable), not asserted drift",
964            anchors.recheck
965        ));
966    }
967    if anchors.unobserved > 0 {
968        degradations.push(format!(
969            "anchors-unobserved — {} anchor(s) could not be observed this pass",
970            anchors.unobserved
971        ));
972    }
973
974    // Adopt / onboarding signal (E1) — the single canonical predicate shared with
975    // the sync brief and the status rollup: a mem with no anchors and no recorded
976    // `#synced` baseline predates its binding, so 0% anchored is expected.
977    let adopt = super::render::mem_predates_binding(engine, resolved);
978
979    FidelityReport {
980        binding: binding_id,
981        destination_mem: dest,
982        adopt,
983        coverage_semantics: binding.coverage_semantics,
984        capabilities,
985        freshness,
986        source_moved_past_synced,
987        coverage,
988        anchors,
989        findings_by_class,
990        backlog,
991        superseded,
992        disposed_excluded,
993        disposed_excluded_rationales,
994        degradations,
995    }
996}
997
998/// Whether a resolved change-detection strategy can retrieve a prior base
999/// version for a three-way merge (B1). Only git-backed strategies (`git`,
1000/// `graph`) hold prior content; `mtime` reports *that* an artifact changed but
1001/// not its previous bytes, and `none` detects nothing — both leave prune with
1002/// no base leg, so it degrades to conflict-flagging regardless of the medium
1003/// type's static base-retrievability ceiling. This is why filesystem+mtime —
1004/// a common non-git dogfood binding — must surface the conflict-flag
1005/// degradation even though `MediumType::Filesystem` advertises retrievability.
1006fn strategy_retrieves_base(strategy: ChangeStrategy) -> bool {
1007    matches!(strategy, ChangeStrategy::Git | ChangeStrategy::Graph)
1008}
1009
1010/// The `signal` wire string for a [`ChangeStrategy`] — `none` for detection-less
1011/// (never a fabricated token, B2).
1012fn signal_wire(strategy: ChangeStrategy) -> &'static str {
1013    match strategy {
1014        ChangeStrategy::None => "none",
1015        ChangeStrategy::Git => "git",
1016        ChangeStrategy::Mtime => "mtime",
1017        ChangeStrategy::Graph => "graph",
1018    }
1019}
1020
1021/// A compact label for a finding target (superseded detail).
1022fn finding_target_label(target: &super::findings::FindingTarget) -> String {
1023    match target {
1024        super::findings::FindingTarget::Anchor { entity, artifact } => {
1025            format!("{entity} → {artifact}")
1026        }
1027        super::findings::FindingTarget::Artifact { artifact } => artifact.clone(),
1028    }
1029}
1030
1031#[cfg(test)]
1032mod tests {
1033    use super::*;
1034
1035    // ---- pure-renderer fixtures ------------------------------------------
1036
1037    fn base_report() -> FidelityReport {
1038        FidelityReport {
1039            binding: "engine/graph".to_string(),
1040            destination_mem: "engine".to_string(),
1041            adopt: false,
1042            coverage_semantics: CoverageSemantics::Exhaustive,
1043            capabilities: vec![FacetCapability {
1044                facet: "src".to_string(),
1045                medium_type: "codebase".to_string(),
1046                enumerable: true,
1047                change_signal: true,
1048                base_version_retrievable: true,
1049                anchor_namespace: "path".to_string(),
1050                signal: "git".to_string(),
1051            }],
1052            freshness: vec![FacetFreshness {
1053                facet: "src".to_string(),
1054                signal: "git".to_string(),
1055                synced: Some("deadbeef".to_string()),
1056                verified: None,
1057                change_detectable: true,
1058            }],
1059            source_moved_past_synced: Some(false),
1060            coverage: GrainCoverage {
1061                denominator: DenominatorBasis::Enumerated { count: 10 },
1062                direct_covered: 6,
1063                tree_only_covered: 3,
1064                uncovered: vec!["src/a.rs".to_string()],
1065                tree_anchors: vec![TreeFanout {
1066                    entity: "engine--big".to_string(),
1067                    artifact: "src/".to_string(),
1068                    fanout: 3,
1069                }],
1070            },
1071            anchors: AnchorComposition {
1072                by_class: BTreeMap::from([
1073                    ("anchored".to_string(), 5),
1074                    ("authored".to_string(), 2),
1075                ]),
1076                by_grain: BTreeMap::from([("file".to_string(), 4), ("tree".to_string(), 1)]),
1077                authored: 2,
1078                observed: 5,
1079                resolves: 4,
1080                drifted: 0,
1081                recheck: 1,
1082                orphaned: 0,
1083                unobserved: 0,
1084            },
1085            findings_by_class: BTreeMap::from([
1086                ("uncovered".to_string(), 1),
1087                ("queued-for-adjudication".to_string(), 1),
1088            ]),
1089            backlog: 1,
1090            superseded: Vec::new(),
1091            disposed_excluded: 0,
1092            disposed_excluded_rationales: Vec::new(),
1093            degradations: vec!["hash-adjudication-deferred — 1 anchor(s) recheck".to_string()],
1094        }
1095    }
1096
1097    /// B1 — the report renders every required element deterministically, with
1098    /// tree fan-out on its own axis, `authored` as its own excluded bucket, and
1099    /// the backlog depth. Two renders of the same input are byte-identical (no
1100    /// LLM, no clock).
1101    #[test]
1102    fn b1_renders_all_elements_deterministically() {
1103        let r = base_report();
1104        let a = render_fidelity_report(&r, 8_000, &[]);
1105        let b = render_fidelity_report(&r, 8_000, &[]);
1106        assert_eq!(a.markdown, b.markdown, "deterministic — identical bytes");
1107
1108        let md = &a.markdown;
1109        // Grain-classed coverage with tree fan-out SEPARATE, never blended.
1110        assert!(md.contains("direct-covered (file / span anchors): 6/10"));
1111        assert!(md.contains(
1112            "tree-anchor fan-out (separate axis): 1 tree anchor(s) fanning out over 3 file(s)"
1113        ));
1114        // The direct % is NOT (6+3)/10 — the tree fan-out is not folded in.
1115        assert!(
1116            !md.contains("9/10"),
1117            "tree fan-out must not blend into direct coverage"
1118        );
1119        // anchor-resolution % over non-authored observed.
1120        assert!(md.contains("anchor-resolution %:** 4/5"));
1121        // authored is its own excluded bucket.
1122        assert!(md.contains("`authored` bucket (excluded from coverage/accuracy denominators): 2"));
1123        // tier-3 backlog depth from the store tally.
1124        assert!(md.contains("tier-3 adjudication backlog:** 1"));
1125        // capability-matrix block + degradation flags.
1126        assert!(md.contains("## Capability matrix"));
1127        assert!(md.contains("## Degradations"));
1128        assert!(md.contains("hash-adjudication-deferred"));
1129        // B5 denominator provenance.
1130        assert!(md.contains("per-medium enumeration `S(D)` = **10**"));
1131    }
1132
1133    /// B2 — a detection-less medium renders `signal: none` → "freshness
1134    /// unknowable", and NO green freshness verdict appears for it.
1135    #[test]
1136    fn b2_detectionless_medium_freshness_unknowable_never_green() {
1137        let mut r = base_report();
1138        r.capabilities = vec![FacetCapability {
1139            facet: "manual".to_string(),
1140            medium_type: "web".to_string(),
1141            enumerable: false,
1142            change_signal: false,
1143            base_version_retrievable: false,
1144            anchor_namespace: "url".to_string(),
1145            signal: "none".to_string(),
1146        }];
1147        r.freshness = vec![FacetFreshness {
1148            facet: "manual".to_string(),
1149            signal: "none".to_string(),
1150            // Even if a stale token were somehow present, it must never be
1151            // rendered as a fresh/green verdict.
1152            synced: Some("should-never-render-green".to_string()),
1153            verified: Some("nor-this".to_string()),
1154            change_detectable: false,
1155        }];
1156        r.source_moved_past_synced = None;
1157        let out = render_fidelity_report(&r, 8_000, &[]);
1158        let md = &out.markdown;
1159        assert!(md.contains("signal: `none`"));
1160        assert!(md.contains("freshness unknowable"));
1161        // REFUSAL: no fabricated green token, no fresh verdict, no baseline
1162        // token laundered as fresh.
1163        assert!(!md.contains("should-never-render-green"));
1164        assert!(
1165            !md.contains("`#synced`: `"),
1166            "no synced token rendered for a non-detectable medium"
1167        );
1168        assert!(
1169            !md.contains("at its `#synced` baseline"),
1170            "no green 'at baseline' verdict"
1171        );
1172    }
1173
1174    /// B1 — base retrievability is *effective*, keyed on the resolved
1175    /// change-detection strategy, not the medium type's static ceiling. A
1176    /// filesystem binding that resolves to `mtime` (no prior content, only a
1177    /// mod-time signal) has no retrievable base leg, so its facet capability
1178    /// reports `base_version_retrievable: false` — which is exactly what the
1179    /// degradation loop keys on to surface the conflict-flag posture. The same
1180    /// filesystem medium backed by `git` keeps the full never-clobber base leg.
1181    #[test]
1182    fn b1_base_retrievability_follows_resolved_strategy_not_medium_ceiling() {
1183        use crate::pipeline::MediumType;
1184
1185        // The medium type's static ceiling advertises retrievability…
1186        assert!(medium_capabilities(MediumType::Filesystem).base_version_retrievable);
1187
1188        // …but the effective capability derives from the resolved strategy.
1189        let fs_mtime = FacetCapability::from_caps(
1190            "prose".to_string(),
1191            "filesystem".to_string(),
1192            medium_capabilities(MediumType::Filesystem),
1193            ChangeStrategy::Mtime,
1194        );
1195        assert!(
1196            !fs_mtime.base_version_retrievable,
1197            "filesystem+mtime has no retrievable base leg — degrades to conflict-flag"
1198        );
1199        assert_eq!(fs_mtime.signal, "mtime");
1200
1201        let fs_git = FacetCapability::from_caps(
1202            "prose".to_string(),
1203            "filesystem".to_string(),
1204            medium_capabilities(MediumType::Filesystem),
1205            ChangeStrategy::Git,
1206        );
1207        assert!(
1208            fs_git.base_version_retrievable,
1209            "filesystem backed by git keeps the never-clobber base leg"
1210        );
1211
1212        // A detection-less strategy also has no base leg.
1213        assert!(!strategy_retrieves_base(ChangeStrategy::None));
1214        assert!(!strategy_retrieves_base(ChangeStrategy::Mtime));
1215        assert!(strategy_retrieves_base(ChangeStrategy::Git));
1216        assert!(strategy_retrieves_base(ChangeStrategy::Graph));
1217
1218        // The linkage the fix restores: a false effective flag drives the
1219        // conflict-flag degradation the report renders (mirrors the derivation
1220        // in compute_fidelity_report's degradation loop).
1221        let mut r = base_report();
1222        r.capabilities = vec![fs_mtime.clone()];
1223        r.degradations = if !fs_mtime.base_version_retrievable {
1224            vec![format!(
1225                "base-version-unretrievable:`{}` — prune degrades to conflict-flagging",
1226                fs_mtime.facet
1227            )]
1228        } else {
1229            Vec::new()
1230        };
1231        let md = render_fidelity_report(&r, 8_000, &[]).markdown;
1232        assert!(
1233            md.contains("base-version-unretrievable:`prose` — prune degrades to conflict-flagging"),
1234            "filesystem+mtime surfaces the conflict-flag degradation in the report"
1235        );
1236    }
1237
1238    /// B3 — aggregates always ship at budget 0 (mode overbudget, every heavy
1239    /// list dropped to hints).
1240    #[test]
1241    fn b3_aggregates_always_ship_at_zero_budget() {
1242        let r = base_report();
1243        let out = render_fidelity_report(&r, 0, &[]);
1244        assert_eq!(out.mode, "overbudget");
1245        let md = &out.markdown;
1246        // Aggregated counts still ship.
1247        assert!(md.contains("direct-covered (file / span anchors): 6/10"));
1248        assert!(md.contains("tier-3 adjudication backlog:** 1"));
1249        assert!(md.contains("## Capability matrix"));
1250        // The per-artifact list did NOT render inline; it is a hint.
1251        assert!(!md.contains("## Uncovered artifacts"));
1252        assert!(md.contains("## Hints"));
1253        assert!(out.hints.iter().any(|(k, _)| k == "uncovered_artifacts"));
1254    }
1255
1256    /// B3 — a large facet's per-artifact list never renders unbounded under a
1257    /// small budget: it is dropped to a hint with an estimated_tokens figure.
1258    /// The complement: `include` forces it in past the budget.
1259    #[test]
1260    fn b3_large_facet_list_truncates_then_include_forces() {
1261        let mut r = base_report();
1262        // A large uncovered facet — 500 artifacts.
1263        r.coverage.uncovered = (0..500).map(|i| format!("src/file_{i}.rs")).collect();
1264        // A budget large enough for the aggregates but not the huge list.
1265        let hard_cost = estimate_tokens(&render_hard_required(&r));
1266        let out = render_fidelity_report(&r, hard_cost + 5, &[]);
1267        assert_eq!(out.mode, "reduced");
1268        assert!(
1269            !out.markdown.contains("src/file_499.rs"),
1270            "big list not rendered unbounded"
1271        );
1272        assert!(out.markdown.contains("## Hints"));
1273        let (_, est) = out
1274            .hints
1275            .iter()
1276            .find(|(k, _)| k == "uncovered_artifacts")
1277            .expect("uncovered list hinted");
1278        assert!(*est > 5, "the hint carries a real estimated_tokens figure");
1279
1280        // Complement: include forces the section in past the budget.
1281        let forced =
1282            render_fidelity_report(&r, hard_cost + 5, &["uncovered_artifacts".to_string()]);
1283        assert!(
1284            forced.markdown.contains("src/file_499.rs"),
1285            "include forces the full list"
1286        );
1287    }
1288
1289    /// B4 — exhaustive vs curated framing differs: exhaustive calls unaccounted
1290    /// artifacts findings; curated calls them information.
1291    #[test]
1292    fn b4_curated_vs_exhaustive_framing() {
1293        let mut exhaustive = base_report();
1294        exhaustive.coverage_semantics = CoverageSemantics::Exhaustive;
1295        let ex_md = render_fidelity_report(&exhaustive, 8_000, &[]).markdown;
1296        assert!(ex_md.contains("Exhaustive coverage:"));
1297        assert!(ex_md.contains("are **findings**"));
1298
1299        let mut curated = base_report();
1300        curated.coverage_semantics = CoverageSemantics::Curated;
1301        let cur_md = render_fidelity_report(&curated, 8_000, &[]).markdown;
1302        assert!(cur_md.contains("Curated coverage:"));
1303        assert!(cur_md.contains("**information**"));
1304        assert!(
1305            !cur_md.contains("are **findings**"),
1306            "curated never frames unaccounted as findings"
1307        );
1308    }
1309
1310    /// B4 — a persisted disposition removes an uncovered artifact from the
1311    /// exhaustive findings count.
1312    #[test]
1313    fn b4_disposition_excludes_from_exhaustive_findings() {
1314        let mut r = base_report();
1315        r.coverage_semantics = CoverageSemantics::Exhaustive;
1316        r.coverage.uncovered = vec!["src/a.rs".to_string(), "src/b.rs".to_string()];
1317        r.disposed_excluded = 1;
1318        let md = render_fidelity_report(&r, 8_000, &[]).markdown;
1319        // 2 uncovered − 1 disposed = 1 finding.
1320        assert!(md.contains("1 unaccounted artifact(s)"));
1321        assert!(md.contains("(1 disposed excluded)"));
1322    }
1323
1324    /// B4 — the authored-exclusion ledger renders each excluded artifact with
1325    /// its reasoning, so the editorial decision stays visible (not just counted).
1326    #[test]
1327    fn b4_authored_exclusion_rationale_is_rendered() {
1328        let mut r = base_report();
1329        r.coverage_semantics = CoverageSemantics::Exhaustive;
1330        r.coverage.uncovered = vec!["src/gen.rs".to_string()];
1331        r.disposed_excluded = 1;
1332        r.disposed_excluded_rationales =
1333            vec![("src/gen.rs".to_string(), "generated; no entity".to_string())];
1334        let md = render_fidelity_report(&r, 8_000, &[]).markdown;
1335        assert!(md.contains("Excluded on purpose (persisted dispositions):"));
1336        assert!(md.contains("`src/gen.rs` — generated; no entity"));
1337    }
1338
1339    /// B5 — the denominator provenance is stated; a non-enumerable medium says
1340    /// so rather than inventing a denominator.
1341    #[test]
1342    fn b5_denominator_provenance_stated() {
1343        let r = base_report();
1344        let md = render_fidelity_report(&r, 8_000, &[]).markdown;
1345        assert!(md.contains("## Denominator provenance"));
1346        assert!(md.contains("per-medium enumeration `S(D)` = **10**"));
1347
1348        let mut non = base_report();
1349        non.coverage.denominator = DenominatorBasis::NonEnumerable {
1350            reason: "the medium type(s) are not enumerable this cycle".to_string(),
1351        };
1352        let md2 = render_fidelity_report(&non, 8_000, &[]).markdown;
1353        assert!(md2.contains("No `S(D)` denominator"));
1354        assert!(md2.contains("not enumerable this cycle"));
1355    }
1356
1357    /// E1 (report half) — a mem that predates its binding renders the onboarding
1358    /// framing: the expected-0%-anchored statement plus the concrete backfill
1359    /// path. REFUSAL: no failure/error framing and no red "are findings" verdict
1360    /// is produced solely by pre-binding history — the uncovered artifacts are
1361    /// reframed as the backfill worklist.
1362    #[test]
1363    fn e1_adopt_report_renders_onboarding_no_red_verdict() {
1364        let mut r = base_report();
1365        r.adopt = true;
1366        r.coverage_semantics = CoverageSemantics::Exhaustive;
1367        r.coverage.uncovered = (0..5).map(|i| format!("src/file_{i}.rs")).collect();
1368        let md = render_fidelity_report(&r, 8_000, &[]).markdown;
1369
1370        // Onboarding framing leads, with the expected-0% statement …
1371        assert!(md.contains("## Adopting — first verify"));
1372        assert!(md.contains("0% anchored is expected — this is onboarding, not a failure."));
1373        // … and the concrete backfill path.
1374        assert!(md.contains("**Backfill path:** run `memstead projection sync engine/graph`"));
1375        // REFUSAL: the exhaustive branch never frames uncovered as red defect
1376        // "findings" under adopt — it is the onboarding backfill worklist.
1377        assert!(
1378            !md.contains("are **findings**"),
1379            "pre-binding history must not produce a red findings verdict"
1380        );
1381        assert!(md.contains("Exhaustive coverage (onboarding):"));
1382        assert!(md.contains("backfill worklist"));
1383
1384        // Complement: without adopt, the same uncovered set IS framed as findings.
1385        r.adopt = false;
1386        let md2 = render_fidelity_report(&r, 8_000, &[]).markdown;
1387        assert!(!md2.contains("## Adopting — first verify"));
1388        assert!(md2.contains("are **findings**"));
1389    }
1390
1391    /// An unknown include key is surfaced as a warning, not silently dropped.
1392    #[test]
1393    fn unknown_include_key_warns() {
1394        let r = base_report();
1395        let out = render_fidelity_report(&r, 8_000, &["bogus".to_string()]);
1396        assert!(out.markdown.contains("unknown include key `bogus`"));
1397    }
1398
1399    // ---- assembly (impure) end-to-end ------------------------------------
1400
1401    use crate::anchor::{Anchor, AnchorHashStability, AnchorProvenanceClass, AnchorSidecar};
1402    use crate::binding::{
1403        BINDING_VERSION, BindingV1, BuildMode, BuildOperation, DEFAULT_ADJUDICATION_CAP,
1404        DEFAULT_FULL_RESYNC_EVERY, Operations, VerifyOperation,
1405    };
1406    use crate::ingest::findings::verify_binding;
1407    use crate::ingest::resolve::resolve_binding_run;
1408    use crate::pipeline::{Facet, IngestTrigger, Medium, MediumType, PatternEntry, PatternMode};
1409    use crate::pipeline_store::{load_pipeline_configs, write_binding, write_facet, write_medium};
1410    use crate::workspace::{
1411        Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
1412    };
1413    use crate::workspace_store::WorkspaceStoreAdapter;
1414
1415    /// The assembly reads the engine, findings store, and enumeration end to
1416    /// end: coverage is classed over `S(D)` with a direct-covered file, a
1417    /// tree-only file, and an uncovered file; the tree fan-out is on its own
1418    /// axis; the `authored` anchor is its own excluded bucket; the tier-3
1419    /// backlog reads from the store the verify pass populated. Read-only on the
1420    /// mem throughout (`&Engine`).
1421    #[test]
1422    fn compute_report_end_to_end() {
1423        let tmp = tempfile::tempdir().unwrap();
1424        let root = tmp.path();
1425        let mem_dir = root.join("mem");
1426        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1427        std::fs::write(
1428            mem_dir.join(".memstead").join("config.json"),
1429            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1430        )
1431        .unwrap();
1432
1433        std::fs::create_dir_all(root.join(".memstead")).unwrap();
1434        std::fs::write(
1435            root.join(".memstead").join("workspace.toml"),
1436            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1437        )
1438        .unwrap();
1439        let mount = Mount {
1440            mem: "engine".to_string(),
1441            schema: Some("default@1.0.0".parse().unwrap()),
1442            storage: MountStorage::Folder {
1443                path: mem_dir.clone(),
1444            },
1445            capability: MountCapability::Write,
1446            lifecycle: MountLifecycle::Eager,
1447            cross_linkable: false,
1448            migration_target: None,
1449        };
1450        crate::FileWorkspaceStore::new()
1451            .save_state(
1452                root,
1453                &Workspace {
1454                    mounts: vec![mount],
1455                    settings: WorkspaceSettings::default(),
1456                },
1457            )
1458            .unwrap();
1459
1460        let out = std::process::Command::new("git")
1461            .args(["init", "-q"])
1462            .current_dir(root)
1463            .output()
1464            .unwrap();
1465        assert!(out.status.success());
1466        std::fs::create_dir_all(root.join("src").join("sub")).unwrap();
1467        std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
1468        std::fs::write(root.join("src").join("uncovered.rs"), "fn b() {}\n").unwrap();
1469        std::fs::write(root.join("src").join("sub").join("deep.rs"), "fn c() {}\n").unwrap();
1470
1471        let mk = |artifact: &str, grain: AnchorGrain, class: AnchorProvenanceClass| Anchor {
1472            artifact: artifact.to_string(),
1473            grain,
1474            class,
1475            at_version: None,
1476            hash: class.is_hash_bearing().then(|| "recorded".to_string()),
1477            hash_stability: AnchorHashStability::Stable,
1478            derived_from: Vec::new(),
1479            binding: None,
1480        };
1481        let mut sidecar = AnchorSidecar::default();
1482        sidecar.set(
1483            "engine--direct",
1484            vec![mk(
1485                "src/present.rs",
1486                AnchorGrain::File,
1487                AnchorProvenanceClass::Anchored,
1488            )],
1489        );
1490        sidecar.set(
1491            "engine--tree",
1492            vec![mk(
1493                "src/sub/",
1494                AnchorGrain::Tree,
1495                AnchorProvenanceClass::Anchored,
1496            )],
1497        );
1498        // An authored anchor — its own excluded bucket, never scored.
1499        sidecar.set(
1500            "engine--auth",
1501            vec![mk(
1502                "src/present.rs",
1503                AnchorGrain::File,
1504                AnchorProvenanceClass::Authored,
1505            )],
1506        );
1507        std::fs::write(
1508            mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
1509            sidecar.to_bytes(),
1510        )
1511        .unwrap();
1512
1513        write_medium(
1514            root,
1515            "engine",
1516            "graph",
1517            &Medium {
1518                name: "graph".to_string(),
1519                medium_type: MediumType::Codebase,
1520                pointer: String::new(),
1521                change_detection: Some("git".to_string()),
1522            },
1523        )
1524        .unwrap();
1525        write_facet(
1526            root,
1527            "engine",
1528            "graph",
1529            &Facet {
1530                name: "graph".to_string(),
1531                medium: "graph".to_string(),
1532                scope: vec![PatternEntry {
1533                    path: "src/**/*.rs".to_string(),
1534                    mode: PatternMode::Allow,
1535                }],
1536                engagement: None,
1537                preparation: None,
1538            },
1539        )
1540        .unwrap();
1541        write_binding(
1542            root,
1543            "engine",
1544            "graph",
1545            &BindingV1 {
1546                version: BINDING_VERSION,
1547                intent: None,
1548                source_facets: vec!["graph".to_string()],
1549                reference_mems: Vec::new(),
1550                destination_mem: "engine".to_string(),
1551                deny_paths: Vec::new(),
1552                coverage_semantics: CoverageSemantics::Exhaustive,
1553                rules: None,
1554                prune: None,
1555                operations: Operations {
1556                    build: Some(BuildOperation {
1557                        mode: BuildMode::Discovery,
1558                        trigger: IngestTrigger::Loop,
1559                        batch_size: 20,
1560                        post_actions: None,
1561                    }),
1562                    sync: None,
1563                    verify: Some(VerifyOperation {
1564                        trigger: IngestTrigger::Manual,
1565                        batch_size: 20,
1566                        adjudication_cap: DEFAULT_ADJUDICATION_CAP,
1567                        full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
1568                    }),
1569                },
1570            },
1571        )
1572        .unwrap();
1573
1574        let engine = Engine::from_workspace_root(root).unwrap();
1575        let configs = load_pipeline_configs(root).unwrap();
1576        let binding = &configs.bindings[0].config;
1577        let resolved = resolve_binding_run(&configs, "engine/graph", binding).unwrap();
1578
1579        // Populate the durable findings store (group A) — read-only on the mem.
1580        let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
1581
1582        // Assemble the tier-1 report (group B) under the same key.
1583        let report = compute_fidelity_report(&engine, root, binding, &resolved, &outcome.key);
1584
1585        // S(D) = the three .rs files under src/.
1586        assert_eq!(
1587            report.coverage.denominator,
1588            DenominatorBasis::Enumerated { count: 3 }
1589        );
1590        // present.rs is directly covered; sub/deep.rs is tree-only; uncovered.rs
1591        // is uncovered.
1592        assert_eq!(report.coverage.direct_covered, 1);
1593        assert_eq!(report.coverage.tree_only_covered, 1);
1594        assert_eq!(
1595            report.coverage.uncovered,
1596            vec!["src/uncovered.rs".to_string()]
1597        );
1598        // The tree anchor's fan-out is on its own axis — one anchor over one file.
1599        assert_eq!(report.coverage.tree_anchors.len(), 1);
1600        assert_eq!(report.coverage.tree_anchors[0].fanout, 1);
1601        assert_eq!(report.coverage.tree_anchors[0].artifact, "src/sub/");
1602        // `authored` is its own excluded bucket, never in the resolution tally.
1603        assert_eq!(report.anchors.authored, 1);
1604        assert_eq!(report.anchors.by_class.get("authored"), Some(&1));
1605        // Two hash-bearing anchors present (file + tree) → both recheck (no
1606        // prepared hash this pass), never drift; observed excludes authored.
1607        assert_eq!(report.anchors.observed, 2);
1608        assert_eq!(report.anchors.recheck, 2);
1609        assert_eq!(report.anchors.drifted, 0);
1610        // Backlog reads from the store the verify pass populated.
1611        assert_eq!(report.backlog, outcome.backlog);
1612        // A degradation flag for the deferred hash adjudication.
1613        assert!(
1614            report
1615                .degradations
1616                .iter()
1617                .any(|d| d.contains("hash-adjudication-deferred"))
1618        );
1619        // The rendered report is deterministic and carries the S(D) statement.
1620        let md = render_fidelity_report(&report, 8_000, &[]).markdown;
1621        assert!(md.contains("per-medium enumeration `S(D)` = **3**"));
1622        // This mem carries anchors, so it does NOT predate its binding — no
1623        // onboarding framing (the E1 complement).
1624        assert!(!report.adopt);
1625        assert!(!md.contains("## Adopting — first verify"));
1626    }
1627
1628    /// E1 (report half) end-to-end — a mem with **no** anchors and no `#synced`
1629    /// baseline predates its binding: `compute_fidelity_report` sets `adopt` from
1630    /// the live engine, and the rendered report leads with onboarding framing
1631    /// with no red findings verdict. Read-only on the mem (`&Engine`).
1632    #[test]
1633    fn compute_report_adopt_when_mem_predates_binding() {
1634        let tmp = tempfile::tempdir().unwrap();
1635        let root = tmp.path();
1636        let mem_dir = root.join("mem");
1637        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1638        std::fs::write(
1639            mem_dir.join(".memstead").join("config.json"),
1640            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1641        )
1642        .unwrap();
1643        std::fs::create_dir_all(root.join(".memstead")).unwrap();
1644        std::fs::write(
1645            root.join(".memstead").join("workspace.toml"),
1646            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1647        )
1648        .unwrap();
1649        let mount = Mount {
1650            mem: "engine".to_string(),
1651            schema: Some("default@1.0.0".parse().unwrap()),
1652            storage: MountStorage::Folder {
1653                path: mem_dir.clone(),
1654            },
1655            capability: MountCapability::Write,
1656            lifecycle: MountLifecycle::Eager,
1657            cross_linkable: false,
1658            migration_target: None,
1659        };
1660        crate::FileWorkspaceStore::new()
1661            .save_state(
1662                root,
1663                &Workspace {
1664                    mounts: vec![mount],
1665                    settings: WorkspaceSettings::default(),
1666                },
1667            )
1668            .unwrap();
1669        let out = std::process::Command::new("git")
1670            .args(["init", "-q"])
1671            .current_dir(root)
1672            .output()
1673            .unwrap();
1674        assert!(out.status.success());
1675        std::fs::create_dir_all(root.join("src")).unwrap();
1676        // In-scope source with no anchor yet — the backfill worklist.
1677        std::fs::write(root.join("src").join("a.rs"), "fn a() {}\n").unwrap();
1678        std::fs::write(root.join("src").join("b.rs"), "fn b() {}\n").unwrap();
1679
1680        write_medium(
1681            root,
1682            "engine",
1683            "graph",
1684            &Medium {
1685                name: "graph".to_string(),
1686                medium_type: MediumType::Codebase,
1687                pointer: String::new(),
1688                change_detection: Some("git".to_string()),
1689            },
1690        )
1691        .unwrap();
1692        write_facet(
1693            root,
1694            "engine",
1695            "graph",
1696            &Facet {
1697                name: "graph".to_string(),
1698                medium: "graph".to_string(),
1699                scope: vec![PatternEntry {
1700                    path: "src/**/*.rs".to_string(),
1701                    mode: PatternMode::Allow,
1702                }],
1703                engagement: None,
1704                preparation: None,
1705            },
1706        )
1707        .unwrap();
1708        write_binding(
1709            root,
1710            "engine",
1711            "graph",
1712            &BindingV1 {
1713                version: BINDING_VERSION,
1714                intent: None,
1715                source_facets: vec!["graph".to_string()],
1716                reference_mems: Vec::new(),
1717                destination_mem: "engine".to_string(),
1718                deny_paths: Vec::new(),
1719                coverage_semantics: CoverageSemantics::Exhaustive,
1720                rules: None,
1721                prune: None,
1722                operations: Operations {
1723                    build: Some(BuildOperation {
1724                        mode: BuildMode::Discovery,
1725                        trigger: IngestTrigger::Loop,
1726                        batch_size: 20,
1727                        post_actions: None,
1728                    }),
1729                    sync: None,
1730                    verify: Some(VerifyOperation {
1731                        trigger: IngestTrigger::Manual,
1732                        batch_size: 20,
1733                        adjudication_cap: DEFAULT_ADJUDICATION_CAP,
1734                        full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
1735                    }),
1736                },
1737            },
1738        )
1739        .unwrap();
1740
1741        let engine = Engine::from_workspace_root(root).unwrap();
1742        let configs = load_pipeline_configs(root).unwrap();
1743        let binding = &configs.bindings[0].config;
1744        let resolved = resolve_binding_run(&configs, "engine/graph", binding).unwrap();
1745        let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
1746        let report = compute_fidelity_report(&engine, root, binding, &resolved, &outcome.key);
1747
1748        // No anchors + no baseline → the mem predates its binding (E1).
1749        assert!(
1750            report.adopt,
1751            "a no-anchor, never-synced mem predates its binding"
1752        );
1753        let md = render_fidelity_report(&report, 8_000, &[]).markdown;
1754        assert!(md.contains("## Adopting — first verify"));
1755        assert!(md.contains("0% anchored is expected"));
1756        // REFUSAL: the uncovered source is NOT a red findings verdict here.
1757        assert!(!md.contains("are **findings**"));
1758        assert!(md.contains("Exhaustive coverage (onboarding):"));
1759    }
1760}