Files
Aura/crates/aura-runner/src/member.rs
T
claude 99e93000c5 fix(aura-runner, aura-cli): milestone-fieldtest bugs — library exposes-neither guard, override class, doc truth
RED-first fixes for the two code bugs the safe-to-embed milestone
fieldtest caught: (1) run_signal_r panicked (exit 101) on a blueprint
exposing neither bias nor a declared tap — the CLI pre-validated but the
library seam, the very surface the milestone promises is kill-free, did
not; the guard now refuses class 2 with the CLI's prose family before
wrap_r (run_measurement has no wrap seam and no hole). (2) The override
unknown-param refusal exited 1 through the one inline site the #297
adjudication missed; it and its wrapped-retired sibling are class 2 now,
three existing pins re-pinned with the adjudication comment.

Doc truth from the same fieldtest: the guide's campaign no-data class
corrected (contained cell faults exit 3, not 1), TapPlanError's rustdoc
no longer advertises the retired exit-1 register, and C30 records that
rev + the embedding's committed lockfile are the reproducible-build
contract (branch-referenced engine deps resolve at lock time).

refs #296
2026-07-26 20:06:07 +02:00

2021 lines
102 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The canonical member-run recipe: harness assembly.
//!
//! The R-scaffolding wrap (`wrap_r`), the single-run and per-member execution
//! paths built on it (`run_signal_r`, `run_blueprint_member`), the loaded-
//! blueprint axis probe (`blueprint_axis_probe`/`blueprint_axis_probe_reopened`)
//! and its #246 override-set machinery (`wrapped_bound_names`,
//! `wrapped_bound_overrides_of`, `override_paths`, `reopen_all`) — the shipped
//! recipe `aura_campaign::MemberRunner` implementors (the CLI's
//! `CliMemberRunner`, and any downstream World program) drive real data
//! through.
use std::collections::{BTreeMap, HashSet};
use std::sync::{mpsc, Arc, LazyLock};
use aura_composites::{cost_graph, risk_executor, StopRule};
use aura_core::{zip_params, Cell, Firing, ParamSpec, PrimitiveBuilder, Scalar, ScalarKind, Timestamp};
use aura_engine::{
blueprint_from_json, window_of, BlueprintNode, CompileError, Composite, GraphBuilder, Harness,
RunManifest, VecSource,
};
use aura_backtest::{
summarize, summarize_r, RunMetrics, RunReport, SimBroker, PM_FIELD_NAMES, PM_RECORD_KINDS,
};
use aura_std::{GatedRecorder, LinComb, Recorder, RollingMax, RollingMin, SeriesReducer, Sub};
use aura_strategy::{cost_port, GEOMETRY_WIDTH};
use aura_campaign::MemberFault;
use crate::binding::ResolvedBinding;
use crate::project::Env;
use crate::tap_plan::{bind_tap_plan, TapPlan};
use crate::translate::{R_SMA_STOP_LENGTH, R_SMA_STOP_K};
use crate::RunnerError;
/// The single build-time commit provenance (`option_env!("AURA_COMMIT")`,
/// falling back to `"unknown"`) — `sim_optimal_manifest`'s `RunManifest.commit`
/// reads this one const, mirroring the CLI shell's own copy (both resolve the
/// same process-wide env var at compile time, so the two cannot disagree).
const ENGINE_COMMIT: &str = match option_env!("AURA_COMMIT") {
Some(c) => c,
None => "unknown",
};
/// The pip size the built-in *synthetic* families (sweep/walkforward/mc over a
/// blueprint) run at: a 5-decimal FX major (`EURUSD`-shaped). The synthetic streams
/// carry no instrument, so there is no recorded geometry sidecar to thread; a
/// single named source keeps the broker's divisor (`SimBroker::builder`) and the
/// recorded broker label (`sim_optimal_manifest`) in lockstep, so they cannot
/// silently drift apart. The real path threads the sidecar's looked-up `pip_size`
/// instead.
pub const SYNTHETIC_PIP_SIZE: f64 = 0.0001;
/// Which data a `run` drives a harness on: the built-in synthetic stream, or real M1
/// close bars for a vetted symbol over an optional window.
#[derive(Debug)]
pub enum RunData {
Synthetic,
Real { symbol: String, from: Option<i64>, to: Option<i64> },
}
/// Build the sim-optimal `RunManifest`: the engine-external descriptor fields
/// (commit, seed-free synthetic run, broker label) are constant across the CLI's
/// built-in harnesses — only `params` and `window` vary. Centralizing the broker
/// label keeps it a single source (and a single edit point for per-asset pip
/// work): the `pip_size` it renders is the one its caller ran the broker at.
pub fn sim_optimal_manifest(
params: Vec<(String, Scalar)>,
window: (Timestamp, Timestamp),
seed: u64,
pip_size: f64,
) -> RunManifest {
// Typed params pass straight through: the manifest carries self-describing
// Scalars, so a length stays `i64` and a scale `f64` in the record.
RunManifest {
commit: ENGINE_COMMIT.to_string(),
params,
defaults: Vec::new(),
window,
seed,
broker: sim_optimal_broker_label(pip_size),
selection: None,
instrument: None,
topology_hash: None,
project: None,
}
}
/// The RAW-namespace BOUND-param defaults of `signal` (#249/#328): every
/// `bound_param_space()` entry as a `(<param>, value)` pair, already RAW
/// (`bound_param_space()`'s own path-qualified name, #203 — no wrap-prefix
/// concatenation), in `bound_param_space()` order — the `RunManifest.defaults`
/// field. Must be read off `signal` BEFORE it is consumed by
/// `wrap_r`/reopening: an axis-reopened bound param has already left
/// `bound_param_space()` by construction (`Composite::reopen` forgets the
/// bound value), so a caller that reopens overrides before calling this
/// naturally excludes them — no separate filter needed. Contrast
/// `wrapped_bound_names`, which DOES wrap-prefix (it matches against the
/// WRAPPED open `param_space()`, a different coordinate system than this
/// manifest-recording helper needs).
pub fn raw_bound_defaults(signal: &Composite) -> Vec<(String, Scalar)> {
signal.bound_param_space().into_iter().map(|b| (b.name, b.value)).collect()
}
/// The plain sim-optimal broker label (no RiskExecutor branch): shared by
/// `sim_optimal_manifest` and (#299) `reproduce_family_in`'s forward-built
/// guard comparison, so the two cannot drift.
pub(crate) fn sim_optimal_broker_label(pip_size: f64) -> String {
format!("sim-optimal(pip_size={pip_size})")
}
/// The honest broker label for the dual-tap r-sma harness: it runs a RiskExecutor
/// branch alongside the SimBroker, so the plain "sim-optimal" label would under-report
/// it (#132). Shared by the single run and the sweep (#299: and `reproduce_family_in`'s
/// forward-built guard comparison) so they cannot drift.
pub(crate) fn r_sma_broker_label(pip_size: f64) -> String {
format!("sim-optimal+risk-executor(pip_size={pip_size})")
}
/// A rise-fall-rise synthetic stream for the r-sma smoke run: long enough to warm
/// the `vol_stop(length=3)` and flip the SMA(2)/SMA(4) cross at least once, so the
/// RiskExecutor opens and closes at least one trade. Deterministic (C1).
pub fn r_sma_prices() -> Vec<(Timestamp, Scalar)> {
[
1.0000_f64, 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,
]
.iter()
.enumerate()
.map(|(i, &p)| (Timestamp(i as i64 + 1), Scalar::f64(p)))
.collect()
}
/// No-local-data refusal (#297: returned, not printed — the CLI/library
/// boundary prints `aura: {message}` and exits with `exit_code`).
pub fn no_real_data(symbol: &str, env: &Env) -> RunnerError {
RunnerError {
exit_code: 1,
message: format!("no local data for symbol '{symbol}' at {}", env.data_path()),
}
}
/// Empty-in-window refusal (#297: returned, not printed). Distinct from
/// `no_real_data`: used only inside `probe_window`, whose callers have already
/// proven the symbol present via `has_symbol` — an empty probe result there is
/// a fact about the requested `--from`/`--to` window, not about symbol
/// absence, so it must not reuse the symbol-absence message (#242).
fn no_data_in_window(symbol: &str, from_ms: Option<i64>, to_ms: Option<i64>, env: &Env) -> RunnerError {
let bound = |b: Option<i64>| b.map_or_else(|| "unbounded".to_string(), |v| v.to_string());
RunnerError {
exit_code: 1,
message: format!(
"no data for symbol '{symbol}' in the requested window [{}, {}] at {}",
bound(from_ms),
bound(to_ms),
env.data_path()
),
}
}
/// Resolve the per-instrument pip from the recorded geometry sidecar, or refuse
/// (#297: returned, not printed) when the symbol has no recorded geometry — the
/// single home of the guessed-pip refusal, shared by `open_real_source` and
/// reproduce's `DataSource::from_choice` chain (`family.rs`). Reads the symbol's geometry metadata
/// (not bar data) before the run source opens, so an instrument with no
/// recorded geometry refuses without a guessed pip; the pip is the provider's
/// recorded value, honest by construction.
pub fn pip_or_refuse(server: &Arc<data_server::DataServer>, symbol: &str, env: &Env) -> Result<f64, RunnerError> {
match aura_ingest::instrument_geometry(server, symbol) {
Some(geo) => Ok(geo.pip_size),
None => Err(RunnerError {
exit_code: 1,
message: format!(
"no recorded geometry for symbol '{symbol}' at {} — \
refusing to run a real instrument with a guessed pip",
env.data_path()
),
}),
}
}
/// Probe the full data window: open a single-pass probe source through the
/// shared opener, drain it for the first/last timestamp, and return
/// `(first, last)`. Refuses (via `no_data_in_window`) when the symbol/window
/// yields no source or no bars — callers have already proven the symbol
/// present, so an empty result here is a window fact, not symbol absence.
/// Shared by `open_real_source` (which needs the manifest window from a probe
/// separate from the run sources) and the CLI shell's `DataSource::full_window`.
/// The probe drains the CLOSE column regardless of the strategy's binding: the
/// bounds are field-independent (every archived bar carries all six fields at
/// one timestamp), and file-level absence is field-independent too.
pub fn probe_window(
server: &Arc<data_server::DataServer>,
symbol: &str,
from_ms: Option<i64>,
to_ms: Option<i64>,
env: &Env,
) -> Result<(Timestamp, Timestamp), RunnerError> {
aura_ingest::archive_extent(server, std::path::Path::new(&env.data_path()), symbol, from_ms, to_ms)
.ok_or_else(|| no_data_in_window(symbol, from_ms, to_ms, env))
}
/// Open the real M1 sources for a recorded symbol over an optional window — one
/// source per resolved binding column, in canonical order — returning the run
/// sources paired with the manifest `window` and the per-instrument `pip_size`.
/// Single home of the real-source construction the single-run handlers share — the
/// sidecar-pip lookup, the `DataServer` `has_symbol` refusal, the probe-window pass, and
/// the run-source open (each refusal returned as a `RunnerError`, #297). Pre-data
/// refusals keep the pip honest by construction. Used by `resolve_run_data`.
#[allow(clippy::type_complexity)]
fn open_real_source(
symbol: &str,
from_ms: Option<i64>,
to_ms: Option<i64>,
env: &Env,
fields: &[aura_ingest::M1Field],
) -> Result<(Vec<Box<dyn aura_engine::Source>>, (Timestamp, Timestamp), f64), RunnerError> {
// Per-instrument pip from the recorded sidecar; resolved BEFORE bar-data access
// so an instrument with no geometry refuses without touching the archive.
let server = Arc::new(data_server::DataServer::new(env.data_path()));
let pip = pip_or_refuse(&server, symbol, env)?;
if !server.has_symbol(symbol) {
return Err(no_real_data(symbol, env));
}
// Manifest window: drain a separate probe (single-pass Source) for first/last ts.
let window = probe_window(&server, symbol, from_ms, to_ms, env)?;
let sources = aura_ingest::open_columns(&server, symbol, from_ms, to_ms, fields)
.ok_or_else(|| no_real_data(symbol, env))?;
Ok((sources, window, pip))
}
/// Short-horizon realized-range window for vol-scaled slippage. Deliberately
/// distinct from `R_SMA_STOP_LENGTH` (3): scaling slippage by the stop's own
/// vol would collapse cost-in-R to a constant (spec 0082). Short enough to warm
/// within the synthetic smoke fixture so the run path exercises non-zero slippage.
const SLIP_VOL_LENGTH: i64 = 5;
/// The optional cost leg of the R scaffolding (#234): the resolved cost-node
/// builders (from `translate::cost_nodes_for`, fully bound — they add no
/// open param, so the wrapped `param_space` is cost-invariant) plus the two
/// recording channels — the aggregate 3-field cost record (`tx_cost`, the
/// `summarize_r` join input) and the `net_r_equity` curve (`tx_net`, recorded
/// only in `!reduce` trace mode).
pub struct CostLeg {
pub nodes: Vec<PrimitiveBuilder>,
pub tx_cost: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
pub tx_net: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
}
/// Interned `col[i]` recorder-port names, built once. The `GraphBuilder::input` API
/// wants `&'static str`; interning here (instead of `format!(...).leak()` per field)
/// means reusing the r-sma harness in a sweep / Monte-Carlo loop reuses these
/// strings rather than leaking 14 fresh ones per build (#132).
static COL_PORTS: LazyLock<Vec<String>> =
LazyLock::new(|| (0..PM_FIELD_NAMES.len()).map(|i| format!("col[{i}]")).collect());
/// Wrap a `signal` composite (a roles→`bias` leg) in the R run
/// scaffolding: pip broker, the per-tap recorders, the vol-stop RiskExecutor, the
/// r_equity / cost legs. The signal is nested; its root roles come from the
/// resolved `binding` (one per entry, canonical column order — the same order the
/// callers open real columns in, so role i receives source i), and the binding's
/// guaranteed close entry always feeds the broker/executor pair. A serialized
/// signal loaded via `blueprint_from_json` runs through exactly the scaffolding
/// the Rust-built signal does.
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
pub fn wrap_r(
signal: Composite,
tx_eq: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
tx_ex: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
tx_r: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
tx_req: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
stop: StopRule,
reduce: bool,
pip_size: f64,
binding: &ResolvedBinding,
cost: Option<CostLeg>,
) -> Composite {
let mut g = GraphBuilder::new("r_sma");
// The strategy signal → Bias, nested as a serializable roles→`bias` leg.
let sig = g.add(BlueprintNode::Composite(signal));
// pip branch (verbatim from the retired `sample_blueprint_with_sinks`, #159).
let broker = g.add(SimBroker::builder(pip_size));
// R branch: bias + price → RiskExecutor(vol_stop) → dense R-record. The stop is a
// fixed `StopRule` — the pinned default constants, or an arbitrary per-regime rule
// the caller resolved.
// In `reduce` mode the per-cycle taps fold online: SeriesReducer folds the eq/ex
// f64 series to one summary row, GatedRecorder retains only the gated R rows — the
// O(cycles)→O(trades) memory win. The raw `Recorder`s (and the r_equity tap) are the
// `--trace` path, where the full per-cycle series is persisted.
let gate_col = PM_FIELD_NAMES
.iter()
.position(|&n| n == "closed_this_cycle")
.expect("PM record has a closed_this_cycle column");
let eq = if reduce {
g.add(SeriesReducer::builder(Firing::Any, tx_eq))
} else {
g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq))
};
let ex = if reduce {
g.add(SeriesReducer::builder(Firing::Any, tx_ex))
} else {
g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex))
};
let exec = g.add(risk_executor(stop, 1.0));
let rrec = if reduce {
g.add(GatedRecorder::builder(PM_RECORD_KINDS.to_vec(), gate_col, Firing::Any, tx_r))
} else {
g.add(Recorder::builder(PM_RECORD_KINDS.to_vec(), Firing::Any, tx_r))
};
// Root roles come from the resolved binding, one per entry in canonical
// column order (the C4 merge tie-break order the callers open columns in).
// The binding guarantees a close entry, which always feeds the broker and
// the executor — "price" below is their node-schema PORT name, not a role
// name. For a single-`price` signal this declares exactly the old weld:
// one role, targets [sig, broker, exec] (feed calls append targets).
let mut close_handle = None;
for entry in binding.entries() {
let role = g.source_role(&entry.role, crate::binding::column_kind(entry.column));
if entry.feeds_signal {
// `NodeHandle::input` takes a `&'static str` port name (the fluent
// `GraphBuilder`'s authoring-time contract, builder.rs) but the
// binding's role names are dynamic (loaded from a blueprint at
// runtime). Vocabulary names use their static form; only an
// override-renamed role leaks its name (once per graph build,
// never per-tick).
let port: &'static str = crate::binding::static_role_name(&entry.role)
.unwrap_or_else(|| Box::leak(entry.role.clone().into_boxed_str()));
g.feed(role, [sig.input(port)]);
}
if entry.column == aura_ingest::M1Field::Close && close_handle.is_none() {
close_handle = Some(role);
}
}
let close = close_handle.expect("ResolvedBinding guarantees a close entry");
g.feed(close, [broker.input("price"), exec.input("price")]);
g.connect(sig.output("bias"), broker.input("exposure"));
g.connect(sig.output("bias"), ex.input("col[0]"));
g.connect(sig.output("bias"), exec.input("bias"));
g.connect(broker.output("equity"), eq.input("col[0]"));
for (i, field) in PM_FIELD_NAMES.iter().enumerate() {
g.connect(exec.output(field), rrec.input(COL_PORTS[i].as_str()));
}
if !reduce {
// r_equity = cum_realized_r + unrealized_r — one tapped series for charting.
let r_equity = g.add(
LinComb::configured(2)
.bind("weights[0]", Scalar::f64(1.0))
.bind("weights[1]", Scalar::f64(1.0)),
);
let req = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_req));
g.connect(exec.output("cum_realized_r"), r_equity.input("term[0]"));
g.connect(exec.output("unrealized_r"), r_equity.input("term[1]"));
g.connect(r_equity.output("value"), req.input("col[0]"));
}
// The optional cost leg (#234 — the #221-deleted wiring rebuilt as a
// campaign-documented feature): a `cost_graph` fed by the executor's four
// geometry outputs, its aggregate record recorded co-temporally with the R
// record (the `summarize_r` positional-join input), and — in `!reduce`
// trace mode — the LinComb(4) net-equity curve into `tx_net`. Absent leg
// (`None`) == today's graph, byte-identical.
if let Some(leg) = cost {
// Components taking a `volatility` extra input (the vol_slippage
// shape), discovered from each builder's own schema past the geometry
// prefix — no second vocabulary of "which node needs the proxy".
let vol_slots: Vec<usize> = leg
.nodes
.iter()
.enumerate()
.filter(|(_, n)| {
n.schema().inputs[GEOMETRY_WIDTH..].iter().any(|p| p.name == "volatility")
})
.map(|(k, _)| k)
.collect();
// The short-horizon realized-range vol proxy, fed from the close role,
// shared by every vol-scaled component (the #221-deleted arm, verbatim
// wiring; built in BOTH modes — the reduce-mode member run charges
// slippage too).
let vol_range = if vol_slots.is_empty() {
None
} else {
let vhi = g.add(RollingMax::builder().named("slip_vol_hi").bind("length", Scalar::i64(SLIP_VOL_LENGTH)));
let vlo = g.add(RollingMin::builder().named("slip_vol_lo").bind("length", Scalar::i64(SLIP_VOL_LENGTH)));
let vrange = g.add(Sub::builder().named("slip_vol_range"));
g.connect(vhi.output("value"), vrange.input("lhs"));
g.connect(vlo.output("value"), vrange.input("rhs"));
g.feed(close, [vhi.input("series"), vlo.input("series")]);
Some(vrange)
};
// One cost_graph composite fans the shared PM-geometry into the
// components and sums their per-field charges (C10).
let cg = g.add(cost_graph(leg.nodes));
g.connect(exec.output("closed_this_cycle"), cg.input("closed"));
g.connect(exec.output("open"), cg.input("open"));
g.connect(exec.output("entry_price"), cg.input("entry_price"));
g.connect(exec.output("stop_price"), cg.input("stop_price"));
for k in vol_slots {
let vrange = vol_range.as_ref().expect("proxy is built whenever a vol slot exists");
g.connect(vrange.output("value"), cg.input(cost_port(k, "volatility")));
}
if reduce {
// The aggregate cost record, gated on the SAME closed flag as the R
// GatedRecorder and flushed once at finalize — so the cost rows stay
// positionally 1:1 with the gated R rows (`summarize_r`'s join). The
// gate rides as an APPENDED col 3: cols 0..2 keep the aura-analysis
// `cost_col` contract (cost_in_r = 0, open_cost_in_r = 2).
let crec = g.add(GatedRecorder::builder(
vec![ScalarKind::F64, ScalarKind::F64, ScalarKind::F64, ScalarKind::Bool],
3,
Firing::Any,
leg.tx_cost,
));
g.connect(cg.output("cost_in_r"), crec.input("col[0]"));
g.connect(cg.output("cum_cost_in_r"), crec.input("col[1]"));
g.connect(cg.output("open_cost_in_r"), crec.input("col[2]"));
g.connect(exec.output("closed_this_cycle"), crec.input("col[3]"));
} else {
// The full per-cycle aggregate cost record (col 0 per-close, col 2
// window-end — what summarize_r folds on the trace path).
let crec = g.add(Recorder::builder(
vec![ScalarKind::F64, ScalarKind::F64, ScalarKind::F64],
Firing::Any,
leg.tx_cost,
));
g.connect(cg.output("cost_in_r"), crec.input("col[0]"));
g.connect(cg.output("cum_cost_in_r"), crec.input("col[1]"));
g.connect(cg.output("open_cost_in_r"), crec.input("col[2]"));
// net_r_equity = cum_realized_r + unrealized_r Σcum_cost_in_r
// Σopen_cost_in_r (the #221-deleted LinComb(4), weights 1,1,-1,-1).
let net_eq = g.add(
LinComb::configured(4)
.bind("weights[0]", Scalar::f64(1.0))
.bind("weights[1]", Scalar::f64(1.0))
.bind("weights[2]", Scalar::f64(-1.0))
.bind("weights[3]", Scalar::f64(-1.0)),
);
g.connect(exec.output("cum_realized_r"), net_eq.input("term[0]"));
g.connect(exec.output("unrealized_r"), net_eq.input("term[1]"));
g.connect(cg.output("cum_cost_in_r"), net_eq.input("term[2]"));
g.connect(cg.output("open_cost_in_r"), net_eq.input("term[3]"));
let net_rec = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, leg.tx_net));
g.connect(net_eq.output("value"), net_rec.input("col[0]"));
}
}
g.build().expect("r_sma wiring resolves")
}
/// Pair each opened source with its role name from the resolved binding, forming
/// the keyed supply `run_bound` resolves by name. `sources` are opened in
/// `binding.columns()` order (== `binding.entries()` order), so entry `i`'s role
/// names source `i` — the one place open-order and role-order meet, made explicit
/// so `bind_sources` verifies the wiring↔supply role match by name (#275).
pub fn key_supply(
binding: &ResolvedBinding,
sources: Vec<Box<dyn aura_engine::Source>>,
) -> Vec<(String, Box<dyn aura_engine::Source>)> {
binding
.entries()
.iter()
.map(|e| e.role.clone())
.zip(sources)
.collect()
}
/// Resolve a `RunData` selector to the `(sources, window, pip_size)` triple the
/// run paths feed to the harness: the built-in synthetic R stream (close-only —
/// the caller guards the binding shape), or the lazily-streamed real sources of
/// the binding's resolved columns (with the sidecar pip + probed window).
/// Single definition used by `run_signal_r`.
#[allow(clippy::type_complexity)]
pub fn resolve_run_data(
data: &RunData,
env: &Env,
binding: &ResolvedBinding,
) -> Result<
(
Vec<Box<dyn aura_engine::Source>>,
(Timestamp, Timestamp),
f64,
),
RunnerError,
> {
match data {
RunData::Synthetic => {
let sources: Vec<Box<dyn aura_engine::Source>> =
vec![Box::new(VecSource::new(r_sma_prices()))];
let window = window_of(&sources).expect("non-empty synthetic stream");
Ok((sources, window, SYNTHETIC_PIP_SIZE))
}
RunData::Real { symbol, from, to } => {
open_real_source(symbol, *from, *to, env, &binding.columns())
}
}
}
/// The variant-name string for a `ScalarKind`, matching the wire/CLI spelling
/// used across the codebase (`F64`/`I64`/`Bool`/`Timestamp`).
fn scalar_kind_name(kind: ScalarKind) -> &'static str {
match kind {
ScalarKind::F64 => "F64",
ScalarKind::I64 => "I64",
ScalarKind::Bool => "Bool",
ScalarKind::Timestamp => "Timestamp",
}
}
/// Bug 3 (#319 fieldtest cycle 2): an `--override` value whose lexed kind
/// does not match the param's declared kind (e.g. the bare literal `2` for
/// an F64 param) reaches this compile boundary as a raw
/// `CompileError::ParamKindMismatch { slot, expected, got }` — `slot` is a
/// flat param-space index, meaningless to a caller. `names`/`params` are the
/// SAME order `compile_with_params` was called with (built from `signal`
/// before it was consumed by `wrap_r`, `C11`: composites inline, preserving
/// order), so `slot` indexes both. This names the param path and both kinds,
/// mirroring the campaign leg's own kind-fault prose
/// (`ref_fault_prose`'s `AxisKindMismatch` arm, `research_docs.rs`). Every
/// other `CompileError` variant keeps the existing Debug fallback — out of
/// this bug's scope, and `names`/`params` only reconstruct the one variant
/// that carries an injected value.
fn compile_error_prose(e: &CompileError, names: &[String], params: &[Scalar]) -> String {
let CompileError::ParamKindMismatch { slot, expected, got } = e else {
return format!("this blueprint does not compile to a runnable harness: {e:?}");
};
let path = names.get(*slot).map(String::as_str).unwrap_or("<unknown>");
let example = match (expected, params.get(*slot)) {
(ScalarKind::F64, Some(Scalar::I64(v))) => format!("{v}.0"),
(ScalarKind::F64, _) => "a decimal literal, e.g. 2.0".to_string(),
(ScalarKind::I64, _) => "a bare integer literal, e.g. 2".to_string(),
(ScalarKind::Bool, _) => "true or false".to_string(),
(ScalarKind::Timestamp, _) => "an integer epoch-ns literal".to_string(),
};
format!(
"--override {path}: expects {}, got {} — write {example}",
scalar_kind_name(*expected),
scalar_kind_name(*got)
)
}
/// Run a signal blueprint through the R scaffolding: hash the signal,
/// wrap it (broker + equity/exposure/R sinks), compile with `params`, bootstrap,
/// 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.
///
/// `topo`: `None` computes `topology_hash` inline from `signal` as always
/// (every existing caller); `Some(hash)` overrides it with the caller's own
/// **reference-semantics** hash (#343, revised) — the exec blueprint leg's
/// `--override` branch passes the loaded base document's content id, computed
/// BEFORE `reopen_all`, so the manifest reaching every consumer inside this
/// function (the record line AND the trace-store persistence below) carries
/// the base document's id, never the reopened topology's own, exactly as the
/// campaign leg's `run_blueprint_member` has always taken its `topo` as a
/// caller-supplied reference (`runner.rs`'s `&cell.strategy_id`).
#[allow(clippy::type_complexity)]
pub fn run_signal_r(
signal: Composite, params: &[Scalar], data: RunData, seed: u64, env: &Env,
plan: TapPlan, topo: Option<&str>,
) -> Result<(RunReport, Vec<String>), RunnerError> {
// #297 fieldtest finding B1 (milestone-safe-to-embed): mirror the CLI's
// own `exec_blueprint_leg` exposes-neither guard (`aura-cli/src/main.rs`,
// `has_bias`/`has_tap`) IN the library, before `wrap_r` below wires a
// "bias" output that does not exist — an embedding host driving
// `run_signal_r` directly (never through the CLI shell) must see this
// same typed refusal instead of `wrap_r`'s wiring `.expect` panicking
// with `UnknownOutPort { name: "bias" }` and killing the host. Same
// detection (`output()`/`taps()`), same prose family as the CLI's,
// minus its own "aura: " print prefix — C14 class 2 (argv-named content).
if !signal.output().iter().any(|o| o.name == "bias") && signal.taps().is_empty() {
return Err(RunnerError {
exit_code: 2,
message: "exec needs either a `bias` output (a strategy) or ≥1 declared tap \
(a measurement); this blueprint exposes neither"
.to_string(),
});
}
// topology_hash's own two-line body, inlined: `content_id_of` over the
// canonical (#164) blueprint JSON — the CLI shell's `topology_hash`
// helper is the same primitive, kept single-sourced at `aura_research`.
// Skipped when the caller already supplies a reference-semantics hash
// (`topo: Some(..)`) — no need to hash a signal whose own topology_hash
// will be overridden anyway.
let topo: String = match topo {
Some(t) => t.to_string(),
None => aura_research::content_id_of(
&aura_engine::blueprint_to_json(&signal).expect("a buildable signal serializes"),
), // 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 propagate as a `RunnerError` for the CLI to
// print and exit on, never a process exit inside the library (#297).
// C14 class 2: fault in argv-named content (#297)
let binding = crate::binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new())
.map_err(|m| RunnerError { exit_code: 2, message: m })?;
// C14 class 2: fault in argv-named content (#297)
if matches!(data, RunData::Synthetic) && !binding.close_only() {
return Err(RunnerError { exit_code: 2, message: crate::binding::synthetic_refusal(signal.name(), &binding) });
}
let names: Vec<String> = signal
.param_space()
.iter()
.map(|p| p.name.clone())
.collect();
let defaults = raw_bound_defaults(&signal); // read before `signal` is consumed below
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let (tx_r, rx_r) = mpsc::channel();
// The req tap (r_equity recorder) is wired but not persisted on this path; keep the
// receiver alive so the sink's sends do not fail, but do not drain it.
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);
// C14 class 2: fault in argv-named content (#297)
let mut flat = wrapped.compile_with_params(params)
.map_err(|e| RunnerError { exit_code: 2, message: compile_error_prose(&e, &names, params) })?;
// Bind each declared tap per the plan's subscription, before bootstrap
// (#283): typed refusals for bad plans, record consumers hold their
// streaming writer in-graph, folds accumulate O(1), live closures run
// inline. Dedup stays caller-owned per TapBindError::DuplicateBind's doc.
// C14 class 2: fault in argv-named content (#297)
let bound = bind_tap_plan(&mut flat, plan, env, &run_name)
.map_err(|e| RunnerError { exit_code: e.exit_class(), message: e.to_string() })?;
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
// names — a `SourceBindError` here can only mean the wiring↔supply pairing
// this function builds is internally inconsistent, never a user input
// mistake. Panic like the adjacent bootstrap invariants above, not a
// process exit dressed as a refusal message.
h.run_bound(key_supply(&binding, sources))
.expect("sources opened against `binding` key-match that binding's own roles by construction");
let eq_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_eq.try_iter().collect();
let ex_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_ex.try_iter().collect();
let r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
let named_params: Vec<(String, Scalar)> =
names.into_iter().zip(params.iter().copied()).collect();
let mut manifest = sim_optimal_manifest(named_params, window, seed, pip_size);
manifest.defaults = defaults;
manifest.broker = r_sma_broker_label(pip_size);
manifest.topology_hash = Some(topo);
manifest.project = env.provenance();
let mut metrics = summarize(&aura_engine::f64_field(&eq_rows, 0), &aura_engine::f64_field(&ex_rows, 0));
metrics.r = Some(summarize_r(&r_rows, &[]));
let skipped = bound.skipped().to_vec();
// Close the tap plan: drain the ≤1-message channels, write fold rows,
// then `index.json` — nothing was buffered during the run (#283). A
// tap-free (or nothing-persisting) plan wrote nothing at all.
bound.finish(&manifest)
.map_err(|e| RunnerError { exit_code: e.exit_class(), message: e.to_string() })?;
Ok((RunReport { manifest, metrics }, skipped))
}
/// Run one bootstrapped member of a loaded-signal sweep: the reduce-mode path
/// (`SeriesReducer` folds eq/ex, `GatedRecorder` retains the gated R rows — the
/// O(cycles)→O(trades) fold), shared by the live sweep AND reproduction so a
/// reproduced member re-derives bit-identically (C1). `signal` is a freshly
/// reloaded blueprint (`Composite` is `!Clone`); `point` is the member's bound
/// cells; `space` gives the by-name manifest params; `topo` the shared signal hash.
#[allow(clippy::too_many_arguments)]
pub fn run_blueprint_member(
signal: Composite,
point: &[Cell],
space: &[ParamSpec],
sources: Vec<Box<dyn aura_engine::Source>>,
window: (Timestamp, Timestamp),
seed: u64,
pip: f64,
topo: &str,
env: &Env,
stop: StopRule,
binding: &ResolvedBinding,
cost: &[aura_research::CostSpec],
instrument: &str,
) -> Result<RunReport, MemberFault> {
let defaults = raw_bound_defaults(&signal); // read before `signal` is consumed below
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let (tx_r, rx_r) = mpsc::channel();
let (tx_req, _rx_req) = mpsc::channel();
// The doc's cost model as an optional wrap leg (#234): empty = no leg,
// exactly the pre-cost graph. The net curve is a !reduce trace concern;
// its sender is wired but unread here (the r_equity tap precedent above).
let (tx_cost, rx_cost) = mpsc::channel();
let (tx_net, _rx_net) = mpsc::channel();
let cost_leg = if cost.is_empty() {
None
} else {
let nodes = crate::translate::cost_nodes_for(cost, instrument)
.map_err(|f| MemberFault::Bind(f.message()))?;
Some(CostLeg { nodes, tx_cost, tx_net })
};
let mut h = wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, stop, true, pip, binding, cost_leg)
.bootstrap_with_cells(point)
.expect("member bootstraps (point kind-checked against param_space)");
// Called from inside `CliMemberRunner::run_member`'s `catch_unwind`
// containment (#272 — that impl's doc contract: "never a process exit
// inside a sweep worker, every refusal a member fault"). A
// `SourceBindError` here is an internal wiring/supply mismatch, not user
// input (see the sibling `.expect` two lines above) — panic so the
// containing `catch_unwind` records it as a per-cell `MemberFault::Panic`
// instead of `process::exit` aborting the whole campaign.
h.run_bound(key_supply(binding, sources))
.expect("sources opened against `binding` key-match that binding's own roles by construction");
let mut named = zip_params(space, point); // by-name params for the manifest record
// One arm's knobs per member (#233 Vol/VolTf, #338 Fixed) — the manifest
// stamp `stop_rule_from_params` reads back (`translate.rs`), so a
// campaign's `RiskRegime::Fixed` cell reproduces its own distance, not
// the baked default vol-stop.
match stop {
StopRule::Vol { length, k } => {
named.push(("stop_length".to_string(), Scalar::i64(length)));
named.push(("stop_k".to_string(), Scalar::f64(k)));
}
StopRule::VolTf { period_minutes, length, k } => {
named.push(("stop_period_minutes".to_string(), Scalar::i64(period_minutes)));
named.push(("stop_length".to_string(), Scalar::i64(length)));
named.push(("stop_k".to_string(), Scalar::f64(k)));
}
StopRule::Fixed(distance) => {
named.push(("stop_distance".to_string(), Scalar::f64(distance)));
}
}
// Stamp the cost model the member ran under, beside the stop knobs (#234,
// the #233 pattern): one `cost[k].<knob>` param per component, in
// component order — the knob name discriminates the variant (each shipped
// cost node has exactly one, distinctly named knob). The name is read off
// `translate::cost_knob`, the same function `cost_nodes_for` binds
// through, so the stamp key cannot drift from the bind key: the manifest
// carries enough to re-derive the exact model later. `reproduce_family_in`
// reads this stamp back via `translate::cost_specs_from_params` (the #233
// stop-regime pattern), so a costed family reproduces net, not gross.
for (k, spec) in cost.iter().enumerate() {
let (knob, v) = crate::translate::cost_knob(spec, instrument)
.map_err(|f| MemberFault::Bind(f.message()))?;
named.push((format!("cost[{k}].{knob}"), Scalar::f64(v)));
}
let mut manifest = sim_optimal_manifest(named, window, seed, pip);
manifest.defaults = defaults;
manifest.broker = r_sma_broker_label(pip);
manifest.topology_hash = Some(topo.to_string());
manifest.project = env.provenance();
let r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
// The member's cost rows (empty when no cost model) join the R reduction:
// net_expectancy_r diverges from gross by exactly the modelled costs.
let cost_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_cost.try_iter().collect();
let (total_pips, max_drawdown) = rx_eq
.try_iter()
.next()
.map(|(_, row)| (row[0].as_f64(), row[1].as_f64()))
.unwrap_or((0.0, 0.0));
let bias_sign_flips =
rx_ex.try_iter().next().map(|(_, row)| row[2].as_i64() as u64).unwrap_or(0);
let mut m = RunMetrics { total_pips, max_drawdown, bias_sign_flips, r: None };
m.r = Some(summarize_r(&r_rows, &cost_rows));
Ok(RunReport { manifest, metrics: m })
}
/// The exact wrapped probe the loaded-blueprint sweep resolves its axes
/// against: the loaded signal wrapped in the r-sma scaffolding (stop bound,
/// reduce, no cost), taps discarded. `param_space()` on it is the axis
/// namespace `--axis` binds; `.axis()` consumes it to seed a sweep. Single
/// source for the sweep terminal, the MC closed-check, AND
/// `aura graph introspect --params`, so the listed names track the swept
/// names by construction (incl. across #159's
/// harness retirement). The reload is infallible under the SAME
/// dispatch-boundary contract the callers already rely on: the doc is
/// `blueprint_from_json`-validated at the `["sweep", ..]` / `["mc", ..]`
/// boundary before this runs, so a malformed doc has already exited 2 and
/// never reaches the `.expect`.
pub fn blueprint_axis_probe(doc: &str, env: &Env) -> Composite {
blueprint_axis_probe_reopened(doc, env, &[])
}
/// The axis probe with a #246 override set re-opened on the strategy BEFORE
/// wrapping — probe and per-member reloads must re-open identically so points
/// resolve against one space. The empty-set form is byte-equal to the old
/// probe (mc/run closed-checks and the open `aura graph introspect --params`
/// lines read that).
pub fn blueprint_axis_probe_reopened(doc: &str, env: &Env, overrides: &[String]) -> Composite {
let signal = blueprint_from_json(doc, &|t| env.resolve(t))
.expect("doc parse-validated at the dispatch boundary; reload is infallible");
let signal = reopen_all(signal, overrides);
// The PROBE binding is lenient (unresolvable roles fall back to close),
// like the probe's synthetic pip: the wrap is built for its param_space
// only, never run over data — strict resolution lives on the run paths.
let probe = crate::binding::probe_binding(signal.input_roles());
let (tx_eq, _) = mpsc::channel();
let (tx_ex, _) = mpsc::channel();
let (tx_r, _) = mpsc::channel();
let (tx_req, _) = mpsc::channel();
wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, true, SYNTHETIC_PIP_SIZE, &probe, None)
}
/// The WRAPPED-coordinate BOUND param name set of `signal` (#246): every
/// `bound_param_space()` entry prefixed with `<signal.name()>.` — the same
/// coordinate `param_space()`'s OPEN entries live in on the wrapped graph.
/// Extracted because `wrapped_bound_overrides_of`, `override_paths`, and the
/// CLI shell's `validate_and_register_axes` each independently rebuilt this
/// exact set (the rule-of-three the neighbouring doc comment itself cites).
pub fn wrapped_bound_names(signal: &Composite) -> HashSet<String> {
let prefix = format!("{}.", signal.name());
signal
.bound_param_space()
.into_iter()
.map(|b| format!("{prefix}{}", b.name))
.collect()
}
/// The override subset of `names` (#246): every name missing the un-reopened
/// wrapped OPEN space but naming a BOUND param of the strategy — returned in
/// STRATEGY coordinates (the wrap segment stripped) for `Composite::reopen`.
/// Names matching neither space are skipped here; the caller decides whether
/// that is an error (sweep boundary) or falls through to its existing
/// resolution errors. The silent variant `reproduce_family_in`/`runner.rs`
/// use over the RECORDED manifest param names (the sweep boundary uses the
/// stricter `override_paths`, which errors on an unmatched name instead).
///
/// #328: matches via [`crate::axes::raw_matches_wrapped`], so `names` may be
/// WRAPPED (every route this cycle leaves untouched) OR RAW (a `Sweep` family
/// minted by `blueprint_sweep_family`, whose manifest now records the raw
/// campaign namespace, #328) — `reproduce_family_in` reproduces families from
/// either route generically, so this helper must tolerate both shapes rather
/// than assume one. Contrast `axes::raw_bound_overrides_of`, whose `names` are
/// ALWAYS RAW (a campaign document's own namespace, #203) by contract.
pub fn wrapped_bound_overrides_of(
names: &[String],
open_space: &[ParamSpec],
signal: &Composite,
) -> Vec<String> {
let bound = wrapped_bound_names(signal);
names
.iter()
.filter(|n| !open_space.iter().any(|p| crate::axes::raw_matches_wrapped(n, &p.name)))
.filter_map(|n| {
bound
.iter()
.find(|b| crate::axes::raw_matches_wrapped(n, b))
.map(|b| crate::axes::wrapped_to_raw_axis(b).to_string())
})
.collect()
}
/// The sweep-boundary variant (#246): like `wrapped_bound_overrides_of`, but
/// an axis matching NEITHER the open nor the bound space is the error — the
/// honest replacement of the retired "fully bound; nothing to sweep" refusal.
///
/// #328: matches via [`crate::axes::raw_matches_wrapped`] rather than exact
/// wrapped-name equality, so a RAW incoming name (`fast.length`) resolves
/// against the WRAPPED open/bound space exactly like an exact wrapped hit
/// (`sma_signal.fast.length`) would — the one shared suffix convention
/// `axes.rs` defines, reused rather than re-derived here. This is the
/// resolution mechanism, not an acceptance gate (contrast the CLI's own
/// `--axis` intake, which refuses a wrapped name before this ever runs on the
/// sweep-verb route); callers whose axes are not intake-filtered this cycle
/// (the walk-forward routes) keep accepting either form unchanged.
pub fn override_paths(
axes: &[(String, Vec<Scalar>)],
open_space: &[ParamSpec],
signal: &Composite,
) -> Result<Vec<String>, String> {
let bound = wrapped_bound_names(signal);
let mut overrides = Vec::new();
for (name, _) in axes {
if open_space.iter().any(|p| crate::axes::raw_matches_wrapped(name, &p.name)) {
continue;
}
match bound.iter().find(|b| crate::axes::raw_matches_wrapped(name, b)) {
Some(b) => overrides.push(crate::axes::wrapped_to_raw_axis(b).to_string()),
None => {
return Err(format!(
"axis {name}: names no param of this blueprint (open or bound) — \
see `aura graph introspect --params <bp>`"
));
}
}
}
Ok(overrides)
}
/// Apply a validated override set to a freshly loaded strategy (#246).
/// Infallible by contract: the set was derived against this same document at
/// the family boundary.
pub fn reopen_all(signal: Composite, overrides: &[String]) -> Composite {
overrides.iter().fold(signal, |s, p| {
s.reopen(p).expect("override set validated at the family boundary")
})
}
/// The Donchian breakout signal leg, a pure `price→bias` Composite so it serialises
/// as blueprint data (#159 cut 2). The signal computation matches the retired fused
/// builder's leg (same nodes, same wiring); the pip/R harness is the generic
/// `wrap_r` wrapper, not part of the signal. `channel = Some(n)` binds both rolling
/// nodes (closed); `None` leaves them open, ganged into the single `channel_length`
/// knob (#61) — the channel is structurally ONE parameter. This carve has no
/// production caller — its only role is regenerating + pinning the shipped
/// examples via this module's own tests; production code loads the shipped
/// JSON, not this builder. `#[cfg(test)]`.
#[cfg(test)]
fn r_breakout_signal(channel: Option<i64>) -> Composite {
use aura_std::{Delay, Gt, Latch};
let mut g = GraphBuilder::new("r_breakout_signal");
g.doc("rolling-extreme breakout latched into a long/short signal");
let delay = g.add(Delay::builder().bind("lag", Scalar::i64(1)));
let mut mx_b = RollingMax::builder().named("channel_hi");
let mut mn_b = RollingMin::builder().named("channel_lo");
if let Some(n) = channel {
mx_b = mx_b.bind("length", Scalar::i64(n));
mn_b = mn_b.bind("length", Scalar::i64(n));
}
let mx = g.add(mx_b);
let mn = g.add(mn_b);
if channel.is_none() {
g.gang("channel_length", [mx.param("length"), mn.param("length")]);
}
let gt_up = g.add(Gt::builder());
let gt_down = g.add(Gt::builder());
let up_latch = g.add(Latch::builder());
let down_latch = g.add(Latch::builder());
let exposure = g.add(Sub::builder()); // up_latch - down_latch -> bias in {-1,0,+1}
let price = g.source_role("price", ScalarKind::F64);
g.feed(price, [delay.input("series"), gt_up.input("a"), gt_down.input("b")]);
g.connect(delay.output("value"), mx.input("series"));
g.connect(delay.output("value"), mn.input("series"));
g.connect(mx.output("value"), gt_up.input("b"));
g.connect(mn.output("value"), gt_down.input("a"));
g.connect(gt_up.output("value"), up_latch.input("set"));
g.connect(gt_down.output("value"), up_latch.input("reset"));
g.connect(gt_down.output("value"), down_latch.input("set"));
g.connect(gt_up.output("value"), down_latch.input("reset"));
g.connect(up_latch.output("value"), exposure.input("lhs"));
g.connect(down_latch.output("value"), exposure.input("rhs"));
g.expose(exposure.output("value"), "bias");
g.build().expect("r_breakout signal wiring resolves")
}
/// The EWMA Bollinger-band mean-reversion signal leg, carved out of the retired fused
/// builder as a pure `price→bias` Composite so it serialises as blueprint data (#159
/// cut 3). Verbatim signal computation vs that retired fused builder, except the band
/// half-width `band_k*sigma` uses `Scale` (a rosterable multiply) in place of
/// `LinComb(1)`. `#[cfg(test)]`: this module's own `wrap_r`/`run_blueprint_member`
/// unit tests below use it as a realistic non-flat-bias fixture, and its only
/// other role is regenerating + pinning the shipped examples; production loads
/// the shipped JSON, not this builder.
#[cfg(test)]
fn r_meanrev_signal(window: Option<i64>, band_k: Option<f64>) -> Composite {
use aura_std::{Add, Ema, Gt, Latch, Mul, Scale, Sqrt};
let mut g = GraphBuilder::new("r_meanrev_signal");
g.doc("EMA deviation against a volatility band, latched into a mean-reversion signal");
let (mut mean_b, mut var_b) =
(Ema::builder().named("mean_window"), Ema::builder().named("var_window"));
if let Some(n) = window {
mean_b = mean_b.bind("length", Scalar::i64(n));
var_b = var_b.bind("length", Scalar::i64(n));
}
let mean = g.add(mean_b);
let dev = g.add(Sub::builder()); // price - mean
let sq = g.add(Mul::builder()); // dev * dev
let var = g.add(var_b); // EWMA variance
if window.is_none() {
g.gang("window", [mean.param("length"), var.param("length")]);
}
let sigma = g.add(Sqrt::builder());
let mut band_b = Scale::builder().named("band");
if let Some(k) = band_k {
band_b = band_b.bind("factor", Scalar::f64(k));
}
let band = g.add(band_b);
let upper = g.add(Add::builder());
let lower = g.add(Sub::builder());
let gt_hi = g.add(Gt::builder());
let gt_lo = g.add(Gt::builder());
let short_latch = g.add(Latch::builder());
let long_latch = g.add(Latch::builder());
let exposure = g.add(Sub::builder()); // long_latch - short_latch -> bias
let price = g.source_role("price", ScalarKind::F64);
g.feed(price, [mean.input("series"), dev.input("lhs"), gt_hi.input("a"), gt_lo.input("b")]);
g.connect(mean.output("value"), dev.input("rhs"));
g.connect(dev.output("value"), sq.input("lhs"));
g.connect(dev.output("value"), sq.input("rhs"));
g.connect(sq.output("value"), var.input("series"));
g.connect(var.output("value"), sigma.input("value"));
g.connect(sigma.output("value"), band.input("signal"));
g.connect(mean.output("value"), upper.input("lhs"));
g.connect(band.output("value"), upper.input("rhs"));
g.connect(mean.output("value"), lower.input("lhs"));
g.connect(band.output("value"), lower.input("rhs"));
g.connect(upper.output("value"), gt_hi.input("b"));
g.connect(lower.output("value"), gt_lo.input("a"));
g.connect(gt_hi.output("value"), short_latch.input("set"));
g.connect(gt_lo.output("value"), short_latch.input("reset"));
g.connect(gt_lo.output("value"), long_latch.input("set"));
g.connect(gt_hi.output("value"), long_latch.input("reset"));
g.connect(long_latch.output("value"), exposure.input("lhs"));
g.connect(short_latch.output("value"), exposure.input("rhs"));
g.expose(exposure.output("value"), "bias");
g.build().expect("r_meanrev signal wiring resolves")
}
/// The OHLC high/low-channel (Donchian-shape) signal leg — the harness-input-
/// binding acceptance strategy (#231): bias goes long when the CLOSE breaks
/// the rolling max of the previous `n` HIGHS, short when it breaks the rolling
/// min of the previous `n` LOWS. Three input roles (`high`, `low`, `close` —
/// the role names ARE the column binding), declared in canonical column order.
/// `channel = Some(n)` binds both rolling nodes (closed); `None` leaves them
/// open, ganged into the single `channel_length` knob (#61). `#[cfg(test)]`
/// (see `r_breakout_signal`'s doc): its only role is regenerating + pinning
/// the shipped examples via this module's own tests; production loads the
/// shipped JSON, not this builder.
#[cfg(test)]
fn r_channel_signal(channel: Option<i64>) -> Composite {
use aura_std::{Delay, Gt, Latch};
let mut g = GraphBuilder::new("hl_channel");
g.doc("prior high/low channel breaks latched into a directional signal");
let delay_hi = g.add(Delay::builder().named("prev_high").bind("lag", Scalar::i64(1)));
let delay_lo = g.add(Delay::builder().named("prev_low").bind("lag", Scalar::i64(1)));
let mut mx_b = RollingMax::builder().named("channel_hi");
let mut mn_b = RollingMin::builder().named("channel_lo");
if let Some(n) = channel {
mx_b = mx_b.bind("length", Scalar::i64(n));
mn_b = mn_b.bind("length", Scalar::i64(n));
}
let mx = g.add(mx_b);
let mn = g.add(mn_b);
if channel.is_none() {
g.gang("channel_length", [mx.param("length"), mn.param("length")]);
}
let gt_up = g.add(Gt::builder());
let gt_down = g.add(Gt::builder());
let up_latch = g.add(Latch::builder());
let down_latch = g.add(Latch::builder());
let exposure = g.add(Sub::builder()); // up_latch - down_latch -> bias in {-1,0,+1}
let high = g.source_role("high", ScalarKind::F64);
let low = g.source_role("low", ScalarKind::F64);
let close = g.source_role("close", ScalarKind::F64);
g.feed(high, [delay_hi.input("series")]);
g.feed(low, [delay_lo.input("series")]);
g.feed(close, [gt_up.input("a"), gt_down.input("b")]);
g.connect(delay_hi.output("value"), mx.input("series"));
g.connect(delay_lo.output("value"), mn.input("series"));
g.connect(mx.output("value"), gt_up.input("b"));
g.connect(mn.output("value"), gt_down.input("a"));
g.connect(gt_up.output("value"), up_latch.input("set"));
g.connect(gt_down.output("value"), up_latch.input("reset"));
g.connect(gt_down.output("value"), down_latch.input("set"));
g.connect(gt_up.output("value"), down_latch.input("reset"));
g.connect(up_latch.output("value"), exposure.input("lhs"));
g.connect(down_latch.output("value"), exposure.input("rhs"));
g.expose(exposure.output("value"), "bias");
g.build().expect("hl_channel signal wiring resolves")
}
#[cfg(test)]
mod tests {
use super::*;
use aura_engine::blueprint_to_json;
use aura_strategy::{ConstantCost, VolSlippageCost};
use aura_vocabulary::std_vocabulary;
use crate::tap_plan::{bind_tap_plan, TapPlanError, TapSubscription};
use crate::project::ProjectEnv;
use std::path::{Path, PathBuf};
#[test]
/// Independently pins the shipped `r_meanrev_signal` carve's FADE direction —
/// short (-1) above the band, long (+1) below — using the Scale-based band it
/// actually ships with. `k = 0` collapses the band to the lagging EWMA mean,
/// isolating direction + latch from the sigma threshold; window 3 (alpha =
/// 0.5) lags the level clearly. `wrap_r`'s `ex` tap reads `sig.output("bias")`
/// directly (before any broker/exec/cost machinery), so `rx_ex` carries the
/// raw signal bias.
fn r_meanrev_signal_fades_short_above_the_band_and_long_below() {
let (tx_eq, _rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let (tx_r, _rx_r) = mpsc::channel();
let (tx_req, _rx_req) = mpsc::channel();
let signal = r_meanrev_signal(Some(3), Some(0.0));
let binding = crate::binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new())
.expect("the price role resolves");
let flat = wrap_r(
signal,
tx_eq,
tx_ex,
tx_r,
tx_req,
StopRule::Vol { length: 3, k: 2.0 },
false,
SYNTHETIC_PIP_SIZE,
&binding,
None,
)
.compile_with_params(&[])
.expect("r-meanrev signal wraps to a valid harness");
let mut h = Harness::bootstrap(flat).expect("r-meanrev harness bootstraps");
// calm (price == lagging mean -> no fade) | sustained UP (price > mean ->
// fade SHORT) | sustained DOWN (price < mean -> fade LONG).
let closes = [
100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 130.0, 130.0, 130.0, 70.0, 70.0, 70.0, 70.0,
];
let prices: Vec<(Timestamp, Scalar)> =
closes.iter().enumerate().map(|(i, &c)| (Timestamp(i as i64), Scalar::f64(c))).collect();
let src: Vec<Box<dyn aura_engine::Source>> = vec![Box::new(VecSource::new(prices))];
h.run(src);
let bias: Vec<f64> =
rx_ex.try_iter().map(|(_, row): (Timestamp, Vec<Scalar>)| row[0].as_f64()).collect();
assert!(!bias.is_empty(), "the meanrev exposure tap must emit once warmed up");
assert_eq!(*bias.first().unwrap(), 0.0, "calm bars (price == mean) must not fade: {bias:?}");
let first_short = bias.iter().position(|&b| b == -1.0).expect("an up-move must fade SHORT (-1)");
let first_long = bias.iter().position(|&b| b == 1.0).expect("a down-move must fade LONG (+1)");
assert!(first_short < first_long, "short (up-fade) must precede long (down-fade): {bias:?}");
assert_eq!(*bias.last().unwrap(), 1.0, "the down-fade long must hold to the end: {bias:?}");
}
#[test]
/// Property: the pip a run resolves and stamps into its manifest must be the
/// pip the in-graph `SimBroker` divides by — the resolved (real, per-instrument)
/// pip has to reach the graph, not just the manifest label. The `SimBroker`
/// integrates `exposure * (price - prev_price) / pip`, so the raw price-move
/// total `total_pips * pip` is pip-invariant: the SAME signal + prices run at
/// two different pips must agree on that product. If `pip` only decorates the
/// label and the graph always divides by the synthetic default, both runs
/// yield the identical `total_pips` and the invariant breaks (and real-data
/// pips are inflated by `1 / 0.0001 = 10^4`).
fn run_blueprint_member_computes_pips_at_the_resolved_pip_not_a_hardwired_default() {
let env = Env::std();
// A price series that drives the meanrev signal into a definite non-flat
// exposure (short an up-move, long a down-move), so broker equity != 0 —
// the same idiom as the fade test above.
let closes = [
100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 130.0, 130.0, 130.0, 70.0, 70.0, 70.0, 70.0,
];
let make_source = || -> Vec<Box<dyn aura_engine::Source>> {
let prices: Vec<(Timestamp, Scalar)> = closes
.iter()
.enumerate()
.map(|(i, &c)| (Timestamp(i as i64), Scalar::f64(c)))
.collect();
vec![Box::new(VecSource::new(prices))]
};
let window = (Timestamp(0), Timestamp(closes.len() as i64 - 1));
let stop = StopRule::Vol { length: 3, k: 2.0 };
let space: Vec<ParamSpec> = vec![];
// Two per-instrument pips, an order of magnitude apart and both distinct
// from the synthetic 0.0001 — i.e. the real-data condition.
let pip_a = 1.0_f64;
let pip_b = 0.1_f64;
let signal_a = r_meanrev_signal(Some(3), Some(0.0));
let binding = crate::binding::resolve_binding(signal_a.name(), signal_a.input_roles(), &BTreeMap::new())
.expect("the price role resolves");
let report_a = run_blueprint_member(
signal_a, &[], &space, make_source(), window, 0, pip_a, "topo", &env, stop, &binding, &[], "GER40",
)
.expect("no cost model: nothing to refuse");
let report_b = run_blueprint_member(
r_meanrev_signal(Some(3), Some(0.0)), &[], &space, make_source(), window, 0, pip_b, "topo", &env, stop, &binding, &[], "GER40",
)
.expect("no cost model: nothing to refuse");
// Guard: the run must have actually traded, else the invariant is vacuous.
assert!(
report_a.metrics.total_pips.abs() > 0.0,
"test needs a non-flat run to be meaningful: {:?}",
report_a.metrics
);
// The pip-invariant raw price-move total must agree across the two pips.
let raw_a = report_a.metrics.total_pips * pip_a;
let raw_b = report_b.metrics.total_pips * pip_b;
assert!(
(raw_a - raw_b).abs() < 1e-9,
"total_pips must be computed at the resolved pip: pip={pip_a} gave total_pips={} \
(raw price-move {raw_a}), pip={pip_b} gave total_pips={} (raw price-move {raw_b}) \
— the resolved pip never reached the in-graph SimBroker",
report_a.metrics.total_pips,
report_b.metrics.total_pips,
);
}
#[test]
fn sim_optimal_manifest_renders_per_instrument_pip() {
let m = sim_optimal_manifest(vec![], (Timestamp(1), Timestamp(2)), 0, 1.0);
assert_eq!(m.broker, "sim-optimal(pip_size=1)");
let m2 = sim_optimal_manifest(vec![], (Timestamp(1), Timestamp(2)), 0, 0.0001);
assert_eq!(m2.broker, "sim-optimal(pip_size=0.0001)");
}
/// The Donchian channel length for the canonical closed r_breakout example.
/// Single source for the emitter and the three proof tests that must all
/// agree with the baked `examples/r_breakout.json` fixture — kept honest
/// by one constant instead of a hand-synced `Some(3)` literal recurring
/// across those call sites. `examples/r_breakout.json` is CLI-owned
/// (`crates/aura-cli/examples/`); these tests read it cross-crate (#295).
const R_BREAKOUT_CHANNEL: i64 = 3;
/// The EWMA window for the canonical closed r_meanrev example (ganged
/// mean/var Ema length; the open form gangs them structurally via the
/// single `window` knob, #61). Single source for the emitter and the
/// proof tests that must all agree with the baked `examples/r_meanrev.json`.
const R_MEANREV_WINDOW: i64 = 3;
/// The Bollinger band half-width (in sigma) for the canonical closed
/// r_meanrev example. Single source alongside [`R_MEANREV_WINDOW`].
const R_MEANREV_BAND_K: f64 = 2.0;
/// The channel length for the canonical closed r_channel example. Single
/// source for the emitter and the proof tests that must agree with the
/// baked `examples/r_channel.json` (mirrors [`R_BREAKOUT_CHANNEL`]).
const R_CHANNEL_LENGTH: i64 = 3;
/// Loads the shipped closed r-sma example (fast=2, slow=4 bound) through
/// the public `blueprint_from_json` path. `examples/r_sma.json` is
/// CLI-owned (`crates/aura-cli/examples/`, also read directly by `aura
/// graph`'s no-argument default); this reads the identical file
/// cross-crate (#295). aura-cli's own `mod tests` in `main.rs` keeps its
/// own verbatim copy of this loader for the tests that stayed there.
fn load_closed_r_sma() -> Composite {
blueprint_from_json(include_str!("../../aura-cli/examples/r_sma.json"), &|t| std_vocabulary(t))
.expect("loads")
}
/// Regenerates the shipped r_breakout examples from the carved signal. Run by hand:
/// `cargo test -p aura-runner emit_r_breakout_examples -- --ignored`. The examples are
/// green-by-construction (serialised from the builder, never hand-authored).
#[test]
#[ignore = "regenerates crates/aura-cli/examples/r_breakout{,_open}.json; run by hand"]
fn emit_r_breakout_examples() {
// CLI-owned target paths (#295: this builder lives in aura-runner, the
// fixture it regenerates lives in aura-cli) — anchored on
// `CARGO_MANIFEST_DIR` (this crate's own root), not process cwd, the
// same pattern `project.rs`'s tmp-dir tests already use.
let examples_dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../aura-cli/examples");
std::fs::create_dir_all(examples_dir).expect("examples dir");
std::fs::write(
concat!(env!("CARGO_MANIFEST_DIR"), "/../aura-cli/examples/r_breakout.json"),
blueprint_to_json(&r_breakout_signal(Some(R_BREAKOUT_CHANNEL))).expect("serialize closed r_breakout"),
)
.expect("write examples/r_breakout.json");
std::fs::write(
concat!(env!("CARGO_MANIFEST_DIR"), "/../aura-cli/tests/fixtures/r_breakout_open.json"),
blueprint_to_json(&r_breakout_signal(None)).expect("serialize open r_breakout"),
)
.expect("write tests/fixtures/r_breakout_open.json");
}
/// The shipped examples are a faithful serialisation of the carved signal (#159 cut 2):
/// re-serialising the carve equals the checked-in bytes. Green-by-construction with the
/// emitter; a drift between builder and file breaks it. Survives the fused builder's
/// retirement (it references `r_breakout_signal`, the carve, not the retired builder).
#[test]
fn shipped_r_breakout_examples_serialize_the_carved_signal() {
assert_eq!(
blueprint_to_json(&r_breakout_signal(Some(R_BREAKOUT_CHANNEL))).expect("serialize closed"),
include_str!("../../aura-cli/examples/r_breakout.json"),
);
assert_eq!(
blueprint_to_json(&r_breakout_signal(None)).expect("serialize open"),
include_str!("../../aura-cli/tests/fixtures/r_breakout_open.json"),
);
}
/// The shipped closed example, reloaded through the data plane and run, grades
/// bit-identically to running the carved signal directly (#159 cut 2). This is
/// r_breakout's durable equivalence anchor after the fused builder retires: it pins
/// that `examples/r_breakout.json` still produces the carve's grade, with no
/// hardcoded golden. `Composite` is `!Clone`, so the example is loaded twice.
#[test]
fn r_breakout_example_loaded_runs_identically_to_the_carved_signal() {
let env = Env::std();
let loaded = blueprint_from_json(include_str!("../../aura-cli/examples/r_breakout.json"), &|t| std_vocabulary(t))
.expect("shipped r_breakout example loads");
let (via_file, _) = run_signal_r(loaded, &[], RunData::Synthetic, 0, &env, TapPlan::record_all(), None)
.expect("r_breakout example runs");
let (via_carve, _) = run_signal_r(r_breakout_signal(Some(R_BREAKOUT_CHANNEL)), &[], RunData::Synthetic, 0, &env, TapPlan::record_all(), None)
.expect("carved r_breakout signal runs");
assert_eq!(via_file.metrics, via_carve.metrics, "loaded example grades identically to the carve");
}
/// Regenerates the shipped r_meanrev examples from the carved signal. Run by hand:
/// `cargo test -p aura-runner emit_r_meanrev_examples -- --ignored`. The examples are
/// green-by-construction (serialised from the builder, never hand-authored).
#[test]
#[ignore = "regenerates crates/aura-cli/examples/r_meanrev{,_open}.json; run by hand"]
fn emit_r_meanrev_examples() {
std::fs::create_dir_all(concat!(env!("CARGO_MANIFEST_DIR"), "/../aura-cli/examples"))
.expect("examples dir");
std::fs::write(
concat!(env!("CARGO_MANIFEST_DIR"), "/../aura-cli/examples/r_meanrev.json"),
blueprint_to_json(&r_meanrev_signal(Some(R_MEANREV_WINDOW), Some(R_MEANREV_BAND_K)))
.expect("serialize closed r_meanrev"),
)
.expect("write examples/r_meanrev.json");
std::fs::write(
concat!(env!("CARGO_MANIFEST_DIR"), "/../aura-cli/tests/fixtures/r_meanrev_open.json"),
blueprint_to_json(&r_meanrev_signal(None, None)).expect("serialize open r_meanrev"),
)
.expect("write tests/fixtures/r_meanrev_open.json");
}
/// Regenerates the shipped r_channel examples from the carved signal. Run by hand:
/// `cargo test -p aura-runner emit_r_channel_examples -- --ignored`. The examples are
/// green-by-construction (serialised from the builder, never hand-authored).
#[test]
#[ignore = "regenerates crates/aura-cli/examples/r_channel{,_open}.json; run by hand"]
fn emit_r_channel_examples() {
std::fs::create_dir_all(concat!(env!("CARGO_MANIFEST_DIR"), "/../aura-cli/examples"))
.expect("examples dir");
std::fs::write(
concat!(env!("CARGO_MANIFEST_DIR"), "/../aura-cli/examples/r_channel.json"),
blueprint_to_json(&r_channel_signal(Some(R_CHANNEL_LENGTH))).expect("serialize closed r_channel"),
)
.expect("write examples/r_channel.json");
std::fs::write(
concat!(env!("CARGO_MANIFEST_DIR"), "/../aura-cli/tests/fixtures/r_channel_open.json"),
blueprint_to_json(&r_channel_signal(None)).expect("serialize open r_channel"),
)
.expect("write tests/fixtures/r_channel_open.json");
}
/// The shipped examples are a faithful serialisation of the carved signal
/// (the r_breakout pin pattern): re-serialising the carve equals the
/// checked-in bytes; a drift between builder and file breaks it.
#[test]
fn shipped_r_channel_examples_serialize_the_carved_signal() {
assert_eq!(
blueprint_to_json(&r_channel_signal(Some(R_CHANNEL_LENGTH))).expect("serialize closed"),
include_str!("../../aura-cli/examples/r_channel.json"),
);
assert_eq!(
blueprint_to_json(&r_channel_signal(None)).expect("serialize open"),
include_str!("../../aura-cli/tests/fixtures/r_channel_open.json"),
);
}
/// A 10-bar high/low/close fixture (canonical column order: high, low,
/// close) that warms the hl_channel graph (Delay(1) + Rolling(3)) and
/// breaks out upward then downward.
fn hlc_sources() -> Vec<Box<dyn aura_engine::Source>> {
let high = [10.5_f64, 10.6, 10.4, 10.8, 11.5, 12.0, 12.2, 11.0, 10.2, 9.8];
let low = [9.5_f64, 9.7, 9.6, 9.9, 10.8, 11.4, 11.6, 10.1, 9.4, 9.0];
let close = [10.0_f64, 10.2, 10.0, 10.5, 11.3, 11.8, 12.0, 10.4, 9.6, 9.2];
[&high[..], &low[..], &close[..]]
.into_iter()
.map(|col| {
let series: Vec<(Timestamp, Scalar)> = col
.iter()
.enumerate()
.map(|(i, &v)| (Timestamp(i as i64 + 1), Scalar::f64(v)))
.collect();
Box::new(VecSource::new(series)) as Box<dyn aura_engine::Source>
})
.collect()
}
/// The shipped closed example, reloaded through the data plane and run
/// through the MULTI-COLUMN wrap, emits bit-identical bias rows to the
/// carved signal — r_channel's durable equivalence anchor (the r_breakout
/// idiom, over three VecSource columns instead of RunData::Synthetic,
/// which honestly refuses multi-column signals). `wrap_r`'s ex tap reads
/// `sig.output("bias")` directly, so `rx_ex` carries the raw signal bias.
#[test]
fn r_channel_example_loaded_runs_identically_to_the_carved_signal() {
let run = |signal: Composite| -> Vec<(Timestamp, Vec<Scalar>)> {
let binding =
crate::binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new())
.expect("high/low/close roles resolve");
let (tx_eq, _rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let (tx_r, _rx_r) = mpsc::channel();
let (tx_req, _rx_req) = mpsc::channel();
let flat = wrap_r(
signal,
tx_eq,
tx_ex,
tx_r,
tx_req,
StopRule::Vol { length: 3, k: 2.0 },
false,
SYNTHETIC_PIP_SIZE,
&binding,
None,
)
.compile_with_params(&[])
.expect("closed hl_channel wraps to a valid harness");
let mut h = Harness::bootstrap(flat).expect("hl_channel harness bootstraps");
h.run(hlc_sources());
rx_ex.try_iter().collect()
};
let loaded = blueprint_from_json(include_str!("../../aura-cli/examples/r_channel.json"), &|t| std_vocabulary(t))
.expect("shipped r_channel example loads");
let via_file = run(loaded);
let via_carve = run(r_channel_signal(Some(R_CHANNEL_LENGTH)));
assert!(!via_carve.is_empty(), "the channel must emit bias rows over the fixture");
assert!(
via_carve.iter().any(|(_, row)| row.iter().any(|s| s.as_f64() != 0.0)),
"the fixture must drive a non-zero bias (an all-zero run would make \
the equivalence vacuous)"
);
assert_eq!(via_file, via_carve, "loaded example emits the carve's bias rows");
}
/// The shipped examples are a faithful serialisation of the carved signal (#159 cut 3):
/// re-serialising the carve equals the checked-in bytes. Green-by-construction with the
/// emitter; a drift between builder and file breaks it.
#[test]
fn shipped_r_meanrev_examples_serialize_the_carved_signal() {
assert_eq!(
blueprint_to_json(&r_meanrev_signal(Some(R_MEANREV_WINDOW), Some(R_MEANREV_BAND_K))).expect("closed"),
include_str!("../../aura-cli/examples/r_meanrev.json"),
);
assert_eq!(
blueprint_to_json(&r_meanrev_signal(None, None)).expect("open"),
include_str!("../../aura-cli/tests/fixtures/r_meanrev_open.json"),
);
}
/// The shipped closed example, reloaded through the data plane and run, grades
/// bit-identically to running the carved signal directly (#159 cut 3). This is
/// r_meanrev's durable equivalence anchor after the fused builder retires: it pins
/// that `examples/r_meanrev.json` still produces the carve's grade, with no
/// hardcoded golden. `Composite` is `!Clone`, so the example is loaded twice.
#[test]
fn r_meanrev_example_loaded_runs_identically_to_the_carved_signal() {
let env = Env::std();
let loaded = blueprint_from_json(include_str!("../../aura-cli/examples/r_meanrev.json"), &|t| std_vocabulary(t))
.expect("shipped r_meanrev example loads");
let (via_file, _) = run_signal_r(loaded, &[], RunData::Synthetic, 0, &env, TapPlan::record_all(), None)
.expect("r_meanrev example runs");
let (via_carve, _) = run_signal_r(
r_meanrev_signal(Some(R_MEANREV_WINDOW), Some(R_MEANREV_BAND_K)),
&[],
RunData::Synthetic,
0,
&env,
TapPlan::record_all(),
None,
)
.expect("carved r_meanrev signal runs");
assert_eq!(via_file.metrics, via_carve.metrics, "loaded example grades identically to the carve");
}
/// #234: the optional cost leg records an aggregate cost stream positionally
/// 1:1 with the R record (`summarize_r`'s positional-join contract) plus a
/// per-cycle net_r_equity curve, in non-reduce trace mode. The cost-less
/// side (`cost: None` == today's graph, byte-identical) is pinned by the
/// suite's untouched equivalence anchors.
#[test]
fn wrap_r_cost_leg_records_cost_rows_co_temporal_with_the_r_record() {
let signal = load_closed_r_sma();
let binding = crate::binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new())
.expect("the price role resolves");
let (tx_eq, _rx_eq) = mpsc::channel();
let (tx_ex, _rx_ex) = mpsc::channel();
let (tx_r, rx_r) = mpsc::channel();
let (tx_req, _rx_req) = mpsc::channel();
let (tx_cost, rx_cost) = mpsc::channel();
let (tx_net, rx_net) = mpsc::channel();
let leg = CostLeg {
nodes: vec![ConstantCost::builder().bind("cost_per_trade", Scalar::f64(0.0005))],
tx_cost,
tx_net,
};
let flat = wrap_r(
signal,
tx_eq,
tx_ex,
tx_r,
tx_req,
StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K },
false,
SYNTHETIC_PIP_SIZE,
&binding,
Some(leg),
)
.compile_with_params(&[])
.expect("costed wrap builds");
let mut h = Harness::bootstrap(flat).expect("costed harness bootstraps");
let src: Vec<Box<dyn aura_engine::Source>> = vec![Box::new(VecSource::new(r_sma_prices()))];
h.run(src);
let r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
let cost_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_cost.try_iter().collect();
let net_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_net.try_iter().collect();
assert!(!r_rows.is_empty(), "the fixture must warm the executor");
assert_eq!(
cost_rows.len(),
r_rows.len(),
"the cost stream is positionally 1:1 with the R record (co-temporality)"
);
assert_eq!(net_rows.len(), cost_rows.len(), "the net curve emits per cost row");
assert!(
cost_rows.iter().any(|(_, row)| row[0].as_f64() != 0.0),
"at least one close must charge a non-zero cost (non-vacuous)"
);
}
/// #234: the reduce-mode cost branch (a `GatedRecorder` with the appended
/// `closed_this_cycle` gate column) stays co-temporal with the gated R
/// record too — the same 1:1 join contract as the trace-mode leg above,
/// but over the member/sweep run path `summarize_r` actually consumes.
#[test]
fn wrap_r_cost_leg_in_reduce_mode_stays_co_temporal_with_the_gated_r_record() {
let signal = load_closed_r_sma();
let binding = crate::binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new())
.expect("the price role resolves");
let (tx_eq, _rx_eq) = mpsc::channel();
let (tx_ex, _rx_ex) = mpsc::channel();
let (tx_r, rx_r) = mpsc::channel();
let (tx_req, _rx_req) = mpsc::channel();
let (tx_cost, rx_cost) = mpsc::channel();
let (tx_net, _rx_net) = mpsc::channel();
let leg = CostLeg {
nodes: vec![ConstantCost::builder().bind("cost_per_trade", Scalar::f64(0.0005))],
tx_cost,
tx_net,
};
let flat = wrap_r(
signal,
tx_eq,
tx_ex,
tx_r,
tx_req,
StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K },
true,
SYNTHETIC_PIP_SIZE,
&binding,
Some(leg),
)
.compile_with_params(&[])
.expect("costed reduce-mode wrap builds");
let mut h = Harness::bootstrap(flat).expect("costed reduce-mode harness bootstraps");
let src: Vec<Box<dyn aura_engine::Source>> = vec![Box::new(VecSource::new(r_sma_prices()))];
h.run(src);
let r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
let cost_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_cost.try_iter().collect();
assert!(!r_rows.is_empty(), "the fixture must warm and close at least one trade");
assert_eq!(
cost_rows.len(),
r_rows.len(),
"the reduce-mode gated cost stream is positionally 1:1 with the gated R record"
);
assert!(
cost_rows.iter().any(|(_, row)| row[0].as_f64() != 0.0),
"at least one gated close must charge a non-zero cost (non-vacuous)"
);
}
/// #234: a cost component that declares an extra `volatility` port (the
/// `VolSlippageCost` shape) is wired to a live realized-range proxy built
/// from the close role (`RollingMax`/`RollingMin`/`Sub` over
/// `SLIP_VOL_LENGTH`), discovered purely from the component's own schema
/// past the geometry prefix (`vol_slots`) — never a disconnected or
/// hardwired-zero input. If that wiring regresses, `ctx.f64_in(GEOMETRY_WIDTH)`
/// inside the component stays permanently empty and every charge is exactly
/// 0.0 (`vol_not_yet_warm_emits_zero_cost_co_temporally`'s withhold case), so a
/// non-zero charge over a genuinely moving price series is a direct witness
/// that the proxy reached the component. The two tests above use
/// `ConstantCost`, which never touches this wiring at all.
#[test]
fn wrap_r_cost_leg_wires_the_vol_slippage_proxy_from_the_close_role() {
let signal = load_closed_r_sma();
let binding = crate::binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new())
.expect("the price role resolves");
let (tx_eq, _rx_eq) = mpsc::channel();
let (tx_ex, _rx_ex) = mpsc::channel();
let (tx_r, rx_r) = mpsc::channel();
let (tx_req, _rx_req) = mpsc::channel();
let (tx_cost, rx_cost) = mpsc::channel();
let (tx_net, _rx_net) = mpsc::channel();
let leg = CostLeg {
nodes: vec![VolSlippageCost::builder().bind("slip_vol_mult", Scalar::f64(0.5))],
tx_cost,
tx_net,
};
let flat = wrap_r(
signal,
tx_eq,
tx_ex,
tx_r,
tx_req,
StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K },
false,
SYNTHETIC_PIP_SIZE,
&binding,
Some(leg),
)
.compile_with_params(&[])
.expect("vol-slippage-costed wrap builds");
let mut h = Harness::bootstrap(flat).expect("vol-slippage-costed harness bootstraps");
let src: Vec<Box<dyn aura_engine::Source>> = vec![Box::new(VecSource::new(r_sma_prices()))];
h.run(src);
let r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
let cost_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_cost.try_iter().collect();
assert!(!r_rows.is_empty(), "the fixture must warm and close at least one trade");
assert_eq!(
cost_rows.len(),
r_rows.len(),
"the vol-slippage cost stream stays positionally 1:1 with the R record"
);
assert!(
cost_rows.iter().any(|(_, row)| row[0].as_f64() != 0.0),
"the volatility proxy must actually feed the component — a disconnected \
proxy leaves cost_in_r at exactly 0.0 on every close: {cost_rows:?}"
);
}
/// #234: a member run under a constant cost model nets the hand-computed
/// per-trade cost — `net_expectancy_r ≈ expectancy_r mean(cost_per_trade
/// / |entry stop|)` over summarize_r's ledger (closed rows + the
/// window-end open row; the CostRunner R-normalization contract), while
/// every gross field stays byte-identical to the cost-less run; and the
/// manifest stamps the component for reproduce to re-derive.
#[test]
fn run_blueprint_member_joins_a_constant_cost_model_into_net_metrics() {
let env = Env::std();
let doc = blueprint_to_json(&load_closed_r_sma()).expect("serializes");
let reload = || blueprint_from_json(&doc, &|t| std_vocabulary(t)).expect("loads");
let space = blueprint_axis_probe(&doc, &env).param_space();
let binding = crate::binding::resolve_binding("costnet", reload().input_roles(), &BTreeMap::new())
.expect("the price role resolves");
let stop = StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K };
// The CLI shell's `DataSource::Synthetic` doesn't cross into aura-runner
// (it's dispatch-layer, #295 out of scope here): this module's own
// `RunData::Synthetic` + `resolve_run_data` stands in, sourcing
// `r_sma_prices()` instead of the CLI's `showcase_prices()` — a
// different but equally non-flat synthetic fixture. Every assertion
// below is computed from the actual run (never a fixture-specific
// literal), so the substitution preserves the property under test.
let sources = || resolve_run_data(&RunData::Synthetic, &env, &binding).expect("test data resolves").0;
let (_, window, pip) =
resolve_run_data(&RunData::Synthetic, &env, &binding).expect("test data resolves");
const CPT: f64 = 0.0005; // price units; the synthetic stream trades near 1.0
let run = |cost: &[aura_research::CostSpec]| {
run_blueprint_member(
reload(),
&[],
&space,
sources(),
window,
0,
pip,
"topo",
&env,
stop,
&binding,
cost,
"GER40",
)
.expect("GER40-keyed cost fixture resolves")
};
let gross = run(&[]);
let netted = run(&[aura_research::CostSpec::Constant {
cost_per_trade: aura_research::CostValue::Scalar(CPT),
}]);
// The trade geometry, independently: a non-reduce cost-less wrap over
// the same realization, draining the dense R record whose ledger
// summarize_r reads.
let (tx_eq, _rx_eq) = mpsc::channel();
let (tx_ex, _rx_ex) = mpsc::channel();
let (tx_r, rx_r) = mpsc::channel();
let (tx_req, _rx_req) = mpsc::channel();
let flat = wrap_r(reload(), tx_eq, tx_ex, tx_r, tx_req, stop, false, pip, &binding, None)
.compile_with_params(&[])
.expect("wraps");
let mut h = Harness::bootstrap(flat).expect("bootstraps");
h.run(sources());
let r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
// summarize_r's ledger: closed rows, plus the final row if open — each
// charged cost_per_trade / |entry stop| (0 when the latched distance
// is 0). PM record cols: closed=0, entry_price=6, stop_price=7,
// open=11 (the aura-analysis r_col contract).
let mut costs: Vec<f64> = Vec::new();
for (i, (_, row)) in r_rows.iter().enumerate() {
let is_last = i == r_rows.len() - 1;
if row[0].as_bool() || (is_last && row[11].as_bool()) {
let latched = (row[6].as_f64() - row[7].as_f64()).abs();
costs.push(if latched > 0.0 { CPT / latched } else { 0.0 });
}
}
let g = gross.metrics.r.as_ref().expect("gross member carries R metrics");
let n = netted.metrics.r.as_ref().expect("netted member carries R metrics");
assert_eq!(
costs.len() as u64, g.n_trades,
"the independent ledger walk must see the member's trades"
);
assert!(costs.iter().any(|&c| c > 0.0), "at least one trade must charge (non-vacuous)");
let mean_cost = costs.iter().sum::<f64>() / costs.len() as f64;
assert_eq!(gross.metrics.total_pips, netted.metrics.total_pips, "gross pips unchanged");
assert_eq!(g.expectancy_r, n.expectancy_r, "gross R stays byte-identical under cost");
assert_eq!(g.n_trades, n.n_trades);
assert_eq!(g.net_expectancy_r, g.expectancy_r, "cost-less: net == gross");
assert!(
(n.net_expectancy_r - (g.expectancy_r - mean_cost)).abs() < 1e-12,
"net = gross mean per-trade cost: net {} gross {} mean_cost {mean_cost}",
n.net_expectancy_r,
g.expectancy_r
);
// The manifest stamps the component (the reproduce re-derivation carrier)...
assert!(
netted
.manifest
.params
.iter()
.any(|(k, v)| k == "cost[0].cost_per_trade" && *v == Scalar::f64(CPT)),
"member manifest stamps the cost component: {:?}",
netted.manifest.params
);
// ...and a cost-less member stamps nothing (content/label stability).
assert!(
!gross.manifest.params.iter().any(|(k, _)| k.starts_with("cost[")),
"a cost-less member stamps no cost params"
);
}
#[test]
/// Review fix (#297 fork 3, item 1): the worker-channel seam a live sweep
/// observes — `run_blueprint_member` given a cost model whose per-instrument
/// map lacks the requested instrument refuses `Err(MemberFault::Bind(msg))`
/// with `msg` byte-identical to `CostKnobFault::message()` (the
/// `cost_knob_fault_message_is_byte_pinned` pin in `translate.rs`, one level
/// up the call chain) — never a panic that `catch_unwind` would instead
/// record as `MemberFault::Panic`. The fault fires before the harness is
/// even bootstrapped, so `sources`/`window` are minimal placeholders.
fn run_blueprint_member_propagates_the_cost_knob_fault_as_a_bind_message() {
let env = Env::std();
let signal = r_meanrev_signal(Some(3), Some(0.0));
let binding = crate::binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new())
.expect("the price role resolves");
let stop = StopRule::Vol { length: 3, k: 2.0 };
let window = (Timestamp(0), Timestamp(0));
let cost = [aura_research::CostSpec::Constant {
cost_per_trade: aura_research::CostValue::PerInstrument(std::collections::BTreeMap::from(
[("EURUSD".to_string(), 2.0)],
)),
}];
let err = match run_blueprint_member(
signal, &[], &[], Vec::new(), window, 0, 1.0, "topo", &env, stop, &binding, &cost, "GER40",
) {
Err(e) => e,
Ok(_) => panic!("GER40 is not in the map — the run must refuse, not succeed"),
};
let expected =
crate::translate::CostKnobFault { knob: "cost_per_trade", instrument: "GER40".to_string() }
.message();
assert_eq!(err, MemberFault::Bind(expected));
}
/// #262 write-side: `run_blueprint_member` given `StopRule::VolTf` actually
/// bootstraps and runs the member (the `risk_executor`/`VolTfStop` arm,
/// end-to-end over a real synthetic realization, not a hand-built value)
/// and stamps all three knobs into the manifest under the keys
/// `stop_rule_from_params`'s round-trip above reads back — the write half
/// of the manifest round-trip pair.
#[test]
fn run_blueprint_member_stamps_the_vol_tf_stop_knobs() {
let env = Env::std();
let doc = blueprint_to_json(&load_closed_r_sma()).expect("serializes");
let reload = || blueprint_from_json(&doc, &|t| std_vocabulary(t)).expect("loads");
let space = blueprint_axis_probe(&doc, &env).param_space();
let binding = crate::binding::resolve_binding("voltfstamp", reload().input_roles(), &BTreeMap::new())
.expect("the price role resolves");
let stop = StopRule::VolTf { period_minutes: 60, length: 3, k: 2.0 };
// See `run_blueprint_member_joins_a_constant_cost_model_into_net_metrics`'s
// note: `RunData::Synthetic` + `resolve_run_data` stands in for the
// CLI-only `DataSource::Synthetic` this test used before the #295 move.
let (sources, window, pip) =
resolve_run_data(&RunData::Synthetic, &env, &binding).expect("test data resolves");
let run = run_blueprint_member(
reload(),
&[],
&space,
sources,
window,
0,
pip,
"topo",
&env,
stop,
&binding,
&[],
"GER40",
)
.expect("no cost model: nothing to refuse");
assert!(
run.manifest.params.contains(&("stop_period_minutes".to_string(), Scalar::i64(60))),
"manifest must stamp stop_period_minutes: {:?}",
run.manifest.params
);
assert!(
run.manifest.params.contains(&("stop_length".to_string(), Scalar::i64(3))),
"manifest must stamp stop_length: {:?}",
run.manifest.params
);
assert!(
run.manifest.params.contains(&("stop_k".to_string(), Scalar::f64(2.0))),
"manifest must stamp stop_k: {:?}",
run.manifest.params
);
}
/// #338 write-side: `run_blueprint_member` given `StopRule::Fixed` actually
/// bootstraps and runs the member (the `risk_executor`/`FixedStop` arm,
/// end-to-end over a real synthetic realization) and stamps `stop_distance`
/// into the manifest under the key `stop_rule_from_params`'s round-trip
/// reads back — the `run_blueprint_member_stamps_the_vol_tf_stop_knobs`
/// precedent above, `Fixed` edition.
#[test]
fn run_blueprint_member_stamps_the_fixed_stop_distance() {
let env = Env::std();
let doc = blueprint_to_json(&load_closed_r_sma()).expect("serializes");
let reload = || blueprint_from_json(&doc, &|t| std_vocabulary(t)).expect("loads");
let space = blueprint_axis_probe(&doc, &env).param_space();
let binding = crate::binding::resolve_binding("fixedstamp", reload().input_roles(), &BTreeMap::new())
.expect("the price role resolves");
let stop = StopRule::Fixed(10.0);
let (sources, window, pip) =
resolve_run_data(&RunData::Synthetic, &env, &binding).expect("test data resolves");
let run = run_blueprint_member(
reload(),
&[],
&space,
sources,
window,
0,
pip,
"topo",
&env,
stop,
&binding,
&[],
"GER40",
)
.expect("no cost model: nothing to refuse");
assert!(
run.manifest.params.contains(&("stop_distance".to_string(), Scalar::f64(10.0))),
"manifest must stamp stop_distance: {:?}",
run.manifest.params
);
assert!(
!run.manifest.params.iter().any(|(k, _)| k == "stop_length" || k == "stop_k"),
"a Fixed stop stamps no vol knobs: {:?}",
run.manifest.params
);
}
/// A fresh project root so a run's `runs/` lands in a temp dir.
fn temp_project_env(name: &str) -> (Env, PathBuf) {
let root = std::env::temp_dir()
.join(format!("aura-member-plan-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create temp project root");
let env = Env::with_project(ProjectEnv {
root: root.clone(),
toml: Default::default(),
commit: None,
native: None,
});
(env, root)
}
/// The shipped `examples/r_sma.json` with one declared tap on node 0
/// ("fast", SMA(length=2)) field 0 — the `tap_recording.rs` patch shape.
/// `Composite` is `!Clone`, so each run loads it afresh.
fn tapped_r_sma() -> Composite {
let doc = include_str!("../../aura-cli/examples/r_sma.json");
let mut v: serde_json::Value = serde_json::from_str(doc).expect("parse r_sma.json");
v["blueprint"]["taps"] =
serde_json::json!([{"name": "fast_tap", "from": {"node": 0, "field": 0}}]);
let patched = serde_json::to_string(&v).expect("re-serialize");
blueprint_from_json(&patched, &|t| std_vocabulary(t)).expect("tapped r_sma loads")
}
fn read_tap_json(root: &Path) -> serde_json::Value {
let text = std::fs::read_to_string(
root.join("runs").join("traces").join("sma_signal").join("fast_tap.json"),
)
.expect("persisted tap trace");
serde_json::from_str(&text).expect("parse tap trace")
}
/// #283 acceptance: a fold plan's one-row summary and a live plan's
/// collected stream both agree with the recorded series of the same
/// blueprint (C1 — three identical runs, three drain policies), and a
/// live-only plan persists nothing at all.
#[test]
fn fold_and_live_plans_agree_with_the_recorded_series() {
// Run A: record (the charting question).
let (env_a, root_a) = temp_project_env("record");
run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_a, TapPlan::record_all(), None)
.expect("run A records");
let series = read_tap_json(&root_a);
let ts: Vec<i64> =
series["ts"].as_array().unwrap().iter().map(|v| v.as_i64().unwrap()).collect();
let vals: Vec<f64> =
series["columns"][0].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
assert!(!vals.is_empty(), "the recorded series is the reference");
// Run B: fold to mean (the aggregate question, same blueprint).
let (env_b, root_b) = temp_project_env("fold");
let mut plan_b = TapPlan::record_all();
plan_b.subscribe("fast_tap", TapSubscription::named("mean"));
run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_b, plan_b, None)
.expect("run B folds");
let row = read_tap_json(&root_b);
let mean = vals.iter().sum::<f64>() / vals.len() as f64; // sequential — FoldState's order
assert_eq!(row["ts"], serde_json::json!([*ts.last().unwrap()]));
assert_eq!(row["columns"], serde_json::json!([[mean]]), "fold row == slice mean, bit-exact");
// Run C: live only (nothing persisted; the closure sees the series).
let (env_c, root_c) = temp_project_env("live");
let (live_tx, live_rx) = std::sync::mpsc::channel();
let mut plan_c = TapPlan::empty();
plan_c.subscribe(
"fast_tap",
TapSubscription::live(move |ts, cell| {
let _ = live_tx.send((ts.0, cell.f64()));
}),
);
run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_c, plan_c, None)
.expect("run C runs live-only");
let got: Vec<(i64, f64)> = live_rx.try_iter().collect();
let want: Vec<(i64, f64)> = ts.iter().copied().zip(vals.iter().copied()).collect();
assert_eq!(got, want, "the live closure saw exactly the recorded series (C1)");
assert!(!root_c.join("runs").exists(), "a live-only plan persists nothing");
}
/// Plan refusals are typed and fire before any store I/O.
#[test]
fn bind_tap_plan_refuses_unknown_tap_and_unknown_label_before_the_store() {
let (env, root) = temp_project_env("refusals");
// Unknown tap name.
let mut flat = tapped_r_sma().compile_with_params(&[]).expect("tapped r_sma compiles");
let mut plan = TapPlan::record_all();
plan.subscribe("no_such_tap", TapSubscription::named("mean"));
// `BoundTaps` (the `Ok` side) carries no `Debug`, so `expect_err` is
// unavailable here — match explicitly instead (same refusal intent).
let err = match bind_tap_plan(&mut flat, plan, &env, "sma_signal") {
Err(e) => e,
Ok(_) => panic!("unknown tap must be refused"),
};
assert!(
matches!(err, TapPlanError::UnknownTap { ref name, .. } if name == "no_such_tap"),
"{err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("fast_tap"),
"unknown-tap refusal enumerates the declared taps (fixture declares only fast_tap): {msg}"
);
// Unknown label — the refusal enumerates the roster.
let mut flat2 = tapped_r_sma().compile_with_params(&[]).expect("tapped r_sma compiles");
let mut plan2 = TapPlan::record_all();
plan2.subscribe("fast_tap", TapSubscription::named("p95"));
let err2 = match bind_tap_plan(&mut flat2, plan2, &env, "sma_signal") {
Err(e) => e,
Ok(_) => panic!("unknown label must be refused"),
};
let msg = err2.to_string();
assert!(msg.starts_with("unknown fold 'p95'") && msg.contains("mean"), "{msg}");
assert!(!root.join("runs").exists(), "refusals fire before any store write");
}
/// C1: two identical record-all runs produce byte-identical tap files.
#[test]
fn two_identical_record_runs_produce_byte_identical_tap_files() {
let (env_a, root_a) = temp_project_env("det-a");
let (env_b, root_b) = temp_project_env("det-b");
run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_a, TapPlan::record_all(), None)
.expect("run a records");
run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_b, TapPlan::record_all(), None)
.expect("run b records");
let a = std::fs::read(
root_a.join("runs").join("traces").join("sma_signal").join("fast_tap.json"),
)
.expect("run a trace");
let b = std::fs::read(
root_b.join("runs").join("traces").join("sma_signal").join("fast_tap.json"),
)
.expect("run b trace");
assert_eq!(a, b, "same input, same bytes (the index carries env provenance; the tap file must not)");
}
/// #297: a refusal returns through the Result seam — the embedding
/// process survives to read it (the #283 fieldtest scenario that used
/// to kill the host).
#[test]
fn run_signal_r_refuses_in_process_instead_of_killing_the_host() {
let (env, _root) = temp_project_env("in-process-refusal");
let mut plan = TapPlan::record_all();
plan.subscribe("fast_tap", TapSubscription::named("median"));
let err = run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env, plan, None)
.unwrap_err();
assert_eq!(err.exit_code, 2);
assert!(err.message.contains("unknown fold"), "{}", err.message);
}
/// The #297 promise at the exposes-neither seam (fieldtest
/// milestone-safe-to-embed, finding B1): a blueprint exposing NEITHER a
/// `bias` output NOR a declared tap is not runnable on this leg, and the
/// library must say so as a returned `RunnerError` — C14 class 2
/// (argv-named content, the CLI guard's own prose family) — never via
/// `wrap_r`'s wiring `.expect` (`UnknownOutPort { name: "bias" }`,
/// member.rs:443) panicking and killing the embedding host. The CLI's
/// shell-side pre-validation (`exec_blueprint_leg`) must not be the only
/// guard on the seam the milestone promises is kill-free.
#[test]
fn run_signal_r_refuses_an_exposes_neither_blueprint_in_process() {
let (env, _root) = temp_project_env("exposes-neither");
// Minimal exposes-neither signal: one role, one node, no exposed
// output, no declared tap.
let mut g = GraphBuilder::new("blind_signal");
let s = g.add(Sub::builder());
let price = g.source_role("price", ScalarKind::F64);
g.feed(price, [s.input("lhs"), s.input("rhs")]);
let blind = g.build().expect("an exposes-neither composite still builds");
let err = run_signal_r(blind, &[], RunData::Synthetic, 0, &env, TapPlan::empty(), None)
.unwrap_err();
assert_eq!(err.exit_code, 2, "argv-named content (C14): {}", err.message);
assert!(err.message.contains("bias"), "{}", err.message);
assert!(err.message.contains("exposes neither"), "{}", err.message);
}
}