// SPDX-License-Identifier: GPL-3.0-only
//! The priority graph adapter (SL-047 §5.2) — the THIRD cordage `Graph`.
//!
//! Consumes `relation_graph`'s `pub(crate)` all-kind scan seam
//! ([`crate::relation_graph::scan_entities`]) to build a cordage `Graph` carrying:
//! - the `needs` **dep overlay** (hard prerequisite, `Reject`) and the `after`
//!   **seq overlay** (soft sequence, `Evict`) — the `backlog_order` template,
//!   emitted KIND-AGNOSTICALLY (DD-2). SL-060 generalised the dep/seq READ gate
//!   ([`relation_graph::dep_seq_for`]) so SLICE (and any future authoring kind) edges
//!   reach these overlays too — backlog is no longer the only source; a kind that
//!   authors no dep/seq simply carries empty axes and contributes no edge;
//! - the SL-046 **reference/lineage overlays** (one per [`REF_LABELS`] entry) — the
//!   consequence inputs;
//! - per-node [`NodeAttr`] (kind, RAW authored status, `promoted`, `base_score`);
//! - a **consequence post-pass** (`leverage`/`optionality`/`score` maps); and
//! - an `OrderSpec` over `[dep Along, seq Along]`.
//!
//! NO partition/channel POLICY yet — `NodeAttr` stores the RAW authored status
//! string; classification (workable/terminal) is PHASE-02. A SEPARATE cordage
//! `Graph` from `backlog_order`'s and `inspect`'s — they share the `Projection`
//! *type*, never a graph instance or a scan (the scan is the shared seam, EX-5).
//!
//! Layering (ADR-001): `priority` → `relation_graph` → `projection` → `cordage`. No
//! cycle. The build is pure over the scanned `Vec` (the disk touch lives in
//! `scan_entities`, the imperative shell).
//!
//! The whole adapter is consumed by the priority CLI surface (SL-047 PHASE-03 —
//! `priority::surface` builds the view rows from `build()`), so the PHASE-01/02
//! self-clearing `not(test)` `dead_code` suppression has retired itself, as designed
//! (`mem.pattern.lint.dead-code-expect-vs-cfg-test`).

use std::collections::BTreeMap;

use crate::catalog::scan::ScanMode;

use cordage::{
    Arity, CyclePolicy, Direction, EdgeAttrs, Graph, GraphBuilder, OrderLayer, OrderSpec,
    OverlayConfig, OverlayId,
};

use crate::facet::EntityFacets;
use crate::priority::config;
use crate::priority::partition::{self, StatusClass};
use crate::projection::Projection;
use crate::relation::RelationLabel;
use crate::relation_graph::{self, EntityKey};
use crate::{dep_seq, entity, integrity};

/// One node's authored attributes (design §5.2). `kind` is the `&'static entity::Kind`
/// descriptor (data, not `Ord` — carries a fn-ptr `scaffold`; stored by reference like
/// `EntityKey` stores `prefix`). `status` is the RAW authored status string — `None`
/// for the status-less REC kind ONLY; RV carries its DERIVED active/done (authored-tier
/// over its finding ledger). NO classification here (workable/terminal is PHASE-02).
/// `promoted` is the backlog `resolution == Promoted` typed flag — DISTINCT from
/// status-terminal, NOT the free-text `origin`.
/// The split base score for a single entity (design §5.1). Both dimensions and
/// `total()` are `is_finite`-sanitised by [`base_score`] — NaN/\u{221e} → 0.0.
#[derive(Debug, Clone, Copy)]
pub(crate) struct BaseScore {
    pub(crate) value_dim: f64,
    pub(crate) risk_dim: f64,
}

impl BaseScore {
    pub(crate) fn total(&self) -> f64 {
        let t = self.value_dim + self.risk_dim;
        if t.is_finite() { t } else { 0.0 }
    }
}

// ── est_cost helpers (SL-172 §5.1/§5.4) ────────────────────────────────────

/// The floor epsilon — guards against division by zero. Hoisted here so both
/// [`base_score`] and [`floor_eps`] share a single source (STD-001).
const EPSILON: f64 = 1e-12;

/// Floor to `EPSILON` if the value dips below it — protects division in
/// `value_dim` from zero-cost estimates.
fn floor_eps(x: f64) -> f64 {
    if x < EPSILON { EPSILON } else { x }
}

/// Context carried into every per-node `est_cost` call — the bare-item anchor
/// (the maximum `upper` among non-terminal estimated items + `margin`).
#[derive(Debug, Clone, Copy)]
pub(crate) struct CostCtx {
    pub(crate) absent: f64,
}

/// Compute the effective cost of an estimate per the β-skewed model (SL-172 §5.1):
/// - With present bounds: `lower + β·(upper − lower)`, floored to `EPSILON`.
/// - Bare (no bounds): the context's `absent` anchor (≥ 1.0), already floored.
fn est_cost(bounds: Option<(f64, f64)>, ctx: CostCtx, ec: &config::EstimateCost) -> f64 {
    match bounds {
        Some((lower, upper)) => floor_eps(lower + ec.skew * (upper - lower)),
        None => ctx.absent,
    }
}

/// Default raw value for a value-bearing entity that authors no `[value]` facet
/// (SL-177; SL-176 D-value-floor-sibling). Default-when-absent, NOT a min-clamp:
/// an authored value (incl. < 1.0 and 0.0) is returned untouched.
pub(crate) const DEFAULT_VALUE: f64 = 1.0;

/// Single definition of an entity's value for priority purposes. Authored value
/// wins; a value-bearing kind with no facet defaults to `DEFAULT_VALUE`; any other
/// valueless kind (records, governance, REV) is None. Consumed by `base_score`'s
/// `value_dim` now and SL-176 burndown later (RV-191 F-1).
fn effective_raw_value(kind: &entity::Kind, f: &EntityFacets) -> Option<f64> {
    f.value
        .as_ref()
        .map(|v| v.value)
        .or_else(|| crate::kinds::is_value_bearing(kind.prefix).then_some(DEFAULT_VALUE))
}

/// Pure base-score computation per entity (design §5.1). Returns the SPLIT
/// `BaseScore` so `explain` can surface `value_dim` / `risk_dim`. No IO.
fn base_score(
    f: &EntityFacets,
    kind: &entity::Kind,
    cfg: &config::PriorityConfig,
    ctx: CostCtx,
) -> BaseScore {
    // value_dim = coefficients.value × value × kind_weight(kind) × tag_term / est_cost
    // tag_term = (1.0 + Σ(coeff - 1.0)).max(0.0): identity base for absent tags,
    // each configured tag pushes the multiplier by its excess over default.
    // Default coeff (1.0) → delta 0 → no effect. Floor at zero prevents a
    // negative multiplier from many demoting tags.
    let tag_term = (1.0 + f.tags.iter().map(|t| cfg.tag_coeff(t) - 1.0).sum::<f64>()).max(0.0);
    let value_dim = {
        let raw = match effective_raw_value(kind, f) {
            Some(v) => {
                let cost = est_cost(
                    f.estimate.as_ref().map(|e| (e.lower, e.upper)),
                    ctx,
                    &cfg.estimate,
                );
                let kw = cfg.kind_weight(kind.prefix);
                cfg.coefficients.value * v * kw * tag_term / cost
            }
            None => 0.0,
        };
        if raw.is_finite() { raw } else { 0.0 }
    };
    // risk_dim = coefficients.risk × exposure(f.risk)
    let risk_dim = {
        let raw = cfg.coefficients.risk * f64::from(crate::risk::exposure(f.risk.as_ref()));
        if raw.is_finite() { raw } else { 0.0 }
    };
    BaseScore {
        value_dim,
        risk_dim,
    }
}

pub(crate) struct NodeAttr {
    pub(crate) kind: &'static entity::Kind,
    pub(crate) status: Option<String>,
    pub(crate) promoted: bool,
    /// The entity's authored `title`, captured from the scan (display-only — the pure
    /// channel layer never reads it). Carried here so the impure surface shell needs
    /// no second per-row disk read (one scan, one read per entity).
    pub(crate) title: String,
    /// The entity's base score (split `value_dim`/`risk_dim`), computed in the base
    /// pre-pass and consumed by the consequence post-pass (PHASE-04) and later
    /// the mint order (PHASE-05).
    pub(crate) base_score: BaseScore,
    /// The entity's authored facets (estimate/value/risk/tags) — carried so the
    /// surface shell projects them into view rows without recomputation
    /// (SL-171 PHASE-01, D2).
    pub(crate) facets: EntityFacets,
}

/// The assembled priority graph (design §5.2). The cordage `Graph`, the
/// `EntityKey ↔ NodeId` projection, the per-node attributes (carrying `base_score`),
/// the consequence post-pass maps (`leverage`/`optionality`/`score`), and the two
/// dep/seq overlay handles. Opaque cordage ids never escape a `pub(crate)` signature.
pub(crate) struct PriorityGraph {
    pub(crate) graph: Graph,
    pub(crate) projection: Projection<EntityKey>,
    pub(crate) attrs: BTreeMap<EntityKey, NodeAttr>,
    /// Recursive needs-leverage per entity (the consequence post-pass) — consumed by
    /// the survey/next/explain surfaces (SL-133 PHASE-05).
    pub(crate) leverage: BTreeMap<EntityKey, f64>,
    /// One-hop ref-optionality per entity (the consequence post-pass) — consumed by
    /// the surfaces (SL-133 PHASE-05).
    pub(crate) optionality: BTreeMap<EntityKey, f64>,
    /// Final score per entity (`base + leverage + optionality`) — the display sort key
    /// consumed by survey/next/explain (SL-133 PHASE-05).
    pub(crate) score: BTreeMap<EntityKey, f64>,
    pub(crate) dep_overlay: OverlayId,
    pub(crate) seq_overlay: OverlayId,
}

/// The reference/lineage relation labels that back a consequence-input overlay — the
/// SL-046 overlay-backed labels MINUS the two target-unvalidated ones (`Drift`/
/// `DecisionRef`, which never resolve). One `Reject`/`Unbounded` overlay each — the
/// reference/lineage consequence-input overlays. Label is overlay identity (the same
/// label from different source kinds shares ONE overlay).
const REF_LABELS: &[RelationLabel] = &[
    RelationLabel::References,
    RelationLabel::Supersedes,
    RelationLabel::DescendsFrom,
    RelationLabel::Parent,
    RelationLabel::Members,
    RelationLabel::Interactions,
    RelationLabel::Fulfils,
    RelationLabel::Related,
    RelationLabel::Reviews,
    RelationLabel::OwningSlice,
];

/// The WORK/LINEAGE label subset whose inbound references count toward consequence
/// (design §5.2, EX-3). `reviews`/`owning_slice` are bookkeeping and EXCLUDED; the
/// two target-unvalidated labels never resolve and so cannot contribute anyway.
/// SL-176 PHASE-03: `Slices` removed; `Fulfils` NEVER added (wrong sign/direction).
const CONSEQUENCE_LABELS: &[RelationLabel] = &[
    RelationLabel::References,
    RelationLabel::DescendsFrom,
    RelationLabel::Parent,
    RelationLabel::Members,
];

/// Build the priority graph once (design §5.2) — the thin `scan_entities(root)?` +
/// delegate wrapper over [`build_from`] (the SL-050 F2 shared-scan seam). A command
/// layer that already holds a scan calls `build_from` directly to avoid a second walk.
///
/// # Errors
///
/// Propagates a scan/read error, or an internal cordage rejection of well-formed
/// adapter input (an adapter bug, not a recoverable condition).
pub(crate) fn build(root: &std::path::Path) -> anyhow::Result<PriorityGraph> {
    build_from(
        &relation_graph::scan_entities(root, &mut vec![], ScanMode::default())?,
        root,
    )
}

/// Build the priority graph from a PRE-SCANNED entity slice (the SL-050 F2 shared-scan
/// seam — the body of [`build`]). The build order breaks the mint-order ↔ consequence
/// ↔ graph cycle by moving consequence to a POST-pass (SL-133 §5.4):
///
/// 1. **Scan** — supplied by the caller (the `relation_graph` seam → entity set + each
///    entity's outbound edges + RAW authored status + estimate/value/risk facets).
/// 2. **Base pre-pass** — pure per-node `base_score` (value/risk dims) from each
///    entity's OWN facets + config + kind into a `BTreeMap<EntityKey, BaseScore>`. No
///    graph needed; feeds the mint tiebreaker.
/// 3. **Mint** every node into the projection in `(base.total() desc via f64::total_cmp,
///    canonical-id asc)` order — consequence EXCLUDED (I3: no graph-derived quantity in
///    the structural tiebreak). The monotonic `NodeId` is the order key's tier-3
///    fallback. A dedicated pre-intern pass (the `backlog_order` C4 discipline): mint
///    EVERY node first, distinct keys asserted, THEN resolve+emit edges (resolve is
///    get-only, never intern inside the edge pass).
/// 4. **Edges** — reference/lineage onto the ref overlays (resolve-only; an
///    unresolved target contributes no edge). `needs` → `dep_overlay` (`Reject`,
///    oriented prereq→src i.e. B→A flip,
///    `EdgeAttrs::new(0, 0)`). `after` → `seq_overlay` (`Evict`, `EdgeAttrs::new(rank,
///    age)`). The dep/seq edges read kind-agnostically (DD-2) via the SL-060 cross-kind
///    [`relation_graph::dep_seq_for`] gate — backlog AND slice author them.
/// 5. `OrderSpec::new([dep Along, seq Along])`, then `builder.build()`.
/// 6. **Consequence post-pass** — recursive needs-leverage + one-hop ref-optionality
///    over the built graph, storing `leverage`/`optionality`/`score` (§5.4 step 6).
///
/// `root` is RETAINED: the per-entity `dep_seq_for` reads (step 3b) are per-item reads
/// NOT part of `scan_entities`, so the body still needs disk access. The mint/edge order
/// is unchanged (the scan order the caller supplies), preserving byte-identical output.
///
/// # Errors
///
/// Propagates a read error, or an internal cordage rejection of well-formed adapter
/// input (an adapter bug, not a recoverable condition).
pub(crate) fn build_from(
    scanned: &[relation_graph::ScannedEntity],
    root: &std::path::Path,
) -> anyhow::Result<PriorityGraph> {
    // The single config load lives HERE (D4) and is threaded into the build seam.
    // SL-194 PHASE-01 lifted it to a parameter so a β sweep can inject a swept
    // `estimate.skew`; every existing caller routes through this byte-identical wrapper.
    build_from_with_cfg(scanned, root, &config::load(root))
}

