feat(engine): FlatGraph::bind_tap + TapBindError (#282)

The run-mode-aware bind seam. bind_tap attaches a CALLER-BUILT Box<dyn Node>
sink to a declared tap — appending the sink node + an edge from the tap's
(node, field) to its input, before bootstrap so the Kahn sort orders it. The
engine constructs no aura-std type (it takes a Box<dyn Node>), keeping its
production dependency aura-core-only. bind_tap raises UndeclaredTap; duplicate
detection across binds is the caller's (the engine keeps no cross-call state),
mirroring how bind_sources' DuplicateFeed is a supply-level fault. TapBindError
is exported for the CLI. A bound tap records exactly the producer's per-cycle
output through the full public pipeline; an undeclared name is refused without
mutating the graph.

refs #282
This commit is contained in:
2026-07-17 23:37:54 +02:00
parent f3daded514
commit 93d0ec45f2
3 changed files with 208 additions and 1 deletions
+96
View File
@@ -312,6 +312,62 @@ pub struct FlatGraph {
pub taps: Vec<FlatTap>,
}
/// A tap-binding fault caught before the run. The output-side twin of
/// `SourceBindError`. `UndeclaredTap` is raised by `bind_tap` itself;
/// `DuplicateBind` is raised by the **caller** (the run-mode-owning layer) when
/// it dedups a tap name across binds — the engine method binds a single tap and
/// keeps no cross-call state, so duplicate detection is the caller's, exactly as
/// `bind_sources`' `DuplicateFeed` is a supply-level, not a per-source, fault.
#[derive(Debug, PartialEq, Eq)]
pub enum TapBindError {
/// A binding names a tap the graph does not declare (raised by `bind_tap`).
UndeclaredTap { name: String },
/// The same tap name was bound more than once (raised by the caller dedup).
DuplicateBind { name: String },
}
impl core::fmt::Display for TapBindError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
TapBindError::UndeclaredTap { name } => {
write!(f, "bind_tap names tap \"{name}\" but the graph declares no such tap")
}
TapBindError::DuplicateBind { name } => {
write!(f, "tap \"{name}\" was bound more than once")
}
}
}
}
impl FlatGraph {
/// Attach a caller-built sink to a declared tap: append `sink` + `sig` to the
/// flat node/signature arrays and an `Edge` from the tap's `(node, field)` to
/// the sink's input slot 0. Constructs no sink itself (the engine has no
/// `aura-std` production dep) — the caller hands in a `Recorder`. Call BEFORE
/// `bootstrap`; the Kahn sort then orders the appended sink. Binding a tap
/// the graph does not declare is a typed `UndeclaredTap` refusal; duplicate
/// detection across binds is the caller's (this method keeps no cross-call
/// state — see `TapBindError`).
pub fn bind_tap(
&mut self,
tap_name: &str,
sink: Box<dyn Node>,
sig: NodeSchema,
) -> Result<(), TapBindError> {
let tap = self
.taps
.iter()
.find(|t| t.name == tap_name)
.ok_or_else(|| TapBindError::UndeclaredTap { name: tap_name.to_string() })?;
let (from, from_field) = (tap.node, tap.field);
let sink_idx = self.nodes.len();
self.nodes.push(sink);
self.signatures.push(sig);
self.edges.push(Edge { from, to: sink_idx, slot: 0, from_field });
Ok(())
}
}
/// A wiring fault caught once, at bootstrap — C7's "type check paid at wiring"
/// generalized to the whole topology.
#[derive(Debug, PartialEq, Eq)]
@@ -2774,4 +2830,44 @@ mod tests {
fired.sort_unstable();
assert_eq!(fired, vec![0, 1]);
}
#[test]
fn bind_tap_attaches_a_caller_built_sink_and_records_the_tapped_series() {
// A tap on a producer; bind_tap attaches a caller-built Recorder; the run
// records exactly the producer's per-cycle output. An undeclared tap name
// is a typed refusal.
let prices = f64_stream(&[(1, 10.0), (2, 20.0), (3, 30.0)]);
// graph: source -> Sma(1) [node 0, producer], declared as tap "d" on (0,0).
let mut flat = FlatGraph {
nodes: vec![Box::new(Sma::new(1))],
signatures: vec![Sma::builder().schema().clone()],
sources: vec![SourceSpec::raw(ScalarKind::F64, vec![Target { node: 0, slot: 0 }])],
edges: vec![],
taps: vec![FlatTap { name: "d".into(), node: 0, field: 0 }],
};
// undeclared name refused
let (tx_bad, _rx_bad) = mpsc::channel();
assert!(matches!(
flat.bind_tap("nope", Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_bad)),
recorder_sig(&[ScalarKind::F64], Firing::Any)),
Err(TapBindError::UndeclaredTap { .. })
));
// declared name binds
let (tx, rx) = mpsc::channel();
flat.bind_tap("d", Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)),
recorder_sig(&[ScalarKind::F64], Firing::Any))
.expect("declared tap binds");
let mut h = Harness::bootstrap(flat).expect("bootstraps with the appended recorder");
h.run(vec![Box::new(VecSource::new(prices))]);
let recorded: Vec<(Timestamp, Vec<Scalar>)> = rx.try_iter().collect();
assert_eq!(
recorded,
vec![
(Timestamp(1), vec![Scalar::f64(10.0)]),
(Timestamp(2), vec![Scalar::f64(20.0)]),
(Timestamp(3), vec![Scalar::f64(30.0)]),
],
"the bound tap records exactly the producer's per-cycle output"
);
}
}
+1 -1
View File
@@ -66,7 +66,7 @@ pub use construction::{replay, GraphSession, Op, OpError};
pub use graph_model::model_to_json;
pub use harness::{
window_of, bind_sources, BootstrapError, Edge, FlatGraph, FlatTap, Harness, Source, SourceBindError,
SourceSpec, SplitMix64, SyntheticSpec, Target, VecSource,
SourceSpec, SplitMix64, SyntheticSpec, TapBindError, Target, VecSource,
};
pub use report::{
derive_position_events, expected_max_of_normals, f64_field, inv_norm_cdf, join_on_ts,