feat(graph): name-addressed tap construction op — declared taps without raw indices

Declared taps (C27, #282) were authorable only by hand-patching the raw
blueprint envelope's `taps` array, where `node` is a raw interior index —
an index miscount was the expected mistake and the root friction behind the
two `aura run` panics the measurement-milestone fieldtest found.

Add a `tap` op to the construction op-script vocabulary — the exact
output-side twin of `expose`. It names its source wire by the dotted
`"node.field"` identifier the op surface uses everywhere (`split_port` +
`resolve_output_field` + `node_index`), so
`{"op":"tap","from":"fast.value","as":"fast_ma"}` replaces patching
`{"node":14,"field":0}`.

Engine (construction.rs): `Op::Tap`, its `tap()` handler (a verbatim twin
of `expose` — same resolvers, same name-dedup, appending a `Tap` to a new
`taps`/`tap_names` namespace), and `OpError::DuplicateTap` (the twin of
`DuplicateOutput`, stricter than the raw envelope, which enforces no
tap-name uniqueness). `finish()` now threads `.with_taps(self.taps)` into
the built `Composite` — fixing a latent drop #282 left (it built
`.with_gangs` but never `.with_taps`, so op-built composites silently lost
their taps).

CLI (graph_construct.rs): `OpDoc::Tap` (externally tagged, `"as"`-renamed
like `expose`) + the `kind_label` / `From` / `format_op_error` arms (the
last compiler-enumerated by the exhaustive match).

No change to the `Tap` / `FlatTap` / `bind_tap` / `aura run` machinery — the
op-declared tap flows through the existing #282 compile + record path
unchanged.

Verification: 7 new tests green (4 engine: resolve+append, bad-port /
ghost-node refusal, duplicate-name refusal, finish-threads-into-FlatGraph;
1 CLI serde round-trip; 2 CLI E2E over the real `aura graph build` /
`aura run` subprocess — one pinning the exact serialized
`"taps":[{"name":"fast_ma","from":{"node":0,"field":0}}]`, one proving an
op-authored tap round-trips through build -> run -> persisted trace). Full
workspace suite green; clippy clean.

The RED-before-GREEN step was verified by a temporary stash of the
production edits (the `Tap` / `with_taps` machinery pre-existed from #282,
so a fresh RED-only test could not be authored) — the new tests fail
without the edits, pass with them.

Docs (the authoring-guide `tap` row, the ledger op-list `tap` line, and a
worked tap-authoring example) are deliberately out of this commit's scope —
they land in #285, the docs follow-up in this same cycle.

