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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user