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
+104
View File
@@ -1294,3 +1294,107 @@ fn graph_build_hints_attach_for_namespaced_ids_in_a_data_only_project() {
"the hint points at the attach verb: {stderr}"
);
}
/// Property (#284): the `tap` op-script op, driven through the real `aura
/// graph build` process (not an in-crate call to `build_from_str`), resolves
/// a name-addressed interior wire (`"fast.value"`, session node 0 / output
/// field 0) into the exact same `{node, field}` shape a raw-index-authored
/// blueprint would carry — the emitted blueprint's `taps` array names the
/// wire by resolved index. This is the process-boundary counterpart of
/// `construction.rs`'s `tap_op_resolves_and_appends` (in-crate call): it pins
/// that the CLI's serde front end (`OpDoc::Tap` → `Op::Tap`) actually reaches
/// the engine and back out to stdout across a real subprocess, the surface a
/// data-level author drives.
#[test]
fn graph_build_emits_a_tap_op_declared_wire() {
let ops = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
{"op":"add","type":"Sub"},
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
{"op":"connect","from":"fast.value","to":"sub.lhs"},
{"op":"connect","from":"slow.value","to":"sub.rhs"},
{"op":"tap","from":"fast.value","as":"fast_ma"},
{"op":"expose","from":"sub.value","as":"bias"}
]"#;
let (stdout, stderr, ok) = run(&["graph", "build"], ops);
assert!(ok, "graph build: stderr: {stderr}");
assert!(
stdout.contains(r#""taps":[{"name":"fast_ma","from":{"node":0,"field":0}}]"#),
"the tap resolves to node 0 (fast), field 0 (value): {stdout}"
);
}
/// Property (#284): a blueprint authored through the name-addressed `tap` op
/// — built by the real `aura graph build` process, never hand-edited by raw
/// node index — is not merely well-formed JSON: `aura run` actually binds and
/// persists its declared tap, exactly as a raw-index-authored tap does
/// (`tap_recording.rs`'s coverage of the underlying bind/persist mechanism).
/// This is the whole point of #284 (removing the raw-index miscount class):
/// the op-authored artifact must round-trip through the full build -> run ->
/// persisted-trace pipeline, not just parse. Taps `"slow.value"` (SMA(4),
/// deliberately not the node-0 `"fast"` port the sibling build-only test
/// checks) so this test cannot pass by accident on the wrong node.
#[test]
fn tap_authored_via_op_script_runs_and_persists_the_series() {
const PRICES: [f64; 18] = [
1.0000, 1.0008, 1.0021, 1.0039, 1.0062, 1.0090, 1.0083, 1.0061, 1.0034, 1.0012, 0.9998,
1.0006, 1.0024, 1.0047, 1.0069, 1.0086, 1.0097, 1.0092,
];
let ops = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
{"op":"add","type":"Sub"},
{"op":"add","type":"Bias","name":"bias","bind":{"scale":{"F64":0.5}}},
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
{"op":"connect","from":"fast.value","to":"sub.lhs"},
{"op":"connect","from":"slow.value","to":"sub.rhs"},
{"op":"connect","from":"sub.value","to":"bias.signal"},
{"op":"tap","from":"slow.value","as":"slow_ma"},
{"op":"expose","from":"bias.bias","as":"bias"}
]"#;
let (blueprint_json, stderr, ok) = run(&["graph", "build"], ops);
assert!(ok, "graph build: stderr: {stderr}");
assert!(blueprint_json.contains("\"taps\""), "blueprint carries the tap: {blueprint_json}");
let dir = temp_cwd("tap-op-run-roundtrip");
let bp_path = dir.join("tapped.json");
std::fs::write(&bp_path, &blueprint_json).expect("write built blueprint");
let (_stdout, stderr2, code2) = run_in(&dir, &["run", bp_path.to_str().unwrap()]);
assert_eq!(code2, Some(0), "aura run over the op-authored tap: {stderr2}");
// `aura graph build` always names the root composite "graph" (`composite_from_str`),
// so the trace store's run-name directory is fixed.
let trace_path = dir.join("runs/traces/graph/slow_ma.json");
let trace_text = std::fs::read_to_string(&trace_path)
.unwrap_or_else(|e| panic!("expected a persisted tap trace at {}: {e}", trace_path.display()));
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse tap trace json");
assert_eq!(trace["tap"], "slow_ma");
assert_eq!(trace["kinds"], serde_json::json!(["F64"]));
// SMA(4), silent until warm (4 samples): first emission at the 4th sample
// (ts=4), a rolling mean of the last four prices per subsequent cycle.
let expected: Vec<f64> = (3..PRICES.len())
.map(|i| PRICES[i - 3..=i].iter().sum::<f64>() / 4.0)
.collect();
let expected_ts: Vec<i64> = (4..=PRICES.len() as i64).collect();
let got_ts: Vec<i64> =
trace["ts"].as_array().expect("ts array").iter().map(|v| v.as_i64().unwrap()).collect();
assert_eq!(got_ts, expected_ts, "recorded timestamps");
let got: Vec<f64> = trace["columns"][0]
.as_array()
.expect("columns[0] array")
.iter()
.map(|v| v.as_f64().unwrap())
.collect();
assert_eq!(got.len(), expected.len(), "recorded row count");
for (i, (g, e)) in got.iter().zip(expected.iter()).enumerate() {
assert!((g - e).abs() < 1e-9, "row {i}: got {g}, expected {e}");
}
}