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"));
}
}