feat(cli): record declared taps on the single-run path (#282)

The payoff: a hand-authored blueprint's declared taps become recorded output
from one `aura run`. run_signal_r binds each tap (hoisted into flat.taps after
wrap_r nests the signal) to a fresh Recorder before bootstrap, drains the
series after the run, and persists each as a ColumnarTrace via the existing
env.trace_store() — the same trace surface the campaign path feeds, so the
existing chart tooling reads them. Duplicate tap names are refused (the
caller-owned DuplicateBind dedup) before anything persists. A tap-free run
writes no trace store — byte-identical to today. Sweep/reduce leaves taps
unbound (run_blueprint_member untouched). Covered end-to-end: a persisted tap
series, the tap-free no-write guard, and the duplicate-name refusal.

refs #282
This commit is contained in:
2026-07-18 00:16:17 +02:00
parent 93d0ec45f2
commit 2e8d74903c
2 changed files with 196 additions and 5 deletions
+44 -5
View File
@@ -54,17 +54,18 @@ use aura_std::Scale;
// `Delay`/`Scale` above.
#[cfg(test)]
use aura_std::{Add, Gt, Latch, Mul, Sqrt};
// `Bias`/`Ema`/`Sma`/`ColumnarTrace` are only reached from the test module; the
// imports are test-only, mirroring `Delay`/`Scale` above.
#[cfg(test)]
// `ColumnarTrace` is now also reached from `run_signal_r`'s tap-persistence
// path, so it is a production import (no longer test-only).
use aura_engine::ColumnarTrace;
// `Bias`/`Ema`/`Sma` are only reached from the test module; the imports are
// test-only, mirroring `Delay`/`Scale` above.
#[cfg(test)]
use aura_std::{Bias, Ema, Sma};
#[cfg(test)]
use aura_std::{ConstantCost, VolSlippageCost};
use std::sync::mpsc;
use std::sync::LazyLock;
use std::collections::{BTreeMap, HashSet};
use std::collections::{BTreeMap, BTreeSet, HashSet};
use clap::{Args, Parser, Subcommand};
/// The pip size the built-in *synthetic* families (sweep/walkforward/mc over a
@@ -1716,10 +1717,12 @@ fn resolve_run_data(
/// run over `data`, and build the RunReport (manifest carries topology_hash).
/// The single construction+run path shared by the `aura run <blueprint.json>` CLI
/// arm and its bit-identical test.
#[allow(clippy::type_complexity)]
fn run_signal_r(
signal: Composite, params: &[Scalar], data: RunData, seed: u64, env: &project::Env,
) -> RunReport {
let topo = topology_hash(&signal); // before signal is consumed
let run_name = signal.name().to_string(); // before signal is consumed by `wrap_r`
// The default binding (name defaults; `aura run` carries no campaign
// overrides). Refusals are the established `aura: ` + exit-1 register.
let binding = binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new())
@@ -1745,9 +1748,28 @@ fn run_signal_r(
let (tx_req, _rx_req) = mpsc::channel();
let (sources, window, pip_size) = resolve_run_data(&data, env, &binding);
let wrapped = wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, false, pip_size, &binding, None);
let flat = wrapped
let mut flat = wrapped
.compile_with_params(params)
.expect("signal binds + wraps to a valid harness");
// Bind each declared tap to a fresh `Recorder` + channel, before bootstrap
// — `flat.taps` already carries the signal's declared taps hoisted to the
// root. Dedup is the caller's per `TapBindError::DuplicateBind`'s doc (the
// engine keeps no cross-call state).
let mut seen: BTreeSet<String> = BTreeSet::new();
let mut tap_drains: Vec<(String, ScalarKind, mpsc::Receiver<(Timestamp, Vec<Scalar>)>)> = Vec::new();
let declared: Vec<aura_engine::FlatTap> = flat.taps.clone();
for tap in &declared {
if !seen.insert(tap.name.clone()) {
eprintln!("aura: {}", aura_engine::TapBindError::DuplicateBind { name: tap.name.clone() });
std::process::exit(1);
}
let kind = flat.signatures[tap.node].output[tap.field].kind;
let (tx, rx) = mpsc::channel();
let sig = Recorder::builder(vec![kind], Firing::Any, tx.clone()).schema().clone();
flat.bind_tap(&tap.name, Box::new(Recorder::new(&[kind], Firing::Any, tx)), sig)
.expect("declared tap binds (name from flat.taps, kind from its own signature)");
tap_drains.push((tap.name.clone(), kind, rx));
}
let mut h = Harness::bootstrap(flat).expect("valid r-sma harness");
// `sources` were opened via `resolve_run_data(&data, env, &binding)` against
// this SAME `binding`, and `key_supply` keys them by that binding's own role
@@ -1769,6 +1791,23 @@ fn run_signal_r(
manifest.project = env.provenance();
let mut metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0));
metrics.r = Some(summarize_r(&r_rows, &[]));
// Drain + persist each declared tap's series, guarded so a tap-free
// `aura run` stays byte-identical to today (no `runs/` write at all).
// `manifest` is built above so the persisted `index.json` carries this
// run's own provenance.
if !tap_drains.is_empty() {
let tap_traces: Vec<ColumnarTrace> = tap_drains
.into_iter()
.map(|(name, kind, rx)| {
let rows: Vec<(Timestamp, Vec<Scalar>)> = rx.try_iter().collect();
ColumnarTrace::from_rows(&name, &[kind], &rows)
})
.collect();
env.trace_store().write(&run_name, &manifest, &tap_traces).unwrap_or_else(|e| {
eprintln!("aura: writing tap traces failed: {e}");
std::process::exit(1);
});
}
RunReport { manifest, metrics }
}