/// Build the priority graph from a PRE-SCANNED entity slice with an INJECTED
/// [`config::PriorityConfig`] (the SL-194 PHASE-01 rebuild seam — the body of
/// [`build_from`], extracted so β perturbation can sweep `estimate.skew` over the same
/// scan). Identical to [`build_from`] except the config is supplied rather than loaded.
///
/// **The injected `cfg` threads the WHOLE build.** The base pre-pass (2c, `base_score`)
/// AND the consequence post-pass (6, `consequence_post_pass` — leverage/optionality
/// coeffs) both read it, so a swept `skew` perturbs base cost and consequence coeffs
/// consistently (design "cfg threads the WHOLE build") — never base-only with default
/// consequence coeffs. Byte-identical to the pre-extraction `build_from` when passed
/// `&config::load(root)` (the behaviour-preservation gate, VT-1).
///
/// # Errors
///
/// Propagates a read error, or an internal cordage rejection of well-formed adapter
/// input (an adapter bug, not a recoverable condition).
pub(crate) fn build_from_with_cfg(
    scanned: &[relation_graph::ScannedEntity],
    root: &std::path::Path,
    cfg: &config::PriorityConfig,
) -> anyhow::Result<PriorityGraph> {
    // 2b. Anchor fold (SL-172 §5.4): max upper among non-terminal estimated items.
    //      If none, fall back to 1.0 (empty-corpus fallback). Terminals (closed/done)
    //      must NOT inflate bare-item cost — their large upper is irrelevant.
    let max_upper = scanned
        .iter()
        .filter(|entity| {
            partition::status_class(entity.kind, entity.status.as_deref()) != StatusClass::Terminal
        })
        .filter_map(|entity| entity.estimate.as_ref().map(|e| e.upper))
        .max_by(f64::total_cmp);
    let absent = match max_upper {
        Some(mu) => mu + cfg.estimate.margin,
        None => 1.0,
    };
    let ctx = CostCtx { absent };

    // 2c. Base pre-pass — compute `base_score` per node from its OWN facets + config +
    //      kind (pure, per-node, graph-free). Runs before mint because it feeds the
    //      tiebreaker (SL-133 §5.4 step 2/3). Carried onto `NodeAttr.base_score` at 3c
    //      and read by the consequence post-pass.
    let base_by_key: BTreeMap<EntityKey, BaseScore> = scanned
        .iter()
        .map(|entity| {
            let base = base_score(
                &EntityFacets {
                    estimate: entity.estimate.clone(),
                    value: entity.value.clone(),
                    risk: entity.risk.clone(),
                    tags: entity.tags.clone(),
                },
                entity.kind,
                cfg,
                ctx,
            );
            (entity.key, base)
        })
        .collect();

    // 3. Mint — (base.total() DESC via f64::total_cmp, canonical-id ASC) (SL-133 §5.4
    //    step 3; was `consequence desc`). The monotonic NodeId is the tier-3 fallback
    //    (the within-level allocation key). Consequence is EXCLUDED from mint — a
    //    graph-derived quantity in the structural tiebreak would couple ordering to the
    //    edges it orders (I3 feedback loop), and `score` is not yet computed. Pre-intern
    //    EVERY node in this order BEFORE any edge resolves (C4), asserting distinct keys.
    let mut order: Vec<EntityKey> = scanned.iter().map(|e| e.key).collect();
    order.sort_by(|a, b| {
        let ba = base_by_key.get(a).map_or(0.0, BaseScore::total);
        let bb = base_by_key.get(b).map_or(0.0, BaseScore::total);
        bb.total_cmp(&ba).then_with(|| a.cmp(b))
    });

    let mut builder = GraphBuilder::new();
    // Reference/lineage overlays (the consequence inputs) + the two dep/seq overlays.
    // Capture every OverlayId from the builder — never fabricate an id.
    let mut ref_by_label: BTreeMap<RelationLabel, OverlayId> = BTreeMap::new();
    for &label in REF_LABELS {
        let ov = builder.overlay(OverlayConfig::new(CyclePolicy::Reject, Arity::Unbounded));
        ref_by_label.insert(label, ov);
    }
    let dep_overlay = builder.overlay(OverlayConfig::new(CyclePolicy::Reject, Arity::Unbounded));
    let seq_overlay = builder.overlay(OverlayConfig::new(CyclePolicy::Evict, Arity::Unbounded));

    let mut projection: Projection<EntityKey> = Projection::new();
    for &key in &order {
        assert!(
            projection.resolve(key).is_none(),
            "priority::graph: duplicate EntityKey {} (canonical ids unique by prefix)",
            key.canonical()
        );
        projection.intern(&mut builder, key);
    }

    // 3b. Read each entity's dep/seq + promoted ONCE through the cross-kind dispatch
    //     (SL-060 §5.2 — `relation_graph::dep_seq_for` replaces the former backlog-prefix
    //     gate: it routes backlog AND slice to their readers and short-circuits every
    //     non-authoring kind with NO disk read, F5). The attrs pass and the edge pass
    //     share one read per entity (no double parse). `promoted` is carried alongside —
    //     backlog-only by construction (every other kind yields `false`).
    let mut dep_seq: BTreeMap<EntityKey, (dep_seq::DepSeq, bool)> = BTreeMap::new();
    for entity in scanned {
        dep_seq.insert(
            entity.key,
            relation_graph::dep_seq_for(root, entity.kind, entity.key.id)?,
        );
    }

    // 3c. Per-node attributes — RAW authored status verbatim, kind, promoted, and the
    //     `base_score` computed in the 2b pre-pass (reused, not recomputed). Only a
    //     backlog item can be `promoted`; every other kind is never promoted.
    //     Facets (estimate/value/risk/tags) are captured here from the scan so the
    //     surface shell projects them into view rows without recomputation (SL-171 D2).
    let mut attrs: BTreeMap<EntityKey, NodeAttr> = BTreeMap::new();
    for entity in scanned {
        let base = base_by_key.get(&entity.key).copied().unwrap_or(BaseScore {
            value_dim: 0.0,
            risk_dim: 0.0,
        });
        attrs.insert(
            entity.key,
            NodeAttr {
                kind: entity.kind,
                status: entity.status.clone(),
                promoted: dep_seq
                    .get(&entity.key)
                    .is_some_and(|(_ds, promoted)| *promoted),
                title: entity.title.clone(),
                base_score: base,
                facets: EntityFacets {
                    estimate: entity.estimate.clone(),
                    value: entity.value.clone(),
                    risk: entity.risk.clone(),
                    tags: entity.tags.clone(),
                },
            },
        );
    }

    // 4. Edges — resolve-only (never intern inside the edge pass). An unresolved
    //    target simply contributes NO edge (it is not recorded — there is no node to
    //    edge from / to).
    for entity in scanned {
        let Some(src) = projection.resolve(entity.key) else {
            debug_assert!(false, "priority::graph: edge-pass key not interned");
            continue;
        };

        // Reference/lineage edges onto the ref overlays (consequence inputs). An
        // unresolved or no-overlay (target-unvalidated) target contributes no edge.
        for edge in &entity.outbound {
            if let Some(dst) = resolve(&projection, &edge.target)
                && let Some(&ov) = ref_by_label.get(&edge.label)
            {
                builder.edge(ov, src, dst, EdgeAttrs::new(0, 0));
            }
        }

        // dep/seq edges — kind-agnostic (DD-2): emission is byte-identical and kind-blind;
        // a kind that authors no dep/seq simply carries empty axes (every non-authoring
        // kind, and any authoring entity with no edges).
        if let Some((ds, _promoted)) = dep_seq.get(&entity.key) {
            // `A.needs = [B]` ⇒ B must precede A: edge B→A (the flip), hard, never
            // evicts. An unresolved prereq contributes no edge (no node to edge from).
            for prereq_ref in &ds.needs {
                if let Some(prereq) = resolve(&projection, prereq_ref) {
                    builder.edge(dep_overlay, prereq, src, EdgeAttrs::new(0, 0));
                }
            }
            // `A.after = [{to=B, rank}]` ⇒ B before A: edge B→A carrying the genuine
            // `(rank, age)` eviction key; `age` is the entry's index in this item's
            // `after` array (the `backlog_order` discipline).
            for (idx, edge) in ds.after.iter().enumerate() {
                if let Some(prereq) = resolve(&projection, &edge.to) {
                    let age = u64::try_from(idx).map_err(|e| {
                        anyhow::anyhow!("priority::graph: after-edge index overflows u64: {e}")
                    })?;
                    builder.edge(seq_overlay, prereq, src, EdgeAttrs::new(edge.rank, age));
                }
            }
        }
    }

    // 5. OrderSpec over [dep Along, seq Along], then build.
    builder.order_spec(OrderSpec::new(vec![
        OrderLayer::new(dep_overlay, Direction::Along),
        OrderLayer::new(seq_overlay, Direction::Along),
    ]));

    let graph = builder.build().map_err(|e| {
        anyhow::anyhow!(
            "priority::graph: cordage rejected well-formed adapter input (internal bug): {e:?}"
        )
    })?;

    // 6. Consequence post-pass (design §5.4 step 6) — two mechanisms:
    //      needs-leverage (recursive DP) + ref-optionality (one-hop).
    //      Reads NodeAttr.base_score from `attrs` (the field is consumed here —
    //      no dead_code).
    let (leverage, optionality, score) =
        consequence_post_pass(&graph, &projection, &attrs, &ref_by_label, dep_overlay, cfg);

    Ok(PriorityGraph {
        graph,
        projection,
        attrs,
        leverage,
        optionality,
        score,
        dep_overlay,
        seq_overlay,
    })
}

