Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

mikronova/funnel-core

src/wiring.rs

//! **Plan-DAG ↔ Component-DAG wiring** — the seam that joins *what the funnel
//! planned* to *the code it lands in*.
//!
//! The funnel decomposes an idea into a [`PlanDag`](crate::plan_dag::PlanDag)
//! (plan steps + their dependency edges). The **Component-DAG** is the other
//! graph: the real crates/modules/symbols and their dependency (or call) edges,
//! owned by Mímir/nornir. Neither graph knows about the other, so the question
//! *"which code does this plan touch, and what is the blast radius?"* has no
//! answer today.
//!
//! This module is that answer's pure half: a **bipartite wiring layer** of
//! `plan_node ↔ component` edges plus the traversal that walks
//! `plan node → wired components → their dependents`, and — the part that makes
//! it honest — the **explicit report of what is NOT wired**.
//!
//! ```text
//!   Plan-DAG              wiring              Component-DAG
//!   ────────              ──────              ─────────────
//!   n-001 ──┐        n-001 → funnel-core      funnel-core ◀── nornir ◀── dwarves
//!   n-002 ──┤ dep    n-002 → (none)  ← UNMAPPED, reported
//!   n-003 ──┘        n-003 → nornir
//! ```
//!
//! ## Purity (FC-7)
//!
//! funnel-core cannot call Mímir — it holds no RPC, no storage, no clock. So the
//! component graph arrives through the [`ComponentGraph`] **trait**: nornir
//! implements it over live `Mimir.DepsOf`/`DependentsOf` RPCs, tests and the
//! offline path use the in-memory [`ComponentDag`]. Every function here is a
//! deterministic, BTree-ordered projection over data the caller supplies.
//!
//! ## Edge direction — read this before using the traversal
//!
//! A [`ComponentEdge`] `from → to` means **`from` depends on `to`** (the Cargo /
//! `dep_graph_edges` DSM reading: the dependent is the source). Therefore:
//!
//! * [`deps_of`](ComponentGraph::deps_of) walks *forward* — what a component needs.
//! * [`dependents_of`](ComponentGraph::dependents_of) walks *backward* — who needs it.
//! * [`blast_radius`](ComponentGraph::blast_radius) is the **transitive dependents**
//!   closure: change this component and these break. That is Mímir's
//!   `AffectedByChange`.
//!
//! Note this is the **opposite** orientation to [`PlanDagEdge`](crate::plan_dag::PlanDagEdge),
//! where `from → to` means "`from` must finish before `to`" (dependency first,
//! dependent second). The two graphs come from different worlds and the seam does
//! not pretend otherwise; a nornir-side adapter that feeds `dep_graph_edges` rows
//! in must orient them as *dependent → dependency*.
//!
//! ## The honesty contract
//!
//! A plan node with no component binding is **never** silently treated as done or
//! as touching nothing. It surfaces in [`WiringReport::unmapped`] with a
//! [`UnmappedReason`], and a [`BlastRadius`] computed over it reports the node in
//! [`BlastRadius::unmapped_nodes`] so an empty `affected` list can never be
//! misread as "nothing downstream" when the truth is "we do not know".

use std::collections::{BTreeMap, BTreeSet};

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

use crate::event::Event;
use crate::ids::NodeId;
use crate::plan_dag::{DagStatus, PlanDag};
use crate::state::Funnel;

// ─────────────────────────────────────────────────────────────────────────────
// Component-DAG side
// ─────────────────────────────────────────────────────────────────────────────

/// Stable identifier of a **component** — a crate, module or symbol node in the
/// Component-DAG.
///
/// Deliberately an opaque string, not a funnel [`id_newtype`](crate::ids): the
/// component namespace belongs to Mímir, not the funnel, so funnel-core neither
/// mints these nor validates their shape. Whatever the component graph calls a
/// node (`"funnel-core"`, `"nornir::funnel::store"`, a uuid) round-trips here
/// unchanged.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ComponentId(pub String);

impl ComponentId {
    pub fn new(raw: impl Into<String>) -> Self {
        Self(raw.into())
    }
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl std::fmt::Display for ComponentId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

impl From<&str> for ComponentId {
    fn from(s: &str) -> Self {
        Self(s.to_owned())
    }
}

impl From<String> for ComponentId {
    fn from(s: String) -> Self {
        Self(s)
    }
}

/// Which relation a [`ComponentEdge`] represents.
///
/// **Open enum** (`#[non_exhaustive]`) — the component graph is Mímir's, and its
/// edge vocabulary will grow (type references, macro expansions, …). See the
/// open-enum contract on [`Event`](crate::event::Event).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ComponentEdgeKind {
    /// A crate/module dependency (`dep_graph_edges`) — the DSM view.
    Dep,
    /// A function call (`call_edges`) — the CPG view.
    Call,
}

impl ComponentEdgeKind {
    #[must_use]
    pub fn as_str(&self) -> &'static str {
        match self {
            ComponentEdgeKind::Dep => "dep",
            ComponentEdgeKind::Call => "call",
        }
    }
}

/// One node of the Component-DAG.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ComponentNode {
    /// The component's stable id — the wiring key.
    pub id: ComponentId,
    /// Human label (crate name, module path).
    pub name: String,
    /// Free-form kind token (`"crate"` / `"module"` / `"fn"`), kept a plain
    /// `String` so Mímir can add node kinds without a funnel-core bump.
    pub kind: String,
    /// The component's source path/prefix, when it has one (`"src/funnel"`,
    /// `"crates/nornir-viz"`). Used by [`Wiring::infer_declared`] to match a plan
    /// node's `targets` globs. `None` for components with no on-disk anchor.
    #[serde(default)]
    pub path: Option<String>,
}

impl ComponentNode {
    /// A crate-kind component with no source path.
    #[must_use]
    pub fn krate(id: impl Into<String>) -> Self {
        let id = id.into();
        Self {
            name: id.clone(),
            id: ComponentId(id),
            kind: "crate".to_string(),
            path: None,
        }
    }

    /// Attach a source path/prefix (builder-style).
    #[must_use]
    pub fn with_path(mut self, path: impl Into<String>) -> Self {
        self.path = Some(path.into());
        self
    }
}

/// One directed Component-DAG edge. **`from` depends on `to`** — see the module
/// docs; the impact direction is the reverse.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct ComponentEdge {
    pub from: ComponentId,
    pub to: ComponentId,
    pub kind: ComponentEdgeKind,
}

impl ComponentEdge {
    /// A dependency edge: `from` depends on `to`.
    #[must_use]
    pub fn dep(from: impl Into<String>, to: impl Into<String>) -> Self {
        Self {
            from: ComponentId(from.into()),
            to: ComponentId(to.into()),
            kind: ComponentEdgeKind::Dep,
        }
    }
    /// A call edge: `from` calls `to`.
    #[must_use]
    pub fn call(from: impl Into<String>, to: impl Into<String>) -> Self {
        Self {
            from: ComponentId(from.into()),
            to: ComponentId(to.into()),
            kind: ComponentEdgeKind::Call,
        }
    }
}

/// An **in-memory Component-DAG** — the offline/test implementation of
/// [`ComponentGraph`].
///
/// This is not a second source of truth: it is the materialised form a caller
/// builds from Mímir rows (or a fixture) when it wants the whole graph in hand.
/// A caller that would rather stay lazy implements [`ComponentGraph`] over live
/// RPCs instead and never builds one of these.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ComponentDag {
    pub nodes: Vec<ComponentNode>,
    pub edges: Vec<ComponentEdge>,
}

impl ComponentDag {
    /// Build from raw nodes + edges, normalising to a deterministic shape:
    /// nodes sorted by id (first wins on a duplicate id), edges sorted and
    /// de-duplicated.
    #[must_use]
    pub fn new(nodes: Vec<ComponentNode>, edges: Vec<ComponentEdge>) -> Self {
        let mut by_id: BTreeMap<ComponentId, ComponentNode> = BTreeMap::new();
        for n in nodes {
            by_id.entry(n.id.clone()).or_insert(n);
        }
        let edge_set: BTreeSet<ComponentEdge> = edges.into_iter().collect();
        Self {
            nodes: by_id.into_values().collect(),
            edges: edge_set.into_iter().collect(),
        }
    }

