diff --git a/docs/plans/0040-wiring-totality-check.md b/docs/plans/0040-wiring-totality-check.md new file mode 100644 index 0000000..ff1a167 --- /dev/null +++ b/docs/plans/0040-wiring-totality-check.md @@ -0,0 +1,586 @@ +# Wiring-totality check — Implementation Plan + +> **Parent spec:** `docs/specs/0040-wiring-totality-check.md` +> +> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run +> this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** Reject a graph that leaves an interior input slot unwired or wires one +slot from more than one producer, at compile time, via a single name-free +structural check in `validate_wiring`. + +**Architecture:** A new `check_ports_connected(nodes, edges, roles)` is added to +`validate_wiring` (crates/aura-engine/src/blueprint.rs), after the existing +index/kind checks and before the nested-composite recursion. It counts coverage of +each `(interior-node, slot)` uniformly across interior edges and role targets, and +rejects any interior input slot covered 0 times (`UnconnectedPort`) or >1 times +(`DoubleWiredPort`). Because `validate_wiring` is called once from +`compile_with_params` and recurses, both the raw `Composite::new` path and the +`GraphBuilder::build()` path inherit it at every nesting level. The blast radius is +exactly four currently-green tests (empirically probed), fixed here. + +**Tech Stack:** `crates/aura-engine/src/blueprint.rs` (the check, the two +`CompileError` variants, raw-surface tests, four flipped-test fixes), +`crates/aura-engine/src/builder.rs` (one builder-surface test), `docs/design/INDEX.md` +(C8 realization note). + +--- + +**Files this plan creates or modifies:** + +- Modify: `crates/aura-engine/src/blueprint.rs` — add two `CompileError` variants + (~:489), add `check_ports_connected` + its call site in `validate_wiring` + (~:538/:546), re-wire 3 negative tests (~:1382/:1452/:1473), fully wire 1 + param-order test (~:1877), add 5 raw-surface tests (test module). +- Modify: `crates/aura-engine/src/builder.rs` — add 1 builder-surface negative test + (after `builder_harness_compiles_identically_to_hand_wired`, ~:272). +- Modify: `docs/design/INDEX.md` — add the C8 cycle-0040 realization note (after the + cycle-0027 realization block, ~:316). + +--- + +### Task 1: The wiring-totality check (two variants + the function + call site) + +**Files:** +- Modify: `crates/aura-engine/src/blueprint.rs:489` (CompileError), `:538`/`:546` + (validate_wiring + new fn) + +- [ ] **Step 1: Add the two `CompileError` variants** + +In `crates/aura-engine/src/blueprint.rs`, replace: + +```rust + /// A root input role `role` has no bound source (`source: None`) — an open port + /// at the root, which has no enclosing graph to wire it. Only a fully source- + /// bound composite is runnable. + UnboundRootRole { role: usize }, +} +``` + +with: + +```rust + /// A root input role `role` has no bound source (`source: None`) — an open port + /// at the root, which has no enclosing graph to wire it. Only a fully source- + /// bound composite is runnable. + UnboundRootRole { role: usize }, + /// An interior node's input `slot` is covered by no edge and no role target — a + /// required port left unconnected (it would bootstrap a silent empty column). + UnconnectedPort { node: usize, slot: usize }, + /// An interior node's input `slot` is covered by more than one edge/role target + /// combined — a slot holds exactly one column, so >1 producer is ill-formed. + DoubleWiredPort { node: usize, slot: usize }, +} +``` + +- [ ] **Step 2: Add `check_ports_connected` and call it from `validate_wiring`** + +In `crates/aura-engine/src/blueprint.rs`, replace the tail of `validate_wiring`: + +```rust + // recurse into nested composites + for item in nodes { + if let BlueprintNode::Composite(c) = item { + validate_wiring(c.nodes(), c.edges(), c.input_roles(), c.output())?; + } + } + Ok(()) +} +``` + +with (insert the call before the recursion, and define the new fn right after): + +```rust + // wiring totality: every interior input slot covered by exactly one wiring act + check_ports_connected(nodes, edges, roles)?; + // recurse into nested composites + for item in nodes { + if let BlueprintNode::Composite(c) = item { + validate_wiring(c.nodes(), c.edges(), c.input_roles(), c.output())?; + } + } + Ok(()) +} + +/// Every interior node's every declared input slot must be covered by exactly one +/// wiring act — one interior edge OR one role target, counted uniformly. Zero = a +/// forgotten connection (would bootstrap a silent empty column); >1 = an ill-formed +/// slot (a slot holds one column). Index-based, name-free; presupposes in-range +/// edge/role indices (runs after the existing index-range checks). Mirrors the +/// single-site shape of `check_param_namespace_injective` (the wiring-side sibling). +fn check_ports_connected( + nodes: &[BlueprintNode], + edges: &[Edge], + roles: &[Role], +) -> Result<(), CompileError> { + let mut coverage: std::collections::HashMap<(usize, usize), usize> = + std::collections::HashMap::new(); + for e in edges { + *coverage.entry((e.to, e.slot)).or_insert(0) += 1; + } + for role in roles { + for t in &role.targets { + *coverage.entry((t.node, t.slot)).or_insert(0) += 1; + } + } + for (n, item) in nodes.iter().enumerate() { + for slot in 0..item.signature().inputs.len() { + match coverage.get(&(n, slot)).copied().unwrap_or(0) { + 1 => {} + 0 => return Err(CompileError::UnconnectedPort { node: n, slot }), + _ => return Err(CompileError::DoubleWiredPort { node: n, slot }), + } + } + } + Ok(()) +} +``` + +- [ ] **Step 3: Build and confirm exactly the four expected flips** + +Run: `cargo build -p aura-engine` +Expected: builds with 0 errors (the two `usize`-only variants break no derive or +exhaustive match; `HashMap` is used by its full path, no new import). + +Run: `cargo test -p aura-engine 2>&1 | tail -3` +Expected: `test result: FAILED. ... 4 failed ...` — and the four are exactly +`bad_interior_index_rejected`, `role_kind_mismatch_rejected`, +`output_port_out_of_range_rejected`, and +`param_space_mirrors_compiled_flat_node_param_order_under_nesting`. (Any other +failure is an un-enumerated flip — STOP and re-survey; the probe established these +four are the complete set.) + +--- + +### Task 2: Re-wire the three under-wired negative tests (covering root role) + +Each wraps a faulty composite `c` in a root with empty edges + empty roles, so the +new check pre-empts the deep fault with `UnconnectedPort` at the root level. Add a +covering root role targeting `c`'s one input slot (mirroring the existing pattern at +blueprint.rs:1414). The deep fault then surfaces via the recursion (the index/kind/ +output checks inside `validate_wiring(c)` run before `c`'s own connectivity check), +so each STILL asserts its original variant. + +**Files:** +- Modify: `crates/aura-engine/src/blueprint.rs:1382`, `:1452`, `:1473` + +- [ ] **Step 1: Re-wire `bad_interior_index_rejected`** + +Replace: + +```rust + let bp = Composite::new( + "root", + vec![BlueprintNode::Composite(c)], + vec![], + vec![], + vec![], // output + ); + // the Ok arm holds Box (not Debug), so assert via the Err arm. + assert_eq!(bp.compile().err(), Some(CompileError::BadInteriorIndex)); +``` + +with: + +```rust + let bp = Composite::new( + "root", + vec![BlueprintNode::Composite(c)], + vec![], + vec![Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }], + vec![], // output + ); + // the Ok arm holds Box (not Debug), so assert via the Err arm. + assert_eq!(bp.compile().err(), Some(CompileError::BadInteriorIndex)); +``` + +- [ ] **Step 2: Re-wire `role_kind_mismatch_rejected`** + +Replace: + +```rust + let bp = Composite::new( + "root", + vec![BlueprintNode::Composite(c)], + vec![], + vec![], + vec![], // output + ); + // the Ok arm holds Box (not Debug), so assert via the Err arm. + assert_eq!(bp.compile().err(), Some(CompileError::RoleKindMismatch { role: 0 })); +``` + +with: + +```rust + let bp = Composite::new( + "root", + vec![BlueprintNode::Composite(c)], + vec![], + vec![Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }], + vec![], // output + ); + // the Ok arm holds Box (not Debug), so assert via the Err arm. + assert_eq!(bp.compile().err(), Some(CompileError::RoleKindMismatch { role: 0 })); +``` + +- [ ] **Step 3: Re-wire `output_port_out_of_range_rejected`** + +Replace: + +```rust + let bp = Composite::new( + "root", + vec![BlueprintNode::Composite(c)], + vec![], + vec![], + vec![], // output + ); + // the Ok arm holds Box (not Debug), so assert via the Err arm. + assert_eq!(bp.compile().err(), Some(CompileError::OutputPortOutOfRange)); +``` + +with: + +```rust + let bp = Composite::new( + "root", + vec![BlueprintNode::Composite(c)], + vec![], + vec![Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }], + vec![], // output + ); + // the Ok arm holds Box (not Debug), so assert via the Err arm. + assert_eq!(bp.compile().err(), Some(CompileError::OutputPortOutOfRange)); +``` + +- [ ] **Step 4: Verify the three pass** + +Run: `cargo test -p aura-engine bad_interior_index_rejected` +Expected: PASS (`1 passed`). +Run: `cargo test -p aura-engine role_kind_mismatch_rejected` +Expected: PASS (`1 passed`). +Run: `cargo test -p aura-engine output_port_out_of_range_rejected` +Expected: PASS (`1 passed`). + +--- + +### Task 3: Fully wire the param-order nesting test + +`param_space_mirrors_compiled_flat_node_param_order_under_nesting` compiles a +deliberately under-wired `strategy` (LinComb's two `term` inputs never fed) only to +read its flat param order. Fully wire it: fan `fast_slow`'s output into both LinComb +terms, and add a covering root role for `strategy`'s `price` input. Wiring changes no +node and no param, so the asserted `space == from_compilat` equality is unchanged. + +**Files:** +- Modify: `crates/aura-engine/src/blueprint.rs:1877` + +- [ ] **Step 1: Wire the `strategy` edges and the root role** + +In the function `param_space_mirrors_compiled_flat_node_param_order_under_nesting`, +replace (this span is unique to that function — it ends with the +`"nested composite compiles"` compile line, which the sibling param-order test lacks): + +```rust + let strategy = Composite::new( + "strategy", + vec![BlueprintNode::Composite(fast_slow), LinComb::builder(2).into()], + vec![], + vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }], + vec![OutField { node: 0, field: 0, name: "out".into() }], + ); + let bp = Composite::new( + "root", + vec![BlueprintNode::Composite(strategy)], + vec![], + vec![], + vec![], // output + ); + + // the aggregated, path-qualified projection (borrows; take it first since + // compile() consumes self — same ordering as the single-level mirror test) + let space = bp.param_space(); + + // the same blueprint, compiled to its flat node array; each flat node's + // own declared params, concatenated in flat-node order + let flat = bp.compile_with_params(&[Scalar::I64(2), Scalar::I64(4), Scalar::F64(1.0), Scalar::F64(-1.0)]).expect("nested composite compiles"); +``` + +with: + +```rust + let strategy = Composite::new( + "strategy", + vec![BlueprintNode::Composite(fast_slow), LinComb::builder(2).into()], + // fan fast_slow's output into both LinComb terms so every interior slot + // is wired (the totality check, cycle 0040); param order is unaffected. + vec![ + Edge { from: 0, to: 1, slot: 0, from_field: 0 }, + Edge { from: 0, to: 1, slot: 1, from_field: 0 }, + ], + vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }], + vec![OutField { node: 0, field: 0, name: "out".into() }], + ); + let bp = Composite::new( + "root", + vec![BlueprintNode::Composite(strategy)], + vec![], + vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }], + vec![], // output + ); + + // the aggregated, path-qualified projection (borrows; take it first since + // compile() consumes self — same ordering as the single-level mirror test) + let space = bp.param_space(); + + // the same blueprint, compiled to its flat node array; each flat node's + // own declared params, concatenated in flat-node order + let flat = bp.compile_with_params(&[Scalar::I64(2), Scalar::I64(4), Scalar::F64(1.0), Scalar::F64(-1.0)]).expect("nested composite compiles"); +``` + +- [ ] **Step 2: Verify it passes** + +Run: `cargo test -p aura-engine param_space_mirrors_compiled_flat_node_param_order_under_nesting` +Expected: PASS (`1 passed`). + +--- + +### Task 4: New raw-surface tests (unwired, double-wired, nested, open-role boundary) + +Add these five tests to the `#[cfg(test)] mod tests` block in +`crates/aura-engine/src/blueprint.rs` (alongside the existing negative tests). They +use the in-module helpers `pass1()` (one f64 input "in", one output) and `sink_f64()` +(one f64 input "in", no output), and the in-scope `Composite`, `Edge`, `Role`, +`Target`, `OutField`, `BlueprintNode`, `CompileError`, `ScalarKind`. + +**Files:** +- Modify: `crates/aura-engine/src/blueprint.rs` (test module) + +- [ ] **Step 1: Add the five tests** + +```rust + #[test] + fn unconnected_interior_slot_rejected() { + // pass1 is fed by a role; sink_f64's one input is left unwired -> rejected. + let bp = Composite::new( + "root", + vec![pass1(), sink_f64()], + vec![], + vec![Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }], + vec![], + ); + assert_eq!( + bp.compile().err(), + Some(CompileError::UnconnectedPort { node: 1, slot: 0 }) + ); + } + + #[test] + fn double_wired_slot_rejected_edge_and_role() { + // sink_f64's one input is targeted by BOTH an edge (from pass1) and a role. + let bp = Composite::new( + "root", + vec![pass1(), sink_f64()], + vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }], + vec![ + Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }, + Role { name: "extra".into(), targets: vec![Target { node: 1, slot: 0 }], source: Some(ScalarKind::F64) }, + ], + vec![], + ); + assert_eq!( + bp.compile().err(), + Some(CompileError::DoubleWiredPort { node: 1, slot: 0 }) + ); + } + + #[test] + fn double_wired_slot_rejected_two_edges() { + // two producers' edges land on sink_f64's single input slot. + let bp = Composite::new( + "root", + vec![pass1(), pass1(), sink_f64()], + vec![ + Edge { from: 0, to: 2, slot: 0, from_field: 0 }, + Edge { from: 1, to: 2, slot: 0, from_field: 0 }, + ], + vec![ + Role { name: "a".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }, + Role { name: "b".into(), targets: vec![Target { node: 1, slot: 0 }], source: Some(ScalarKind::F64) }, + ], + vec![], + ); + assert_eq!( + bp.compile().err(), + Some(CompileError::DoubleWiredPort { node: 2, slot: 0 }) + ); + } + + #[test] + fn unconnected_slot_in_nested_composite_rejected() { + // inner composite c: pass1 (fed by c's role) + sink_f64 (interior slot + // unwired). The root covers c's one input, so the fault surfaces only via + // the recursion into c -> UnconnectedPort at c's interior index (1, 0). + let c = Composite::new( + "c", + vec![pass1(), sink_f64()], + vec![], + vec![Role { name: "in".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }], + vec![OutField { node: 0, field: 0, name: "out".into() }], + ); + let bp = Composite::new( + "root", + vec![BlueprintNode::Composite(c)], + vec![], + vec![Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }], + vec![], + ); + assert_eq!( + bp.compile().err(), + Some(CompileError::UnconnectedPort { node: 1, slot: 0 }) + ); + } + + #[test] + fn open_role_provider_is_not_flagged_unconnected() { + // a composite whose OPEN input role (source: None) feeds its interior slot, + // used as a nested node with that role covered by the enclosing root, must + // compile — the open role is a provider, not an unwired consumer. + let c = Composite::new( + "c", + vec![pass1()], + vec![], + vec![Role { name: "in".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }], + vec![OutField { node: 0, field: 0, name: "out".into() }], + ); + let bp = Composite::new( + "root", + vec![BlueprintNode::Composite(c)], + vec![], + vec![Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }], + vec![], + ); + assert!(bp.compile().is_ok(), "open role as provider must not be mis-flagged"); + } +``` + +- [ ] **Step 2: Verify the five pass** + +Run: `cargo test -p aura-engine unconnected_interior_slot_rejected` +Expected: PASS (`1 passed`). +Run: `cargo test -p aura-engine double_wired_slot_rejected_edge_and_role` +Expected: PASS (`1 passed`). +Run: `cargo test -p aura-engine double_wired_slot_rejected_two_edges` +Expected: PASS (`1 passed`). +Run: `cargo test -p aura-engine unconnected_slot_in_nested_composite_rejected` +Expected: PASS (`1 passed`). +Run: `cargo test -p aura-engine open_role_provider_is_not_flagged_unconnected` +Expected: PASS (`1 passed`). + +--- + +### Task 5: Builder-surface forgotten-leg test + +Mirror `builder_harness_compiles_identically_to_hand_wired` but omit the +exposure→broker connection: every name resolves so `build()` succeeds, but +`compile_with_params` rejects the uncovered broker exposure slot. Proves the +`GraphBuilder` surface inherits the check. + +**Files:** +- Modify: `crates/aura-engine/src/builder.rs` (test module, after + `builder_harness_compiles_identically_to_hand_wired`) + +- [ ] **Step 1: Add the test** + +```rust + #[test] + fn unconnected_port_rejected_on_builder_surface() { + use crate::CompileError; + use aura_core::Scalar; + // The forgotten-exposure-leg harness: every port/field name resolves, so + // build() succeeds; but broker.input("exposure") is never connected, so the + // wiring-totality check rejects it at compile (broker is node 1, slot 0). + let (tx_eq, _r1) = mpsc::channel(); + let mut g = GraphBuilder::new("root"); + let expo = g.add(Exposure::builder()); + let broker = g.add(SimBroker::builder(0.0001)); + let eq = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq)); + let price = g.source_role("price", ScalarKind::F64); + g.feed(price, [expo.input("signal"), broker.input("price")]); + // BUG: g.connect(expo.output("exposure"), broker.input("exposure")) missing. + g.connect(broker.output("equity"), eq.input("col[0]")); + let compiled = g + .build() + .expect("all port/field names resolve") + .compile_with_params(&[Scalar::F64(0.5)]); + assert_eq!( + compiled.err(), + Some(CompileError::UnconnectedPort { node: 1, slot: 0 }) + ); + } +``` + +- [ ] **Step 2: Verify it passes** + +Run: `cargo test -p aura-engine unconnected_port_rejected_on_builder_surface` +Expected: PASS (`1 passed`). + +--- + +### Task 6: C8 ledger realization note + +**Files:** +- Modify: `docs/design/INDEX.md:316` (after the cycle-0027 realization block, before + the blank line preceding `### C9`) + +- [ ] **Step 1: Insert the cycle-0040 realization note** + +Find the end of the C8 "Realization (cycle 0027 — name input ports…)" block (the +paragraph ending "…a name-consuming validation is its own future cycle.", ~line 316), +and insert a blank line then this paragraph immediately after it: + +```text +**Realization (cycle 0040 — wiring totality: every input slot connected exactly +once, #65).** The node contract's "the engine provides a window into each input" +presupposes each input is actually fed; until now an unwired interior input slot was +accepted and bootstrapped to a silent empty column (`harness.rs`), and two producers +into one slot — ill-formed, since a slot holds one column — was likewise +uncompiled-against. Cycle 0040 makes a valid graph **total and single-valued** over +its interior input slots: `check_ports_connected` (in `validate_wiring`, run at every +nesting level) requires every interior node's every declared input slot to be covered +by **exactly one** wiring act — one interior `Edge { to, slot }` or one role +`Target { node, slot }`, edges and role targets counted uniformly. Zero coverage is +`CompileError::UnconnectedPort`, more than one is `CompileError::DoubleWiredPort`. A +composite's **own** input roles (`source: None`) are coverage *providers* — the +wired-by-enclosing boundary, the root case already guarded by `UnboundRootRole` — +never consumers, so only interior input slots are subject to the rule. There is **no +optional-input concept**: every declared input port is required (no shipped node runs +meaningfully without one; an unwarmed mode-A input is *wired-but-not-yet-valued*, not +unwired). The check is **index-based and name-free** — it touches no name machinery +and emits nothing into the compilat, so C23 is untouched (it proves the existing +raw-index wiring is total and single-valued). Inherited identically by the raw +`Composite::new` path and the `GraphBuilder::build()` path (both compile via +`compile_with_params`). +``` + +- [ ] **Step 2: Confirm the note landed** + +Run: `grep -c "cycle 0040 — wiring totality" docs/design/INDEX.md` +Expected: `1` + +--- + +### Task 7: Full-suite + lint gate + +**Files:** none (verification only) + +- [ ] **Step 1: Full workspace test** + +Run: `cargo test --workspace 2>&1 | grep -E "test result:" | grep -v "0 failed" | head` +Expected: no output (every `test result:` line reports `0 failed`). + +- [ ] **Step 2: Clippy** + +Run: `cargo clippy --workspace --all-targets -- -D warnings` +Expected: finishes with 0 warnings/errors.