/// Consequence post-pass (design §5.4 step 6). Pure over the built graph.
/// Returns (leverage, optionality, score) keyed by `EntityKey`.
fn consequence_post_pass(
    graph: &Graph,
    projection: &Projection<EntityKey>,
    attrs: &BTreeMap<EntityKey, NodeAttr>,
    ref_by_label: &BTreeMap<RelationLabel, OverlayId>,
    dep_overlay: OverlayId,
    cfg: &config::PriorityConfig,
) -> (
    BTreeMap<EntityKey, f64>,
    BTreeMap<EntityKey, f64>,
    BTreeMap<EntityKey, f64>,
) {
    use std::collections::BTreeSet;

    // ── node-id ↔ EntityKey helpers ──
    let ek = |nid: cordage::NodeId| -> Option<EntityKey> { projection.key_of(nid) };
    let base_of = |nid: cordage::NodeId| -> f64 {
        ek(nid)
            .and_then(|k| attrs.get(&k))
            .map_or(0.0, |a| a.base_score.total())
    };
    // SL-176 PHASE-03: split value/risk accessors for the burndown post-pass.
    let value_dim_of = |nid: cordage::NodeId| -> f64 {
        ek(nid)
            .and_then(|k| attrs.get(&k))
            .map_or(0.0, |a| a.base_score.value_dim)
    };
    let risk_dim_of = |nid: cordage::NodeId| -> f64 {
        ek(nid)
            .and_then(|k| attrs.get(&k))
            .map_or(0.0, |a| a.base_score.risk_dim)
    };
    // SL-176 PHASE-03 / SL-177 PHASE-02: raw value accessor routed through the
    // priority-tier seam — authored value wins, value-bearing kind defaults to 1.0,
    // valueless kind is 0.0. The burndown numerator and denominator both use this.
    let raw_value_of = |nid: cordage::NodeId| -> f64 {
        ek(nid)
            .and_then(|k| attrs.get(&k))
            .and_then(|a| effective_raw_value(a.kind, &a.facets))
            .unwrap_or(0.0)
    };

    // ── Component partition: each dep_overlay SCC from provenance is one component;
    //      every other node is its own singleton. EVERY node is assigned up front so
    //      the condensation DAG below is total (RV-137 F-1: a lazily-assigned-on-visit
    //      scheme can't be topo-ordered). ──
    let cycles = graph.provenance().cycles();
    let mut node_to_component: BTreeMap<cordage::NodeId, usize> = BTreeMap::new();
    let mut component_members: Vec<BTreeSet<cordage::NodeId>> = Vec::new();
    for cyc in cycles {
        if cyc.overlay() != dep_overlay {
            continue;
        }
        let comp_idx = component_members.len();
        for &n in cyc.nodes() {
            node_to_component.insert(n, comp_idx);
        }
        component_members.push(cyc.nodes().clone());
    }
    for nid in graph.ordered() {
        node_to_component.entry(nid).or_insert_with(|| {
            let comp_idx = component_members.len();
            component_members.push(BTreeSet::from([nid]));
            comp_idx
        });
    }
    let component_count = component_members.len();
    let comp_of = |nid: cordage::NodeId| -> Option<usize> { node_to_component.get(&nid).copied() };

    // ── Condensation DAG: an edge c → c' means a member of component c has a
    //      dep out-edge (a DEPENDENT) landing in c'. Per RV-137 F-1 the leverage DP
    //      must run in reverse-topo order of THIS graph — reverse graph.ordered() is
    //      NOT a valid order because a seq edge can perturb an SCC member's level and
    //      place it before an external dependent, dropping that dependent's resolved
    //      leverage. Per RV-137 F-2 each external dependent NODE is held in a set, so a
    //      dependent that needs >1 member counts ONCE per component. ──
    let mut comp_dependents: Vec<BTreeSet<cordage::NodeId>> =
        vec![BTreeSet::new(); component_count];
    let mut comp_succ: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); component_count];
    for (c, ((dependents, succ), members)) in comp_dependents
        .iter_mut()
        .zip(comp_succ.iter_mut())
        .zip(component_members.iter())
        .enumerate()
    {
        for &m in members {
            for (d, _) in graph.out_edges(dep_overlay, m) {
                match comp_of(d) {
                    Some(dc) if dc != c => {
                        dependents.insert(d);
                        succ.insert(dc);
                    }
                    _ => {} // intra-component (or unresolved) → contributes 0
                }
            }
        }
    }

    // Reverse-topo of the condensation via iterative post-order DFS: post-order emits
    // a component AFTER all its successors, so every dependent's leverage is resolved
    // before the component that leans on it. (The condensation is acyclic; the visited
    // guard is a belt-and-braces backstop.)
    let mut topo: Vec<usize> = Vec::with_capacity(component_count);
    let mut visited = vec![false; component_count];
    for start in 0..component_count {
        if visited.get(start).copied().unwrap_or(true) {
            continue;
        }
        let mut stack: Vec<(usize, bool)> = vec![(start, false)];
        while let Some((c, emit)) = stack.pop() {
            if emit {
                topo.push(c);
                continue;
            }
            if visited.get(c).copied().unwrap_or(true) {
                continue;
            }
            if let Some(slot) = visited.get_mut(c) {
                *slot = true;
            }
            stack.push((c, true));
            if let Some(succ) = comp_succ.get(c) {
                for &sc in succ {
                    if !visited.get(sc).copied().unwrap_or(true) {
                        stack.push((sc, false));
                    }
                }
            }
        }
    }

    // ── leverage DP over the condensation in reverse-topo order. leverage(c) =
    //      dep_coeff · Σ over UNIQUE external dependents D of (base(D) + leverage(D));
    //      every member of c carries the same component leverage. ──
    let mut leverage_by_node: BTreeMap<cordage::NodeId, f64> = BTreeMap::new();
    for &c in &topo {
        let Some(dependents) = comp_dependents.get(c) else {
            continue;
        };
        let mut sum = 0.0f64;
        for &d in dependents {
            sum += base_of(d) + leverage_by_node.get(&d).copied().unwrap_or(0.0);
        }
        let lev = cfg.consequence.dep_coeff * sum;
        let lev = if lev.is_finite() { lev } else { 0.0 };
        if let Some(members) = component_members.get(c) {
            for &m in members {
                leverage_by_node.insert(m, lev);
            }
        }
    }

    // ── optionality: one-hop ref over CONSEQUENCE_LABELS (design §5.4 step 6).
    //      N's referencers are in_edges(ov, N) over the CONSEQUENCE_LABELS subset only.
    let mut optionality_by_node: BTreeMap<cordage::NodeId, f64> = BTreeMap::new();
    for nid in graph.ordered() {
        let mut sum = 0.0f64;
        for &label in CONSEQUENCE_LABELS {
            if let Some(&ov) = ref_by_label.get(&label) {
                for (src, _) in graph.in_edges(ov, nid) {
                    sum += base_of(src);
                }
            }
        }
        let opt = cfg.consequence.ref_coeff * sum;
        let opt = if opt.is_finite() { opt } else { 0.0 };
        optionality_by_node.insert(nid, opt);
    }

    // ── SL-176 PHASE-03: fulfils value-burndown post-pass (D-priority-burndown).
    //      A backlog item's value_dim is REDUCED by the lifecycle-gated raw value of
    //      the slices that fulfil it. Degree ignored, non-conserving across multi-item,
    //      excluded from mint tiebreak. Fulfils overlay backs in_edges (REF_LABELS only).
    let fulfils_ov = ref_by_label.get(&RelationLabel::Fulfils).copied();
    let mut burndown_by_node: BTreeMap<cordage::NodeId, f64> = BTreeMap::new();
    if let Some(ov) = fulfils_ov {
        for nid in graph.ordered() {
            let raw_val = raw_value_of(nid);
            if raw_val <= 0.0 {
                burndown_by_node.insert(nid, 0.0);
                continue;
            }
            // delivered = Σ over fulfils in_edges of gate(status(src)) · raw_value(src)
            // gate = 1.0 iff source slice status ∈ {started, audit, reconcile, done}
            let mut delivered = 0.0f64;
            for (src, _) in graph.in_edges(ov, nid) {
                let Some(src_key) = ek(src) else { continue };
                let Some(src_attr) = attrs.get(&src_key) else {
                    continue;
                };
                let gate = match src_attr.status.as_deref() {
                    Some("started" | "audit" | "reconcile" | "done") => 1.0,
                    _ => 0.0,
                };
                if gate > 0.0 {
                    delivered += gate * raw_value_of(src);
                }
            }
            // r = clamp(delivered / raw_value, 0, 1)
            let r = (delivered / raw_val).clamp(0.0, 1.0);
            let burn = value_dim_of(nid) * (1.0 - r);
            let burn = if burn.is_finite() { burn } else { 0.0 };
            burndown_by_node.insert(nid, burn);
        }
    }

    // ── assemble into EntityKey-keyed maps ──
    let mut leverage: BTreeMap<EntityKey, f64> = BTreeMap::new();
    let mut optionality: BTreeMap<EntityKey, f64> = BTreeMap::new();
    let mut score: BTreeMap<EntityKey, f64> = BTreeMap::new();
    for nid in graph.ordered() {
        if let Some(k) = ek(nid) {
            let lev = leverage_by_node.get(&nid).copied().unwrap_or(0.0);
            let opt = optionality_by_node.get(&nid).copied().unwrap_or(0.0);
            // SL-176 PHASE-03: score = risk_dim + lev + opt + burndown_term
            // where burndown_term = value_dim · (1 − r) = the attenuated value.
            // A node with no fulfils inbound has r=0 ⇒ burndown_term = value_dim
            // ⇒ score = risk_dim + value_dim + lev + opt == base_of + lev + opt (unchanged).
            let burn = burndown_by_node
                .get(&nid)
                .copied()
                .unwrap_or(value_dim_of(nid));
            let sc = risk_dim_of(nid) + lev + opt + burn;
            let sc = if sc.is_finite() { sc } else { 0.0 };
            leverage.insert(k, lev);
            optionality.insert(k, opt);
            score.insert(k, sc);
        }
    }
    (leverage, optionality, score)
}

