Files
Aura/docs/specs/0040-wiring-totality-check.md
T
Brummel 21c1621bd0 docs,engine: drop coined "compilat", use FlatGraph / "flat graph"
"Compilat" (German "Kompilat") was a coined noun for the product of the
bootstrap compilation — neither English nor a natural fit. The runtime
artifact already has a code identifier for exactly this thing: the
`FlatGraph` struct (harness.rs). Replace the coinage with that identifier:

- prose mentions          -> "flat graph" (mirrors the type, reads plainly)
- definitional anchors    -> `FlatGraph` (C11, C23, the running-graph line)
- `render_compilat`       -> `render_flat_graph` (historical render symbol;
                             keeps the `render_blueprint` / `render_flat_graph`
                             source-vs-product pairing)
- `intra-compilat`        -> `intra-graph`
- `from_compilat` (test)  -> `from_flat`

"compilation" / "re-compilation" / "bootstrap-as-compilation" (the process,
ordinary English) are deliberately left untouched. Behaviour-preserving:
only comments, design ledger, specs/plans, one test-local variable and its
assert messages change. Full workspace test suite green; clippy clean.
2026-06-14 17:02:15 +02:00

18 KiB

Wiring-totality check: reject unwired or double-wired input ports — Design Spec

Date: 2026-06-14 Status: Draft — awaiting user spec review Authors: orchestrator + Claude

Narrowed scope of issue #65 (see the reconciliation comment on that issue). The original "promote names to load-bearing wiring keys / close the exposure-price swap structurally" framing is dropped: #64 (the typed-handle, named-port GraphBuilder, commit a6a314b) already moved authoring to handles + visible port names, so the residual same-kind swap is a valid-but-wrong name choice no structural check can catch, and a structural fix (per-port semantic tags) would push domain semantics into the deliberately domain-free engine (C10/C7). This cycle ships only the name-free half that stands on its own.

Goal

Make a graph that leaves an interior input slot unwired, or wires one slot from more than one producer, a compile-time error instead of a silently-wrong run. Today an unwired input slot is bootstrapped with an empty column (crates/aura-engine/src/harness.rs:138-148: every declared slot gets an AnyColumn and a SlotState { last_ts: i64::MIN }, fresh only when fed) — so a forgotten connection runs a deterministic but wrong backtest, and two producers into one slot is ill-formed (a slot holds exactly one column) yet uncompiled- against. A single new structural check at the existing compile boundary closes both, for both authoring surfaces, without touching the flat graph (C23).

Non-goal (explicitly out of scope, dropped this cycle): promoting port/field/node names to load-bearing wiring keys; node-name or port/field-name uniqueness; per-port semantic tags; and any claim of closing the SimBroker exposure/price swap. This spec is purely about wiring totality (every required slot wired exactly once), an index-based property.

Architecture

The check lives in one place: validate_wiring (crates/aura-engine/src/blueprint.rs:496), the pre-build structural validation that Composite::compile_with_params already calls once (blueprint.rs:201) and that already recurses into nested composites (blueprint.rs:540-543). A new check_ports_connected(nodes, edges, roles) added to that function therefore runs at every nesting level automatically, and — because the typed GraphBuilder lowers to Composite::new and is compiled through the same compile_with_params (no separate compile path) — it is inherited identically by the raw Composite::new path and the GraphBuilder::build() path with zero builder-side code. This is the same single-site, both-surfaces shape check_param_namespace_injective (blueprint.rs:409) already uses for the param address space; this is its wiring-side sibling.

Coverage is counted uniformly across the two wiring mechanisms: an interior Edge { to, slot } and a role Target { node, slot } each contribute one unit of coverage to the pair (node_index, slot). A composite's own input roles (Role, blueprint.rs:120) are coverage providers — their targets feed interior slots — never consumers: a composite's own open roles (source: None) are the legitimate "unwired here, wired by the enclosing graph" boundary and are not themselves subject to the rule; the root case is already guarded by the existing UnboundRootRole check (blueprint.rs:202-205). So the rule is scoped to interior nodes' input slots, and a role's source field is irrelevant to coverage counting (root-bound and nested-open roles count their targets the same).

The check is index-based and name-free; it reads only signature().inputs.len() and the existing index-wired Edge/Target, and emits nothing into the FlatGraph. C23 is untouched: it proves the existing raw-index wiring is total (every slot ≥1) and single-valued (every slot ≤1) without introducing a name.

Concrete code shapes

Worked acceptance example (criterion evidence) — a forgotten leg now fails fast

The empirical evidence the criterion demands: the SimBroker harness an author writes via the GraphBuilder, with the exposure leg forgotten. Today this compiles and runs a wrong equity curve (broker slot 0 fed an empty column); after this cycle it is rejected at compile.

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: the exposure producer is never connected to broker.input("exposure").
// (g.connect(expo.output("exposure"), broker.input("exposure")) is missing.)
g.connect(broker.output("equity"), eq.input("col[0]"));