    #[must_use]
    pub fn node_count(&self) -> usize {
        self.nodes.len()
    }
    #[must_use]
    pub fn edge_count(&self) -> usize {
        self.edges.len()
    }

    /// The node record for `id`, if the graph holds it.
    #[must_use]
    pub fn node(&self, id: &ComponentId) -> Option<&ComponentNode> {
        self.nodes.iter().find(|n| &n.id == id)
    }

    /// The robot-observable blob (LAW 6).
    #[must_use]
    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "node_count": self.node_count(),
            "edge_count": self.edge_count(),
            "nodes": self.nodes.iter().map(|n| serde_json::json!({
                "id": n.id.as_str(), "name": n.name, "kind": n.kind, "path": n.path,
            })).collect::<Vec<_>>(),
            "edges": self.edges.iter().map(|e| serde_json::json!({
                "from": e.from.as_str(), "to": e.to.as_str(), "kind": e.kind.as_str(),
            })).collect::<Vec<_>>(),
        })
    }
}

/// **The seam.** Everything funnel-core needs to know about the Component-DAG.
///
/// funnel-core is pure, so it never fetches this graph — a host implements the
/// three required methods over whatever it has (Mímir RPCs, a cached DSM, a test
/// fixture) and gets the traversal for free. The contract:
///
/// * [`has_component`](ComponentGraph::has_component) — is `id` a node of this
///   graph? A wire to an id that answers `false` is **dangling** and is reported,
///   never followed.
/// * [`deps_of`](ComponentGraph::deps_of) — the components `id` directly depends
///   on (edge `id → x`).
/// * [`dependents_of`](ComponentGraph::dependents_of) — the components that
///   directly depend on `id` (edge `x → id`). This is the impact direction.
/// * [`components`](ComponentGraph::components) — the whole node set, when the
///   implementation can enumerate it cheaply. The default is `None`, which is a
///   load-bearing "unknown", not "empty": it makes
///   [`WiringReport::unplanned`] `None` rather than an empty list that would read
///   as "every component is planned".
///
/// Implementations must return **unknown ids as an empty vector**, not a panic —
/// a live graph can lag the wiring. Order need not be sorted; the traversal
/// sorts. The provided traversals keep a visited set, so a **cyclic** graph
/// (call edges routinely are) terminates.
pub trait ComponentGraph {
    /// Does the graph hold this component?
    fn has_component(&self, id: &ComponentId) -> bool;

    /// Direct dependencies of `id` (what it needs).
    fn deps_of(&self, id: &ComponentId) -> Vec<ComponentId>;

    /// Direct dependents of `id` (who needs it) — the impact direction.
    fn dependents_of(&self, id: &ComponentId) -> Vec<ComponentId>;

    /// Every component in the graph, when enumerable. `None` (the default) means
    /// "this implementation cannot list its nodes" — honestly distinct from
    /// `Some(vec![])`, which means "the graph is empty".
    fn components(&self) -> Option<Vec<ComponentId>> {
        None
    }

    /// **Blast radius** — the transitive-dependents closure of `id` (`id` itself
    /// excluded), sorted. "Change this component and these are affected", the
    /// pure twin of Mímir's `AffectedByChange`. BFS with a visited set, so a
    /// cyclic component graph terminates.
    fn blast_radius(&self, id: &ComponentId) -> Vec<ComponentId> {
        closure(id, |c| self.dependents_of(c))
    }

    /// The transitive-**dependency** closure of `id` (`id` excluded), sorted —
    /// everything it (indirectly) needs. The mirror of
    /// [`blast_radius`](ComponentGraph::blast_radius).
    fn dependency_closure(&self, id: &ComponentId) -> Vec<ComponentId> {
        closure(id, |c| self.deps_of(c))
    }
}

/// Shared BFS closure used by both traversal directions. Visits each component
/// once (terminates on cycles), excludes the seed, returns sorted ids.
fn closure(
    seed: &ComponentId,
    step: impl Fn(&ComponentId) -> Vec<ComponentId>,
) -> Vec<ComponentId> {
    let mut seen: BTreeSet<ComponentId> = BTreeSet::new();
    let mut stack: Vec<ComponentId> = step(seed);
    while let Some(cur) = stack.pop() {
        if &cur == seed || !seen.insert(cur.clone()) {
            continue;
        }
        stack.extend(step(&cur));
    }
    seen.into_iter().collect()
}

impl ComponentGraph for ComponentDag {
    fn has_component(&self, id: &ComponentId) -> bool {
        self.nodes.iter().any(|n| &n.id == id)
    }
    fn deps_of(&self, id: &ComponentId) -> Vec<ComponentId> {
        self.edges
            .iter()
            .filter(|e| &e.from == id)
            .map(|e| e.to.clone())
            .collect()
    }
    fn dependents_of(&self, id: &ComponentId) -> Vec<ComponentId> {
        self.edges
            .iter()
            .filter(|e| &e.to == id)
            .map(|e| e.from.clone())
            .collect()
    }
    fn components(&self) -> Option<Vec<ComponentId>> {
        Some(self.nodes.iter().map(|n| n.id.clone()).collect())
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// The wiring layer
// ─────────────────────────────────────────────────────────────────────────────

/// Where a wire came from — its provenance, which decides how much it is trusted.
///
/// **Open enum** (`#[non_exhaustive]`) — new suggesters will appear (git-blame,
/// a model, a CI signal), and the wire token is free-form on purpose so they
/// never need a schema bump. Unknown provenance already fails closed to
/// [`WireStatus::Proposed`], so growth is safe. See the open-enum contract on
/// [`Event`](crate::event::Event).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum WireOrigin {
    /// A human dragged the wire in the funnel pane. Ground truth.
    Drawn,
    /// Mímir suggested it (plan text → symbol → component). A suggestion.
    Inferred,
    /// Derived from the plan node's own `targets` globs by
    /// [`Wiring::infer_declared`] — the pure, Mímir-free inference.
    Declared,
}

impl WireOrigin {
    /// The stable wire token.
    #[must_use]
    pub fn as_str(&self) -> &'static str {
        match self {
            WireOrigin::Drawn => "drawn",
            WireOrigin::Inferred => "inferred",
            WireOrigin::Declared => "declared",
        }
    }

    /// Parse the wire token. Unknown/empty → `None` (the free-form field stays
    /// future-proof — a token this build has never heard of is *not* silently
    /// coerced into a trusted origin).
    #[must_use]
    pub fn parse(s: &str) -> Option<Self> {
        match s {
            "drawn" => Some(WireOrigin::Drawn),
            "inferred" => Some(WireOrigin::Inferred),
            "declared" => Some(WireOrigin::Declared),
            _ => None,
        }
    }

    /// The status a wire of this origin carries when the event left `status`
    /// empty: a **drawn** wire is confirmed by the act of drawing it; a machine
    /// suggestion is only ever `Proposed` until a human confirms.
    #[must_use]
    pub fn default_status(&self) -> WireStatus {
        match self {
            WireOrigin::Drawn => WireStatus::Confirmed,
            WireOrigin::Inferred | WireOrigin::Declared => WireStatus::Proposed,
        }
    }
}

/// How far along the confirm/reject cycle a wire is. Only [`Confirmed`](WireStatus::Confirmed)
/// wires **bind** — i.e. count as a real plan↔component mapping and are followed
/// by the traversal.
///
/// Deliberately **closed** (no `#[non_exhaustive]`, unlike [`WireOrigin`]): these
/// three are a complete state machine — a suggestion is pending, accepted, or
/// refused, and there is no fourth answer. [`binds`](WireStatus::binds) is the
/// total accessor most callers actually want. See the open/closed contract on
/// [`Event`](crate::event::Event).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WireStatus {
    /// Suggested, awaiting a human confirm/reject. Does **not** bind.
    Proposed,
    /// Accepted — this plan node really does touch this component.
    Confirmed,
    /// Explicitly rejected. Kept in the log (the log is append-only) so the same
    /// bad suggestion is not re-proposed forever; does not bind.
    Rejected,
}

