audit: close #284/#285 cycle — GraphBuilder::tap twin restores C24 symmetry

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).
This commit is contained in:
2026-07-18 16:42:39 +02:00
parent a5e02ff541
commit 592750b122
2 changed files with 73 additions and 2 deletions
+21 -2
View File
@@ -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<ScalarKind>, Vec<InPort>)>,
out: Vec<(String, OutPort)>,
taps: Vec<(String, OutPort)>,
gangs: Vec<(String, Vec<ParamRef>)>,
}
@@ -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<Item = ParamRef>) {
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);
}
+52
View File
@@ -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(&params).expect("replay compiles");
let built_flat = built.compile_with_params(&params).expect("builder compiles");
assert_eq!(replay_flat.taps, built_flat.taps);
assert_eq!(replay_flat.edges, built_flat.edges);
}
}