From f3daded514c7b026601e65b5e0c6dc4223db983a Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 17 Jul 2026 23:18:00 +0200 Subject: [PATCH] feat(engine): compile resolves and hoists declared taps (#282) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compile core. resolve_tap_wire resolves a blueprint Tap's interior {node, field} through the lowering table exactly as OutField re-exports and edges resolve — a leaf to its remapped index (output-arity checked), a nested composite through its output remap. Taps are terminal (not re-exported through the boundary): a flat_taps accumulator threads through the lowering recursion, so an interior composite's taps hoist to the root FlatGraph.taps with remapped wires — the case the CLI single-run wrapper relies on. validate_wiring gains a tap leg (threaded at all three call sites incl. GraphSession::finish) and CompileError::TapWireOutOfRange names a bad wire. Root-resolve, out-of-arity, and nested-hoist are pinned green. refs #282 --- crates/aura-engine/src/blueprint.rs | 149 +++++++++++++++++++++- crates/aura-engine/src/construction.rs | 2 +- crates/aura-engine/src/harness.rs | 10 +- crates/aura-engine/src/lib.rs | 2 +- crates/aura-engine/tests/flat_taps_e2e.rs | 124 +++++++++++++----- 5 files changed, 244 insertions(+), 43 deletions(-) diff --git a/crates/aura-engine/src/blueprint.rs b/crates/aura-engine/src/blueprint.rs index 116b1f8..a17c99d 100644 --- a/crates/aura-engine/src/blueprint.rs +++ b/crates/aura-engine/src/blueprint.rs @@ -18,7 +18,7 @@ use aura_core::{ Cell, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Scalar, ScalarKind, }; -use crate::harness::{BootstrapError, Edge, FlatGraph, Harness, SourceSpec, Target}; +use crate::harness::{BootstrapError, Edge, FlatGraph, FlatTap, Harness, SourceSpec, Target}; use crate::sweep::sweep; use crate::{GridSpace, ParamRange, RandomSpace, RunReport, SweepFamily}; @@ -326,7 +326,7 @@ impl Composite { // structural validation, all pre-build (no node constructed): let space = self.param_space(); check_param_namespace_injective(&space)?; - validate_wiring(&self.nodes, &self.edges, &self.input_roles, &self.output)?; + validate_wiring(&self.nodes, &self.edges, &self.input_roles, &self.output, &self.taps)?; check_root_roles_bound(&self.input_roles)?; if point.len() != space.len() { @@ -345,6 +345,7 @@ impl Composite { let mut flat_nodes: Vec> = Vec::new(); let mut flat_signatures: Vec = Vec::new(); let mut flat_edges: Vec = Vec::new(); + let mut flat_taps: Vec = Vec::new(); let mut cursor = 0usize; let lowerings = lower_items( @@ -354,6 +355,7 @@ impl Composite { &mut flat_nodes, &mut flat_signatures, &mut flat_edges, + &mut flat_taps, )?; for e in &self.edges { @@ -373,12 +375,20 @@ impl Composite { flat_sources.push(SourceSpec::named(role.name.clone(), kind, targets)); } + // the root's own declared taps resolve through the root `lowerings`, same + // as any nested composite's — the root frame IS a composite frame, just + // the outermost one, so it hoists into the same accumulator. + for tap in &self.taps { + let (node, field) = resolve_tap_wire(&tap.from, &tap.name, &lowerings, &flat_signatures)?; + flat_taps.push(FlatTap { name: tap.name.clone(), node, field }); + } + Ok(FlatGraph { nodes: flat_nodes, signatures: flat_signatures, sources: flat_sources, edges: flat_edges, - taps: Vec::new(), + taps: flat_taps, }) } @@ -853,6 +863,9 @@ pub enum CompileError { DoubleWiredPort { node: usize, slot: usize }, /// A gang table failed structural validation at a minting boundary. BadGang(GangFault), + /// A declared tap's wire references a non-existent interior node, or a field + /// beyond that producer's output arity. + TapWireOutOfRange { tap: String }, } /// The typed detail of a `CompileError::BadGang` (one arm per `check_gangs` @@ -901,6 +914,7 @@ pub(crate) fn validate_wiring( edges: &[Edge], roles: &[Role], output: &[OutField], + taps: &[Tap], ) -> Result<(), CompileError> { // edges: index-range + producer/consumer kind match. The kind-mismatch variant // is the SAME one bootstrap returns today (Bootstrap(KindMismatch)), just raised @@ -932,12 +946,20 @@ pub(crate) fn validate_wiring( return Err(CompileError::OutputPortOutOfRange); } } + // taps: each declared tap's node index in range (structural leg only — deep + // field-arity is validated at resolve time by `resolve_tap_wire`, mirroring + // how the output leg above pins the node index). + for tap in taps { + if tap.from.node >= nodes.len() { + return Err(CompileError::TapWireOutOfRange { tap: tap.name.clone() }); + } + } // 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())?; + validate_wiring(c.nodes(), c.edges(), c.input_roles(), c.output(), c.taps())?; } } Ok(()) @@ -1158,6 +1180,7 @@ fn lower_items( flat_nodes: &mut Vec>, flat_signatures: &mut Vec, flat_edges: &mut Vec, + flat_taps: &mut Vec, ) -> Result, CompileError> { let mut lowerings = Vec::with_capacity(items.len()); for item in items { @@ -1181,6 +1204,7 @@ fn lower_items( flat_nodes, flat_signatures, flat_edges, + flat_taps, )?); } } @@ -1197,6 +1221,7 @@ fn inline_composite( flat_nodes: &mut Vec>, flat_signatures: &mut Vec, flat_edges: &mut Vec, + flat_taps: &mut Vec, ) -> Result { // `name` is the non-load-bearing render symbol (#13); it dissolves at inline // (C23 — the boundary does not reach the flat graph), so it is not destructured. @@ -1207,11 +1232,12 @@ fn inline_composite( // `gangs` is an authoring-time gate only (checked at `with_gangs`); it has // no runtime representation in the flat graph, same as `name`. `doc` is // the prose twin of `name` (#125) and dissolves alongside it. - let Composite { name: _, doc: _, nodes, edges, input_roles, output, taps: _, gangs: _ } = c; + let Composite { name: _, doc: _, nodes, edges, input_roles, output, taps, gangs: _ } = c; let item_count = nodes.len(); // recursively lower interior items, then rewrite interior edges through them - let interior = lower_items(nodes, point, cursor, flat_nodes, flat_signatures, flat_edges)?; + let interior = lower_items(nodes, point, cursor, flat_nodes, flat_signatures, flat_edges, flat_taps)?; + for e in &edges { for fe in rewrite_edge(e, &interior, flat_signatures)? { flat_edges.push(fe); @@ -1257,6 +1283,15 @@ fn inline_composite( roles.push(flat_targets); } + // this composite's own declared taps hoist into the flat, bottom-up + // accumulator now — a tap is terminal (no re-export through the boundary, + // unlike `output`), so it resolves through THIS level's freshly-lowered + // `interior` and is pushed once, never touched again by an enclosing level. + for tap in &taps { + let (node, field) = resolve_tap_wire(&tap.from, &tap.name, &interior, flat_signatures)?; + flat_taps.push(FlatTap { name: tap.name.clone(), node, field }); + } + Ok(ItemLowering::Composite { output: out, roles }) } @@ -1305,6 +1340,35 @@ fn resolve_target(t: &Target, lowerings: &[ItemLowering]) -> Result, } } +/// Resolve a declared tap's interior `{node, field}` into a flat producer +/// `(node, field)` through the lowering table — a Leaf resolves to its remapped +/// index with an output-arity check; a nested Composite resolves through its +/// `output` re-export remap. Mirrors the `OutField` resolution loop in +/// `inline_composite`. Taps do NOT re-export through the boundary (a tap is +/// terminal) — this is used both for a composite's own taps (resolved through its +/// freshly-lowered interior) and for the root's own taps (resolved through the +/// root `lowerings`). +fn resolve_tap_wire( + w: &TapWire, + name: &str, + lowerings: &[ItemLowering], + flat_signatures: &[NodeSchema], +) -> Result<(usize, usize), CompileError> { + let err = || CompileError::TapWireOutOfRange { tap: name.to_string() }; + if w.node >= lowerings.len() { + return Err(err()); + } + match &lowerings[w.node] { + ItemLowering::Leaf { index } => { + if w.field >= flat_signatures[*index].output.len() { + return Err(err()); + } + Ok((*index, w.field)) + } + ItemLowering::Composite { output, .. } => output.get(w.field).copied().ok_or_else(err), + } +} + /// The declared scalar kind of a flat node's input slot (for role kind-checking). fn slot_kind(t: Target, flat_signatures: &[NodeSchema]) -> Result { flat_signatures[t.node] @@ -3624,4 +3688,77 @@ mod tests { .with_taps(vec![Tap { name: "p_long".into(), from: TapWire { node: 3, field: 0 } }]); assert_eq!(tapped.taps(), &[Tap { name: "p_long".into(), from: TapWire { node: 3, field: 0 } }]); } + + /// A tap on a root leaf producer resolves to that producer's flat (node, field) — + /// the property `compile` must give a declared tap a load-bearing flat identity. + #[test] + fn compile_resolves_a_root_tap_to_flat_indices() { + let bp = Composite::new( + "m", + vec![Sub::builder().into()], + vec![], + vec![Role { + name: "a".into(), + targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }], + source: Some(ScalarKind::F64), + }], + vec![], + ) + .with_taps(vec![Tap { name: "d".into(), from: TapWire { node: 0, field: 0 } }]); + let flat = bp.compile_with_params(&[]).expect("compiles"); + assert_eq!(flat.taps, vec![crate::harness::FlatTap { name: "d".into(), node: 0, field: 0 }]); + } + + /// A tap wire naming a field beyond its producer's output arity is rejected — + /// `compile` must not silently resolve a tap into a bogus flat index. + #[test] + fn compile_rejects_a_tap_wire_out_of_output_arity() { + let bp = Composite::new( + "m", + vec![Sub::builder().into()], + vec![], + vec![Role { + name: "a".into(), + targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }], + source: Some(ScalarKind::F64), + }], + vec![], + ) + .with_taps(vec![Tap { name: "bad".into(), from: TapWire { node: 0, field: 9 } }]); // Sub has 1 output field + assert!(matches!(bp.compile_with_params(&[]), Err(CompileError::TapWireOutOfRange { .. }))); + } + + /// A tap declared on a NESTED composite hoists to the root `FlatGraph.taps` + /// with its wire remapped through the inline offset — the case the CLI + /// wrapper needs (a tap named deep in an authored blueprint must still surface + /// as a single flat, by-name-bindable measurement point at the root). + #[test] + fn compile_hoists_an_interior_composite_tap_to_the_root() { + let inner = Composite::new( + "inner", + vec![Sub::builder().into()], + vec![], + vec![Role { + name: "x".into(), + targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }], + source: None, + }], + vec![OutField { node: 0, field: 0, name: "o".into() }], + ) + .with_taps(vec![Tap { name: "inner_d".into(), from: TapWire { node: 0, field: 0 } }]); + let root = Composite::new( + "root", + vec![BlueprintNode::Composite(inner)], + vec![], + vec![Role { + name: "a".into(), + targets: vec![Target { node: 0, slot: 0 }], + source: Some(ScalarKind::F64), + }], + vec![], + ); + let flat = root.compile_with_params(&[]).expect("compiles"); + // the inner Sub lowered to flat node 0; the hoisted tap points at it + assert_eq!(flat.taps, vec![crate::harness::FlatTap { name: "inner_d".into(), node: 0, field: 0 }]); + } } diff --git a/crates/aura-engine/src/construction.rs b/crates/aura-engine/src/construction.rs index ffa93e7..8f258e0 100644 --- a/crates/aura-engine/src/construction.rs +++ b/crates/aura-engine/src/construction.rs @@ -399,7 +399,7 @@ impl<'v> GraphSession<'v> { .with_gangs(self.gangs) .map_err(OpError::Incomplete)?; check_param_namespace_injective(&c.param_space()).map_err(OpError::Incomplete)?; - validate_wiring(c.nodes(), c.edges(), c.input_roles(), c.output()).map_err(|e| match e { + validate_wiring(c.nodes(), c.edges(), c.input_roles(), c.output(), c.taps()).map_err(|e| match e { CompileError::UnconnectedPort { node, slot } => OpError::UnconnectedPort { node: self.ids[node].clone(), slot: self.schemas[node].inputs[slot].name.clone(), diff --git a/crates/aura-engine/src/harness.rs b/crates/aura-engine/src/harness.rs index 96a8415..72cc167 100644 --- a/crates/aura-engine/src/harness.rs +++ b/crates/aura-engine/src/harness.rs @@ -288,12 +288,10 @@ impl SyntheticSpec { /// A resolved measurement tap in the flat graph: a name paired with a flat /// producer `(node, output-field)`. The output-side twin of `SourceSpec` — its -/// `name` is meant to survive compile and be load-bearing for a future -/// by-name binding step (like `SourceSpec.role`, #275), planned as `bind_tap` -/// in a later slice of #282. This slice only carries the type; `compile` -/// resolves no `Tap`s yet and always emits an empty `taps` list — the -/// lowering remap that would populate it from a blueprint `Tap` lands with -/// `bind_tap`. +/// `name` survives compile and is load-bearing for by-name binding (like +/// `SourceSpec.role`, #275). `compile` resolves each blueprint `Tap` into a +/// `FlatTap` through the same lowering remap that hoists edges/`OutField` +/// re-exports (interior-composite taps hoist to the root list). #[derive(Clone, Debug, PartialEq, Eq)] pub struct FlatTap { pub name: String, diff --git a/crates/aura-engine/src/lib.rs b/crates/aura-engine/src/lib.rs index abdafcc..452bfaf 100644 --- a/crates/aura-engine/src/lib.rs +++ b/crates/aura-engine/src/lib.rs @@ -65,7 +65,7 @@ pub use builder::{BuildError, GraphBuilder, InPort, NodeHandle, OutPort, RoleHan pub use construction::{replay, GraphSession, Op, OpError}; pub use graph_model::model_to_json; pub use harness::{ - window_of, bind_sources, BootstrapError, Edge, FlatGraph, Harness, Source, SourceBindError, + window_of, bind_sources, BootstrapError, Edge, FlatGraph, FlatTap, Harness, Source, SourceBindError, SourceSpec, SplitMix64, SyntheticSpec, Target, VecSource, }; pub use report::{ diff --git a/crates/aura-engine/tests/flat_taps_e2e.rs b/crates/aura-engine/tests/flat_taps_e2e.rs index 72a3a58..4d65482 100644 --- a/crates/aura-engine/tests/flat_taps_e2e.rs +++ b/crates/aura-engine/tests/flat_taps_e2e.rs @@ -1,19 +1,16 @@ -//! End-to-end coverage for the `FlatTap` / `FlatGraph.taps` plumbing (#282, -//! "thread all literal sites"): a `Composite` can declare a `Tap` (via -//! `with_taps`), but this slice adds only the flat-graph *carrier* — no -//! resolution step exists yet. These tests pin the two halves of that -//! contract at the public `aura_engine` boundary: `compile()` never surfaces -//! a declared `Tap` into the flat graph it hands to `Harness::bootstrap` -//! (`compile_never_resolves_declared_taps_yet`), and the field's presence is -//! otherwise perfectly transparent to compilation of the rest of the graph -//! (`tapped_composite_compiles_identically_to_untapped_besides_the_taps_list`) -//! — a future resolving slice (`bind_tap`) is expected to flip the first of -//! these; until it does, a regression that started eagerly resolving taps, -//! or one that let a `Tap` perturb node/edge flattening, would be caught -//! here. +//! End-to-end coverage for the `FlatTap` / `FlatGraph.taps` plumbing (#282): +//! a `Composite` can declare a `Tap` (via `with_taps`), and `compile()` now +//! resolves it into the flat graph it hands to `Harness::bootstrap`. These +//! tests pin the two halves of that contract at the public `aura_engine` +//! boundary: `compile()` surfaces a declared `Tap` into `FlatGraph.taps`, +//! resolved to its flat producer `(node, field)` +//! (`compile_resolves_a_declared_tap_at_the_public_boundary`), and doing so +//! is otherwise transparent to compilation of the rest of the graph — the +//! same node/edge structure, just an added taps entry +//! (`tapped_composite_compiles_identically_to_untapped_besides_the_taps_list`). use aura_core::ScalarKind; -use aura_engine::{Composite, OutField, Role, Tap, TapWire, Target}; +use aura_engine::{BlueprintNode, CompileError, Composite, FlatTap, OutField, Role, Tap, TapWire, Target}; use aura_std::Sub; /// A trivial one-node composite with a single declared output (`Sub`'s @@ -35,28 +32,29 @@ fn leaf(taps: Vec) -> Composite { .with_taps(taps) } -/// Property: **`compile()` resolves no `Tap`s in this slice.** A composite -/// declaring a valid `Tap` (naming a real interior producer, `node: 0, field: -/// 0`) still compiles to a `FlatGraph` whose `taps` list is empty — the -/// declaration is carried on the `Composite` only; nothing yet lowers it into -/// the flat `FlatTap` the run loop could someday read. If a later change -/// started eagerly resolving `Tap`s into `FlatGraph.taps` inside `compile()` -/// without an explicit opt-in (`bind_tap`), this test would flip green->red, -/// flagging the design-changing move for the ledger instead of it happening -/// silently. +/// Property: **`compile()` resolves a declared `Tap` at the public +/// boundary.** A composite declaring a valid `Tap` (naming a real interior +/// producer, `node: 0, field: 0`) compiles to a `FlatGraph` whose `taps` list +/// carries the resolved `FlatTap` — name preserved, wire resolved to the flat +/// producer index. #[test] -fn compile_never_resolves_declared_taps_yet() { +fn compile_resolves_a_declared_tap_at_the_public_boundary() { let tapped = leaf(vec![Tap { name: "spread".into(), from: TapWire { node: 0, field: 0 } }]); - let flat = tapped.compile().expect("a tap-bearing composite still compiles"); - assert!(flat.taps.is_empty(), "compile() must not resolve declared Taps in this slice: {:?}", flat.taps); + let flat = tapped.compile().expect("a tap-bearing composite compiles"); + assert_eq!( + flat.taps, + vec![FlatTap { name: "spread".into(), node: 0, field: 0 }], + "compile() must resolve the declared Tap into FlatGraph.taps: {:?}", + flat.taps + ); } /// Property: **a declared `Tap` is otherwise inert at compile time** — it /// changes nothing about the flattened node/edge structure. Compiling the /// same composite with and without a `Tap` declaration must produce /// structurally identical flat graphs (same node count, same edges); the -/// only difference tap-bearing scaffolding is *allowed* to make is the (here, -/// still-empty) `taps` list itself. +/// only difference tap-bearing scaffolding is allowed to make is the `taps` +/// list itself (empty vs. carrying the one resolved entry). #[test] fn tapped_composite_compiles_identically_to_untapped_besides_the_taps_list() { let untapped = leaf(vec![]).compile().expect("untapped compiles"); @@ -66,5 +64,73 @@ fn tapped_composite_compiles_identically_to_untapped_besides_the_taps_list() { assert_eq!(untapped.nodes.len(), tapped.nodes.len(), "tap declaration must not add/remove flat nodes"); assert_eq!(untapped.edges, tapped.edges, "tap declaration must not perturb flat edges"); - assert_eq!(untapped.taps, tapped.taps, "both compile to the same (empty) taps list in this slice"); + assert!(untapped.taps.is_empty(), "the untapped composite compiles to an empty taps list"); + assert_eq!( + tapped.taps, + vec![FlatTap { name: "spread".into(), node: 0, field: 0 }], + "the tapped composite compiles to the one resolved entry" + ); +} + +/// Property: **the `validate_wiring` structural leg rejects a tap whose wire +/// names a node index beyond the composite's own node count**, before any +/// lowering or field-arity resolution ever runs — mirroring how the sibling +/// `output` leg raises `OutputPortOutOfRange` for the same shape of mistake. +/// This is the early, cheap structural gate (`tap.from.node >= nodes.len()`) +/// added alongside tap resolution in this iteration; without it, a +/// wildly-out-of-range tap wire would only be caught by luck at whichever +/// later stage happens to index into a lowering table, not deterministically +/// at compile's first validation pass. +#[test] +fn compile_rejects_a_tap_wire_node_out_of_range() { + let tapped = leaf(vec![Tap { name: "ghost".into(), from: TapWire { node: 1, field: 0 } }]); + match tapped.compile() { + Err(CompileError::TapWireOutOfRange { tap }) => assert_eq!(tap, "ghost"), + other => panic!("expected TapWireOutOfRange{{tap: \"ghost\"}}, got {:?}", other.map(|_| ())), + } +} + +/// Property: **a `Tap` declared on a nested composite hoists all the way to +/// the root `FlatGraph.taps`, with its wire remapped through the inline +/// offset** accumulated by every flat node lowered ahead of it — not just +/// carried through unresolved, and not resolved to its interior-local index. +/// The root here lowers a plain leaf node first (flat index 0) followed by +/// the tapped composite (whose interior `Sub` therefore lands at flat index +/// 1, not 0), so a remap that silently stayed at the interior-local index — +/// or hoisted at all — is caught by asserting the hoisted `FlatTap` points at +/// index 1. This is the exact shape the by-name tap-binding CLI wrapper +/// needs: a tap declared arbitrarily deep in an authored blueprint must +/// surface as one flat, addressable measurement point at the root. +#[test] +fn compile_hoists_a_nested_composite_tap_to_the_root_with_remapped_index() { + let inner = Composite::new( + "inner", + vec![Sub::builder().into()], + vec![], + vec![Role { name: "x".into(), targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }], source: None }], + vec![OutField { node: 0, field: 0, name: "o".into() }], + ) + .with_taps(vec![Tap { name: "inner_d".into(), from: TapWire { node: 0, field: 0 } }]); + + let root = Composite::new( + "root", + vec![Sub::builder().into(), BlueprintNode::Composite(inner)], + vec![], + vec![ + Role { + name: "lead".into(), + targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }], + source: Some(ScalarKind::F64), + }, + Role { name: "a".into(), targets: vec![Target { node: 1, slot: 0 }], source: Some(ScalarKind::F64) }, + ], + vec![], + ); + + let flat = root.compile().expect("root with a nested tapped composite compiles"); + assert_eq!( + flat.taps, + vec![FlatTap { name: "inner_d".into(), node: 1, field: 0 }], + "the inner tap must hoist to root FlatGraph.taps, remapped to the inline-offset flat node index" + ); }