// Every NAME resolves (both ports exist), so the builder succeeds:
let composite = g.build().expect("all port/field names resolve");
// The wiring-totality check fires at compile: broker's exposure slot is uncovered.
let compiled = composite.compile_with_params(&params);
assert!(matches!(compiled, Err(CompileError::UnconnectedPort { .. })));

The complementary double-wire, on the raw Composite::new surface (showing the same check guards the non-builder path) — one slot fed by an edge and a role target:

// broker input slot 1 ("price") is targeted by BOTH a role target and an edge.
let bp = Composite::new(
    "root",
    vec![/* ... exposure producer @0, broker @1, ... */],
    vec![Edge { from: 0, to: 1, slot: 1, from_field: 0 }],     // edge -> (broker, slot 1)
    vec![Role { name: "price".into(),
                targets: vec![Target { node: 1, slot: 1 }],     // role ALSO -> (broker, slot 1)
                source: Some(ScalarKind::F64) }],
    vec![/* output */],
);
assert!(matches!(bp.compile_with_params(&params),
                 Err(CompileError::DoubleWiredPort { node: 1, slot: 1 })));

Before → after: the new check (the load-bearing change)

New function, mirroring check_param_namespace_injective's single-pass shape:

/// 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 (runs a silent empty column); >1 = an ill-formed slot
/// (one slot holds one column). Index-based, name-free; presupposes in-range
/// edge/role indices (run after the existing index-range checks).
fn check_ports_connected(
    nodes: &[BlueprintNode],
    edges: &[Edge],
    roles: &[Role],
) -> Result<(), CompileError> {
    let mut coverage: HashMap<(usize, usize), usize> = 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(())
}

Call site, inside validate_wiring — after the existing edge/role/output index-range + kind checks (so all indices are known in-range) and before the nested-composite recursion, so it runs once per level:

fn validate_wiring(nodes, edges, roles, output) -> Result<(), CompileError> {
    // ... existing edge index+kind checks (blueprint.rs:506-517) ...
    // ... existing role target index+kind checks (blueprint.rs:520-531) ...
    // ... existing output re-export index checks (blueprint.rs:533-538) ...

    check_ports_connected(nodes, edges, roles)?;   // <-- NEW: totality, this level

    for item in nodes {                            // existing recursion (540-543)
        if let BlueprintNode::Composite(c) = item {
            validate_wiring(c.nodes(), c.edges(), c.input_roles(), c.output())?;
        }
    }
    Ok(())
}

Before → after: two new CompileError variants

pub enum CompileError {
    // ... existing variants (BadInteriorIndex, RoleKindMismatch, ...
    //     DuplicateParamPath, UnboundRootRole) unchanged ...

    /// 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 },
}

Components

  • check_ports_connected (new, crates/aura-engine/src/blueprint.rs) — the coverage check above. Pure, pre-build, reads signature() only.
  • validate_wiring (modified, blueprint.rs:496) — one new call after the index-range checks, before the recursion.
  • CompileError (modified, blueprint.rs:465) — two new variants. The enum derives Debug, PartialEq, Eq; the new variants carry only usize fields, so the derives still hold (no manual impl).
  • Design ledger (docs/design/INDEX.md) — a small realization note under C8 recording the new structural invariant (text below). No C23 amendment: names are not involved.

Ledger amendment (under C8, after the cycle-0027 realization block)

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 flat graph, 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).

Data flow

Composite::compile_with_paramsvalidate_wiring(nodes, edges, roles, output) → [existing index/kind checks] → check_ports_connected(nodes, edges, roles) → [recurse per nested composite] → (on Ok) UnboundRootRole check → lower_itemsFlatGraph. The new check is a pure read over the same three slices validate_wiring already holds; it adds no data to the flat graph and no work to the run loop.

Error handling

Two new typed CompileError variants, each carrying the offending (node, slot) (per-level interior node index — the same addressing convention BadInteriorIndex-adjacent code uses). They surface from compile_with_params exactly like the existing construction-phase faults. No panics, no new error channel. When multiple slots are faulty, the first encountered (lowest node index, then lowest slot) is returned — deterministic by iteration order; callers that need all faults are out of scope (the existing variants are first-fault too).