impl WireStatus {
    #[must_use]
    pub fn as_str(&self) -> &'static str {
        match self {
            WireStatus::Proposed => "proposed",
            WireStatus::Confirmed => "confirmed",
            WireStatus::Rejected => "rejected",
        }
    }
    /// Parse the wire token; unknown/empty → `None` (see [`Wire::status_kind`]
    /// for what an unknown token then resolves to).
    #[must_use]
    pub fn parse(s: &str) -> Option<Self> {
        match s {
            "proposed" => Some(WireStatus::Proposed),
            "confirmed" => Some(WireStatus::Confirmed),
            "rejected" => Some(WireStatus::Rejected),
            _ => None,
        }
    }
    /// Does a wire in this status count as a real mapping?
    #[must_use]
    pub fn binds(&self) -> bool {
        matches!(self, WireStatus::Confirmed)
    }
}

/// One folded `plan_node ↔ component` wire — the bipartite edge, last-write-wins
/// per `(plan_node, component)` pair (see [`Event::PlanComponentWired`]).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Wire {
    /// The Plan-DAG node id this wire starts at — an idea card (`i-NNN`) or a
    /// decomposed task node (`n-NNN`), matching [`PlanDagNode::id`](crate::plan_dag::PlanDagNode::id).
    pub plan_node: String,
    /// The Component-DAG node it lands on.
    pub component: ComponentId,
    /// Free-form provenance token — read typed via [`Wire::origin_kind`].
    pub origin: String,
    /// Free-form status token — read typed via [`Wire::status_kind`].
    pub status: String,
    /// The suggester's confidence in `0.0 ..= 1.0`, when it had one. Never used
    /// to auto-confirm: it ranks suggestions for a human, nothing more.
    pub confidence: Option<f64>,
    /// Why this wire was drawn/suggested/rejected.
    pub why: Option<String>,
    /// When the latest row for this pair landed.
    pub ts: DateTime<Utc>,
}

impl Wire {
    /// The typed provenance, or `None` for a token this build does not know.
    #[must_use]
    pub fn origin_kind(&self) -> Option<WireOrigin> {
        WireOrigin::parse(&self.origin)
    }

    /// The typed status.
    ///
    /// An explicit token always wins. An empty/unknown status token falls back to
    /// the origin's [`default_status`](WireOrigin::default_status), and an
    /// unknown *origin* falls back to [`WireStatus::Proposed`] — so no wire this
    /// build cannot fully understand ever binds by accident.
    #[must_use]
    pub fn status_kind(&self) -> WireStatus {
        WireStatus::parse(&self.status).unwrap_or_else(|| {
            self.origin_kind()
                .map_or(WireStatus::Proposed, |o| o.default_status())
        })
    }

    /// Does this wire count as a real plan↔component mapping?
    #[must_use]
    pub fn binds(&self) -> bool {
        self.status_kind().binds()
    }

    /// The robot-observable row (LAW 6).
    #[must_use]
    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "plan_node": self.plan_node,
            "component": self.component.as_str(),
            "origin": self.origin,
            "status": self.status_kind().as_str(),
            "binds": self.binds(),
            "confidence": self.confidence,
            "why": self.why,
        })
    }
}

/// The folded **wiring layer** — every `plan_node ↔ component` wire in the
/// funnel, in deterministic `(plan_node, component)` order.
///
/// A pure projection of [`Funnel::wires`], exactly like every other surface in
/// this crate: build it with [`Wiring::from_funnel`], never by hand-mutating
/// state.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Wiring {
    wires: Vec<Wire>,
}

impl Wiring {
    /// Project the wiring layer out of a replayed [`Funnel`].
    #[must_use]
    pub fn from_funnel(funnel: &Funnel) -> Self {
        // `Funnel::wires` is a BTreeMap keyed by (plan_node, component) — already
        // in the deterministic order we want.
        Self {
            wires: funnel.wires.values().cloned().collect(),
        }
    }

    /// Every wire, `(plan_node, component)`-ordered — including proposed and
    /// rejected ones (a UI needs them to run the confirm/reject cycle).
    #[must_use]
    pub fn wires(&self) -> &[Wire] {
        &self.wires
    }

    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.wires.is_empty()
    }

    /// Every wire touching `plan_node`, whatever its status.
    #[must_use]
    pub fn wires_for(&self, plan_node: &str) -> Vec<&Wire> {
        self.wires
            .iter()
            .filter(|w| w.plan_node == plan_node)
            .collect()
    }

    /// The components `plan_node` is **bound** to (confirmed wires only), sorted.
    /// An empty result means *unmapped* — see [`WiringReport`].
    #[must_use]
    pub fn bound_components(&self, plan_node: &str) -> Vec<ComponentId> {
        let mut out: Vec<ComponentId> = self
            .wires
            .iter()
            .filter(|w| w.plan_node == plan_node && w.binds())
            .map(|w| w.component.clone())
            .collect();
        out.sort();
        out.dedup();
        out
    }

    /// Reverse lookup: which plan nodes are bound to `component`, sorted. This is
    /// the progress/ownership overlay — "which plan step claims this crate?" — and
    /// an empty result on a component with churn is *unplanned work*.
    #[must_use]
    pub fn plan_nodes_for(&self, component: &ComponentId) -> Vec<String> {
        let mut out: Vec<String> = self
            .wires
            .iter()
            .filter(|w| &w.component == component && w.binds())
            .map(|w| w.plan_node.clone())
            .collect();
        out.sort();
        out.dedup();
        out
    }

    /// **The traversal**, for a single plan node: its bound components, then their
    /// transitive dependents in the component graph.
    ///
    /// Reports the node in [`BlastRadius::unmapped_nodes`] when it has no binding,
    /// so an empty [`affected`](BlastRadius::affected) can never be misread.
    #[must_use]
    pub fn blast_radius<G: ComponentGraph + ?Sized>(
        &self,
        plan_node: &str,
        graph: &G,
    ) -> BlastRadius {
        self.radius_over(&[plan_node.to_string()], graph)
    }

    /// **The joint traversal**: the plan cone → the component cone.
    ///
    /// Takes `plan_node` *and every Plan-DAG node downstream of it* (its
    /// decomposition children and dependent steps — see
    /// [`PlanDag::descendants`]), unions their bound components, and returns the
    /// component blast radius of that union. This answers the real planning
    /// question: *"if I start here, what code does the rest of this plan touch,
    /// and what ripples out of it?"*
    ///
    /// Every node in the cone that has no binding is listed in
    /// [`BlastRadius::unmapped_nodes`], so a partially-wired plan reports a
    /// partial radius as **incomplete** rather than as fact.
    #[must_use]
    pub fn cone_blast_radius<G: ComponentGraph + ?Sized>(
        &self,
        plan_dag: &PlanDag,
        plan_node: &str,
        graph: &G,
    ) -> BlastRadius {
        let mut cone: Vec<String> = plan_dag.descendants(plan_node);
        cone.push(plan_node.to_string());
        cone.sort();
        cone.dedup();
        self.radius_over(&cone, graph)
    }

    /// Shared body of the two traversals: union the bindings of `plan_nodes`,
    /// split present/missing against the graph, then close over dependents.
    fn radius_over<G: ComponentGraph + ?Sized>(
        &self,
        plan_nodes: &[String],
        graph: &G,
    ) -> BlastRadius {
        let mut touched: BTreeSet<ComponentId> = BTreeSet::new();
        let mut missing: BTreeSet<ComponentId> = BTreeSet::new();
        let mut unmapped: Vec<String> = Vec::new();
        for id in plan_nodes {
            let bound = self.bound_components(id);
            if bound.is_empty() {
                unmapped.push(id.clone());
                continue;
            }
            for c in bound {
                if graph.has_component(&c) {
                    touched.insert(c);
                } else {
                    // Bound to something the graph does not hold: report it, never
                    // follow it (a closure over a phantom id would be fiction).
                    missing.insert(c);
                }
            }
        }
        let mut affected: BTreeSet<ComponentId> = BTreeSet::new();
        for c in &touched {
            affected.extend(graph.blast_radius(c));
        }
        for c in &touched {
            affected.remove(c);
        }
        unmapped.sort();
        unmapped.dedup();
        BlastRadius {
            plan_nodes: plan_nodes.to_vec(),
            touched: touched.into_iter().collect(),
            affected: affected.into_iter().collect(),
            unmapped_nodes: unmapped,
            missing: missing.into_iter().collect(),
        }
    }

    /// The robot-observable blob (LAW 6).
    #[must_use]
    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "wire_count": self.wires.len(),
            "bound_count": self.wires.iter().filter(|w| w.binds()).count(),
            "wires": self.wires.iter().map(Wire::state_json).collect::<Vec<_>>(),
        })
    }

    /// **The pure inference**: propose `declared` wires from each plan node's own
    /// `targets` globs, matched against the component graph's node paths/names.
    ///
    /// This is the half funnel-core can do honestly with no Mímir: the funnel
    /// already records file-glob `targets` on every `NodeAdded`, and a component
    /// carries a [`path`](ComponentNode::path). A component matches a target when
    /// the glob matches its path or its name, or when the target names a file
    /// *inside* the component's path. Mímir's symbol-level inference is the impure
    /// half and arrives over the same seam as `inferred` wires.
    ///
    /// Returns the [`Event::PlanComponentWired`] rows to apply+append, in
    /// `(plan_node, component)` order, stamped `ts` (funnel-core reads no clock).
    /// Pairs that already have a wire — including a **rejected** one — are skipped,
    /// so re-running never resurrects a suggestion a human threw away and never
    /// downgrades a confirmed wire.
    #[must_use]
    pub fn infer_declared(funnel: &Funnel, graph: &ComponentDag, ts: DateTime<Utc>) -> Vec<Event> {
        let mut out: Vec<Event> = Vec::new();
        for plan in funnel.plans.values() {
            for node in plan.nodes.values() {
                let nid = node.id.as_str();
                for comp in &graph.nodes {
                    if funnel
                        .wires
                        .contains_key(&(nid.to_string(), comp.id.0.clone()))
                    {
                        continue;
                    }
                    let Some(target) = node
                        .targets
                        .iter()
                        .find(|t| component_matches_target(comp, t))
                    else {
                        continue;
                    };
                    out.push(Event::PlanComponentWired {
                        plan_node: nid.to_string(),
                        component: comp.id.0.clone(),
                        origin: WireOrigin::Declared.as_str().to_string(),
                        status: String::new(),
                        confidence: None,
                        why: Some(format!("target `{target}` matches component `{}`", comp.id)),
                        ts,
                    });
                }
            }
        }
        out.sort_by_key(wire_key);
        out
    }
}

