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:
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user