feat(aura-engine): reject unwired or double-wired input ports
A graph that leaves an interior input slot unconnected, or wires one slot from more than one producer, is now a compile-time error instead of a silently-wrong run. Until now an unwired interior slot was accepted and bootstrapped to an empty column (harness.rs:137-148), so a forgotten connection ran a deterministic but wrong backtest; two producers into one slot — ill-formed, since a slot holds one column — was likewise uncompiled-against. A single name-free check, `check_ports_connected`, is added to `validate_wiring` 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 zero coverage (new `CompileError::UnconnectedPort`) or >1 (new `CompileError::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 with no builder-side code (mirrors `check_param_namespace_injective`, the param-side sibling). A composite's own open input roles (source: None) are coverage providers — the wired-by-enclosing boundary, the root already guarded by UnboundRootRole — not consumers. Index-based and name-free: nothing reaches the compilat, so C23 holds. Supporting fix: the check computes `signature()` on every interior node before the recursion validates that node's interior, so `derive_signature` and `interior_slot_kind` are made bounds-total (a placeholder kind for an out-of-range OutField/target, never a panic; the real fault is still reported by validate_wiring's guarded OutputPortOutOfRange/BadInteriorIndex). This latent panic was exposed by the new check, not introduced by it. Test maintenance (blast radius enumerated empirically, not hand-traced): four existing tests relied incidentally on under-wiring being accepted — three negative tests get a covering root role so their intended deep fault still surfaces via the recursion, and one param-order nesting test is fully wired (param order unchanged). Six new tests: five raw-surface (unwired, double-wire via edge+role and edge+edge, nested, open-role boundary) and one builder-surface forgotten-leg (proving the GraphBuilder inherits the check). Narrowed scope of #65: the original "promote names to load-bearing wiring keys / close the exposure-price swap structurally" goal was dropped — #64 already moved authoring to handles + visible port names, so the residual same-kind swap is a valid-but-wrong name choice no structural check catches, and a structural fix would push domain semantics into the domain-free engine (C10/C7). This ships the name-free half that stands on its own: wiring completeness, which catches forgotten and doubled connections. Verified: cargo test --workspace 231 passed / 0 failed; cargo clippy --workspace --all-targets -- -D warnings clean. closes #65
This commit is contained in:
@@ -90,7 +90,16 @@ fn derive_signature(c: &Composite) -> NodeSchema {
|
||||
.output()
|
||||
.iter()
|
||||
.map(|of| {
|
||||
let kind = c.nodes()[of.node].signature().output[of.field].kind;
|
||||
// bounds-total: a structurally-invalid OutField (out-of-range node/field)
|
||||
// yields a placeholder kind, never a panic — the real fault is reported by
|
||||
// validate_wiring's guarded output check (OutputPortOutOfRange). signature()
|
||||
// is computed speculatively by the parent's edge/role/connectivity checks
|
||||
// before the recursion validates this composite's interior (cycle 0040).
|
||||
let kind = c
|
||||
.nodes()
|
||||
.get(of.node)
|
||||
.and_then(|n| n.signature().output.get(of.field).map(|f| f.kind))
|
||||
.unwrap_or(ScalarKind::F64);
|
||||
FieldSpec { name: leak_name(&of.name), kind }
|
||||
})
|
||||
.collect();
|
||||
@@ -103,7 +112,12 @@ fn derive_signature(c: &Composite) -> NodeSchema {
|
||||
/// resolving one level (a target into a nested composite reads that composite's
|
||||
/// derived input-port kind).
|
||||
fn interior_slot_kind(nodes: &[BlueprintNode], _edges: &[Edge], t: &Target) -> ScalarKind {
|
||||
nodes[t.node].signature().inputs[t.slot].kind
|
||||
// bounds-total: a target into a missing node/slot yields a placeholder kind, never a
|
||||
// panic — the real fault is reported by validate_wiring's guarded index checks.
|
||||
nodes
|
||||
.get(t.node)
|
||||
.and_then(|n| n.signature().inputs.get(t.slot).map(|p| p.kind))
|
||||
.unwrap_or(ScalarKind::F64)
|
||||
}
|
||||
|
||||
/// Leak a boundary name into a `&'static str` for a derived `FieldSpec`. Acceptable
|
||||
@@ -486,6 +500,12 @@ pub enum CompileError {
|
||||
/// 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 },
|
||||
}
|
||||
|
||||
/// Pre-build structural validation via `signature()` (no node constructed): every
|
||||
@@ -536,6 +556,8 @@ fn validate_wiring(
|
||||
return Err(CompileError::OutputPortOutOfRange);
|
||||
}
|
||||
}
|
||||
// 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 {
|
||||
@@ -545,6 +567,39 @@ fn validate_wiring(
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Recursive read-only walk for `Blueprint::param_space`: a leaf contributes its
|
||||
/// declared params under `<prefix>.<node-name>.<param>` (the node name is the
|
||||
/// instance name, default = the lowercased type label); a composite pushes its
|
||||
@@ -1383,7 +1438,7 @@ mod tests {
|
||||
"root",
|
||||
vec![BlueprintNode::Composite(c)],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }],
|
||||
vec![], // output
|
||||
);
|
||||
// the Ok arm holds Box<dyn Node> (not Debug), so assert via the Err arm.
|
||||
@@ -1453,7 +1508,7 @@ mod tests {
|
||||
"root",
|
||||
vec![BlueprintNode::Composite(c)],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }],
|
||||
vec![], // output
|
||||
);
|
||||
// the Ok arm holds Box<dyn Node> (not Debug), so assert via the Err arm.
|
||||
@@ -1474,13 +1529,117 @@ mod tests {
|
||||
"root",
|
||||
vec![BlueprintNode::Composite(c)],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }],
|
||||
vec![], // output
|
||||
);
|
||||
// the Ok arm holds Box<dyn Node> (not Debug), so assert via the Err arm.
|
||||
assert_eq!(bp.compile().err(), Some(CompileError::OutputPortOutOfRange));
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consume_of_missing_output_field_is_rejected() {
|
||||
// a single-field composite; a consumer reads from_field 1 (past the 1-field
|
||||
@@ -1877,7 +2036,12 @@ mod tests {
|
||||
let strategy = Composite::new(
|
||||
"strategy",
|
||||
vec![BlueprintNode::Composite(fast_slow), LinComb::builder(2).into()],
|
||||
vec![],
|
||||
// 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() }],
|
||||
);
|
||||
@@ -1885,7 +2049,7 @@ mod tests {
|
||||
"root",
|
||||
vec![BlueprintNode::Composite(strategy)],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }],
|
||||
vec![], // output
|
||||
);
|
||||
|
||||
|
||||
@@ -271,6 +271,32 @@ mod tests {
|
||||
assert_eq!(built_flat.sources, hand_flat.sources);
|
||||
}
|
||||
|
||||
#[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 })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_in_port_is_reported_at_build() {
|
||||
let mut g = GraphBuilder::new("x");
|
||||
|
||||
@@ -315,6 +315,28 @@ and params and across both graph levels. This does **not** close #21 (a swapped
|
||||
same-kind wiring is still only kind-checked) — it makes those slots
|
||||
self-documenting; a name-consuming validation is its own future cycle.
|
||||
|
||||
**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`).
|
||||
|
||||
### C9 — Fractal, acyclic composition
|
||||
**Guarantee.** A composite is itself a `Node` that wires a sub-graph and exposes
|
||||
one output; signal, combined signal, and (with execution) strategy are all the
|
||||
|
||||
Reference in New Issue
Block a user