Testing strategy

  1. Unwired interior slot → UnconnectedPort. Build the forgotten-exposure-leg harness (the worked example above, builder surface) and a raw Composite::new variant; assert compile_with_params(&params) is Err(CompileError::UnconnectedPort { .. }) with the expected (node, slot).
  2. Double-wired slot → DoubleWiredPort. The raw double-wire above (edge + role into one slot) and an edge+edge variant; assert Err(CompileError::DoubleWiredPort { node, slot }).
  3. Nested-level enforcement. A composite whose interior leaves a slot unwired, nested inside a root whose own wiring is complete; assert the nested fault still surfaces (the recursion carries the check down).
  4. Open-role boundary stays legal. A composite with an open input role (source: None) whose target feeds an interior slot compiles when used as a nested node with that role wired by the enclosing graph — the role-as-provider path is not mis-flagged as an unwired slot.
  5. Regression — valid graphs still compile. The canonical harness (crates/aura-engine/src/test_fixtures.rs) and the builder harness (builder_harness_compiles_identically_to_hand_wired) still compile green; the aura-cli sample / sweep / param_space / render tests stay green.
  6. C23 regression — flat graph byte-identical. For any graph that still compiles, the lowered FlatGraph is unchanged (the existing identity test covers this; the check adds nothing to lowering).

Blast radius (empirically enumerated — fixed in this iteration, not deferred)

The authoritative blast radius was determined by a throwaway probe (the check implemented, cargo test --workspace --no-fail-fast run, then discarded): exactly four currently-green tests flip, all in crates/aura-engine/src/blueprint.rs (119 passed, 4 failed; every other crate — including the aura-cli sample/sweep/param_space/ render tests — stays green). Three are negative tests that wrap a single-role composite c in a root with empty edges and empty roles (vec![BlueprintNode::Composite(c)], vec![], vec![], vec![]), leaving the root's one interior node (a composite exposes one derived input port per role, blueprint.rs:76-99) uncovered, so check_ports_connected — running at the root level before the recursion — returns UnconnectedPort { node: 0, slot: 0 } and pre-empts the deeper fault each targets:

  • bad_interior_index_rejected (~1372) — asserts BadInteriorIndex (a fault inside c).
  • role_kind_mismatch_rejected (~1440) — asserts RoleKindMismatch { role: 0 }.
  • output_port_out_of_range_rejected (~1463) — asserts OutputPortOutOfRange.

The fourth is a compile-success param-order test, param_space_mirrors_compiled_flat_node_param_order_under_nesting (~1856), which today compiles a deliberately under-wired nested graph just to read its flat param order: the inner strategy composite (~1877) holds [Composite(fast_slow), LinComb(2)] but wires neither of LinComb's two term inputs, and the root (~1885) wraps strategy with empty edges/roles, leaving strategy's price input uncovered too. The new check rejects it (UnconnectedPort) where it currently returns Ok.

Resolution (in this iteration):

  • The three negative tests: give each root a covering input Role whose target is c's input slot (Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }, mirroring the existing covering-role pattern at ~1414). The root level then passes and the recursion surfaces the intended deep fault (the index/kind/output checks run before c's own connectivity check), so each STILL asserts its original variant.
  • The fourth (param-order) test: fully wire the contrived graph — fan fast_slow's output into both LinComb terms (Edge { from: 0, to: 1, slot: 0, from_field: 0 } and Edge { from: 0, to: 1, slot: 1, from_field: 0 } in strategy) and add a covering root role for strategy's price input. Wiring does not change node order or params, so the asserted param_space == compiled-flat-param-order equality is unchanged; only the graph becomes valid.

These fixtures merely relied incidentally on under-wiring being accepted, which this cycle makes invalid — the load-bearing test maintenance a new structural invariant entails.

Two adjacent negative tests are safe by ordering and need no change: compile_rejects_kind_mismatch_without_building (~2013) — the edge kind-check fires before check_ports_connected; and unbound_root_role_is_rejected (~2042) — its root slot is covered by its role target, and UnboundRootRole is checked after validate_wiring returns. The fully-wired Composite-wrap tests (blueprint.rs ~1146, 1247, 1285, 1292, 1337) all carry a covering root role/edge and stay green.

Acceptance criteria

  • check_ports_connected exists in validate_wiring; the two CompileError variants exist; both the raw Composite::new and the GraphBuilder::build() surfaces reject an unwired and a double-wired slot (proved by a negative test on each surface for at least UnconnectedPort, and at least one DoubleWiredPort test).
  • The nested-level test (3) and the open-role-boundary test (4) pass.
  • All four empirically-enumerated blast-radius tests are fixed and green: the three negative tests (bad_interior_index_rejected, role_kind_mismatch_rejected, output_port_out_of_range_rejected) re-wired with a covering root role and still asserting their original deep-fault variant; and the param-order test (param_space_mirrors_compiled_flat_node_param_order_under_nesting) fully wired and still asserting its param_space == compiled-flat-param-order equality.
  • The full existing suite stays green (regression 5) and the flat graph is byte-identical for graphs that still compile (regression 6).
  • The C8 ledger realization note is landed.
  • cargo test --workspace green; cargo clippy --workspace --all-targets -D warnings clean.