diff --git a/crates/aura-engine/src/builder.rs b/crates/aura-engine/src/builder.rs index c964611..4474a9b 100644 --- a/crates/aura-engine/src/builder.rs +++ b/crates/aura-engine/src/builder.rs @@ -10,7 +10,9 @@ use aura_core::{NodeSchema, ScalarKind}; -use crate::blueprint::{BlueprintNode, CompileError, Composite, Gang, GangFault, GangMember, OutField, Role}; +use crate::blueprint::{ + BlueprintNode, CompileError, Composite, Gang, GangFault, GangMember, OutField, Role, Tap, TapWire, +}; use crate::harness::{Edge, Target}; /// A typed, `Copy` reference to a node added to a [`GraphBuilder`], carrying the @@ -92,6 +94,7 @@ pub struct GraphBuilder { edges: Vec<(OutPort, InPort)>, roles: Vec<(String, Option, Vec)>, out: Vec<(String, OutPort)>, + taps: Vec<(String, OutPort)>, gangs: Vec<(String, Vec)>, } @@ -106,6 +109,7 @@ impl GraphBuilder { edges: Vec::new(), roles: Vec::new(), out: Vec::new(), + taps: Vec::new(), gangs: Vec::new(), } } @@ -149,6 +153,15 @@ impl GraphBuilder { self.out.push((name.to_string(), from)); } + /// Declare a measurement tap on a producer's output field under `name` + /// (resolved at build) — the output-side twin of [`GraphBuilder::expose`]: a + /// recorded observation point (a `Composite.taps` entry, #284/C27), not a + /// boundary output. The builder-surface counterpart of the op-script `tap` op, + /// so a Rust-authored graph reaches taps the same way an op-script does (C24). + pub fn tap(&mut self, from: OutPort, name: &str) { + self.taps.push((name.to_string(), from)); + } + /// Fuse two or more sibling params into one public knob (resolved at build). pub fn gang(&mut self, name: &str, members: impl IntoIterator) { self.gangs.push((name.to_string(), members.into_iter().collect())); @@ -185,6 +198,12 @@ impl GraphBuilder { output.push(OutField { node: from.node, field, name: name.clone() }); } + let mut taps = Vec::with_capacity(self.taps.len()); + for (name, from) in &self.taps { + let field = self.resolve_field(from)?; + taps.push(Tap { name: name.clone(), from: TapWire { node: from.node, field } }); + } + let mut gangs = Vec::with_capacity(self.gangs.len()); for (name, refs) in &self.gangs { let mut members = Vec::with_capacity(refs.len()); @@ -230,7 +249,7 @@ impl GraphBuilder { let kind = kind.unwrap_or(ScalarKind::F64); gangs.push(Gang { name: name.clone(), kind, members }); } - let mut composite = Composite::new(self.name, self.nodes, edges, roles, output); + let mut composite = Composite::new(self.name, self.nodes, edges, roles, output).with_taps(taps); if let Some(doc) = self.doc { composite = composite.with_doc(doc); } diff --git a/crates/aura-engine/src/construction.rs b/crates/aura-engine/src/construction.rs index 74e842f..ed48c31 100644 --- a/crates/aura-engine/src/construction.rs +++ b/crates/aura-engine/src/construction.rs @@ -1096,4 +1096,56 @@ mod tests { assert_eq!(replay_flat.edges, built_flat.edges); assert_eq!(replay_flat.sources, built_flat.sources); } + + /// C24 lockstep extended to taps (#284): the op-script's `Op::Tap` replay and + /// the `GraphBuilder::tap` twin serialize to byte-identical blueprint JSON and + /// compile to byte-identical `FlatGraph.taps` — the tap op is the by-identifier + /// face of the same construction semantics `GraphBuilder::build` runs, not a + /// second one. Guards the builder/op-script tap symmetry (both surfaces can + /// declare a tap, and they resolve it identically). + #[test] + fn tap_replay_matches_builder() { + use crate::blueprint_serde::blueprint_to_json; + use crate::GraphBuilder; + use aura_core::Scalar; + use aura_std::{Sma, Sub}; + + // (a) op-script: an SMA crossover with a tap on the raw spread (sub.value). + let ops = vec![ + Op::Source { role: "price".into(), kind: ScalarKind::F64 }, + Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] }, + Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] }, + Op::Add { type_id: "Sub".into(), as_name: Some("sub".into()), bind: vec![] }, + Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] }, + Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() }, + Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() }, + Op::Tap { from: "sub.value".into(), as_name: "spread".into() }, + Op::Expose { from: "sub.value".into(), as_name: "bias".into() }, + ]; + let replayed = super::replay("g", ops, &std_vocabulary).expect("replays"); + + // (b) the GraphBuilder twin — same adds/feeds/connects + g.tap(...) / g.expose(...). + let mut g = GraphBuilder::new("g"); + let fast = g.add(Sma::builder().named("fast")); + let slow = g.add(Sma::builder().named("slow")); + let sub = g.add(Sub::builder().named("sub")); + let price = g.source_role("price", ScalarKind::F64); + g.feed(price, [fast.input("series"), slow.input("series")]); + g.connect(fast.output("value"), sub.input("lhs")); + g.connect(slow.output("value"), sub.input("rhs")); + g.tap(sub.output("value"), "spread"); + g.expose(sub.output("value"), "bias"); + let built = g.build().expect("root resolves"); + + assert_eq!( + blueprint_to_json(&replayed).expect("replay serializes"), + blueprint_to_json(&built).expect("builder serializes"), + ); + + let params = [Scalar::i64(2), Scalar::i64(4)]; + let replay_flat = replayed.compile_with_params(¶ms).expect("replay compiles"); + let built_flat = built.compile_with_params(¶ms).expect("builder compiles"); + assert_eq!(replay_flat.taps, built_flat.taps); + assert_eq!(replay_flat.edges, built_flat.edges); + } }