/// Get-only resolve of an authored ref string to a minted node, or `None`. A ref
/// that fails to parse as a canonical ref (free-text), or parses to an id never
/// minted (no entity dir), is `None` → a dangler. NEVER interns.
fn resolve(projection: &Projection<EntityKey>, reference: &str) -> Option<cordage::NodeId> {
    let (kref, id) = integrity::parse_canonical_ref(reference).ok()?;
    projection.resolve(EntityKey {
        prefix: kref.kind.prefix,
        id,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use std::path::Path;

    /// Write `root/<rel>` with `body`, creating parents.
    fn write(root: &Path, rel: &str, body: &str) {
        let path = root.join(rel);
        fs::create_dir_all(path.parent().unwrap()).unwrap();
        fs::write(path, body).unwrap();
    }

    fn tmp() -> tempfile::TempDir {
        tempfile::tempdir().unwrap()
    }

    /// SL-048 PHASE-04: rewrite a legacy `[relationships]` body (`key = [...]` lines)
    /// into the migrated on-disk shape for `source` — tier-1 simple-list axes become
    /// `[[relation]]` rows (canonical order is laundered by `read_block`, so emit
    /// order here is irrelevant); every other line (the typed `needs`/`after`/
    /// `triggers` payload axes, or any non-migrated label) stays verbatim in a
    /// `[relationships]` table emitted FIRST (F1). Keeps these fixtures' inline bodies
    /// readable while exercising the post-cut storage shape.
    fn migrate_body(source: &crate::entity::Kind, rels: &str) -> String {
        use crate::relation::RelationLabel;
        let mut typed = String::new();
        let mut rows = String::new();
        for line in rels.lines() {
            let trimmed = line.trim();
            if trimmed.is_empty() {
                continue;
            }
            let key = trimmed.split('=').next().unwrap_or("").trim();
            let is_simple_list = trimmed.contains('[') && !trimmed.contains('{');
            let migrated = is_simple_list
                && RelationLabel::from_name(key)
                    .and_then(|l| crate::relation::lookup(source, l, None))
                    .is_some_and(|r| {
                        r.tier == crate::relation::Tier::One
                            && r.link != crate::relation::LinkPolicy::LifecycleOnly
                    });
            if migrated {
                let inner = trimmed
                    .split_once('[')
                    .and_then(|(_, rest)| rest.rsplit_once(']'))
                    .map(|(refs, _)| refs)
                    .unwrap_or("");
                for t in inner.split(',') {
                    let t = t.trim().trim_matches('"');
                    if !t.is_empty() {
                        rows.push_str(&format!(
                            "[[relation]]\nlabel = \"{key}\"\ntarget = \"{t}\"\n"
                        ));
                    }
                }
            } else {
                typed.push_str(line);
                typed.push('\n');
            }
        }
        let typed_table = if typed.trim().is_empty() {
            String::new()
        } else {
            format!("[relationships]\n{typed}")
        };
        format!("{typed_table}{rows}")
    }

    /// Seed a slice (toml + md) with a legacy `[relationships]` body (rewritten to the
    /// SL-048 migrated shape via [`migrate_body`]).
    fn seed_slice(root: &Path, id: u32, rels: &str) {
        write(
            root,
            &format!(".doctrine/slice/{id:03}/slice-{id:03}.toml"),
            &format!(
                "id = {id}\nslug = \"s\"\ntitle = \"S\"\nstatus = \"proposed\"\n\
                 created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n{}",
                migrate_body(&crate::slice::SLICE_KIND, rels)
            ),
        );
        write(
            root,
            &format!(".doctrine/slice/{id:03}/slice-{id:03}.md"),
            "scope\n",
        );
    }

    /// Seed a requirement (an edge target only — has a top-level status).
    fn seed_requirement(root: &Path, id: u32) {
        write(
            root,
            &format!(".doctrine/requirement/{id:03}/requirement-{id:03}.toml"),
            &format!("id = {id}\nslug = \"r\"\ntitle = \"R\"\nstatus = \"active\"\n"),
        );
        write(
            root,
            &format!(".doctrine/requirement/{id:03}/requirement-{id:03}.md"),
            "r\n",
        );
    }

    /// Seed a backlog issue with a `[relationships]` body and a `resolution`.
    fn seed_issue(root: &Path, id: u32, status: &str, resolution: &str, rels: &str) {
        write(
            root,
            &format!(".doctrine/backlog/issue/{id:03}/backlog-{id:03}.toml"),
            &format!(
                "id = {id}\nslug = \"i\"\ntitle = \"I\"\nkind = \"issue\"\nstatus = \"{status}\"\n\
                 resolution = \"{resolution}\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
                 {}",
                migrate_body(&crate::backlog::ISSUE_KIND, rels)
            ),
        );
        write(
            root,
            &format!(".doctrine/backlog/issue/{id:03}/backlog-{id:03}.md"),
            "b\n",
        );
    }

    /// Seed a risk backlog item (so a second backlog kind exists for dep/seq).
    fn seed_risk(root: &Path, id: u32, status: &str, rels: &str) {
        write(
            root,
            &format!(".doctrine/backlog/risk/{id:03}/backlog-{id:03}.toml"),
            &format!(
                "id = {id}\nslug = \"k\"\ntitle = \"K\"\nkind = \"risk\"\nstatus = \"{status}\"\n\
                 resolution = \"\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
                 {}",
                migrate_body(&crate::backlog::RISK_KIND, rels)
            ),
        );
        write(
            root,
            &format!(".doctrine/backlog/risk/{id:03}/backlog-{id:03}.md"),
            "k\n",
        );
    }

    /// Seed a reconciliation record (status-LESS by design).
    fn seed_rec(root: &Path, id: u32, owning_slice: &str) {
        write(
            root,
            &format!(".doctrine/rec/{id:03}/rec-{id:03}.toml"),
            &format!(
                "id = {id}\nslug = \"r\"\ntitle = \"R\"\n\
                 [rec]\nmove = \"accept\"\nowning_slice = \"{owning_slice}\"\n"
            ),
        );
    }

    /// Seed a review (status-LESS authored; status derived from findings).
    fn seed_review(root: &Path, id: u32, target: &str, findings: &str) {
        write(
            root,
            &format!(".doctrine/review/{id:03}/review-{id:03}.toml"),
            &format!(
                "id = {id}\nslug = \"r\"\ntitle = \"R\"\n\
                 [review]\nfacet = \"reconciliation\"\nraiser = \"a\"\nresponder = \"b\"\n\
                 [target]\nref = \"{target}\"\n{findings}"
            ),
        );
    }

    fn key(prefix: &'static str, id: u32) -> EntityKey {
        EntityKey { prefix, id }
    }

    // -- VT-1: builds; node set equals the scanned set; distinct keys ----------

    #[test]
    fn builds_over_multi_kind_corpus_node_set_equals_scanned() {
        let dir = tmp();
        let root = dir.path();
        seed_slice(
            root,
            1,
            "[[relation]]\nlabel = \"references\"\nrole = \"implements\"\ntarget = \"REQ-005\"\n",
        );
        seed_requirement(root, 5);
        seed_issue(root, 1, "open", "", "slices = [\"SL-001\"]\n");
        seed_rec(root, 1, "SL-001");
        seed_review(root, 1, "SL-001", "");

        let pg = build(root).unwrap();
        // Node set equals the scanned entity set (one NodeAttr per scanned entity).
        let scanned: std::collections::BTreeSet<EntityKey> =
            relation_graph::scan_entities(root, &mut vec![], ScanMode::default())
                .unwrap()
                .iter()
                .map(|e| e.key)
                .collect();
        let minted: std::collections::BTreeSet<EntityKey> = pg.attrs.keys().copied().collect();
        assert_eq!(minted, scanned, "every scanned entity is a node");
        // Each key resolves (distinct keys, all interned).
        for k in &scanned {
            assert!(
                pg.projection.resolve(*k).is_some(),
                "{} minted",
                k.canonical()
            );
        }
        assert_eq!(pg.attrs.len(), scanned.len());
        // NodeAttr.kind carries the kind descriptor (its prefix matches the key).
        for (k, attr) in &pg.attrs {
            assert_eq!(
                attr.kind.prefix, k.prefix,
                "NodeAttr.kind matches the key prefix"
            );
        }
    }

    // -- VT-1 + EX-2: NodeAttr status/promoted reads -------------------------

    #[test]
    fn node_attr_status_promoted_per_kind() {
        let dir = tmp();
        let root = dir.path();
        seed_slice(root, 1, "");
        seed_requirement(root, 5);
        // A promoted issue (resolution == promoted) vs a plain open one.
        seed_issue(root, 1, "resolved", "promoted", "");
        seed_issue(root, 2, "open", "", "");
        seed_rec(root, 1, "SL-001");
        // A review with one OPEN finding ⇒ derived status "active".
        seed_review(
            root,
            1,
            "SL-001",
            "[[finding]]\nid = \"F-1\"\nstatus = \"open\"\nseverity = \"minor\"\n\
             title = \"t\"\ndetail = \"d\"\n",
        );
        // A review with all VERIFIED ⇒ derived status "done".
        seed_review(
            root,
            2,
            "SL-001",
            "[[finding]]\nid = \"F-1\"\nstatus = \"verified\"\nseverity = \"minor\"\n\
             title = \"t\"\ndetail = \"d\"\n",
        );

        let pg = build(root).unwrap();
        // Slice carries its raw authored status.
        assert_eq!(pg.attrs[&key("SL", 1)].status.as_deref(), Some("proposed"));
        assert!(!pg.attrs[&key("SL", 1)].promoted);
        // Requirement carries its top-level status.
        assert_eq!(pg.attrs[&key("REQ", 5)].status.as_deref(), Some("active"));
        // REC is status-less.
        assert_eq!(pg.attrs[&key("REC", 1)].status, None);
        // Promoted issue: flag set, status raw "resolved".
        assert_eq!(pg.attrs[&key("ISS", 1)].status.as_deref(), Some("resolved"));
        assert!(
            pg.attrs[&key("ISS", 1)].promoted,
            "resolution=promoted ⇒ promoted"
        );
        // Plain issue: not promoted.
        assert!(!pg.attrs[&key("ISS", 2)].promoted);
        // RV status is DERIVED, not stored.
        assert_eq!(pg.attrs[&key("RV", 1)].status.as_deref(), Some("active"));
        assert_eq!(pg.attrs[&key("RV", 2)].status.as_deref(), Some("done"));
    }

    // -- VT-7: mint uses BASE only (consequence excluded), score is post-pass ---

    #[test]
    fn mint_order_base_desc_then_canonical_asc_and_permutation_invariant() {
        let dir = tmp();
        let root = dir.path();
        // Three issues with DIFFERENT base scores (value facet over est_cost=6.5):
        // ISS-001 value 5 → base 5/6.5; ISS-002 value 25 → base 25/6.5;
        // ISS-003 value 15 → base 15/6.5. Mint order is base.total() DESC, ties by id ASC.
        // Crucially the consequence/edge topology does NOT enter mint (I3).
        seed_issue_with_facets(root, 1, "", "lower = 0.0\nupper = 10.0", "value = 5.0", "");
        seed_issue_with_facets(root, 2, "", "lower = 0.0\nupper = 10.0", "value = 25.0", "");
        seed_issue_with_facets(root, 3, "", "lower = 0.0\nupper = 10.0", "value = 15.0", "");

        let pg = build(root).unwrap();
        // NodeId reflects mint order: lower NodeId = minted earlier (higher base).
        let n1 = pg.projection.resolve(key("ISS", 1)).unwrap();
        let n2 = pg.projection.resolve(key("ISS", 2)).unwrap();
        let n3 = pg.projection.resolve(key("ISS", 3)).unwrap();
        assert!(
            n2 < n3,
            "ISS-002 (base 25/6.5) mints before ISS-003 (base 15/6.5)"
        );
        assert!(
            n3 < n1,
            "ISS-003 (base 15/6.5) mints before ISS-001 (base 5/6.5)"
        );

        // Permutation invariance: re-seed the same corpus in a DIFFERENT authoring order
        // (BTree, no clock/RNG) — the score map and the mint order are identical.
        let dir2 = tmp();
        let root2 = dir2.path();
        seed_issue_with_facets(
            root2,
            3,
            "",
            "lower = 0.0\nupper = 10.0",
            "value = 15.0",
            "",
        );
        seed_issue_with_facets(
            root2,
            2,
            "",
            "lower = 0.0\nupper = 10.0",
            "value = 25.0",
            "",
        );
        seed_issue_with_facets(root2, 1, "", "lower = 0.0\nupper = 10.0", "value = 5.0", "");
        let pg2 = build(root2).unwrap();
        assert_eq!(pg.score, pg2.score, "score map is permutation-invariant");
        let m1 = pg2.projection.resolve(key("ISS", 1)).unwrap();
        let m2 = pg2.projection.resolve(key("ISS", 2)).unwrap();
        let m3 = pg2.projection.resolve(key("ISS", 3)).unwrap();
        assert!(m2 < m3 && m3 < m1, "mint order is permutation-invariant");
    }

    #[test]
    fn mint_order_is_blind_to_consequence_topology() {
        let dir = tmp();
        let root = dir.path();
        // ISS-001 (no facets, base 0) is referenced by TWO slices via `slices` (a
        // CONSEQUENCE_LABELS edge → high optionality in the post-pass). ISS-002 has a
        // value facet (base > 0) but no inbound references. Under the OLD policy the
        // referenced ISS-001 would mint first (consequence desc); under the score model
        // mint is base-only, so ISS-002 (higher base) mints FIRST — consequence is
        // excluded from the structural tiebreak (I3). The post-pass still gives ISS-001
        // a positive score, but that does not reorder the mint.
        seed_issue(root, 1, "open", "", "");
        seed_issue_with_facets(root, 2, "", "lower = 0.0\nupper = 10.0", "value = 25.0", "");
        seed_slice(root, 1, "slices = [\"ISS-001\"]\n");
        seed_slice(root, 2, "slices = [\"ISS-001\"]\n");

        let pg = build(root).unwrap();
        let n1 = pg.projection.resolve(key("ISS", 1)).unwrap();
        let n2 = pg.projection.resolve(key("ISS", 2)).unwrap();
        assert!(
            n2 < n1,
            "ISS-002 (base 25/6.5≈3.846) mints before the heavily-referenced ISS-001 (base 0) — mint is base-only (I3)"
        );
        // The post-pass still credits ISS-001's optionality (two slices reference it,
        // both base 0 here → optionality 0). SL-177 PHASE-02: valueless ISS-001 has
        // default 1.0 → value_dim = 1.0/11.0 ≈ 0.0909 → score = 0.0909.
        assert!((pg.score.get(&key("ISS", 1)).copied().unwrap_or(0.0) - 1.0 / 11.0).abs() < 1e-9);
    }

    // -- EX-4: dep/seq edges; an unresolved target contributes no edge ---------

    #[test]
    fn dep_seq_edges_emitted_for_backlog_unresolved_contributes_no_edge() {
        let dir = tmp();
        let root = dir.path();
        // ISS-001 needs RSK-001 (resolvable) and ISS-099 (unresolved); after ISS-002.
        seed_issue(
            root,
            1,
            "open",
            "",
            "needs = [\"RSK-001\", \"ISS-099\"]\nafter = [{ to = \"ISS-002\", rank = 0 }]\n",
        );
        seed_issue(root, 2, "open", "", "");
        seed_risk(root, 1, "open", "");

        let pg = build(root).unwrap();
        // The dep overlay carries the resolvable needs edge (RSK-001 → ISS-001, the
        // B→A flip): RSK-001 is a predecessor of ISS-001 in `dep`.
        let iss1 = pg.projection.resolve(key("ISS", 1)).unwrap();
        let rsk1 = pg.projection.resolve(key("RSK", 1)).unwrap();
        let dep_preds: Vec<_> = pg
            .graph
            .in_edges(pg.dep_overlay, iss1)
            .map(|(s, _)| s)
            .collect();
        // The unresolved ISS-099 needs ref produced NO edge — RSK-001 is the ONLY
        // dep predecessor of ISS-001 (the dangling-record was dropped; the absence of
        // a phantom edge is the surviving behaviour).
        assert_eq!(
            dep_preds,
            vec![rsk1],
            "only the resolvable needs prereq edges (B→A); unresolved adds nothing"
        );
        // The after edge (ISS-002 → ISS-001) lands on the seq overlay.
        let iss2 = pg.projection.resolve(key("ISS", 2)).unwrap();
        let seq_preds: Vec<_> = pg
            .graph
            .in_edges(pg.seq_overlay, iss1)
            .map(|(s, _)| s)
            .collect();
        assert!(
            seq_preds.contains(&iss2),
            "after edge oriented predecessor→src"
        );
    }

    #[test]
    fn nodes_authoring_no_dep_seq_carry_no_edges() {
        let dir = tmp();
        let root = dir.path();
        // SL-176 PHASE-03: `Slices` removed from CONSEQUENCE_LABELS — use
        // `references(implements)` for the optionality witness.
        // SL-001 references(implements) REQ-005 → REQ-005 gets optionality from SL-001's base.
        // SL-001 needs a value facet so its base_score is non-zero.
        // Author the slice toml directly with facet + implements edge.
        write(
            root,
            ".doctrine/slice/001/slice-001.toml",
            "id = 1\nslug = \"s\"\ntitle = \"S\"\nstatus = \"proposed\"\n\
             created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
             [estimate]\nlower = 0.0\nupper = 10.0\n\
             [value]\nvalue = 25.0\n\
             [[relation]]\nlabel = \"references\"\nrole = \"implements\"\ntarget = \"REQ-005\"\n",
        );
        write(root, ".doctrine/slice/001/slice-001.md", "scope\n");
        seed_issue_with_facets(root, 1, "", "lower = 0.0\nupper = 10.0", "value = 25.0", "");
        seed_issue(root, 2, "open", "", "");
        seed_requirement(root, 5);
        seed_slice(root, 2, "");
        let pg = build(root).unwrap();
        let sl1 = pg.projection.resolve(key("SL", 1)).unwrap();
        let sl2 = pg.projection.resolve(key("SL", 2)).unwrap();
        assert_eq!(pg.graph.in_edges(pg.dep_overlay, sl1).count(), 0);
        assert_eq!(pg.graph.in_edges(pg.seq_overlay, sl1).count(), 0);
        assert_eq!(pg.graph.in_edges(pg.dep_overlay, sl2).count(), 0);
        // The resolvable `references(implements)` ref edge landed: REQ-005's optionality
        // reflects SL-001's base (25/6.5).
        assert!(
            (pg.optionality.get(&key("REQ", 5)).copied().unwrap_or(0.0) - 25.0 / 6.5).abs() < 1e-9,
            "resolvable consequence ref produces its edge (witnessed via optionality)"
        );
    }

    // -- SL-060 VT-1/VT-2: cross-kind slice dep/seq reaches the same overlays ---

    #[test]
    fn slice_needs_lands_on_dep_overlay_cross_kind() {
        let dir = tmp();
        let root = dir.path();
        // SL-001 needs SL-002 — a slice→slice hard prerequisite. The cross-kind
        // `dep_seq_for` slice arm reads it; emission is kind-blind, so it lands on the
        // SAME dep overlay the backlog `needs` does, oriented prereq→dependent (B→A).
        seed_slice(root, 1, "needs = [\"SL-002\"]\n");
        seed_slice(root, 2, "");
        let pg = build(root).unwrap();
        let sl1 = pg.projection.resolve(key("SL", 1)).unwrap();
        let sl2 = pg.projection.resolve(key("SL", 2)).unwrap();
        let dep_preds: Vec<_> = pg
            .graph
            .in_edges(pg.dep_overlay, sl1)
            .map(|(s, _)| s)
            .collect();
        assert_eq!(
            dep_preds,
            vec![sl2],
            "slice→slice needs lands on the dep overlay (B→A flip), like backlog"
        );
    }

    #[test]
    fn slice_after_lands_on_seq_overlay_with_rank_and_array_index_age() {
        let dir = tmp();
        let root = dir.path();
        // SL-001 after SL-002 (rank 7, array index 0) then SL-003 (rank 0, index 1).
        // The slice seq overlay must carry the SAME (rank, age=array index) eviction key
        // the backlog seq overlay does (INV-2 parity, kind-blind emission).
        seed_slice(
            root,
            1,
            "after = [{ to = \"SL-002\", rank = 7 }, { to = \"SL-003\" }]\n",
        );
        seed_slice(root, 2, "");
        seed_slice(root, 3, "");
        let pg = build(root).unwrap();
        let sl1 = pg.projection.resolve(key("SL", 1)).unwrap();
        let sl2 = pg.projection.resolve(key("SL", 2)).unwrap();
        let sl3 = pg.projection.resolve(key("SL", 3)).unwrap();
        // Collect (predecessor, rank, age) off the seq overlay's in-edges of SL-001.
        let seq: BTreeMap<_, _> = pg
            .graph
            .in_edges(pg.seq_overlay, sl1)
            .map(|(s, a)| (s, (a.rank(), a.age())))
            .collect();
        assert_eq!(
            seq.get(&sl2).copied(),
            Some((7, 0)),
            "first after edge: authored rank 7, age = array index 0"
        );
        assert_eq!(
            seq.get(&sl3).copied(),
            Some((0, 1)),
            "second after edge: default rank 0, age = array index 1"
        );
    }

    // -- A free-text / no-overlay outbound target produces no edge -------------

    #[test]
    fn free_text_outbound_target_produces_no_edge() {
        let dir = tmp();
        let root = dir.path();
        // A backlog drift edge is target-unvalidated (no overlay) → it produces no
        // edge at all. With the lone item (no facets), nothing references it and it
        // references no real node, so its score stays at the 0 floor — the surviving
        // behaviour of the dropped dangling record.
        seed_issue(root, 1, "open", "", "drift = [\"some-free-text\"]\n");
        let pg = build(root).unwrap();
        let n = pg.projection.resolve(key("ISS", 1)).unwrap();
        assert_eq!(
            pg.graph.out_edges(pg.dep_overlay, n).count(),
            0,
            "free-text drift target produces no dep edge"
        );
        // SL-177 PHASE-02: valueless backlog item defaults to 1.0 via
        // effective_raw_value → passes burndown guard → score = value_dim = 1.0.
        assert_eq!(
            pg.score.get(&key("ISS", 1)).copied().unwrap_or(0.0),
            1.0,
            "free-text drift target: valueless item score = 1.0 (default)"
        );
    }

    // ── PHASE-04 scoring tests ───────────────────────────────────────────

    /// Seed a backlog item with estimate + value + risk facets for scoring tests.
    fn seed_issue_with_facets(
        root: &Path,
        id: u32,
        rels: &str,
        estimate: &str,
        value: &str,
        risk_facet: &str,
    ) {
        write(
            root,
            &format!(".doctrine/backlog/issue/{id:03}/backlog-{id:03}.toml"),
            &format!(
                "id = {id}\nslug = \"i\"\ntitle = \"I\"\nkind = \"issue\"\nstatus = \"open\"\n\
                 resolution = \"\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
                 {}\n[estimate]\n{}\n[value]\n{}\n[facet]\n{}\n",
                migrate_body(&crate::backlog::ISSUE_KIND, rels),
                estimate,
                value,
                risk_facet,
            ),
        );
        write(
            root,
            &format!(".doctrine/backlog/issue/{id:03}/backlog-{id:03}.md"),
            "b\n",
        );
    }

    // ── VT-2: base_score matrix ─────────────────────────────────────────

    #[test]
    fn base_score_all_facets_present() {
        let dir = tmp();
        let root = dir.path();
        seed_issue_with_facets(
            root,
            1,
            "",
            "lower = 2.0\nupper = 8.0",
            "value = 10.0",
            "likelihood = \"high\"\nimpact = \"critical\"",
        );
        let pg = build(root).unwrap();
        let bs = pg.attrs[&key("ISS", 1)].base_score;
        // value_dim = 1.0(value coeff) * 10.0 * 1.0(kind_weight) * 1.0(Σtag) / est_cost
        //   est_cost = lower + β·(upper-lower) = 2.0 + 0.65*(8.0-2.0) = 5.9
        //   value_dim = 10.0 / 5.9 ≈ 1.694915254
        // risk_dim  = 2.0(risk coeff) * 12(exposure: high=3 × critical=4)
        //          = 24.0
        assert!(
            (bs.value_dim - 10.0 / 5.9).abs() < 1e-9,
            "value_dim should be 10/5.9"
        );
        assert!((bs.risk_dim - 24.0).abs() < 1e-9, "risk_dim should be 24.0");
        assert!(
            (bs.total() - (10.0 / 5.9 + 24.0)).abs() < 1e-9,
            "total should be 10/5.9 + 24"
        );
    }

    #[test]
    fn base_score_value_only_risk_absent() {
        let dir = tmp();
        let root = dir.path();
        seed_issue_with_facets(root, 1, "", "lower = 0.0\nupper = 2.0", "value = 5.0", "");
        let pg = build(root).unwrap();
        let bs = pg.attrs[&key("ISS", 1)].base_score;
        // est_cost = lower + β·(upper-lower) = 0.0 + 0.65*(2.0-0.0) = 1.3
        // value_dim = 1.0 * 5.0 / 1.3 ≈ 3.846153846
        assert!(
            (bs.value_dim - 5.0 / 1.3).abs() < 1e-9,
            "value_dim should be 5.0/1.3"
        );
        assert!((bs.risk_dim - 0.0).abs() < 1e-9, "risk_dim should be 0");
        assert!(
            (bs.total() - 5.0 / 1.3).abs() < 1e-9,
            "total should be 5.0/1.3"
        );
    }

    #[test]
    fn base_score_risk_only_value_absent() {
        let dir = tmp();
        let root = dir.path();
        seed_issue_with_facets(
            root,
            1,
            "",
            "",
            "",
            "likelihood = \"low\"\nimpact = \"medium\"",
        );
        let pg = build(root).unwrap();
        let bs = pg.attrs[&key("ISS", 1)].base_score;
        // SL-177: ISS is value-bearing; no authored value → default 1.0.
        // est_cost = absent = 1.0 (lone bare item); value_dim = 1.0 * 1.0 * 1.0 * 1.0 / 1.0 = 1.0.
        assert!(
            (bs.value_dim - 1.0).abs() < 1e-9,
            "value_dim should be 1.0 (default)"
        );
        // risk_dim = 2.0 * 2 (low=1 × medium=2) = 4.0
        assert!((bs.risk_dim - 4.0).abs() < 1e-9, "risk_dim should be 4.0");
        assert!((bs.total() - 5.0).abs() < 1e-9, "total should be 5.0");
    }

    #[test]
    fn base_score_neither_facet_present() {
        let dir = tmp();
        let root = dir.path();
        seed_issue(root, 1, "open", "", "");
        let pg = build(root).unwrap();
        let bs = pg.attrs[&key("ISS", 1)].base_score;
        // SL-177: ISS is value-bearing; no authored value → default 1.0.
        // est_cost = absent = 1.0 (lone bare item); value_dim = 1.0.
        assert!(
            (bs.value_dim - 1.0).abs() < 1e-9,
            "value_dim should be 1.0 (default)"
        );
        assert!((bs.risk_dim - 0.0).abs() < 1e-9, "risk_dim should be 0");
        assert!((bs.total() - 1.0).abs() < 1e-9, "total should be 1.0");
    }

    #[test]
    fn base_score_bare_item_empty_corpus_fallback_cost_one() {
        let dir = tmp();
        let root = dir.path();
        // A lone bare item — no estimate anywhere in the corpus → absent = 1.0 (empty fallback).
        // est_cost = absent = 1.0; value_dim = 1.0 * 3.0 / 1.0 = 3.0.
        seed_issue_with_facets(
            root,
            1,
            "",
            "", // no estimate
            "value = 3.0",
            "",
        );
        let pg = build(root).unwrap();
        let bs = pg.attrs[&key("ISS", 1)].base_score;
        assert!((bs.value_dim - 3.0).abs() < 1e-9, "value_dim should be 3.0");
    }

    // ── SL-177: effective_raw_value / DEFAULT_VALUE ─────────────────────

    /// Valueless SL (value-bearing, no authored value) → value_dim = DEFAULT_VALUE / est_cost.
    /// Red: old behaviour was 0.0. Green: equals the explicit value=1.0 computation.
    #[test]
    fn base_score_valueless_sl_equals_explicit_value_one() {
        let dir = tmp();
        let root = dir.path();
        // ISS-001: no value facet (implicit default 1.0); ISS-002: value = 1.0 explicitly.
        seed_issue_with_facets(root, 1, "", "lower = 0.0\nupper = 10.0", "", "");
        seed_issue_with_facets(root, 2, "", "lower = 0.0\nupper = 10.0", "value = 1.0", "");
        let pg = build(root).unwrap();
        let bs1 = pg.attrs[&key("ISS", 1)].base_score;
        let bs2 = pg.attrs[&key("ISS", 2)].base_score;
        // Both have absent = 1.0 (empty-corpus fallback) because neither has an estimate…
        // Wait: both HAVE an estimate (lower=0, upper=10). max_upper = 10.0. absent = 10.0 + margin(1.0) = 11.0.
        // est_cost = lower + β·(upper-lower) = 0.0 + 0.65*10.0 = 6.5.
        // ISS-001 value_dim = 1.0(default) * 1.0 / 6.5; ISS-002 value_dim = 1.0(authored) * 1.0 / 6.5.
        let expected = 1.0 / 6.5;
        assert!(
            (bs1.value_dim - expected).abs() < 1e-9,
            "valueless SL value_dim = 1.0/6.5 = {expected}, got {}",
            bs1.value_dim
        );
        assert!(
            (bs2.value_dim - expected).abs() < 1e-9,
            "explicit value=1.0 SL value_dim = 1.0/6.5 = {expected}, got {}",
            bs2.value_dim
        );
    }

    /// Valueless ASM and REV → effective_raw_value None → value_dim == 0.
    #[test]
    fn base_score_valueless_asm_and_rev_value_dim_zero() {
        // Test effective_raw_value directly on real kind descriptors from the
        // integrity table — avoids the disk-seed complexity for REV (which nests
        // under a slice).
        let facets = crate::facet::EntityFacets {
            estimate: None,
            value: None,
            risk: None,
            tags: vec![],
        };
        // Find ASM and REV kinds from the integrity table.
        let asm_kind = crate::integrity::KINDS
            .iter()
            .find(|k| k.kind.prefix == "ASM")
            .map(|k| k.kind)
            .expect("ASM in KINDS");
        let rev_kind = crate::integrity::KINDS
            .iter()
            .find(|k| k.kind.prefix == "REV")
            .map(|k| k.kind)
            .expect("REV in KINDS");
        let iss_kind = crate::integrity::KINDS
            .iter()
            .find(|k| k.kind.prefix == "ISS")
            .map(|k| k.kind)
            .expect("ISS in KINDS");
        assert_eq!(effective_raw_value(asm_kind, &facets), None);
        assert_eq!(effective_raw_value(rev_kind, &facets), None);
        assert_eq!(
            effective_raw_value(iss_kind, &facets),
            Some(DEFAULT_VALUE),
            "ISS is value-bearing → default"
        );
        // value_dim for ASM/REV is 0.
        let cfg = config::PriorityConfig::default();
        let ctx = CostCtx { absent: 1.0 };
        let bs = base_score(&facets, asm_kind, &cfg, ctx);
        assert!(
            (bs.value_dim - 0.0).abs() < 1e-9,
            "ASM value_dim should be 0"
        );
        let bs = base_score(&facets, rev_kind, &cfg, ctx);
        assert!(
            (bs.value_dim - 0.0).abs() < 1e-9,
            "REV value_dim should be 0"
        );
        let bs = base_score(&facets, iss_kind, &cfg, ctx);
        assert!(
            (bs.value_dim - 1.0).abs() < 1e-9,
            "ISS value_dim should be 1.0 (default)"
        );
    }

    /// No-clamp: authored value=0.3 on SL → 0.3, not 1.0. Authored 0.0 → value_dim == 0.
    #[test]
    fn base_score_authored_value_preserved_no_clamp() {
        let dir = tmp();
        let root = dir.path();
        // ISS-001: value = 0.3; ISS-002: value = 0.0.
        seed_issue_with_facets(root, 1, "", "lower = 0.0\nupper = 10.0", "value = 0.3", "");
        seed_issue_with_facets(root, 2, "", "lower = 0.0\nupper = 10.0", "value = 0.0", "");
        let pg = build(root).unwrap();
        let bs1 = pg.attrs[&key("ISS", 1)].base_score;
        let bs2 = pg.attrs[&key("ISS", 2)].base_score;
        // est_cost = 0.0 + 0.65*10.0 = 6.5.
        assert!(
            (bs1.value_dim - 0.3 / 6.5).abs() < 1e-9,
            "authored 0.3 should be 0.3/6.5, not clamped to 1.0"
        );
        assert!(
            (bs2.value_dim - 0.0).abs() < 1e-9,
            "authored 0.0 stays 0.0 (not defaulted to 1.0)"
        );
    }

    // ── VT-4: directions & classes ──────────────────────────────────────

    #[test]
    fn leverage_flows_out_edges_dep_overlay() {
        // A needs B: dep edge B→A. out_edges(dep_overlay, B) = [A].
        // B's leverage = dep_coeff * (base(A) + leverage(A)).
        // A has no dependents → leverage(A)=0.
        let dir = tmp();
        let root = dir.path();
        seed_issue_with_facets(root, 1, "needs = [\"ISS-002\"]\n", "", "value = 10.0", "");
        seed_issue_with_facets(root, 2, "", "", "value = 3.0", "");
        let pg = build(root).unwrap();
        // ISS-002 base = 3.0, ISS-001 base = 10.0 (both bare, absent=1.0)
        // ISS-002 is prereq (src of dep edge B→A); out_edges(dep_overlay, ISS-002) = {ISS-001}
        // ISS-001 has no dependents → leverage(ISS-001) = 0
        // leverage(ISS-002) = 0.5 * (base(ISS-001) + 0) = 5.0
        let lev2 = pg.leverage[&key("ISS", 2)];
        let lev1 = pg.leverage[&key("ISS", 1)];
        assert!((lev1 - 0.0).abs() < 1e-9, "ISS-001 has no dependents");
        assert!((lev2 - 5.0).abs() < 1e-9, "ISS-002 gets 0.5 * 10.0");
    }

    #[test]
    fn optionality_flows_in_edges_over_consequence_labels_one_hop() {
        // SL-001 has a `slices` edge to ISS-001 (CONSEQUENCE_LABELS member).
        // optionality(ISS-001) = ref_coeff * base(SL-001). One hop, no recursion.
        let dir = tmp();
        let root = dir.path();
        seed_slice(root, 1, "slices = [\"ISS-001\"]\n");
        seed_issue_with_facets(root, 1, "", "", "value = 7.0", "");
        let pg = build(root).unwrap();
        // SL-001 base = 0 (no value facet)
        // ISS-001 base = 7.0
        // optionality(ISS-001) = 1.0 * base(SL-001) = 0.0
        let opt = pg.optionality[&key("ISS", 1)];
        assert!(
            (opt - 0.0).abs() < 1e-9,
            "SL-001 has no value → optionality=0"
        );
        // ISS-001 itself is not referenced by anyone
        let opt_sl = pg.optionality[&key("SL", 1)];
        assert!(
            (opt_sl - 0.0).abs() < 1e-9,
            "SL-001 is not a ref target of a consequence label"
        );
    }

    #[test]
    fn reviews_and_owning_slice_edges_contribute_zero_optionality() {
        // A review targeting ISS-001 creates a `reviews` edge (NOT in CONSEQUENCE_LABELS).
        // A rec creates `owning_slice` (NOT in CONSEQUENCE_LABELS).
        // Neither should contribute to optionality.
        let dir = tmp();
        let root = dir.path();
        seed_issue_with_facets(root, 1, "", "", "value = 5.0", "");
        seed_review(root, 1, "ISS-001", "");
        seed_rec(root, 1, "ISS-001");
        let pg = build(root).unwrap();
        // ISS-001 optionality should be 0 — reviews and owning_slice don't count.
        let opt = pg.optionality[&key("ISS", 1)];
        assert!(
            (opt - 0.0).abs() < 1e-9,
            "reviews/owning_slice contribute 0"
        );
    }

    #[test]
    fn dangling_target_contributes_zero() {
        // An edge to an unresolved target contributes nothing.
        let dir = tmp();
        let root = dir.path();
        // SL-001 has a `slices` edge to ISS-099 (doesn't exist).
        seed_slice(root, 1, "slices = [\"ISS-099\"]\n");
        seed_issue_with_facets(root, 1, "", "", "value = 3.0", "");
        let pg = build(root).unwrap();
        // ISS-099 was never seeded → no edge, no optionality contribution.
        assert!(pg.optionality.get(&key("ISS", 1)).copied().unwrap_or(0.0) == 0.0);
    }

    // ── VT-4b: leverage is recursive ────────────────────────────────────

    #[test]
    fn leverage_recursive_chain() {
        // A needs B, B needs C. Chain: top (A) → middle (B) → leaf (C).
        // ISS-001 needs ISS-002, ISS-002 needs ISS-003.
        // Dep edges: ISS-002→ISS-001, ISS-003→ISS-002.
        // out_edges: ISS-001=[], ISS-002=[ISS-001], ISS-003=[ISS-002]
        // base(ISS-001)=2, base(ISS-002)=3, base(ISS-003)=5.
        // leverage(ISS-001) = 0 (no dependents)
        // leverage(ISS-002) = 0.5 * (base(ISS-001) + 0) = 1.0
        // leverage(ISS-003) = 0.5 * (base(ISS-002) + 1.0) = 0.5 * 4 = 2.0
        let dir = tmp();
        let root = dir.path();
        seed_issue_with_facets(root, 1, "needs = [\"ISS-002\"]\n", "", "value = 2.0", "");
        seed_issue_with_facets(root, 2, "needs = [\"ISS-003\"]\n", "", "value = 3.0", "");
        seed_issue_with_facets(root, 3, "", "", "value = 5.0", "");
        let pg = build(root).unwrap();
        let lev_1 = pg.leverage[&key("ISS", 1)];
        let lev_2 = pg.leverage[&key("ISS", 2)];
        let lev_3 = pg.leverage[&key("ISS", 3)];
        assert!((lev_1 - 0.0).abs() < 1e-9, "ISS-001 has no dependents");
        assert!((lev_2 - 1.0).abs() < 1e-9, "ISS-002 gets 0.5 * ISS-001");
        assert!(
            (lev_3 - 2.0).abs() < 1e-9,
            "ISS-003 gets 0.5 * (ISS-002+l2)"
        );
    }

    #[test]
    fn leverage_diamond_double_counts_shared_leaf() {
        // Top needs B and C. B and C both need D.
        // ISS-001 needs ISS-002, ISS-001 needs ISS-003.
        // ISS-002 needs ISS-004. ISS-003 needs ISS-004.
        // ISS-004 is the shared leaf. Top-to-leaf direction: ISS-001 → ISS-002/ISS-003 → ISS-004.
        // Dep edges: ISS-002→ISS-001, ISS-003→ISS-001, ISS-004→ISS-002, ISS-004→ISS-003.
        // Leverage flows opposite: from dependents to prereqs.
        // ISS-001 has no dependents (it's the top) → lever(ISS-001)=0
        // ISS-002's dependent: ISS-001. lever(ISS-002) = 0.5 * (base(ISS-001)+0) = 0.5*10 = 5.0
        // ISS-003's dependent: ISS-001. lever(ISS-003) = 0.5 * 10 = 5.0
        // ISS-004's dependents: ISS-002 and ISS-003.
        //   lever(ISS-004) = 0.5 * ((base(ISS-002)+lev(ISS-002)) + (base(ISS-003)+lev(ISS-003)))
        //                  = 0.5 * ((1+5) + (1+5)) = 6.0
        let dir = tmp();
        let root = dir.path();
        seed_issue_with_facets(
            root,
            1,
            "needs = [\"ISS-002\", \"ISS-003\"]\n",
            "",
            "value = 10.0",
            "",
        );
        seed_issue_with_facets(root, 2, "needs = [\"ISS-004\"]\n", "", "value = 1.0", "");
        seed_issue_with_facets(root, 3, "needs = [\"ISS-004\"]\n", "", "value = 1.0", "");
        seed_issue_with_facets(root, 4, "", "", "value = 5.0", "");
        let pg = build(root).unwrap();
        let lev_1 = pg.leverage[&key("ISS", 1)];
        let lev_2 = pg.leverage[&key("ISS", 2)];
        let lev_3 = pg.leverage[&key("ISS", 3)];
        let lev_4 = pg.leverage[&key("ISS", 4)];
        assert!((lev_1 - 0.0).abs() < 1e-9);
        assert!((lev_2 - 5.0).abs() < 1e-9);
        assert!((lev_3 - 5.0).abs() < 1e-9);
        assert!(
            (lev_4 - 6.0).abs() < 1e-9,
            "D double-counted through both paths"
        );
    }

    #[test]
    fn ref_optionality_is_one_hop_no_transitive_accumulation() {
        // SL-176 PHASE-03: `Slices` removed from CONSEQUENCE_LABELS.
        // ISS-001 references(concerns) ISS-002. ISS-002's optionality sees only ISS-001.
        let dir = tmp();
        let root = dir.path();
        seed_slice(root, 1, "");
        seed_issue_with_facets(root, 1, "", "", "value = 5.0", "");
        seed_issue_with_facets(root, 2, "", "", "value = 3.0", "");
        let pg = build(root).unwrap();
        // No references edges authored — no optionality anywhere.
        assert!(
            (pg.optionality[&key("ISS", 2)] - 0.0).abs() < 1e-9,
            "ISS-002 has no referencers"
        );
        assert!(
            (pg.optionality[&key("ISS", 1)] - 0.0).abs() < 1e-9,
            "ISS-001 has no referencers"
        );
        assert!(
            (pg.optionality[&key("SL", 1)] - 0.0).abs() < 1e-9,
            "SL-001 has no referencers"
        );
    }

    // ── VT-6: determinism + finite outputs ──────────────────────────────

    #[test]
    fn equal_scores_tiebreak_id_asc() {
        // Two identical items with the same facets → same base_score.
        // Their scores should be equal, and the BTreeMap order (id asc) is
        // the natural tiebreak.
        let dir = tmp();
        let root = dir.path();
        seed_issue_with_facets(root, 1, "", "", "value = 10.0", "");
        seed_issue_with_facets(root, 2, "", "", "value = 10.0", "");
        let pg = build(root).unwrap();
        let s1 = pg.score[&key("ISS", 1)];
        let s2 = pg.score[&key("ISS", 2)];
        // Equal base, no leverage/optionality → equal scores.
        assert!((s1 - s2).abs() < 1e-9, "equal bases yield equal scores");
        // Keys are ordered by canonical id (ISS-001 < ISS-002).
        let keys: Vec<_> = pg.score.keys().collect();
        assert!(keys[0] < keys[1], "BTreeMap orders by id asc");
    }

    #[test]
    fn near_max_coefficients_produce_no_nan_or_inf() {
        // Feed a config with COEFF_MAX coefficients (loaded from doctrine.toml)
        // and verify that scores/leverage/optionality are finite.
        let dir = tmp();
        let root = dir.path();
        let max_val = config::COEFF_MAX;
        write(
            root,
            ".doctrine/doctrine.toml",
            &format!(
                "[priority]\ncoefficients = {{ value = {max_val}, risk = {max_val} }}\n\
                 consequence = {{ dep_coeff = 1.0, ref_coeff = {max_val} }}\n"
            ),
        );
        // A needs B: B accrues leverage from A
        seed_issue_with_facets(root, 1, "needs = [\"ISS-002\"]\n", "", "value = 1e6", "");
        seed_issue_with_facets(
            root,
            2,
            "",
            "",
            "value = 1e6",
            "likelihood = \"critical\"\nimpact = \"critical\"",
        );
        let pg = build(root).unwrap();
        for (_k, &s) in &pg.score {
            assert!(s.is_finite(), "score should be finite, got {s}");
        }
        for (_k, &lev) in &pg.leverage {
            assert!(lev.is_finite(), "leverage should be finite, got {lev}");
        }
        for (_k, &opt) in &pg.optionality {
            assert!(opt.is_finite(), "optionality should be finite, got {opt}");
        }
    }

    // ── VT-8: termination / condensation ────────────────────────────────

    #[test]
    fn self_loop_yields_finite_leverage() {
        // A needs A — a self-loop. Should produce finite leverage.
        let dir = tmp();
        let root = dir.path();
        seed_issue_with_facets(root, 1, "needs = [\"ISS-001\"]\n", "", "value = 5.0", "");
        let pg = build(root).unwrap();
        let lev = pg.leverage[&key("ISS", 1)];
        assert!(lev.is_finite(), "self-loop leverage should be finite");
    }

    #[test]
    fn multi_member_scc_with_external_dependent() {
        // A↔B (mutual needs) forming an SCC, with external dependent C (C needs B).
        // The {A,B} component is from provenance().cycles().
        // Intra-component edges (A→B, B→A) contribute 0.
        // External: C depends on B → base(C)+leverage(C) flows to {A,B} component once.
        // A and B report the same finite component leverage.
        let dir = tmp();
        let root = dir.path();
        seed_issue_with_facets(root, 1, "needs = [\"ISS-002\"]\n", "", "value = 1.0", "");
        seed_issue_with_facets(root, 2, "needs = [\"ISS-001\"]\n", "", "value = 1.0", "");
        seed_issue_with_facets(root, 3, "needs = [\"ISS-002\"]\n", "", "value = 10.0", "");
        let pg = build(root).unwrap();
        // C (=ISS-003 base=10) depends on B (=ISS-002). C has no dependents → lev(C)=0.
        // {A,B} component gets 0.5 * (base(C) + lev(C)) = 0.5 * 10 = 5.0.
        // Intra-component edges A↔B contribute 0.
        let lev_a = pg.leverage[&key("ISS", 1)];
        let lev_b = pg.leverage[&key("ISS", 2)];
        let lev_c = pg.leverage[&key("ISS", 3)];
        assert!(lev_c == 0.0, "C has no dependents");
        assert!(
            (lev_a - lev_b).abs() < 1e-9,
            "A and B report the same component leverage"
        );
        assert!((lev_a - 5.0).abs() < 1e-9, "component leverage = 0.5 * 10");
        assert!(lev_a.is_finite(), "leverage should be finite");
    }

    #[test]
    fn scc_leverage_uses_component_topo_order_under_seq_perturbation() {
        // RV-137 F-1: reverse graph.ordered() is NOT reverse-topo of the CONDENSED
        // graph. A↔B SCC; external dependent D needs A; D has its own dependent E
        // (E needs D) so leverage(D) is recursive/nonzero; a seq edge (B after D)
        // perturbs ordered() so a member of {A,B} is visited before D's leverage
        // resolves. The component must still pick up D's RESOLVED leverage.
        //   dep edges: A needs B → B→A; B needs A → A→B (SCC {A,B});
        //              D needs A → A→D (D is the component's external dependent);
        //              E needs D → D→E (so out_edges(D)={E}).
        //   leverage(E)=0; leverage(D)=0.5*(base(E)+0)=0.5*8=4;
        //   leverage({A,B})=0.5*(base(D)+leverage(D))=0.5*(2+4)=3.
        //   The pre-fix first-member-hit code drops leverage(D) → 0.5*2=1.
        let dir = tmp();
        let root = dir.path();
        seed_issue_with_facets(root, 1, "needs = [\"ISS-002\"]\n", "", "value = 0.0", ""); // A
        seed_issue_with_facets(
            root,
            2,
            "needs = [\"ISS-001\"]\nafter = [{ to = \"ISS-003\", rank = 0 }]\n",
            "",
            "value = 0.0",
            "",
        ); // B (SCC member + seq "after D")
        seed_issue_with_facets(root, 3, "needs = [\"ISS-001\"]\n", "", "value = 2.0", ""); // D needs A
        seed_issue_with_facets(root, 4, "needs = [\"ISS-003\"]\n", "", "value = 8.0", ""); // E needs D
        let pg = build(root).unwrap();
        let lev_a = pg.leverage[&key("ISS", 1)];
        let lev_b = pg.leverage[&key("ISS", 2)];
        let lev_d = pg.leverage[&key("ISS", 3)];
        let lev_e = pg.leverage[&key("ISS", 4)];
        assert!((lev_e - 0.0).abs() < 1e-9, "E has no dependents");
        assert!((lev_d - 4.0).abs() < 1e-9, "D = 0.5 * base(E)");
        assert!(
            (lev_a - lev_b).abs() < 1e-9,
            "A and B share component leverage"
        );
        assert!(
            (lev_a - 3.0).abs() < 1e-9,
            "{{A,B}} picks up D's RESOLVED leverage: 0.5*(2+4)=3, not 0.5*2=1"
        );
    }

    #[test]
    fn scc_external_dependent_counted_once_per_component() {
        // RV-137 F-2: an external dependent that needs >1 SCC member must be counted
        // ONCE for the component, not once per member.
        //   A↔B SCC; D needs A AND D needs B → out_edges(A)∋D, out_edges(B)∋D.
        //   {A,B} external dependents = {D} (deduped). leverage = 0.5*(base(D)+0)=5.
        //   The pre-fix per-member sum counts D twice → 0.5*(10+10)=10.
        let dir = tmp();
        let root = dir.path();
        seed_issue_with_facets(root, 1, "needs = [\"ISS-002\"]\n", "", "value = 0.0", ""); // A
        seed_issue_with_facets(root, 2, "needs = [\"ISS-001\"]\n", "", "value = 0.0", ""); // B
        seed_issue_with_facets(
            root,
            3,
            "needs = [\"ISS-001\", \"ISS-002\"]\n",
            "",
            "value = 10.0",
            "",
        ); // D needs A AND B
        let pg = build(root).unwrap();
        let lev_a = pg.leverage[&key("ISS", 1)];
        let lev_b = pg.leverage[&key("ISS", 2)];
        let lev_d = pg.leverage[&key("ISS", 3)];
        assert!((lev_d - 0.0).abs() < 1e-9, "D has no dependents");
        assert!(
            (lev_a - lev_b).abs() < 1e-9,
            "A and B share component leverage"
        );
        assert!(
            (lev_a - 5.0).abs() < 1e-9,
            "D counted once per component: 0.5*10=5, not 0.5*20=10"
        );
    }

    // ── tag_term helpers ────────────────────────────────────────────────

    /// Seed a backlog issue with tags, estimate, and value facets.
    fn seed_issue_with_tags(root: &Path, id: u32, tags: &str, value: &str, estimate: &str) {
        write(
            root,
            &format!(".doctrine/backlog/issue/{id:03}/backlog-{id:03}.toml"),
            &format!(
                "id = {id}\nslug = \"i\"\ntitle = \"I\"\nkind = \"issue\"\nstatus = \"open\"\n\
                 resolution = \"\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
                 tags = [{tags}]\n\
                 [estimate]\n{estimate}\n\
                 [value]\n{value}\n",
            ),
        );
        write(
            root,
            &format!(".doctrine/backlog/issue/{id:03}/backlog-{id:03}.md"),
            "b\n",
        );
    }

    // ── VT-14: tag_term in base_score ───────────────────────────────────

    #[test]
    fn base_score_empty_tags_identity() {
        // No tags → tag_term = 1.0 → value_dim unchanged (identity).
        let dir = tmp();
        let root = dir.path();
        seed_issue_with_facets(root, 1, "", "lower = 0.0\nupper = 10.0", "value = 10.0", "");
        let pg = build(root).unwrap();
        let bs = pg.attrs[&key("ISS", 1)].base_score;
        // value_dim = 1.0 * 10.0 * 1.0 * 1.0 / est_cost
        //   est_cost = 0.0 + 0.65*(10.0-0.0) = 6.5
        //          = 10.0 / 6.5 ≈ 1.538461538
        assert!(
            (bs.value_dim - 10.0 / 6.5).abs() < 1e-9,
            "empty tags → identity"
        );
    }

    #[test]
    fn base_score_with_tag_coefficient() {
        // tags = ["area:foo"], tag_coeff("area:foo") = 2.0
        // tag_term = 1.0 + (2.0 - 1.0) = 2.0 → doubles value_dim
        let dir = tmp();
        let root = dir.path();
        write(
            root,
            ".doctrine/doctrine.toml",
            "[priority]\ntag_coefficients = { \"area:foo\" = 2.0 }\n",
        );
        seed_issue_with_tags(
            root,
            1,
            "\"area:foo\"",
            "value = 10.0",
            "lower = 0.0\nupper = 10.0",
        );
        let pg = build(root).unwrap();
        let bs = pg.attrs[&key("ISS", 1)].base_score;
        // value_dim = 1.0 * 10.0 * 1.0 * 2.0 / est_cost
        //   est_cost = 0.0 + 0.65*(10.0-0.0) = 6.5
        //          = 20.0 / 6.5 ≈ 3.076923077
        assert!(
            (bs.value_dim - 20.0 / 6.5).abs() < 1e-9,
            "tag coeff 2.0 doubles value_dim"
        );
    }

    #[test]
    fn base_score_multiple_tags() {
        // tags = ["a", "b"], tag_coeff("a") = 1.5, tag_coeff("b") = 2.0
        // tag_term = 1.0 + (1.5 - 1.0) + (2.0 - 1.0) = 2.5
        let dir = tmp();
        let root = dir.path();
        write(
            root,
            ".doctrine/doctrine.toml",
            "[priority]\ntag_coefficients = { a = 1.5, b = 2.0 }\n",
        );
        seed_issue_with_tags(
            root,
            1,
            "\"a\", \"b\"",
            "value = 6.0",
            "lower = 2.0\nupper = 4.0",
        );
        let pg = build(root).unwrap();
        let bs = pg.attrs[&key("ISS", 1)].base_score;
        // value_dim = 1.0 * 6.0 * 1.0 * 2.5 / est_cost
        //   est_cost = 2.0 + 0.65*(4.0-2.0) = 3.3
        //          = 15.0 / 3.3 ≈ 4.545454545
        assert!(
            (bs.value_dim - 15.0 / 3.3).abs() < 1e-9,
            "tag_term 2.5 → value_dim = 15.0/3.3"
        );
    }

    #[test]
    fn base_score_demoting_tag() {
        // tags = ["wontfix"], tag_coeff("wontfix") = 0.5
        // tag_term = 1.0 + (0.5 - 1.0) = 0.5 → halves value_dim
        let dir = tmp();
        let root = dir.path();
        write(
            root,
            ".doctrine/doctrine.toml",
            "[priority]\ntag_coefficients = { wontfix = 0.5 }\n",
        );
        seed_issue_with_tags(
            root,
            1,
            "\"wontfix\"",
            "value = 20.0",
            "lower = 0.0\nupper = 10.0",
        );
        let pg = build(root).unwrap();
        let bs = pg.attrs[&key("ISS", 1)].base_score;
        // value_dim = 1.0 * 20.0 * 1.0 * 0.5 / est_cost
        //   est_cost = 0.0 + 0.65*(10.0-0.0) = 6.5
        //          = 10.0 / 6.5 ≈ 1.538461538
        assert!(
            (bs.value_dim - 10.0 / 6.5).abs() < 1e-9,
            "demoting tag halves value_dim"
        );
    }

    #[test]
    fn base_score_multi_demote_floors_at_zero() {
        // tags = ["x", "y"], both tag_coeff = 0.0
        // tag_term = 1.0 + (0.0 - 1.0) + (0.0 - 1.0) = -1.0 → max(0.0) = 0.0
        let dir = tmp();
        let root = dir.path();
        write(
            root,
            ".doctrine/doctrine.toml",
            "[priority]\ntag_coefficients = { x = 0.0, y = 0.0 }\n",
        );
        seed_issue_with_tags(
            root,
            1,
            "\"x\", \"y\"",
            "value = 10.0",
            "lower = 0.0\nupper = 10.0",
        );
        let pg = build(root).unwrap();
        let bs = pg.attrs[&key("ISS", 1)].base_score;
        // value_dim = 1.0 * 10.0 * 1.0 * 0.0 / 6.5 = 0.0 (tag_term floors at 0)
        assert!(
            (bs.value_dim - 0.0).abs() < 1e-9,
            "multi-demote floors at zero, not negative"
        );
    }

    // ── VT-3: fulfils value-burndown post-pass ─────────────────────────

    /// burndown lowers score: a done slice fulfilling a valued backlog item
    /// attenuates the item's value_dim. The item's score is strictly lower than
    /// an identical un-fulfilled item.
    #[test]
    fn burndown_lowers_score() {
        let dir = tmp();
        let root = dir.path();
        // Two identical backlog items (value=10.0, bare → est_cost=absent=1.0,
        // value_dim=10.0). ISS-002 has a `done` slice fulfilling it; ISS-001 does not.
        // SL-001: value=4.0, status="done", fulfils ISS-002.
        // Burndown on ISS-002: delivered=4.0, r=4.0/10.0=0.4, burn=10.0*0.6=6.0.
        seed_issue_with_facets(root, 1, "", "", "value = 10.0", "");
        seed_issue_with_facets(root, 2, "", "", "value = 10.0", "");
        write(
            root,
            ".doctrine/slice/001/slice-001.toml",
            "id = 1\nslug = \"s\"\ntitle = \"S\"\nstatus = \"done\"\n\
             created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
             [value]\nvalue = 4.0\n\
             [[relation]]\nlabel = \"fulfils\"\ntarget = \"ISS-002\"\n",
        );
        write(root, ".doctrine/slice/001/slice-001.md", "scope\n");
        let pg = build(root).unwrap();
        let s1 = pg.score[&key("ISS", 1)];
        let s2 = pg.score[&key("ISS", 2)];
        // ISS-001: no fulfils → burndown_term = value_dim = 10.0, score = 10.0.
        assert!((s1 - 10.0).abs() < 1e-9, "ISS-001 unchanged, got {s1}");
        // ISS-002: 40% burndown → burn = 6.0, score = 6.0.
        assert!((s2 - 6.0).abs() < 1e-9, "ISS-002 burndown to 6.0, got {s2}");
        assert!(
            s2 < s1,
            "burndown strictly lowers the fulfilled item's score"
        );
    }

    /// lifecycle gate: only started/audit/reconcile/done slices burn value;
    /// proposed/design/plan/ready/abandoned slices burn nothing.
    #[test]
    fn burndown_lifecycle_gate() {
        let dir = tmp();
        let root = dir.path();
        // ISS-001: fulfilled by SL-001 (status="ready" → gate=0 → no burn).
        // ISS-002: fulfilled by SL-002 (status="started" → gate=1 → full burn).
        // ISS-003: fulfilled by SL-003 (status="done" → gate=1 → full burn).
        // All ISS: value=10.0 bare (value_dim=10.0). All SL: value=4.0.
        seed_issue_with_facets(root, 1, "", "", "value = 10.0", "");
        seed_issue_with_facets(root, 2, "", "", "value = 10.0", "");
        seed_issue_with_facets(root, 3, "", "", "value = 10.0", "");
        // SL-001 ready — gate=0.
        write(
            root,
            ".doctrine/slice/001/slice-001.toml",
            "id = 1\nslug = \"s\"\ntitle = \"S\"\nstatus = \"ready\"\n\
             created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
             [value]\nvalue = 4.0\n\
             [[relation]]\nlabel = \"fulfils\"\ntarget = \"ISS-001\"\n",
        );
        write(root, ".doctrine/slice/001/slice-001.md", "scope\n");
        // SL-002 started — gate=1.
        write(
            root,
            ".doctrine/slice/002/slice-002.toml",
            "id = 2\nslug = \"s\"\ntitle = \"S\"\nstatus = \"started\"\n\
             created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
             [value]\nvalue = 4.0\n\
             [[relation]]\nlabel = \"fulfils\"\ntarget = \"ISS-002\"\n",
        );
        write(root, ".doctrine/slice/002/slice-002.md", "scope\n");
        // SL-003 done — gate=1.
        write(
            root,
            ".doctrine/slice/003/slice-003.toml",
            "id = 3\nslug = \"s\"\ntitle = \"S\"\nstatus = \"done\"\n\
             created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
             [value]\nvalue = 4.0\n\
             [[relation]]\nlabel = \"fulfils\"\ntarget = \"ISS-003\"\n",
        );
        write(root, ".doctrine/slice/003/slice-003.md", "scope\n");
        let pg = build(root).unwrap();
        // ISS-001: ready gate=0 → burn=10.0, score=10.0 (unchanged).
        assert!(
            (pg.score[&key("ISS", 1)] - 10.0).abs() < 1e-9,
            "ready status burns nothing: Fulfils burndown lifecycle gate"
        );
        // ISS-002: started gate=1 → burn=6.0, score=6.0.
        assert!(
            (pg.score[&key("ISS", 2)] - 6.0).abs() < 1e-9,
            "started status burns fully: Fulfils burndown lifecycle gate"
        );
        // ISS-003: started gate=1 → burn=6.0, score=6.0.
        assert!(
            (pg.score[&key("ISS", 3)] - 6.0).abs() < 1e-9,
            "done (via started) burns fully: Fulfils burndown lifecycle gate"
        );
    }

    /// non-conservation: one done slice fulfilling TWO items burns each item
    /// independently — the slice's value is not "spent once."
    #[test]
    fn burndown_non_conservation() {
        let dir = tmp();
        let root = dir.path();
        // SL-001 (value=4.0, done) fulfils both ISS-001 and ISS-002.
        // Each ISS has value=10.0 bare (value_dim=10.0).
        // Each ISS independently: delivered=4.0, r=0.4, burn=6.0, score=6.0.
        seed_issue_with_facets(root, 1, "", "", "value = 10.0", "");
        seed_issue_with_facets(root, 2, "", "", "value = 10.0", "");
        write(
            root,
            ".doctrine/slice/001/slice-001.toml",
            "id = 1\nslug = \"s\"\ntitle = \"S\"\nstatus = \"done\"\n\
             created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
             [value]\nvalue = 4.0\n\
             [[relation]]\nlabel = \"fulfils\"\ntarget = \"ISS-001\"\n\
             [[relation]]\nlabel = \"fulfils\"\ntarget = \"ISS-002\"\n",
        );
        write(root, ".doctrine/slice/001/slice-001.md", "scope\n");
        let pg = build(root).unwrap();
        // Both items burned identically — slice's 4.0 is NOT consumed by one item
        // and unavailable to the other.
        let s1 = pg.score[&key("ISS", 1)];
        let s2 = pg.score[&key("ISS", 2)];
        assert!((s1 - 6.0).abs() < 1e-9, "ISS-001 burndown to 6.0, got {s1}");
        assert!((s2 - 6.0).abs() < 1e-9, "ISS-002 burndown to 6.0, got {s2}");
        assert!(
            (s1 - s2).abs() < 1e-9,
            "Fulfils burndown is non-conserving across multi-item"
        );
    }

    /// originates_from is inert for priority: it feeds NO priority pass —
    /// neither optionality nor the burndown changes.
    #[test]
    fn originates_from_inert_for_priority() {
        let dir = tmp();
        let root = dir.path();
        // ISS-001 authors originates_from → SL-001 (OriginatesFrom label).
        // SL-001 has value=5.0. OriginatesFrom is NOT in REF_LABELS → no overlay
        // → contributes neither optionality nor burndown.
        seed_issue(root, 1, "open", "", "originates_from = [\"SL-001\"]\n");
        seed_slice(root, 1, "");
        let pg = build(root).unwrap();
        // Neither entity gets optionality from the originates_from edge.
        assert!(
            pg.optionality.get(&key("ISS", 1)).copied().unwrap_or(0.0) == 0.0,
            "originates_from is not a CONSEQUENCE_LABELS member → optionality = 0"
        );
        assert!(
            pg.optionality.get(&key("SL", 1)).copied().unwrap_or(0.0) == 0.0,
            "SL target of originates_from gets no optionality"
        );
        // Burndown unaffected: no Fulfils edge exists (r=0, burn = value_dim).
        // SL-177 PHASE-02: ISS-001 is value-bearing valueless → default 1.0 → score=1.0.
        assert!(
            (pg.score[&key("ISS", 1)] - 1.0).abs() < 1e-9,
            "originates_from: valueless item score = 1.0 (default); still no lev/opt change"
        );
    }

    /// exact-value / wrong-denominator trap: value_dim ≠ raw_value when
    /// estimate/cost diverges them. Assert the hand-computed post-burndown
    /// score EXACTLY — catches r computed with value_dim instead of raw value,
    /// or delivered subtracted directly instead of via the r ratio.
    #[test]
    fn burndown_exact_value_divergence_trap() {
        let dir = tmp();
        let root = dir.path();
        // ISS-001: value=10.0, estimate lower=0 upper=20.
        //   est_cost = 0 + 0.65*20 = 13.0, value_dim = 10.0/13.0 ≈ 0.7692307692307693.
        //   raw_value = 10.0 (≠ value_dim).
        // SL-001: value=5.0, status="done", fulfils ISS-001.
        //   Burndown on ISS-001: delivered = gate(1.0) * raw_value(SL-001) = 5.0.
        //   r = 5.0 / raw_value(ISS-001) = 5.0 / 10.0 = 0.5.
        //   burn = value_dim(ISS-001) * (1 - r) = 0.7692307692307693 * 0.5
        //        = 0.38461538461538464.
        //   score = 0 + 0 + 0 + burn ≈ 0.38461538461538464.
        seed_issue_with_facets(root, 1, "", "lower = 0.0\nupper = 20.0", "value = 10.0", "");
        write(
            root,
            ".doctrine/slice/001/slice-001.toml",
            "id = 1\nslug = \"s\"\ntitle = \"S\"\nstatus = \"done\"\n\
             created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
             [value]\nvalue = 5.0\n\
             [[relation]]\nlabel = \"fulfils\"\ntarget = \"ISS-001\"\n",
        );
        write(root, ".doctrine/slice/001/slice-001.md", "scope\n");
        let pg = build(root).unwrap();
        let expected: f64 = 10.0 / 13.0 * 0.5; // = 0.38461538461538464
        let got = pg.score[&key("ISS", 1)];
        assert!(
            (got - expected).abs() < 1e-9,
            "Fulfils burndown uses raw_value denominator ({expected}) not value_dim, got {got}"
        );
    }

    /// decomposition: Fulfils is NOT in CONSEQUENCE_LABELS (zero optionality
    /// from the fulfils edge), Slices is removed from CONSEQUENCE_LABELS, so
    /// the fulfilled item's score delta vs no-fulfils baseline equals the
    /// burndown term ONLY. Catches Fulfils wrongly left in CONSEQUENCE_LABELS
    /// or Slices not fully removed (double-count).
    #[test]
    fn burndown_decomposition() {
        let dir = tmp();
        let root = dir.path();
        // ISS-001: value=10.0 bare (value_dim=10.0). No slices/references edges.
        // SL-001: value=4.0, status="done", fulfils ISS-001.
        seed_issue_with_facets(root, 1, "", "", "value = 10.0", "");
        write(
            root,
            ".doctrine/slice/001/slice-001.toml",
            "id = 1\nslug = \"s\"\ntitle = \"S\"\nstatus = \"done\"\n\
             created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
             [value]\nvalue = 4.0\n\
             [[relation]]\nlabel = \"fulfils\"\ntarget = \"ISS-001\"\n",
        );
        write(root, ".doctrine/slice/001/slice-001.md", "scope\n");
        let pg = build(root).unwrap();
        // ISS-001 optionality: the fulfils edge is on the Fulfils overlay, NOT in
        // CONSEQUENCE_LABELS → contributes 0. No other inbound consequence edges.
        assert!(
            pg.optionality[&key("ISS", 1)] == 0.0,
            "Fulfils not in CONSEQUENCE_LABELS: optionality from fulfils = 0"
        );
        // Score delta vs no-fulfils baseline: the only difference is the burndown term.
        // No-fulfils baseline: score = value_dim = 10.0.
        // With fulfils: burn = 10.0 * (1 - 4.0/10.0) = 6.0, score = 6.0.
        // Delta = 4.0 = value_dim * r = burndown term ONLY (no lev/opt delta).
        let s = pg.score[&key("ISS", 1)];
        assert!(
            (s - 6.0).abs() < 1e-9,
            "Fulfils burndown decomposition: score=6.0, got {s}"
        );
        // Verify the delta equals value_dim * r alone (proof: 10.0 * 0.4 = 4.0).
        let baseline = 10.0;
        let delta = baseline - s;
        assert!(
            (delta - 4.0).abs() < 1e-9,
            "decomposition: delta={delta} equals burndown term ONLY (no slices/optionality double-count)"
        );
    }

    // ── VT-4 (SL-177 PHASE-02): burndown raw_value retrofit ──────────────

    /// VT-1 (F-1 regression guard): a valueless slice (value-bearing, no authored
    /// value) in a delivering status fulfils a valued backlog item. The slice's
    /// default 1.0 (via `effective_raw_value`) contributes to `delivered` → the
    /// item's score is reduced vs an unfulfilled baseline.
    #[test]
    fn burndown_valueless_fulfilling_slice_delivers_default_value() {
        let dir = tmp();
        let root = dir.path();
        // ISS-001: unfulfilled, value=10.0 → value_dim=10.0, score=10.0.
        // ISS-002: fulfilled by valueless SL-001 in `started`.
        //   SL-001 has no [value] facet → effective_raw_value returns 1.0 (default).
        //   delivered = gate(1.0) * 1.0 = 1.0, r = 1.0/10.0 = 0.1.
        //   burn = 10.0 * 0.9 = 9.0, score = 9.0.
        seed_issue_with_facets(root, 1, "", "", "value = 10.0", "");
        seed_issue_with_facets(root, 2, "", "", "value = 10.0", "");
        write(
            root,
            ".doctrine/slice/001/slice-001.toml",
            "id = 1\nslug = \"s\"\ntitle = \"S\"\nstatus = \"started\"\n\
             created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
             [[relation]]\nlabel = \"fulfils\"\ntarget = \"ISS-002\"\n",
        );
        write(root, ".doctrine/slice/001/slice-001.md", "scope\n");
        let pg = build(root).unwrap();
        let s1 = pg.score[&key("ISS", 1)];
        let s2 = pg.score[&key("ISS", 2)];
        // Unfulfilled baseline: 10.0.
        assert!(
            (s1 - 10.0).abs() < 1e-9,
            "ISS-001 unfulfilled baseline 10.0, got {s1}"
        );
        // Fulfilled by valueless slice: delivered=1.0, r=0.1, burn=9.0.
        assert!(
            (s2 - 9.0).abs() < 1e-9,
            "ISS-002 burndown by valueless slice to 9.0, got {s2}"
        );
        assert!(
            s2 < s1,
            "valueless fulfilling slice reduces the item's score"
        );
        assert!(
            s2 > 0.0,
            "score is positive (delivered > 0 from default value), got {s2}"
        );
    }

    /// VT-2 (exclusion): a non-value-bearing kind as a fulfils source would
    /// contribute 0 to delivered — `effective_raw_value` returns None for REV
    /// and record kinds, and the `unwrap_or(0.0)` in `raw_value_of` converts
    /// that to 0.0. Since the relation graph constrains Fulfils sources to SL
    /// (always value-bearing), this is tested via the `raw_value_of` closure
    /// semantics: verify that `effective_raw_value` returns None for REV/ASM,
    /// which the burndown path converts to a 0 contribution.
    #[test]
    fn burndown_non_value_bearing_source_contributes_zero() {
        // Test effective_raw_value directly: REV and ASM are NOT value-bearing.
        let facets = crate::facet::EntityFacets {
            estimate: None,
            value: None,
            risk: None,
            tags: vec![],
        };
        let rev_kind = crate::integrity::KINDS
            .iter()
            .find(|k| k.kind.prefix == "REV")
            .map(|k| k.kind)
            .expect("REV in KINDS");
        let asm_kind = crate::integrity::KINDS
            .iter()
            .find(|k| k.kind.prefix == "ASM")
            .map(|k| k.kind)
            .expect("ASM in KINDS");
        let iss_kind = crate::integrity::KINDS
            .iter()
            .find(|k| k.kind.prefix == "ISS")
            .map(|k| k.kind)
            .expect("ISS in KINDS");
        // Non-value-bearing → None → raw_value_of returns 0.0 (via unwrap_or).
        assert_eq!(effective_raw_value(rev_kind, &facets), None);
        assert_eq!(effective_raw_value(asm_kind, &facets), None);
        // Value-bearing without authored value → Some(1.0) (default).
        assert_eq!(effective_raw_value(iss_kind, &facets), Some(DEFAULT_VALUE));
        // When routed through the burndown closure (raw_value_of), these map to:
        //   effective_raw_value(None) → unwrap_or(0.0) → 0.0 (can't deliver value)
        //   effective_raw_value(Some(1.0)) → unwrap_or(0.0) → 1.0 (delivers default)
        // This guards against the old path that read f.value directly and missed
        // the default for value-bearing kinds.
    }

    // ── SL-194 VT-1: build_from == build_from_with_cfg(…, load(root)) ─────────

    /// The behaviour-preservation gate for the SL-194 rebuild-seam extraction:
    /// `build_from` must be byte-identical to `build_from_with_cfg` fed the same
    /// `config::load(root)` it would have loaded internally. Compares the observable
    /// products — the score/leverage/optionality maps, the base scores, and the minted
    /// node order — over a multi-kind corpus with dep + facet variety.
    #[test]
    fn build_from_equals_build_from_with_cfg_over_loaded_config() {
        let dir = tmp();
        let root = dir.path();
        // A corpus with base-score variety, a needs edge (leverage), and a ref edge
        // (optionality) so every consequence path is exercised.
        seed_issue_with_facets(
            root,
            1,
            "needs = [\"RSK-001\"]",
            "lower = 0.0\nupper = 10.0",
            "value = 25.0",
            "",
        );
        seed_issue_with_facets(root, 2, "", "lower = 1.0\nupper = 4.0", "value = 5.0", "");
        seed_risk(root, 1, "open", "");
        seed_slice(root, 1, "references = [\"REQ-005\"]");
        seed_requirement(root, 5);

        let scanned =
            relation_graph::scan_entities(root, &mut vec![], ScanMode::default()).unwrap();
        let via_load = build_from(&scanned, root).unwrap();
        let via_cfg = build_from_with_cfg(&scanned, root, &config::load(root)).unwrap();

        assert_eq!(via_load.score, via_cfg.score, "score map identical");
        assert_eq!(
            via_load.leverage, via_cfg.leverage,
            "leverage map identical"
        );
        assert_eq!(
            via_load.optionality, via_cfg.optionality,
            "optionality map identical"
        );
        // Base scores per node identical (the injected cfg drove base_score identically).
        let base = |pg: &PriorityGraph| -> std::collections::BTreeMap<EntityKey, (f64, f64)> {
            pg.attrs
                .iter()
                .map(|(k, a)| (*k, (a.base_score.value_dim, a.base_score.risk_dim)))
                .collect()
        };
        assert_eq!(base(&via_load), base(&via_cfg), "base scores identical");
        // Minted node order identical (NodeId assignment is the mint tiebreak).
        let order = |pg: &PriorityGraph| -> Vec<EntityKey> {
            pg.graph
                .ordered()
                .iter()
                .filter_map(|n| pg.projection.key_of(*n))
                .collect()
        };
        assert_eq!(order(&via_load), order(&via_cfg), "minted order identical");
    }
}