/// Sort key for the events [`Wiring::infer_declared`] emits.
fn wire_key(ev: &Event) -> (String, String) {
    match ev {
        Event::PlanComponentWired {
            plan_node,
            component,
            ..
        } => (plan_node.clone(), component.clone()),
        _ => (String::new(), String::new()),
    }
}

/// Does `comp` satisfy the plan node's `target` glob?
///
/// Three rules, in order: the glob matches the component's path; the target names
/// a file *inside* the component's path (`src/funnel` owns `src/funnel/store.rs`);
/// the glob matches the component's name.
fn component_matches_target(comp: &ComponentNode, target: &str) -> bool {
    if target.is_empty() {
        return false;
    }
    if let Some(path) = comp.path.as_deref() {
        if !path.is_empty() && (glob_match(target, path) || target.starts_with(&format!("{path}/")))
        {
            return true;
        }
    }
    glob_match(target, &comp.name)
}

/// Minimal glob match — `*` (any run, `/` included) and `?` (one char); every
/// other byte is literal. Iterative with backtracking, so it never recurses and
/// always terminates.
fn glob_match(pat: &str, s: &str) -> bool {
    let (p, t): (Vec<char>, Vec<char>) = (pat.chars().collect(), s.chars().collect());
    let (mut pi, mut ti) = (0usize, 0usize);
    let (mut star, mut mark): (Option<usize>, usize) = (None, 0);
    while ti < t.len() {
        if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) {
            pi += 1;
            ti += 1;
        } else if pi < p.len() && p[pi] == '*' {
            star = Some(pi);
            mark = ti;
            pi += 1;
        } else if let Some(sp) = star {
            // Backtrack: let the last `*` swallow one more char.
            pi = sp + 1;
            mark += 1;
            ti = mark;
        } else {
            return false;
        }
    }
    while pi < p.len() && p[pi] == '*' {
        pi += 1;
    }
    pi == p.len()
}

// ─────────────────────────────────────────────────────────────────────────────
// Traversal result
// ─────────────────────────────────────────────────────────────────────────────

/// The result of a plan → component traversal (see [`Wiring::blast_radius`] and
/// [`Wiring::cone_blast_radius`]).
///
/// **Read [`unmapped_nodes`](BlastRadius::unmapped_nodes) before
/// [`affected`](BlastRadius::affected).** An empty `affected` list with an empty
/// `unmapped_nodes` means "nothing downstream". An empty `affected` list with a
/// non-empty `unmapped_nodes` means "we do not know" — the plan is not wired.
/// [`is_complete`](BlastRadius::is_complete) is the one-call form of that check.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct BlastRadius {
    /// The plan nodes the radius was computed over (the cone), sorted.
    pub plan_nodes: Vec<String>,
    /// Components directly bound to those plan nodes AND present in the graph.
    pub touched: Vec<ComponentId>,
    /// Transitive dependents of [`touched`](BlastRadius::touched), excluding
    /// `touched` itself — the code this plan ripples into.
    pub affected: Vec<ComponentId>,
    /// Plan nodes in the cone with **no bound component**. Their contribution to
    /// the radius is unknown, so the radius is a lower bound, not a fact.
    pub unmapped_nodes: Vec<String>,
    /// Bound components the graph does not hold (a stale or mistyped wire). Not
    /// followed — a closure over a phantom id would be fiction.
    pub missing: Vec<ComponentId>,
}

impl BlastRadius {
    /// Is this radius the whole truth — every plan node in the cone bound, and
    /// every binding resolvable in the graph?
    #[must_use]
    pub fn is_complete(&self) -> bool {
        self.unmapped_nodes.is_empty() && self.missing.is_empty()
    }

    /// `touched ∪ affected`, sorted — every component this plan cone reaches.
    #[must_use]
    pub fn reach(&self) -> Vec<ComponentId> {
        let mut all: BTreeSet<ComponentId> = self.touched.iter().cloned().collect();
        all.extend(self.affected.iter().cloned());
        all.into_iter().collect()
    }

    /// The robot-observable blob (LAW 6).
    #[must_use]
    pub fn state_json(&self) -> serde_json::Value {
        fn ids(v: &[ComponentId]) -> Vec<&str> {
            v.iter().map(ComponentId::as_str).collect()
        }
        serde_json::json!({
            "plan_nodes": self.plan_nodes,
            "touched": ids(&self.touched),
            "affected": ids(&self.affected),
            "unmapped_nodes": self.unmapped_nodes,
            "missing": ids(&self.missing),
            "is_complete": self.is_complete(),
            "reach_count": self.reach().len(),
        })
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// The honesty report
// ─────────────────────────────────────────────────────────────────────────────

/// Why a Plan-DAG node has no component binding. Ordered by how much attention it
/// needs — a dangling wire is a data error, a proposal only needs a click.
///
/// **Open enum** (`#[non_exhaustive]`) — this is a diagnostic vocabulary, and the
/// honest thing to do when a new way of being unmapped is discovered is to name
/// it, not to squeeze it into an existing reason. See the open-enum contract on
/// [`Event`](crate::event::Event).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum UnmappedReason {
    /// A confirmed wire exists but points at a component the graph does not hold.
    /// A stale or mistyped id — the loudest case, because it *looks* mapped.
    Dangling,
    /// Only unconfirmed suggestions exist. Someone must confirm or reject them.
    OnlyProposed,
    /// Every candidate wire was rejected. The node needs a *new* wire.
    OnlyRejected,
    /// Nothing was ever drawn, inferred or declared for this node.
    NoWire,
}

impl UnmappedReason {
    #[must_use]
    pub fn as_str(&self) -> &'static str {
        match self {
            UnmappedReason::Dangling => "dangling",
            UnmappedReason::OnlyProposed => "only_proposed",
            UnmappedReason::OnlyRejected => "only_rejected",
            UnmappedReason::NoWire => "no_wire",
        }
    }
}

