feat: measurement run verb — shape-dispatch aura run on blueprint output
Phase 3 of the Stratification milestone (contract C28). `aura run` now
shape-dispatches on the loaded blueprint: a `bias` output takes the
existing strategy path (wrap_r + R evaluation), byte-identical; a
blueprint that declares taps but exposes no `bias` runs bare via the new
`run_measurement` (bootstrap, run, drain the declared taps) with no
SimBroker, no risk executor, and no per-cycle equity/exposure/r
recorders; a blueprint exposing neither is refused. This gives the
measurement layer its own run verb and removes the O(cycles) recording
retention (the measured ~2 GB peak RSS over 12y UK100 M1) the mandatory
R scaffold imposed on a measurement-shaped run.
A new `MeasurementReport { manifest, taps }` (aura-engine::report, beside
RunReport) is the stdout/record shape — no R metrics, since a bare run
has no broker. The manifest's `broker` carries an interim "measurement"
sentinel; the honest `Option<String>` model is deferred to the #147
metric-genericity block, keeping this phase #147-free and the
strategy-path C18 goldens byte-identical.
`run_signal_r` is untouched (the tap machinery is duplicated into
`run_measurement`, not extracted). The measurement arm's closed-blueprint
guard uses `signal.param_space()` rather than `blueprint_axis_probe`,
which welds wrap_r and would panic on a no-`bias` signal.
refs #286
This commit is contained in:
+159
-38
@@ -19,7 +19,8 @@ use aura_engine::{
|
||||
blueprint_from_json, blueprint_to_json, f64_field, join_on_ts, monte_carlo, param_stability,
|
||||
r_metrics_from_rs, summarize, summarize_r, walk_forward, window_of, BindError, BlueprintNode,
|
||||
Composite, FamilySelection, GraphBuilder, Harness, JoinedRow, McAggregate,
|
||||
McFamily, RBootstrap, RollMode, RunManifest, RunMetrics, RunReport, SelectionMode,
|
||||
McFamily, MeasurementReport, RBootstrap, RollMode, RunManifest, RunMetrics, RunReport,
|
||||
SelectionMode,
|
||||
SweepFamily, SweepPoint, SyntheticSpec, VecSource, WalkForwardResult,
|
||||
WindowBounds, WindowRoller, WindowRun,
|
||||
};
|
||||
@@ -1812,6 +1813,106 @@ fn run_signal_r(
|
||||
RunReport { manifest, metrics }
|
||||
}
|
||||
|
||||
/// The measurement-run manifest: the honest subset of [`sim_optimal_manifest`]
|
||||
/// with no broker/R specifics. `broker` carries the interim `"measurement"`
|
||||
/// sentinel — `RunManifest.broker` is a required `String` today; the honest
|
||||
/// `Option<String>` model is deferred to the #147 metric-genericity block so this
|
||||
/// phase stays #147-free and the strategy-path C18 goldens byte-identical.
|
||||
fn measurement_manifest(
|
||||
params: Vec<(String, Scalar)>,
|
||||
window: (Timestamp, Timestamp),
|
||||
seed: u64,
|
||||
) -> RunManifest {
|
||||
RunManifest {
|
||||
commit: ENGINE_COMMIT.to_string(),
|
||||
params,
|
||||
defaults: Vec::new(),
|
||||
window,
|
||||
seed,
|
||||
broker: "measurement".to_string(),
|
||||
selection: None,
|
||||
instrument: None,
|
||||
topology_hash: None,
|
||||
project: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The bare measurement run (C28 phase 3): `run_signal_r` MINUS `wrap_r` and the
|
||||
/// eq/ex/r R-evaluation, KEEPING the declared-tap bind → drain → persist (C27).
|
||||
/// No broker, no risk executor, no per-cycle equity/exposure/r recorders — this
|
||||
/// is where the measured O(cycles) retention is removed. The tap machinery is
|
||||
/// duplicated (not extracted) so `run_signal_r` stays byte-identical.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn run_measurement(
|
||||
signal: Composite, params: &[Scalar], data: RunData, seed: u64, env: &project::Env,
|
||||
) -> MeasurementReport {
|
||||
let topo = topology_hash(&signal);
|
||||
let run_name = signal.name().to_string();
|
||||
let binding = binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new())
|
||||
.unwrap_or_else(|m| {
|
||||
eprintln!("aura: {m}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
if matches!(data, RunData::Synthetic) && !binding.close_only() {
|
||||
eprintln!("aura: {}", binding::synthetic_refusal(signal.name(), &binding));
|
||||
std::process::exit(1);
|
||||
}
|
||||
let names: Vec<String> = signal.param_space().iter().map(|p| p.name.clone()).collect();
|
||||
let defaults = wrapped_bound_defaults(&signal);
|
||||
let (sources, window, _pip_size) = resolve_run_data(&data, env, &binding);
|
||||
|
||||
// Compile the signal DIRECTLY — no wrap_r, no broker/executor/eq-ex-r sinks.
|
||||
let mut flat = signal.compile_with_params(params).unwrap_or_else(|e| {
|
||||
eprintln!("aura: this blueprint does not compile to a runnable harness: {e:?}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
// Bind each declared tap to a Recorder (mirrors run_signal_r:1759-1773).
|
||||
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 measurement harness");
|
||||
h.run_bound(key_supply(&binding, sources))
|
||||
.expect("sources opened against `binding` key-match that binding's own roles by construction");
|
||||
|
||||
let named_params: Vec<(String, Scalar)> =
|
||||
names.into_iter().zip(params.iter().copied()).collect();
|
||||
let mut manifest = measurement_manifest(named_params, window, seed);
|
||||
manifest.defaults = defaults;
|
||||
manifest.topology_hash = Some(topo);
|
||||
manifest.project = env.provenance();
|
||||
|
||||
// Drain + persist each declared tap (mirrors run_signal_r:1799-1811).
|
||||
let tap_names: Vec<String> = tap_drains.iter().map(|(n, _, _)| n.clone()).collect();
|
||||
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);
|
||||
});
|
||||
}
|
||||
MeasurementReport { manifest, taps: tap_names }
|
||||
}
|
||||
|
||||
/// #260: the r-sma sugar/MC paths below run with either no cost model (empty
|
||||
/// `cost` slice) or CLI-flag cost specs (scalar-only — `cost_specs_from_params`
|
||||
/// only ever wraps `CostValue::Scalar`), so an instrument-keyed map can never
|
||||
@@ -3586,47 +3687,67 @@ fn dispatch_run(a: RunCmd, env: &project::Env) {
|
||||
eprintln!("aura: {path}: {msg}");
|
||||
std::process::exit(2);
|
||||
});
|
||||
// `wrap_r` (below, via `run_signal_r` AND the axis probe right here) welds
|
||||
// the loaded signal by its hardcoded `"bias"` output port (the R-evaluator
|
||||
// contract). A signal exposing any other output shape (a measurement-only
|
||||
// blueprint) would otherwise only surface as `blueprint_axis_probe`'s
|
||||
// `wrap_r` call panicking on `BuildError::UnknownOutPort` a few lines
|
||||
// down — refuse here instead, before that probe runs.
|
||||
if !signal.output().iter().any(|o| o.name == "bias") {
|
||||
let exposed: Vec<&str> = signal.output().iter().map(|o| o.name.as_str()).collect();
|
||||
// Shape dispatch (C28 phase 3): a `bias` output → the strategy path
|
||||
// (wrap_r + R evaluation), byte-identical; else ≥1 declared tap → a
|
||||
// bare measurement run (no wrap_r); else an inert blueprint → refuse.
|
||||
// The closed-blueprint guard differs per arm: the strategy arm keeps
|
||||
// `blueprint_axis_probe` (which welds wrap_r by the `bias` port); the
|
||||
// measurement arm uses the signal's own `param_space()` (wrap_r-free —
|
||||
// probing a no-`bias` signal would panic on UnknownOutPort).
|
||||
let has_bias = signal.output().iter().any(|o| o.name == "bias");
|
||||
let has_tap = !signal.taps().is_empty();
|
||||
if has_bias {
|
||||
// Refuse an open (free-knob) blueprint at the dispatch boundary,
|
||||
// mirroring `blueprint_mc_family`'s closed-guard: `run` bootstraps
|
||||
// over the EMPTY point, so a free knob would panic in
|
||||
// `compile_with_params` — reject it clean instead (#176).
|
||||
let free = blueprint_axis_probe(&doc, env).param_space();
|
||||
if !free.is_empty() {
|
||||
eprintln!(
|
||||
"aura: run requires a closed blueprint (no free parameters); {} free knob(s) — \
|
||||
bind them or use `aura sweep --axis`",
|
||||
free.len()
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
let params = match a.params.as_deref() {
|
||||
Some(j) => parse_param_cells(j).unwrap_or_else(|m| {
|
||||
eprintln!("aura: {m}");
|
||||
std::process::exit(2);
|
||||
}),
|
||||
None => Vec::new(),
|
||||
};
|
||||
let data = run_data_from(a.real.as_deref(), a.from, a.to);
|
||||
let report = run_signal_r(signal, ¶ms, data, a.seed.unwrap_or(0), env);
|
||||
println!("{}", report.to_json());
|
||||
} else if has_tap {
|
||||
// Measurement path: wrap_r-free closed guard via the signal's own
|
||||
// open knobs (blueprint_axis_probe would weld wrap_r and panic).
|
||||
if !signal.param_space().is_empty() {
|
||||
eprintln!(
|
||||
"aura: run requires a closed blueprint (no free parameters); {} free knob(s) — \
|
||||
bind them or use `aura sweep --axis`",
|
||||
signal.param_space().len()
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
let params = match a.params.as_deref() {
|
||||
Some(j) => parse_param_cells(j).unwrap_or_else(|m| {
|
||||
eprintln!("aura: {m}");
|
||||
std::process::exit(2);
|
||||
}),
|
||||
None => Vec::new(),
|
||||
};
|
||||
let data = run_data_from(a.real.as_deref(), a.from, a.to);
|
||||
let report = run_measurement(signal, ¶ms, data, a.seed.unwrap_or(0), env);
|
||||
println!("{}", report.to_json());
|
||||
} else {
|
||||
eprintln!(
|
||||
"aura: `aura run` expects a strategy with a bias output; this blueprint \
|
||||
exposes {}",
|
||||
if exposed.is_empty() {
|
||||
"no outputs".to_string()
|
||||
} else {
|
||||
exposed.join(", ")
|
||||
}
|
||||
"aura: `aura run` needs either a `bias` output (a strategy) or ≥1 \
|
||||
declared tap (a measurement); this blueprint exposes neither"
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
// Refuse an open (free-knob) blueprint at the dispatch boundary, mirroring
|
||||
// `blueprint_mc_family`'s closed-guard: `run` bootstraps over the EMPTY point, so a
|
||||
// free knob would panic in `compile_with_params` — reject it clean instead (#176).
|
||||
let free = blueprint_axis_probe(&doc, env).param_space();
|
||||
if !free.is_empty() {
|
||||
eprintln!(
|
||||
"aura: run requires a closed blueprint (no free parameters); {} free knob(s) — \
|
||||
bind them or use `aura sweep --axis`",
|
||||
free.len()
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
let params = match a.params.as_deref() {
|
||||
Some(j) => parse_param_cells(j).unwrap_or_else(|m| {
|
||||
eprintln!("aura: {m}");
|
||||
std::process::exit(2);
|
||||
}),
|
||||
None => Vec::new(),
|
||||
};
|
||||
let data = run_data_from(a.real.as_deref(), a.from, a.to);
|
||||
let report = run_signal_r(signal, ¶ms, data, a.seed.unwrap_or(0), env);
|
||||
println!("{}", report.to_json());
|
||||
}
|
||||
None => {
|
||||
eprintln!(
|
||||
|
||||
Reference in New Issue
Block a user