closes #284
This commit is contained in:
2026-07-18 16:22:18 +02:00
parent cb6211c888
commit 43a1cc44d3
3 changed files with 243 additions and 1 deletions
+105 -1
View File
@@ -14,7 +14,7 @@ use aura_core::{BindOpError, NodeSchema, PrimitiveBuilder, Scalar, ScalarKind};
use crate::blueprint::{
check_param_namespace_injective, check_root_roles_bound, edge_kind_check, validate_wiring,
BlueprintNode, Composite, Gang, GangMember, OutField, Role,
BlueprintNode, Composite, Gang, GangMember, OutField, Role, Tap, TapWire,
};
use crate::builder::{resolve_input_slot, resolve_output_field, PortResolveError};
use crate::harness::{Edge, Target};
@@ -37,6 +37,9 @@ pub enum Op {
Connect { from: String, to: String },
/// Re-export `"node.field"` at the boundary under `as_name`.
Expose { from: String, as_name: String },
/// Declare a measurement tap on `"node.field"` under `as_name` (#284) — the
/// output-side twin of `Expose` (a `Tap`, not an `OutField`).
Tap { from: String, as_name: String },
/// Fuse two or more sibling params into one public knob (#61).
Gang { as_name: String, into: Vec<String> },
}
@@ -51,6 +54,11 @@ pub enum OpError {
/// distinct fault class from an interior node-id clash, so callers can tell the
/// two apart from the error value alone.
DuplicateOutput(String),
/// A tap-name collision (the separate `tap_names` namespace) — two taps named
/// alike would collide as recorded series. A distinct fault class from an
/// interior id (`DuplicateIdentifier`) or a boundary-output clash
/// (`DuplicateOutput`).
DuplicateTap(String),
DuplicateRole(String),
UnknownIdentifier(String),
UnknownRole(String),
@@ -101,6 +109,8 @@ pub struct GraphSession<'v> {
role_names: HashMap<String, usize>,
out: Vec<OutField>,
out_names: HashSet<String>,
taps: Vec<Tap>,
tap_names: HashSet<String>,
coverage: HashMap<(usize, usize), usize>,
gangs: Vec<Gang>,
}
@@ -121,6 +131,8 @@ impl<'v> GraphSession<'v> {
role_names: HashMap::new(),
out: Vec::new(),
out_names: HashSet::new(),
taps: Vec::new(),
tap_names: HashSet::new(),
coverage: HashMap::new(),
gangs: Vec::new(),
}
@@ -136,6 +148,7 @@ impl<'v> GraphSession<'v> {
Op::Feed { role, into } => self.feed(role, into),
Op::Connect { from, to } => self.connect(from, to),
Op::Expose { from, as_name } => self.expose(from, as_name),
Op::Tap { from, as_name } => self.tap(from, as_name),
Op::Gang { as_name, into } => self.gang(as_name, into),
}
}
@@ -304,6 +317,25 @@ impl<'v> GraphSession<'v> {
Ok(())
}
/// Declare a measurement tap on a resolved interior output wire — the
/// output-side twin of `expose`. Node resolution (`node_index` →
/// `UnknownIdentifier`), the output-field resolver, and the name-dedup are the
/// same as `expose`'s; only the appended item differs: a `Tap` on the separate
/// `taps`/`tap_names` namespace (dedup → `DuplicateTap`), not an `OutField`.
fn tap(&mut self, from: String, as_name: String) -> Result<(), OpError> {
let (from_node, from_field_name) = Self::split_port(&from)?;
let fi = self.node_index(&from_node)?;
let field = resolve_output_field(&self.schemas[fi], &from_field_name).map_err(|e| match e {
PortResolveError::Unknown => OpError::UnknownOutPort { node: from_node.clone(), name: from_field_name.clone() },
PortResolveError::Ambiguous => OpError::AmbiguousOutPort { node: from_node.clone(), name: from_field_name.clone() },
})?;
if !self.tap_names.insert(as_name.clone()) {
return Err(OpError::DuplicateTap(as_name));
}
self.taps.push(Tap { name: as_name, from: TapWire { node: fi, field } });
Ok(())
}
/// Eager arm of the gang gate: resolve every member against the CURRENT
/// (post-bind) open param lists — a member bound at `Add` has left the open
/// schema and correctly fails as `UnknownParam`, mirroring `try_bind` — enforce
@@ -396,6 +428,7 @@ impl<'v> GraphSession<'v> {
// doc-carrying surface yet (#125 scope cut) — an op-built composite
// carries no authored rationale until that vocabulary grows a slot.
let c = Composite::new(self.name, self.nodes, self.edges, roles, self.out)
.with_taps(self.taps)
.with_gangs(self.gangs)
.map_err(OpError::Incomplete)?;
check_param_namespace_injective(&c.param_space()).map_err(OpError::Incomplete)?;
@@ -639,6 +672,77 @@ mod tests {
);
}
/// `tap` names an interior output wire by `"node.field"` (the output-side twin
/// of `expose`), appending a resolved `Tap` to the composite — the
/// name-addressed wire resolves to the same interior `{node, field}` a
/// raw-index tap would carry (`fast` is session node 0, its `value` field 0).
#[test]
fn tap_op_resolves_and_appends() {
use crate::blueprint::{Tap, TapWire};
let mut s = scaffold();
s.apply(Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] }).unwrap();
s.apply(Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() }).unwrap();
s.apply(Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() }).unwrap();
s.apply(Op::Expose { from: "sub.value".into(), as_name: "bias".into() }).unwrap();
assert_eq!(s.apply(Op::Tap { from: "fast.value".into(), as_name: "fast_ma".into() }), Ok(()));
let c = s.finish().expect("finishes");
assert_eq!(c.taps(), &[Tap { name: "fast_ma".into(), from: TapWire { node: 0, field: 0 } }]);
}
/// `tap` reuses `expose`'s resolvers: a bad output field maps to the
/// by-identifier `UnknownOutPort`; an unknown source node to `UnknownIdentifier`
/// (via `node_index`), never the nonexistent `UnknownNode`.
#[test]
fn tap_op_bad_out_port_and_ghost_node_rejected() {
let mut s = scaffold();
assert_eq!(
s.apply(Op::Tap { from: "fast.nope".into(), as_name: "t".into() }),
Err(OpError::UnknownOutPort { node: "fast".into(), name: "nope".into() })
);
assert_eq!(
s.apply(Op::Tap { from: "ghost.value".into(), as_name: "t".into() }),
Err(OpError::UnknownIdentifier("ghost".into()))
);
}
/// A second tap under the same boundary name is a `DuplicateTap` — the twin of
/// `expose`'s `DuplicateOutput`, over the separate `tap_names` namespace.
#[test]
fn tap_op_duplicate_name_rejected() {
let mut s = scaffold();
assert_eq!(s.apply(Op::Tap { from: "fast.value".into(), as_name: "t".into() }), Ok(()));
assert_eq!(
s.apply(Op::Tap { from: "slow.value".into(), as_name: "t".into() }),
Err(OpError::DuplicateTap("t".into()))
);
}
/// `finish()` threads op-declared taps into `Composite.taps`, and the #282
/// compile path hoists them into `FlatGraph.taps` — proving the `.with_taps`
/// thread this op adds reaches the flat graph (guards the latent drop `finish`
/// had: it built `.with_gangs` but never `.with_taps`). `fast` lowers to flat
/// node 0, its `value` field 0.
#[test]
fn tap_op_threads_into_flat_graph() {
use crate::harness::FlatTap;
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: None, 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: "fast.value".into(), as_name: "fast_ma".into() },
Op::Expose { from: "sub.value".into(), as_name: "bias".into() },
];
let flat = super::replay("g", ops, &aura_std::std_vocabulary)
.expect("replays")
.compile_with_params(&[Scalar::i64(2), Scalar::i64(4)])
.expect("compiles");
assert_eq!(flat.taps, vec![FlatTap { name: "fast_ma".into(), node: 0, field: 0 }]);
}
/// The gang op fuses two sibling params into one public knob; the replayed
/// composite's `param_space()` carries exactly the gang address.
#[test]