/// A Plan-DAG node **with** at least one resolvable component binding.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MappedNode {
    pub id: String,
    pub title: String,
    /// `true` for a decomposed task node, `false` for a mother idea card.
    pub is_task: bool,
    /// The bound components present in the graph, sorted.
    pub components: Vec<ComponentId>,
}

/// A Plan-DAG node with **no** resolvable component binding — the gap, named.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UnmappedNode {
    pub id: String,
    pub title: String,
    /// `true` for a decomposed task node, `false` for a mother idea card.
    pub is_task: bool,
    /// The node's run status, so a caller can flag the ugly case loudly: a `Done`
    /// node that was never wired means work landed in code nobody can point at.
    pub status: DagStatus,
    pub reason: UnmappedReason,
}

/// A confirmed wire pointing at a component the graph does not hold.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DanglingWire {
    pub plan_node: String,
    pub component: ComponentId,
}

/// An unconfirmed suggestion awaiting a human's confirm/reject.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ProposedWire {
    pub plan_node: String,
    pub component: ComponentId,
    pub origin: String,
    pub confidence: Option<f64>,
    pub why: Option<String>,
}

/// **The honest join** of a [`PlanDag`] and a [`ComponentGraph`] over a
/// [`Wiring`]: what is mapped, and — first-class, never inferred from silence —
/// what is not.
///
/// Deterministic (FC-7): [`mapped`](WiringReport::mapped) and
/// [`unmapped`](WiringReport::unmapped) follow the Plan-DAG's own node order;
/// every other list is sorted.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct WiringReport {
    /// The plan scope, mirroring [`PlanDag::sel_plan`].
    pub sel_plan: Option<String>,
    /// Plan nodes with at least one resolvable binding.
    pub mapped: Vec<MappedNode>,
    /// Plan nodes with none — each with the [`UnmappedReason`].
    pub unmapped: Vec<UnmappedNode>,
    /// Confirmed wires pointing at components the graph does not hold.
    pub dangling: Vec<DanglingWire>,
    /// Suggestions awaiting confirm/reject.
    pub proposed: Vec<ProposedWire>,
    /// Components in the graph that **no** plan node is bound to — unplanned work.
    ///
    /// `None` means the graph could not enumerate its nodes
    /// ([`ComponentGraph::components`] returned `None`), which is *not* the same
    /// as `Some(vec![])` ("every component is claimed"). A live RPC-backed graph
    /// answers `None`; a materialised [`ComponentDag`] answers `Some`.
    pub unplanned: Option<Vec<ComponentId>>,
}

impl WiringReport {
    /// Join a Plan-DAG, the wiring layer and a component graph into the report.
    ///
    /// Every node of `plan_dag` lands in exactly one of
    /// [`mapped`](WiringReport::mapped) / [`unmapped`](WiringReport::unmapped) —
    /// no node is ever dropped, which is what makes the unmapped list trustworthy.
    #[must_use]
    pub fn of<G: ComponentGraph + ?Sized>(plan_dag: &PlanDag, wiring: &Wiring, graph: &G) -> Self {
        let mut mapped: Vec<MappedNode> = Vec::new();
        let mut unmapped: Vec<UnmappedNode> = Vec::new();
        let mut dangling: BTreeSet<(String, ComponentId)> = BTreeSet::new();
        let mut proposed_set: BTreeMap<(String, ComponentId), ProposedWire> = BTreeMap::new();
        let mut claimed: BTreeSet<ComponentId> = BTreeSet::new();

        for n in &plan_dag.nodes {
            let wires = wiring.wires_for(&n.id);
            let mut present: Vec<ComponentId> = Vec::new();
            let mut had_dangling = false;
            let mut had_proposed = false;
            for w in &wires {
                match w.status_kind() {
                    WireStatus::Confirmed => {
                        if graph.has_component(&w.component) {
                            present.push(w.component.clone());
                            claimed.insert(w.component.clone());
                        } else {
                            had_dangling = true;
                            dangling.insert((n.id.clone(), w.component.clone()));
                        }
                    }
                    WireStatus::Proposed => {
                        had_proposed = true;
                        proposed_set.insert(
                            (n.id.clone(), w.component.clone()),
                            ProposedWire {
                                plan_node: n.id.clone(),
                                component: w.component.clone(),
                                origin: w.origin.clone(),
                                confidence: w.confidence,
                                why: w.why.clone(),
                            },
                        );
                    }
                    WireStatus::Rejected => {}
                }
            }
            present.sort();
            present.dedup();
            // `group` is `Some(plan_id)` only for decomposed task nodes.
            let is_task = n.group.is_some();
            if present.is_empty() {
                let reason = if had_dangling {
                    UnmappedReason::Dangling
                } else if had_proposed {
                    UnmappedReason::OnlyProposed
                } else if wires.is_empty() {
                    UnmappedReason::NoWire
                } else {
                    UnmappedReason::OnlyRejected
                };
                unmapped.push(UnmappedNode {
                    id: n.id.clone(),
                    title: n.title.clone(),
                    is_task,
                    status: n.status,
                    reason,
                });
            } else {
                mapped.push(MappedNode {
                    id: n.id.clone(),
                    title: n.title.clone(),
                    is_task,
                    components: present,
                });
            }
        }

        let unplanned = graph.components().map(|all| {
            let mut out: Vec<ComponentId> =
                all.into_iter().filter(|c| !claimed.contains(c)).collect();
            out.sort();
            out.dedup();
            out
        });

        Self {
            sel_plan: plan_dag.sel_plan.clone(),
            mapped,
            unmapped,
            dangling: dangling
                .into_iter()
                .map(|(plan_node, component)| DanglingWire {
                    plan_node,
                    component,
                })
                .collect(),
            proposed: proposed_set.into_values().collect(),
            unplanned,
        }
    }

    /// Is **every** Plan-DAG node bound to at least one resolvable component?
    ///
    /// The gate a caller runs before trusting a blast radius or a progress
    /// overlay. Vacuously `true` for an empty Plan-DAG.
    #[must_use]
    pub fn is_fully_mapped(&self) -> bool {
        self.unmapped.is_empty()
    }

    /// Fraction of Plan-DAG nodes with a resolvable binding, `0.0 ..= 1.0`. An
    /// empty Plan-DAG is `1.0` (nothing is unmapped).
    #[must_use]
    pub fn coverage(&self) -> f64 {
        let total = self.mapped.len() + self.unmapped.len();
        if total == 0 {
            return 1.0;
        }
        self.mapped.len() as f64 / total as f64
    }

    /// The unmapped node ids, in Plan-DAG order — the short form for an error
    /// message or a CI gate.
    #[must_use]
    pub fn unmapped_ids(&self) -> Vec<String> {
        self.unmapped.iter().map(|u| u.id.clone()).collect()
    }

    /// The unmapped **task** nodes only (mother idea cards filtered out) — the
    /// stricter reading for a caller that treats an idea card as a container.
    #[must_use]
    pub fn unmapped_tasks(&self) -> Vec<&UnmappedNode> {
        self.unmapped.iter().filter(|u| u.is_task).collect()
    }

    /// The worst case: **task** nodes that are `Done` yet never wired. Work landed
    /// in code the plan cannot point at — the one row a human must always see.
    ///
    /// Task nodes only: a mother idea card reads [`DagStatus::Done`] as soon as it
    /// is *decomposed* (its `Planned` lane), which is not finished work, so
    /// including ideas here would drown the real signal in false positives.
    #[must_use]
    pub fn done_but_unmapped(&self) -> Vec<&UnmappedNode> {
        self.unmapped
            .iter()
            .filter(|u| u.is_task && u.status == DagStatus::Done)
            .collect()
    }

