From 592750b122a8ef1970ad882eab8289125f9b172a Mon Sep 17 00:00:00 2001 From: claude Date: Sat, 18 Jul 2026 16:42:39 +0200 Subject: [PATCH] =?UTF-8?q?audit:=20close=20#284/#285=20cycle=20=E2=80=94?= =?UTF-8?q?=20GraphBuilder::tap=20twin=20restores=20C24=20symmetry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cycle-close tidy for the #284 (tap construction op) + #285 (docs) cycle. The architect drift review (cb6211c..HEAD) came back drift-clean on the ledger contracts the cycle touched — C27 machinery untouched, a faithful `expose` twin (C25 preserved), lockstep op<->OpDoc / OpError<->format_op_error arms paired, and the #285 docs verified accurate against aura-std/src/session.rs — save one drift item, fixed here. Drift item (fixed): the op-script gained `Op::Tap` but `GraphBuilder` had no `tap()` twin, softening C24 (replay-equals-builder = one construction semantics) and C17 (builder-primacy) — every other op verb has a builder twin. Unlike the op-script's `doc` gap (a ledger-recorded #125 scope cut), this asymmetry had no rationale; a tap is a first-class authoring act on both surfaces, so denying only the fluent builder was an accident of incremental delivery, not a design cut. Fix: `GraphBuilder::tap` (builder.rs), the exact output-side mirror of `expose` — a `taps: Vec<(String, OutPort)>` accumulator resolved at `build()` into `Tap { name, from: TapWire }` and threaded via `.with_taps`. Plus a construction.rs lockstep test `tap_replay_matches_builder` proving the op-script `Op::Tap` replay and the `GraphBuilder::tap` twin serialize to byte-identical blueprint JSON and compile to identical `FlatGraph.taps` — the tap op is the by-identifier face of the same construction semantics, not a second one. Carry-on (architect debt-low, no action): commit 43a1cc4's body phrase "op-built composites silently lost their taps" over-claims a prior drop (no tap op existed pre-cycle); the "latent" qualifier keeps it honest, so the wording is left as-is rather than rewriting an already-landed commit. Verification: full `cargo test --workspace` green; `cargo clippy --workspace --all-targets -- -D warnings` clean. The bench is report-only by project convention and was not run — this cycle adds a construction-time op + docs, no hot-path change. Fieldtest: the #284 `tap` surface is exercised from the public interface by two E2E consumer-path tests (op-script -> `aura graph build` -> `aura run` -> persisted trace) plus two worked authoring examples hand-verified through the built binary; #285 is documentation. The per-cycle fieldtest is satisfied. Spec + plan working files (docs/specs/tap-construction-op.md, docs/plans/tap-construction-op.md) discarded per audit Step 5 (git-ignored, never committed; their design intent lives in the commit bodies + the ledger). --- crates/aura-engine/src/builder.rs | 23 +++++++++++- crates/aura-engine/src/construction.rs | 52 ++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) 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); + } }