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:
@@ -10,7 +10,9 @@
|
|||||||
|
|
||||||
use aura_core::{NodeSchema, ScalarKind};
|
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};
|
use crate::harness::{Edge, Target};
|
||||||
|
|
||||||
/// A typed, `Copy` reference to a node added to a [`GraphBuilder`], carrying the
|
/// A typed, `Copy` reference to a node added to a [`GraphBuilder`], carrying the
|
||||||
@@ -92,6 +94,7 @@ pub struct GraphBuilder {
|
|||||||
edges: Vec<(OutPort, InPort)>,
|
edges: Vec<(OutPort, InPort)>,
|
||||||
roles: Vec<(String, Option<ScalarKind>, Vec<InPort>)>,
|
roles: Vec<(String, Option<ScalarKind>, Vec<InPort>)>,
|
||||||
out: Vec<(String, OutPort)>,
|
out: Vec<(String, OutPort)>,
|
||||||
|
taps: Vec<(String, OutPort)>,
|
||||||
gangs: Vec<(String, Vec<ParamRef>)>,
|
gangs: Vec<(String, Vec<ParamRef>)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +109,7 @@ impl GraphBuilder {
|
|||||||
edges: Vec::new(),
|
edges: Vec::new(),
|
||||||
roles: Vec::new(),
|
roles: Vec::new(),
|
||||||
out: Vec::new(),
|
out: Vec::new(),
|
||||||
|
taps: Vec::new(),
|
||||||
gangs: Vec::new(),
|
gangs: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,6 +153,15 @@ impl GraphBuilder {
|
|||||||
self.out.push((name.to_string(), from));
|
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).
|
/// 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>) {
|
pub fn gang(&mut self, name: &str, members: impl IntoIterator<Item = ParamRef>) {
|
||||||
self.gangs.push((name.to_string(), members.into_iter().collect()));
|
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() });
|
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());
|
let mut gangs = Vec::with_capacity(self.gangs.len());
|
||||||
for (name, refs) in &self.gangs {
|
for (name, refs) in &self.gangs {
|
||||||
let mut members = Vec::with_capacity(refs.len());
|
let mut members = Vec::with_capacity(refs.len());
|
||||||
@@ -230,7 +249,7 @@ impl GraphBuilder {
|
|||||||
let kind = kind.unwrap_or(ScalarKind::F64);
|
let kind = kind.unwrap_or(ScalarKind::F64);
|
||||||
gangs.push(Gang { name: name.clone(), kind, members });
|
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 {
|
if let Some(doc) = self.doc {
|
||||||
composite = composite.with_doc(doc);
|
composite = composite.with_doc(doc);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1096,4 +1096,56 @@ mod tests {
|
|||||||
assert_eq!(replay_flat.edges, built_flat.edges);
|
assert_eq!(replay_flat.edges, built_flat.edges);
|
||||||
assert_eq!(replay_flat.sources, built_flat.sources);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user