    /// The robot-observable blob (LAW 6).
    #[must_use]
    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "sel_plan": self.sel_plan,
            "is_fully_mapped": self.is_fully_mapped(),
            "coverage": self.coverage(),
            "mapped_count": self.mapped.len(),
            "unmapped_count": self.unmapped.len(),
            "unmapped_ids": self.unmapped_ids(),
            "done_but_unmapped": self.done_but_unmapped().iter().map(|u| u.id.clone()).collect::<Vec<_>>(),
            "mapped": self.mapped.iter().map(|m| serde_json::json!({
                "id": m.id,
                "title": m.title,
                "is_task": m.is_task,
                "components": m.components.iter().map(ComponentId::as_str).collect::<Vec<_>>(),
            })).collect::<Vec<_>>(),
            "unmapped": self.unmapped.iter().map(|u| serde_json::json!({
                "id": u.id,
                "title": u.title,
                "is_task": u.is_task,
                "status": u.status.as_str(),
                "reason": u.reason.as_str(),
            })).collect::<Vec<_>>(),
            "dangling": self.dangling.iter().map(|d| serde_json::json!({
                "plan_node": d.plan_node, "component": d.component.as_str(),
            })).collect::<Vec<_>>(),
            "proposed": self.proposed.iter().map(|p| serde_json::json!({
                "plan_node": p.plan_node,
                "component": p.component.as_str(),
                "origin": p.origin,
                "confidence": p.confidence,
            })).collect::<Vec<_>>(),
            "unplanned": self.unplanned.as_ref()
                .map(|v| v.iter().map(ComponentId::as_str).collect::<Vec<_>>()),
            "unplanned_known": self.unplanned.is_some(),
        })
    }
}

/// Convenience: build the wire event for a **human-drawn** wire (confirmed by the
/// act of drawing). The funnel pane's drag gesture emits exactly this.
#[must_use]
pub fn draw_wire(
    plan_node: impl Into<String>,
    component: impl Into<String>,
    why: Option<String>,
    ts: DateTime<Utc>,
) -> Event {
    Event::PlanComponentWired {
        plan_node: plan_node.into(),
        component: component.into(),
        origin: WireOrigin::Drawn.as_str().to_string(),
        status: WireStatus::Confirmed.as_str().to_string(),
        confidence: None,
        why,
        ts,
    }
}

/// Convenience: reject a suggested wire. Append-only — the rejection is a new row
/// over the same `(plan_node, component)` pair, not a deletion.
#[must_use]
pub fn reject_wire(
    plan_node: impl Into<String>,
    component: impl Into<String>,
    why: Option<String>,
    ts: DateTime<Utc>,
) -> Event {
    Event::PlanComponentWired {
        plan_node: plan_node.into(),
        component: component.into(),
        origin: WireOrigin::Inferred.as_str().to_string(),
        status: WireStatus::Rejected.as_str().to_string(),
        confidence: None,
        why,
        ts,
    }
}

