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