/// The [`NodeId`] a plan-node wire addresses, when the id names a task node.
/// Helper for callers that want to move from the wiring's string ids back into
/// the typed funnel world.
#[must_use]
pub fn wire_node_id(w: &Wire) -> NodeId {
    NodeId::new(w.plan_node.clone())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::event::{Event, ItemKind, NodeStatus};
    use crate::ids::{IdeaId, NodeId, PlanId};
    use chrono::{Duration, Utc};

    /// `i-001 → plan p-001 → nodes n-001 → n-002 → n-003` (a dependency chain).
    fn funnel_with_chain() -> Funnel {
        let mut f = Funnel::default();
        let base = Utc::now() - Duration::hours(3);
        let idea = IdeaId::seq(1);
        let plan = PlanId::seq(1);
        f.apply(&Event::IdeaSubmitted {
            id: idea.clone(),
            source: "human:alice".into(),
            text: "wire the plan to the code".into(),
            refs: vec![],
            item_kind: ItemKind::Idea,
            ts: base,
        })
        .unwrap();
        f.apply(&Event::PlanCreated {
            id: plan.clone(),
            idea_id: idea,
            summary: "wiring".into(),
            planner: "test".into(),
            ts: base,
        })
        .unwrap();
        for (i, targets) in [
            (1u64, vec!["src/wiring.rs".to_string()]),
            (2, vec![]),
            (3, vec!["crates/nornir-viz/*".to_string()]),
        ] {
            f.apply(&Event::NodeAdded {
                plan_id: plan.clone(),
                node_id: NodeId::seq(i),
                kind: "code:write".into(),
                params: serde_json::Map::new(),
                targets,
                prompt_excerpt: None,
                ts: base,
            })
            .unwrap();
        }
        for (a, b) in [(1u64, 2u64), (2, 3)] {
            f.apply(&Event::EdgeAdded {
                plan_id: plan.clone(),
                from_node: NodeId::seq(a),
                to_node: NodeId::seq(b),
                ts: base,
            })
            .unwrap();
        }
        f
    }

    /// `dwarves → nornir → funnel-core`, plus an unrelated `ordning`.
    fn component_dag() -> ComponentDag {
        ComponentDag::new(
            vec![
                ComponentNode::krate("funnel-core").with_path("src"),
                ComponentNode::krate("nornir").with_path("crates/nornir"),
                ComponentNode::krate("nornir-viz").with_path("crates/nornir-viz"),
                ComponentNode::krate("dwarves").with_path("crates/dwarves"),
                ComponentNode::krate("ordning").with_path("crates/ordning"),
            ],
            vec![
                ComponentEdge::dep("nornir", "funnel-core"),
                ComponentEdge::dep("nornir-viz", "nornir"),
                ComponentEdge::dep("dwarves", "nornir"),
            ],
        )
    }

    fn wire(f: &mut Funnel, node: &str, comp: &str, origin: &str, status: &str) {
        f.apply(&Event::PlanComponentWired {
            plan_node: node.to_string(),
            component: comp.to_string(),
            origin: origin.to_string(),
            status: status.to_string(),
            confidence: None,
            why: None,
            ts: Utc::now(),
        })
        .unwrap();
    }

    // ── the graph traversal ─────────────────────────────────────────────────

    #[test]
    fn blast_radius_walks_dependents_not_dependencies() {
        let g = component_dag();
        let fc = ComponentId::new("funnel-core");
        // Change funnel-core ⇒ nornir, nornir-viz and dwarves are affected.
        assert_eq!(
            g.blast_radius(&fc),
            vec![
                ComponentId::new("dwarves"),
                ComponentId::new("nornir"),
                ComponentId::new("nornir-viz"),
            ],
            "blast radius must be the transitive DEPENDENTS closure"
        );
        // The opposite direction is a different set — reversing the traversal
        // (the classic bug) would make this fail.
        assert_eq!(g.dependency_closure(&fc), Vec::<ComponentId>::new());
        assert_eq!(
            g.dependency_closure(&ComponentId::new("nornir-viz")),
            vec![ComponentId::new("funnel-core"), ComponentId::new("nornir")],
        );
        // A leaf of the impact direction has an empty radius.
        assert!(g.blast_radius(&ComponentId::new("dwarves")).is_empty());
    }

    #[test]
    fn blast_radius_terminates_on_a_cyclic_component_graph() {
        // Call graphs are routinely cyclic (mutual recursion); the closure must
        // not spin. Also asserts the seed is excluded even when it is reachable
        // from itself.
        let g = ComponentDag::new(
            vec![
                ComponentNode::krate("a"),
                ComponentNode::krate("b"),
                ComponentNode::krate("c"),
            ],
            vec![
                ComponentEdge::call("a", "b"),
                ComponentEdge::call("b", "c"),
                ComponentEdge::call("c", "a"),
            ],
        );
        let r = g.blast_radius(&ComponentId::new("a"));
        assert_eq!(r, vec![ComponentId::new("b"), ComponentId::new("c")]);
        assert!(!r.contains(&ComponentId::new("a")), "seed must be excluded");
    }

    #[test]
    fn unknown_component_traverses_to_nothing_rather_than_panicking() {
        let g = component_dag();
        let ghost = ComponentId::new("does-not-exist");
        assert!(!g.has_component(&ghost));
        assert!(g.blast_radius(&ghost).is_empty());
        assert!(g.dependents_of(&ghost).is_empty());
    }

    // ── the plan → component traversal ──────────────────────────────────────

    #[test]
    fn plan_node_blast_radius_joins_the_two_dags() {
        let mut f = funnel_with_chain();
        wire(&mut f, "n-001", "funnel-core", "drawn", "confirmed");
        let w = Wiring::from_funnel(&f);
        let g = component_dag();
        let r = w.blast_radius("n-001", &g);
        assert_eq!(r.touched, vec![ComponentId::new("funnel-core")]);
        assert_eq!(
            r.affected,
            vec![
                ComponentId::new("dwarves"),
                ComponentId::new("nornir"),
                ComponentId::new("nornir-viz"),
            ]
        );
        assert!(r.is_complete());
        assert_eq!(r.reach().len(), 4);
    }

    #[test]
    fn cone_traversal_unions_the_plan_descendants_bindings() {
        let mut f = funnel_with_chain();
        wire(&mut f, "n-001", "funnel-core", "drawn", "confirmed");
        wire(&mut f, "n-002", "nornir", "drawn", "confirmed");
        wire(&mut f, "n-003", "ordning", "drawn", "confirmed");
        let w = Wiring::from_funnel(&f);
        let g = component_dag();
        let dag = PlanDag::from_funnel(&f, Some("p-001"));

        // From n-002 the plan cone is {n-002, n-003} ⇒ nornir + ordning.
        let r = w.cone_blast_radius(&dag, "n-002", &g);
        assert_eq!(r.plan_nodes, vec!["n-002".to_string(), "n-003".to_string()]);
        assert_eq!(
            r.touched,
            vec![ComponentId::new("nornir"), ComponentId::new("ordning")]
        );
        // ordning has no dependents; nornir's are dwarves + nornir-viz.
        assert_eq!(
            r.affected,
            vec![ComponentId::new("dwarves"), ComponentId::new("nornir-viz")]
        );
        assert!(r.is_complete());

        // A single-node radius from n-002 must NOT see n-003's component — this
        // is what makes the cone traversal load-bearing.
        let single = w.blast_radius("n-002", &g);
        assert!(!single.touched.contains(&ComponentId::new("ordning")));
    }

    #[test]
    fn touched_components_are_never_double_counted_as_affected() {
        let mut f = funnel_with_chain();
        wire(&mut f, "n-001", "funnel-core", "drawn", "confirmed");
        wire(&mut f, "n-002", "nornir", "drawn", "confirmed");
        let w = Wiring::from_funnel(&f);
        let g = component_dag();
        let dag = PlanDag::from_funnel(&f, Some("p-001"));
        let r = w.cone_blast_radius(&dag, "n-001", &g);
        // nornir is directly touched by n-002 AND a dependent of funnel-core; it
        // must appear once, in `touched`.
        assert!(r.touched.contains(&ComponentId::new("nornir")));
        assert!(!r.affected.contains(&ComponentId::new("nornir")));
    }

    // ── the honesty contract ────────────────────────────────────────────────

    #[test]
    fn an_unwired_plan_node_reports_unknown_not_empty() {
        let f = funnel_with_chain();
        let w = Wiring::from_funnel(&f);
        let g = component_dag();
        let r = w.blast_radius("n-002", &g);
        assert!(r.touched.is_empty());
        assert!(r.affected.is_empty());
        // The load-bearing distinction: empty-because-unknown, NOT empty-because-none.
        assert_eq!(r.unmapped_nodes, vec!["n-002".to_string()]);
        assert!(
            !r.is_complete(),
            "an unwired node must never report a complete radius"
        );
    }

    #[test]
    fn a_wired_leaf_reports_an_empty_but_complete_radius() {
        let mut f = funnel_with_chain();
        wire(&mut f, "n-001", "dwarves", "drawn", "confirmed");
        let w = Wiring::from_funnel(&f);
        let r = w.blast_radius("n-001", &component_dag());
        assert!(r.affected.is_empty());
        // Same empty `affected` as the test above, opposite meaning.
        assert!(r.is_complete());
        assert!(r.unmapped_nodes.is_empty());
    }

    #[test]
    fn report_lists_every_unmapped_node_with_its_reason() {
        let mut f = funnel_with_chain();
        wire(&mut f, "n-001", "funnel-core", "drawn", "confirmed");
        wire(&mut f, "n-002", "nornir", "inferred", ""); // proposed, not confirmed
        wire(&mut f, "n-003", "ghost-crate", "drawn", "confirmed"); // dangling
        let w = Wiring::from_funnel(&f);
        let dag = PlanDag::from_funnel(&f, Some("p-001"));
        let rep = WiringReport::of(&dag, &w, &component_dag());

        assert!(!rep.is_fully_mapped());
        assert_eq!(rep.mapped.len(), 1);
        assert_eq!(rep.mapped[0].id, "n-001");
        // i-001 (the mother idea), n-002 and n-003 are all unmapped, each named.
        let reasons: BTreeMap<&str, UnmappedReason> = rep
            .unmapped
            .iter()
            .map(|u| (u.id.as_str(), u.reason))
            .collect();
        assert_eq!(reasons.get("i-001"), Some(&UnmappedReason::NoWire));
        assert_eq!(reasons.get("n-002"), Some(&UnmappedReason::OnlyProposed));
        assert_eq!(reasons.get("n-003"), Some(&UnmappedReason::Dangling));
        assert_eq!(rep.unmapped.len(), 3);

        // A proposed wire is surfaced for the confirm/reject cycle …
        assert_eq!(rep.proposed.len(), 1);
        assert_eq!(rep.proposed[0].plan_node, "n-002");
        // … and a dangling one is called out separately.
        assert_eq!(rep.dangling.len(), 1);
        assert_eq!(rep.dangling[0].component, ComponentId::new("ghost-crate"));

        // Only the mother idea is a non-task; the two unmapped tasks are stricter.
        assert_eq!(rep.unmapped_tasks().len(), 2);
        // Coverage: 1 of 4 plan-DAG nodes.
        assert!((rep.coverage() - 0.25).abs() < 1e-9);
    }

    #[test]
    fn a_proposed_wire_does_not_bind_until_confirmed() {
        let mut f = funnel_with_chain();
        wire(&mut f, "n-001", "funnel-core", "inferred", "");
        let w = Wiring::from_funnel(&f);
        let g = component_dag();
        assert!(
            w.bound_components("n-001").is_empty(),
            "a suggestion must not bind"
        );
        assert!(!w.blast_radius("n-001", &g).is_complete());

        // Confirm it (last-write-wins over the same pair) and it binds.
        let mut f2 = f.clone();
        wire(&mut f2, "n-001", "funnel-core", "drawn", "confirmed");
        let w2 = Wiring::from_funnel(&f2);
        assert_eq!(
            w2.bound_components("n-001"),
            vec![ComponentId::new("funnel-core")]
        );
        assert_eq!(w2.wires().len(), 1, "LWW must not duplicate the pair");

        // Reject it and it stops binding again — append-only, no deletion.
        let mut f3 = f2.clone();
        f3.apply(&reject_wire("n-001", "funnel-core", None, Utc::now()))
            .unwrap();
        let w3 = Wiring::from_funnel(&f3);
        assert!(w3.bound_components("n-001").is_empty());
        let rep = WiringReport::of(&PlanDag::from_funnel(&f3, Some("p-001")), &w3, &g);
        let n1 = rep.unmapped.iter().find(|u| u.id == "n-001").unwrap();
        assert_eq!(n1.reason, UnmappedReason::OnlyRejected);
    }

    #[test]
    fn an_unknown_status_or_origin_token_never_binds() {
        // Future-proofing must fail CLOSED: a token this build cannot read is a
        // suggestion, never a confirmed mapping.
        let w = Wire {
            plan_node: "n-001".into(),
            component: ComponentId::new("funnel-core"),
            origin: "quantum-oracle".into(),
            status: "probably".into(),
            confidence: Some(0.99),
            why: None,
            ts: Utc::now(),
        };
        assert_eq!(w.status_kind(), WireStatus::Proposed);
        assert_eq!(w.origin_kind(), None);
        assert!(!w.binds(), "an unreadable wire must not bind");

        // An empty status falls back to the ORIGIN's natural status …
        let drawn = Wire {
            origin: "drawn".into(),
            status: String::new(),
            ..w.clone()
        };
        assert_eq!(drawn.status_kind(), WireStatus::Confirmed);
        let inferred = Wire {
            origin: "inferred".into(),
            status: String::new(),
            ..w.clone()
        };
        assert_eq!(inferred.status_kind(), WireStatus::Proposed);
        // … but an explicit token always wins over the origin default.
        let vetoed = Wire {
            origin: "drawn".into(),
            status: "rejected".into(),
            ..w
        };
        assert_eq!(vetoed.status_kind(), WireStatus::Rejected);
        assert!(!vetoed.binds());
    }

    #[test]
    fn done_but_unmapped_is_the_loud_row() {
        let mut f = funnel_with_chain();
        f.apply(&Event::NodeStatusChanged {
            plan_id: PlanId::seq(1),
            node_id: NodeId::seq(2),
            status: NodeStatus::Done,
            why: None,
            ts: Utc::now(),
        })
        .unwrap();
        let w = Wiring::from_funnel(&f);
        let rep = WiringReport::of(
            &PlanDag::from_funnel(&f, Some("p-001")),
            &w,
            &component_dag(),
        );
        let loud = rep.done_but_unmapped();
        assert_eq!(loud.len(), 1);
        assert_eq!(loud[0].id, "n-002");
        assert_eq!(loud[0].reason, UnmappedReason::NoWire);
        // The mother idea card also reads `Done` (it is decomposed / `Planned`)
        // and is also unmapped — but it is NOT finished work, so it must stay out
        // of the loud row or the signal drowns.
        let mother = rep.unmapped.iter().find(|u| u.id == "i-001").unwrap();
        assert_eq!(mother.status, DagStatus::Done);
        assert!(!mother.is_task);
        assert!(!loud.iter().any(|u| u.id == "i-001"));
    }

    #[test]
    fn unplanned_is_none_when_the_graph_cannot_enumerate() {
        /// A lazy, RPC-shaped graph: answers neighbours, cannot list nodes.
        struct Lazy;
        impl ComponentGraph for Lazy {
            fn has_component(&self, _: &ComponentId) -> bool {
                true
            }
            fn deps_of(&self, _: &ComponentId) -> Vec<ComponentId> {
                vec![]
            }
            fn dependents_of(&self, _: &ComponentId) -> Vec<ComponentId> {
                vec![]
            }
        }
        let mut f = funnel_with_chain();
        wire(&mut f, "n-001", "funnel-core", "drawn", "confirmed");
        let w = Wiring::from_funnel(&f);
        let dag = PlanDag::from_funnel(&f, Some("p-001"));

        let lazy = WiringReport::of(&dag, &w, &Lazy);
        assert!(
            lazy.unplanned.is_none(),
            "unknown must not be reported as an empty list"
        );
        assert_eq!(
            lazy.state_json()["unplanned_known"],
            serde_json::json!(false)
        );

        // The materialised graph CAN answer, and names the unclaimed components.
        let full = WiringReport::of(&dag, &w, &component_dag());
        assert_eq!(
            full.unplanned,
            Some(vec![
                ComponentId::new("dwarves"),
                ComponentId::new("nornir"),
                ComponentId::new("nornir-viz"),
                ComponentId::new("ordning"),
            ])
        );
    }

    // ── the pure inference ──────────────────────────────────────────────────

    #[test]
    fn declared_inference_proposes_wires_from_target_globs() {
        let f = funnel_with_chain();
        let g = component_dag();
        let ts = Utc::now();
        let evs = Wiring::infer_declared(&f, &g, ts);
        // n-001 targets `src/wiring.rs` → funnel-core (path `src` owns the file);
        // n-003 targets `crates/nornir-viz/*` → nornir-viz; n-002 has no targets.
        let pairs: Vec<(String, String)> = evs.iter().map(wire_key).collect();
        assert_eq!(
            pairs,
            vec![
                ("n-001".to_string(), "funnel-core".to_string()),
                ("n-003".to_string(), "nornir-viz".to_string()),
            ]
        );

        // They are PROPOSALS: applying them leaves both nodes unmapped.
        let mut f2 = f.clone();
        for e in &evs {
            f2.apply(e).unwrap();
        }
        let w = Wiring::from_funnel(&f2);
        assert!(w.bound_components("n-001").is_empty());
        let rep = WiringReport::of(&PlanDag::from_funnel(&f2, Some("p-001")), &w, &g);
        assert_eq!(rep.proposed.len(), 2);
        assert!(!rep.is_fully_mapped());

        // Re-running is idempotent, and never resurrects a rejected suggestion.
        assert!(Wiring::infer_declared(&f2, &g, ts).is_empty());
        let mut f3 = f2.clone();
        f3.apply(&reject_wire("n-001", "funnel-core", None, Utc::now()))
            .unwrap();
        assert!(
            Wiring::infer_declared(&f3, &g, ts).is_empty(),
            "rejection must stick"
        );
    }

    #[test]
    fn glob_match_handles_stars_and_literals() {
        assert!(glob_match(
            "crates/nornir-viz/*",
            "crates/nornir-viz/src/lib.rs"
        ));
        assert!(glob_match("*.rs", "src/wiring.rs"));
        assert!(glob_match("src/*/mod.rs", "src/funnel/mod.rs"));
        assert!(glob_match("exact", "exact"));
        assert!(!glob_match("crates/nornir/*", "crates/nornir-viz/lib.rs"));
        assert!(!glob_match("*.toml", "src/wiring.rs"));
        assert!(glob_match("*", ""));
        assert!(!glob_match("", "x"));
    }

    // ── determinism (FC-7) ──────────────────────────────────────────────────

    #[test]
    fn every_projection_is_byte_identical_across_runs() {
        let mut f = funnel_with_chain();
        wire(&mut f, "n-003", "nornir-viz", "drawn", "confirmed");
        wire(&mut f, "n-001", "funnel-core", "drawn", "confirmed");
        wire(&mut f, "n-002", "ghost", "inferred", "confirmed");
        let g = component_dag();
        let dag = PlanDag::from_funnel(&f, Some("p-001"));
        let one = Wiring::from_funnel(&f);
        let two = Wiring::from_funnel(&f);
        assert_eq!(one, two);
        assert_eq!(one.state_json(), two.state_json());
        assert_eq!(
            WiringReport::of(&dag, &one, &g).state_json(),
            WiringReport::of(&dag, &two, &g).state_json()
        );
        assert_eq!(
            one.cone_blast_radius(&dag, "i-001", &g).state_json(),
            two.cone_blast_radius(&dag, "i-001", &g).state_json()
        );
        // Wire order is (plan_node, component), not insertion order.
        assert_eq!(
            one.wires()
                .iter()
                .map(|w| w.plan_node.as_str())
                .collect::<Vec<_>>(),
            vec!["n-001", "n-002", "n-003"]
        );
    }

    #[test]
    fn helpers_build_the_events_the_pane_emits() {
        let ts = Utc::now();
        let ev = draw_wire("n-001", "funnel-core", Some("drag".into()), ts);
        match &ev {
            Event::PlanComponentWired {
                plan_node,
                component,
                origin,
                status,
                ..
            } => {
                assert_eq!(
                    (plan_node.as_str(), component.as_str()),
                    ("n-001", "funnel-core")
                );
                assert_eq!(origin, "drawn");
                assert_eq!(status, "confirmed");
            }
            _ => panic!("draw_wire must build a PlanComponentWired"),
        }
        let mut f = funnel_with_chain();
        f.apply(&ev).unwrap();
        let w = Wiring::from_funnel(&f);
        assert_eq!(wire_node_id(&w.wires()[0]), NodeId::new("n-001"));
    }
}