feat(cli,engine): multi-column opening on every real-data path (#231 task 4)
The six Close-only open sites route through the resolved binding's
column set via open_columns (probe_window, open_real_source,
run_sources/windowed_sources, the campaign member open, the trace
re-run open) — a strategy declaring high/low/close roles gets exactly
those columns, merged in the canonical C4 order. Synthetic data refuses
any binding beyond {close} with the honest --real remedy (the walk
generates a close series only); the sweep/mc family builders ride their
established Err contract, run/walkforward/reproduce refuse exit-1.
Proof at two layers: an engine e2e composes an OHLC blueprint from data
and runs it over four inline VecSources to hand-computed rows (the
merge-order pin), and an archive-gated CLI e2e sweeps a high/low
blueprint over GER40 opening the High and Low columns.
Verified: engine OHLC e2e green, full workspace suite green, clippy -D
warnings clean.
refs #231
This commit is contained in:
@@ -24,7 +24,7 @@ use aura_engine::{
|
||||
blueprint_from_json, f64_field, summarize, summarize_r, ColumnarTrace, FamilySelection,
|
||||
RunReport,
|
||||
};
|
||||
use aura_ingest::{instrument_geometry, unix_ms_to_epoch_ns, M1Field, M1FieldSource};
|
||||
use aura_ingest::{instrument_geometry, open_columns_window, unix_ms_to_epoch_ns};
|
||||
use aura_registry::{CampaignRunRecord, WriteKind};
|
||||
use aura_research::{
|
||||
campaign_to_json, content_id_of, parse_campaign, parse_process, validate_campaign,
|
||||
@@ -263,12 +263,14 @@ impl MemberRunner for CliMemberRunner<'_> {
|
||||
})?;
|
||||
let from = unix_ms_to_epoch_ns(window_ms.0);
|
||||
let to = unix_ms_to_epoch_ns(window_ms.1);
|
||||
let source = match M1FieldSource::open_window(
|
||||
// One source per resolved binding column, canonical order (the same
|
||||
// order `wrap_r` declares the roles in — the shared plan).
|
||||
let sources = match open_columns_window(
|
||||
&self.server,
|
||||
&cell.instrument,
|
||||
Some(from),
|
||||
Some(to),
|
||||
M1Field::Close,
|
||||
&binding.columns(),
|
||||
) {
|
||||
Some(s) => s,
|
||||
// No archived file overlaps the window at all.
|
||||
@@ -279,10 +281,11 @@ impl MemberRunner for CliMemberRunner<'_> {
|
||||
});
|
||||
}
|
||||
};
|
||||
// A window that overlaps a file but holds zero matching bars yields a
|
||||
// source whose first peek is None (open_window's documented contract)
|
||||
// — the same no-data condition.
|
||||
if aura_engine::Source::peek(&source).is_none() {
|
||||
// A window that overlaps a file but holds zero matching bars yields
|
||||
// sources whose first peek is None (open_window's documented contract)
|
||||
// — the same no-data condition (all columns decode the same bars, so
|
||||
// probing the first source covers the set).
|
||||
if aura_engine::Source::peek(sources[0].as_ref()).is_none() {
|
||||
return Err(MemberFault::NoData {
|
||||
instrument: cell.instrument.clone(),
|
||||
window_ms,
|
||||
@@ -298,7 +301,7 @@ impl MemberRunner for CliMemberRunner<'_> {
|
||||
signal,
|
||||
&point,
|
||||
&space,
|
||||
vec![Box::new(source)],
|
||||
sources,
|
||||
(from, to),
|
||||
0,
|
||||
geo.pip_size,
|
||||
@@ -823,23 +826,23 @@ pub(crate) fn persist_campaign_traces(
|
||||
cell_rec.instrument, from.0, to.0
|
||||
)
|
||||
};
|
||||
let source = M1FieldSource::open_window(
|
||||
server,
|
||||
&cell_rec.instrument,
|
||||
Some(from),
|
||||
Some(to),
|
||||
M1Field::Close,
|
||||
)
|
||||
.ok_or_else(no_data)?;
|
||||
if aura_engine::Source::peek(&source).is_none() {
|
||||
return Err(no_data());
|
||||
}
|
||||
let signal = blueprint_from_json(blueprint_json, &|t| env.resolve(t))
|
||||
.expect("stored blueprint passed the referential gate; reload is infallible");
|
||||
// Same name-default binding the member ran under (campaign
|
||||
// overrides thread through in the DataSection task).
|
||||
let binding =
|
||||
crate::binding::resolve_binding(&cell_rec.strategy, signal.input_roles(), &BTreeMap::new())?;
|
||||
let sources = open_columns_window(
|
||||
server,
|
||||
&cell_rec.instrument,
|
||||
Some(from),
|
||||
Some(to),
|
||||
&binding.columns(),
|
||||
)
|
||||
.ok_or_else(no_data)?;
|
||||
if aura_engine::Source::peek(sources[0].as_ref()).is_none() {
|
||||
return Err(no_data());
|
||||
}
|
||||
// The cell's OWN regime (#219/#212), not the hardcoded default: the
|
||||
// recorded member ran under `cell_rec.regime`, bound through the same
|
||||
// `stop_rule_for_regime` helper `CliMemberRunner::run_member` uses, so
|
||||
@@ -853,7 +856,6 @@ pub(crate) fn persist_campaign_traces(
|
||||
let mut h = crate::wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, stop, false, geo.pip_size, &binding)
|
||||
.bootstrap_with_cells(&point)
|
||||
.expect("the member's point re-bootstraps (it already ran this realization)");
|
||||
let sources: Vec<Box<dyn aura_engine::Source>> = vec![Box::new(source)];
|
||||
h.run(sources);
|
||||
// Drain ALL FOUR channels (`run_signal_r` leaves req undrained; the
|
||||
// trace path must not): eq/ex/req feed the taps, r feeds the metrics.
|
||||
|
||||
+113
-45
@@ -516,11 +516,15 @@ fn pip_or_refuse(
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe the full data window: open a single-pass probe `M1FieldSource`, drain it
|
||||
/// for the first/last timestamp, and return `(first, last)`. Refuses (via
|
||||
/// `no_real_data`) when the symbol/window yields no source or no bars. Shared by
|
||||
/// `open_real_source` (which needs the manifest window from a probe separate from
|
||||
/// the run source) and `DataSource::full_window`.
|
||||
/// 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_real_data`) when the symbol/window yields
|
||||
/// no source or no bars. Shared by `open_real_source` (which needs the manifest
|
||||
/// window from a probe separate from the run sources) and
|
||||
/// `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.
|
||||
fn probe_window(
|
||||
server: &std::sync::Arc<data_server::DataServer>,
|
||||
symbol: &str,
|
||||
@@ -528,28 +532,32 @@ fn probe_window(
|
||||
to_ms: Option<i64>,
|
||||
env: &project::Env,
|
||||
) -> (Timestamp, Timestamp) {
|
||||
let mut probe = aura_ingest::M1FieldSource::open(server, symbol, from_ms, to_ms, aura_ingest::M1Field::Close)
|
||||
.unwrap_or_else(|| no_real_data(symbol, env));
|
||||
let first = aura_engine::Source::peek(&probe).unwrap_or_else(|| no_real_data(symbol, env));
|
||||
let mut probe = aura_ingest::open_columns(server, symbol, from_ms, to_ms, &[aura_ingest::M1Field::Close])
|
||||
.unwrap_or_else(|| no_real_data(symbol, env))
|
||||
.pop()
|
||||
.expect("open_columns yields one source per requested field");
|
||||
let first = aura_engine::Source::peek(probe.as_ref()).unwrap_or_else(|| no_real_data(symbol, env));
|
||||
let mut last = first;
|
||||
while let Some((t, _)) = aura_engine::Source::next(&mut probe) {
|
||||
while let Some((t, _)) = aura_engine::Source::next(&mut *probe) {
|
||||
last = t;
|
||||
}
|
||||
(first, last)
|
||||
}
|
||||
|
||||
/// Open a real M1-close source for a recorded symbol over an optional window, returning
|
||||
/// the run source paired with its manifest `window` and per-instrument `pip_size`.
|
||||
/// 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 an stderr + exit 1). Pre-data refusals keep the
|
||||
/// the run-source open (each refusal an stderr + exit 1). Pre-data refusals keep the
|
||||
/// pip honest by construction. Used by `resolve_run_data`.
|
||||
fn open_real_source(
|
||||
symbol: &str,
|
||||
from_ms: Option<i64>,
|
||||
to_ms: Option<i64>,
|
||||
env: &project::Env,
|
||||
) -> (Box<dyn aura_engine::Source>, (Timestamp, Timestamp), f64) {
|
||||
fields: &[aura_ingest::M1Field],
|
||||
) -> (Vec<Box<dyn aura_engine::Source>>, (Timestamp, Timestamp), f64) {
|
||||
// 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 = std::sync::Arc::new(data_server::DataServer::new(env.data_path()));
|
||||
@@ -559,12 +567,9 @@ fn open_real_source(
|
||||
}
|
||||
// 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 source: Box<dyn aura_engine::Source> =
|
||||
match aura_ingest::M1FieldSource::open(&server, symbol, from_ms, to_ms, aura_ingest::M1Field::Close) {
|
||||
Some(s) => Box::new(s),
|
||||
None => no_real_data(symbol, env),
|
||||
};
|
||||
(source, window, pip)
|
||||
let sources = aura_ingest::open_columns(&server, symbol, from_ms, to_ms, fields)
|
||||
.unwrap_or_else(|| no_real_data(symbol, env));
|
||||
(sources, window, pip)
|
||||
}
|
||||
|
||||
impl DataSource {
|
||||
@@ -625,28 +630,31 @@ impl DataSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// A fresh full-window source per member (single-pass). Synthetic: showcase.
|
||||
fn run_sources(&self, env: &project::Env) -> Vec<Box<dyn aura_engine::Source>> {
|
||||
/// A fresh full-window source set per member (single-pass): the synthetic
|
||||
/// showcase close stream, or one real source per resolved binding column
|
||||
/// in canonical order (callers guard the synthetic arm to `{close}`).
|
||||
fn run_sources(&self, env: &project::Env, fields: &[aura_ingest::M1Field]) -> Vec<Box<dyn aura_engine::Source>> {
|
||||
match self {
|
||||
DataSource::Synthetic => vec![Box::new(VecSource::new(showcase_prices()))],
|
||||
DataSource::Real { server, symbol, from_ms, to_ms, .. } => vec![Box::new(
|
||||
aura_ingest::M1FieldSource::open(server, symbol, *from_ms, *to_ms, aura_ingest::M1Field::Close)
|
||||
.unwrap_or_else(|| no_real_data(symbol, env)),
|
||||
)],
|
||||
DataSource::Real { server, symbol, from_ms, to_ms, .. } => {
|
||||
aura_ingest::open_columns(server, symbol, *from_ms, *to_ms, fields)
|
||||
.unwrap_or_else(|| no_real_data(symbol, env))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A fresh windowed source for an IS/OOS sub-window (walk-forward). Synthetic:
|
||||
/// `walkforward_window_source`. Real: `open_window` (ns-native `Timestamp`).
|
||||
/// A fresh windowed source set for an IS/OOS sub-window (walk-forward).
|
||||
/// Synthetic: `walkforward_window_source`. Real: one source per resolved
|
||||
/// binding column over the ns-native window.
|
||||
fn windowed_sources(
|
||||
&self, from: Timestamp, to: Timestamp, env: &project::Env,
|
||||
&self, from: Timestamp, to: Timestamp, env: &project::Env, fields: &[aura_ingest::M1Field],
|
||||
) -> Vec<Box<dyn aura_engine::Source>> {
|
||||
match self {
|
||||
DataSource::Synthetic => vec![Box::new(walkforward_window_source(from, to))],
|
||||
DataSource::Real { server, symbol, .. } => vec![Box::new(
|
||||
aura_ingest::M1FieldSource::open_window(server, symbol, Some(from), Some(to), aura_ingest::M1Field::Close)
|
||||
.unwrap_or_else(|| no_real_data(symbol, env)),
|
||||
)],
|
||||
DataSource::Real { server, symbol, .. } => {
|
||||
aura_ingest::open_columns_window(server, symbol, Some(from), Some(to), fields)
|
||||
.unwrap_or_else(|| no_real_data(symbol, env))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1118,6 +1126,10 @@ fn reproduce_family_in(
|
||||
eprintln!("aura: {m}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
if matches!(data, DataSource::Synthetic) && !binding.close_only() {
|
||||
eprintln!("aura: {}", binding::synthetic_refusal(&hash, &binding));
|
||||
std::process::exit(1);
|
||||
}
|
||||
let space = wrap_r(reload(), tx_eq, tx_ex, tx_r, tx_req, stop, true, SYNTHETIC_PIP_SIZE, &binding).param_space();
|
||||
let point = point_from_params(&space, &stored.manifest.params);
|
||||
// A MonteCarlo member carries no tuning params (the params-join is empty), so its
|
||||
@@ -1150,7 +1162,7 @@ fn reproduce_family_in(
|
||||
// stored window bounds; the winner params come from the shared
|
||||
// manifest->cells recovery below (as Sweep members do).
|
||||
let (from, to) = stored.manifest.window;
|
||||
let s = data.windowed_sources(from, to, env);
|
||||
let s = data.windowed_sources(from, to, env, &binding.columns());
|
||||
let w = window_of(&s).expect("non-empty OOS window");
|
||||
(s, w)
|
||||
}
|
||||
@@ -1163,9 +1175,9 @@ fn reproduce_family_in(
|
||||
_ => match data {
|
||||
DataSource::Real { .. } => {
|
||||
let (from, to) = stored.manifest.window;
|
||||
(data.windowed_sources(from, to, env), (from, to))
|
||||
(data.windowed_sources(from, to, env, &binding.columns()), (from, to))
|
||||
}
|
||||
DataSource::Synthetic => (data.run_sources(env), data.full_window(env)),
|
||||
DataSource::Synthetic => (data.run_sources(env, &binding.columns()), data.full_window(env)),
|
||||
},
|
||||
};
|
||||
let rerun = run_blueprint_member(
|
||||
@@ -1374,13 +1386,15 @@ fn wrap_r(
|
||||
}
|
||||
|
||||
/// Resolve a `RunData` selector to the `(sources, window, pip_size)` triple the
|
||||
/// r-sma run paths feed to the harness: the built-in synthetic R stream,
|
||||
/// or a lazily-streamed real M1 close source (with its sidecar pip + probed window).
|
||||
/// 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)]
|
||||
fn resolve_run_data(
|
||||
data: &RunData,
|
||||
env: &project::Env,
|
||||
binding: &binding::ResolvedBinding,
|
||||
) -> (
|
||||
Vec<Box<dyn aura_engine::Source>>,
|
||||
(Timestamp, Timestamp),
|
||||
@@ -1394,8 +1408,7 @@ fn resolve_run_data(
|
||||
(sources, window, SYNTHETIC_PIP_SIZE)
|
||||
}
|
||||
RunData::Real { symbol, from, to } => {
|
||||
let (source, window, pip_size) = open_real_source(symbol, *from, *to, env);
|
||||
(vec![source], window, pip_size)
|
||||
open_real_source(symbol, *from, *to, env, &binding.columns())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1416,6 +1429,10 @@ fn run_signal_r(
|
||||
eprintln!("aura: {m}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
if matches!(data, RunData::Synthetic) && !binding.close_only() {
|
||||
eprintln!("aura: {}", binding::synthetic_refusal(signal.name(), &binding));
|
||||
std::process::exit(1);
|
||||
}
|
||||
let names: Vec<String> = signal
|
||||
.param_space()
|
||||
.iter()
|
||||
@@ -1427,7 +1444,7 @@ fn run_signal_r(
|
||||
// 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);
|
||||
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);
|
||||
let flat = wrapped
|
||||
.compile_with_params(params)
|
||||
@@ -1569,6 +1586,9 @@ fn blueprint_sweep_family(
|
||||
// Strict binding resolution (name defaults — the verb path carries no
|
||||
// campaign overrides): the family's open plan and wrap plan in one value.
|
||||
let binding = binding::resolve_binding(probe_signal.name(), probe_signal.input_roles(), &BTreeMap::new())?;
|
||||
if matches!(data, DataSource::Synthetic) && !binding.close_only() {
|
||||
return Err(binding::synthetic_refusal(probe_signal.name(), &binding));
|
||||
}
|
||||
let pip = data.pip_size();
|
||||
let window = data.full_window(env);
|
||||
// a single throwaway floated build, only to resolve param_space (borrow) then seed
|
||||
@@ -1601,7 +1621,7 @@ fn blueprint_sweep_family(
|
||||
.sweep(|point| {
|
||||
// fresh per-member graph (Composite is !Clone, reload per member) run through
|
||||
// the shared reduce-mode member path — the same fn reproduction re-runs.
|
||||
run_blueprint_member(reload(doc), point, &space, data.run_sources(env), window, 0, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, &binding)
|
||||
run_blueprint_member(reload(doc), point, &space, data.run_sources(env, &binding.columns()), window, 0, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, &binding)
|
||||
})
|
||||
// render the sweep terminal's BindError to a message (the fn's String error contract),
|
||||
// so `UnknownKnob("nope")` still surfaces verbatim at the IO wrapper.
|
||||
@@ -1632,7 +1652,7 @@ fn blueprint_sweep_over(
|
||||
binder = binder.axis(n, vals.clone());
|
||||
}
|
||||
binder.sweep_with_lattice(|point| {
|
||||
let sources = data.windowed_sources(from, to, env);
|
||||
let sources = data.windowed_sources(from, to, env, &binding.columns());
|
||||
let window = window_of(&sources).expect("non-empty in-sample window");
|
||||
run_blueprint_member(reload(doc), point, &space, sources, window, 0, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, binding)
|
||||
})
|
||||
@@ -1650,7 +1670,7 @@ fn run_oos_blueprint(
|
||||
let reload = blueprint_from_json(doc, &|t| env.resolve(t))
|
||||
.expect("doc parse-validated at the dispatch boundary; reload is infallible");
|
||||
let pip = data.pip_size();
|
||||
let sources = data.windowed_sources(from, to, env);
|
||||
let sources = data.windowed_sources(from, to, env, &binding.columns());
|
||||
let window = window_of(&sources).expect("non-empty out-of-sample window");
|
||||
let report = run_blueprint_member(reload, params, space, sources, window, 0, pip, topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, binding);
|
||||
(Vec::new(), report)
|
||||
@@ -1685,6 +1705,10 @@ fn blueprint_walkforward_family(
|
||||
eprintln!("aura: {m}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
if matches!(data, DataSource::Synthetic) && !binding.close_only() {
|
||||
eprintln!("aura: {}", binding::synthetic_refusal(probe_signal.name(), &binding));
|
||||
std::process::exit(1);
|
||||
}
|
||||
// Validate the `--axis` grid ONCE at the dispatch boundary, mirroring `aura sweep`
|
||||
// (which resolves its axes a single time before any member runs). `walk_forward` fans
|
||||
// the per-window closure out across the windows in parallel, so a `BindError` raised
|
||||
@@ -1746,6 +1770,11 @@ fn blueprint_mc_family(
|
||||
// Strict binding resolution (name defaults — mc's synthetic family binds
|
||||
// no campaign overrides); the exit-free Err contract of this builder.
|
||||
let binding = binding::resolve_binding(probe_signal.name(), probe_signal.input_roles(), &BTreeMap::new())?;
|
||||
if !binding.close_only() {
|
||||
// MC draws ALWAYS run the seeded synthetic close walk (real-data mc
|
||||
// routes through the campaign sugar and never reaches this builder).
|
||||
return Err(binding::synthetic_refusal(probe_signal.name(), &binding));
|
||||
}
|
||||
let pip = data.pip_size();
|
||||
// probe the wrapped param_space (the same probe the sweep resolves against);
|
||||
// MC needs it empty. `blueprint_axis_probe` is the single source of that wrap.
|
||||
@@ -3871,13 +3900,52 @@ mod tests {
|
||||
let env = project::Env::std();
|
||||
let d = DataSource::Synthetic;
|
||||
assert_eq!(d.pip_size(), SYNTHETIC_PIP_SIZE);
|
||||
assert!(!d.run_sources(&env).is_empty());
|
||||
assert!(!d.run_sources(&env, &[aura_ingest::M1Field::Close]).is_empty());
|
||||
assert_eq!(d.wf_window_sizes(), (24, 12, 12));
|
||||
// full_window equals window_of over the showcase stream (byte-unchanged source)
|
||||
let s: Vec<Box<dyn aura_engine::Source>> = vec![Box::new(VecSource::new(showcase_prices()))];
|
||||
assert_eq!(d.full_window(&env), window_of(&s).unwrap());
|
||||
}
|
||||
|
||||
/// A multi-column blueprint over synthetic data (a single close walk)
|
||||
/// refuses honestly through the builders' exit-free Err contract, naming
|
||||
/// the beyond-close columns and the --real remedy — never a panic from a
|
||||
/// source-count mismatch. High/low-consuming, closed (mc requires it),
|
||||
/// with the mandatory `bias` output.
|
||||
const OHLC_REFUSAL_BLUEPRINT: &str = r#"{
|
||||
"format_version": 1,
|
||||
"blueprint": {
|
||||
"name": "hl_range",
|
||||
"nodes": [ {"primitive":{"type":"Sub"}} ],
|
||||
"edges": [],
|
||||
"input_roles": [
|
||||
{"name":"high","targets":[{"node":0,"slot":0}],"source":"F64"},
|
||||
{"name":"low","targets":[{"node":0,"slot":1}],"source":"F64"}
|
||||
],
|
||||
"output": [{"node":0,"field":0,"name":"bias"}]
|
||||
}
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn synthetic_data_refuses_a_multi_column_blueprint() {
|
||||
let env = project::Env::std();
|
||||
let err = blueprint_mc_family(OHLC_REFUSAL_BLUEPRINT, 2, &DataSource::Synthetic, &env)
|
||||
.expect_err("a high/low blueprint cannot run over the synthetic close walk");
|
||||
assert_eq!(
|
||||
err,
|
||||
"strategy \"hl_range\" consumes columns beyond close (high, low) — synthetic \
|
||||
data generates a close series only; run with --real <SYMBOL>"
|
||||
);
|
||||
let err = blueprint_sweep_family(
|
||||
OHLC_REFUSAL_BLUEPRINT,
|
||||
&[("x".to_string(), vec![Scalar::i64(1)])],
|
||||
&DataSource::Synthetic,
|
||||
&env,
|
||||
)
|
||||
.expect_err("the synthetic sweep path refuses the same shape");
|
||||
assert!(err.contains("consumes columns beyond close"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wf_real_roller_sizes_are_90_30_30_days_in_ns() {
|
||||
// Independent expected value: a day reconstructed from its time units
|
||||
@@ -4134,7 +4202,7 @@ mod tests {
|
||||
reload(),
|
||||
&[],
|
||||
&space,
|
||||
data.run_sources(&env),
|
||||
data.run_sources(&env, &binding.columns()),
|
||||
window,
|
||||
0,
|
||||
pip,
|
||||
|
||||
@@ -1427,6 +1427,162 @@ fn reproduce_real_walkforward_family_does_not_panic() {
|
||||
);
|
||||
}
|
||||
|
||||
// Fixture blueprints for the verb-level multi-column binding coverage below
|
||||
// (#231 task 4 quality follow-up): a CLOSED high/low blueprint (`run`
|
||||
// requires zero free knobs) and an OPEN high/low blueprint with two
|
||||
// SMA-length axes (`walkforward` requires >= 1 axis). Written to a fresh
|
||||
// temp file per test rather than a tracked `examples/` fixture — these exist
|
||||
// only to drive the refusal / real-open contract, not as authoring samples.
|
||||
const HL_RANGE_CLOSED_BLUEPRINT: &str = r#"{
|
||||
"format_version": 1,
|
||||
"blueprint": {
|
||||
"name": "hl_range",
|
||||
"nodes": [ {"primitive":{"type":"Sub"}} ],
|
||||
"edges": [],
|
||||
"input_roles": [
|
||||
{"name":"high","targets":[{"node":0,"slot":0}],"source":"F64"},
|
||||
{"name":"low","targets":[{"node":0,"slot":1}],"source":"F64"}
|
||||
],
|
||||
"output": [{"node":0,"field":0,"name":"bias"}]
|
||||
}
|
||||
}"#;
|
||||
const HL_SIGNAL_OPEN_BLUEPRINT: &str = r#"{"format_version":1,"blueprint":{"name":"hl_signal","nodes":[{"primitive":{"type":"SMA","name":"fast"}},{"primitive":{"type":"SMA","name":"slow"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"high","targets":[{"node":0,"slot":0}],"source":"F64"},{"name":"low","targets":[{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
|
||||
|
||||
/// Property (#231 task 4 quality follow-up): `aura run` over a multi-column
|
||||
/// blueprint with NO `--real` (the synthetic default) refuses through the
|
||||
/// verb-level exit-1 + stderr contract — `binding::synthetic_refusal`'s exact
|
||||
/// prose, not merely its `Result` shape (the unit-mod test one layer down
|
||||
/// covers the `Err`-returning builders only). Data-free: the guard fires
|
||||
/// directly after binding resolution, before any archive access, so no
|
||||
/// gating is needed.
|
||||
#[test]
|
||||
fn run_synthetic_refuses_a_multi_column_blueprint_before_data_access() {
|
||||
let dir = temp_cwd("run_synthetic_multicolumn_refusal");
|
||||
let fixture = dir.join("hl_range.json");
|
||||
std::fs::write(&fixture, HL_RANGE_CLOSED_BLUEPRINT).expect("write fixture");
|
||||
let out = Command::new(BIN).args(["run", fixture.to_str().unwrap()]).output().expect("spawn aura run");
|
||||
assert_eq!(out.status.code(), Some(1), "status: {:?}", out.status);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(
|
||||
stderr.trim_end(),
|
||||
"aura: strategy \"hl_range\" consumes columns beyond close (high, low) — synthetic \
|
||||
data generates a close series only; run with --real <SYMBOL>"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Property (#231 task 4 quality follow-up): the sibling exit-process arm at
|
||||
/// `blueprint_walkforward_family` — `aura walkforward` over a multi-column
|
||||
/// blueprint with no `--real` — refuses identically. Data-free (the guard
|
||||
/// fires directly after binding resolution, before any window/archive
|
||||
/// access); run inside a temp cwd purely by the project's verb-test
|
||||
/// convention (no store write is expected on this refusal path either).
|
||||
#[test]
|
||||
fn walkforward_synthetic_refuses_a_multi_column_blueprint_before_data_access() {
|
||||
let dir = temp_cwd("walkforward_synthetic_multicolumn_refusal");
|
||||
let fixture = dir.join("hl_signal.json");
|
||||
std::fs::write(&fixture, HL_SIGNAL_OPEN_BLUEPRINT).expect("write fixture");
|
||||
let out = Command::new(BIN)
|
||||
.args([
|
||||
"walkforward", fixture.to_str().unwrap(),
|
||||
"--axis", "hl_signal.fast.length=2,4",
|
||||
"--axis", "hl_signal.slow.length=8",
|
||||
])
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.expect("spawn aura walkforward");
|
||||
assert_eq!(out.status.code(), Some(1), "status: {:?}", out.status);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(
|
||||
stderr.trim_end(),
|
||||
"aura: strategy \"hl_signal\" consumes columns beyond close (high, low) — synthetic \
|
||||
data generates a close series only; run with --real <SYMBOL>"
|
||||
);
|
||||
assert!(!dir.join("runs").exists(), "a refused synthetic family must not start a run store");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Property (#231 task 4 quality follow-up): the positive counterpart of the
|
||||
/// two refusals above — a multi-column blueprint over `--real GER40` opens
|
||||
/// BOTH the high and low columns end to end (`open_real_source` ->
|
||||
/// `resolve_run_data` -> `run_signal_r`) and produces a real `RunReport`,
|
||||
/// never the single-close weld the pre-task-4 code hardcoded. Gated on the
|
||||
/// local GER40 archive (skip-on-no-data convention).
|
||||
#[test]
|
||||
fn run_real_multi_column_blueprint_opens_high_and_low_columns() {
|
||||
if !local_data_present() {
|
||||
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
|
||||
return;
|
||||
}
|
||||
let dir = temp_cwd("run_real_multicolumn_open");
|
||||
let fixture = dir.join("hl_range.json");
|
||||
std::fs::write(&fixture, HL_RANGE_CLOSED_BLUEPRINT).expect("write fixture");
|
||||
let out = Command::new(BIN)
|
||||
.args([
|
||||
"run", fixture.to_str().unwrap(),
|
||||
"--real", "GER40",
|
||||
"--from", GER40_SEPT2024_FROM_MS,
|
||||
"--to", GER40_SEPT2024_TO_MS,
|
||||
])
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert_eq!(
|
||||
out.status.code(), Some(0),
|
||||
"a high/low blueprint over real GER40 data must run cleanly, got status={:?} stderr={}",
|
||||
out.status, String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(
|
||||
stdout.trim_start().starts_with("{\"manifest\":"),
|
||||
"exit 0 must carry a JSON report, got: {stdout}"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Property (#231 task 4, "every real-data path" — `run --real` above covers
|
||||
/// only `open_real_source`/`run_signal_r`; `sweep --real` walks a DISTINCT
|
||||
/// real-data path, `blueprint_sweep_family` -> `blueprint_sweep_over` ->
|
||||
/// `DataSource::windowed_sources`, which independently needed `binding.
|
||||
/// columns()` threaded through in task 4): a multi-column (high/low) OPEN
|
||||
/// blueprint swept over `--real GER40` opens both columns per member and
|
||||
/// produces one member line per grid point, not a source-count-mismatch panic
|
||||
/// or a fallback to a single close source. Gated on the local GER40 archive.
|
||||
#[test]
|
||||
fn sweep_real_multi_column_blueprint_opens_high_and_low_columns() {
|
||||
if !local_data_present() {
|
||||
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
|
||||
return;
|
||||
}
|
||||
let (dir, _g) = fresh_project();
|
||||
let fixture_dir = temp_cwd("sweep_real_multicolumn_open");
|
||||
let fixture = fixture_dir.join("hl_signal.json");
|
||||
std::fs::write(&fixture, HL_SIGNAL_OPEN_BLUEPRINT).expect("write fixture");
|
||||
let out = std::process::Command::new(BIN)
|
||||
.args([
|
||||
"sweep", fixture.to_str().unwrap(),
|
||||
"--real", "GER40",
|
||||
"--from", GER40_SEPT2024_FROM_MS,
|
||||
"--to", GER40_SEPT2024_TO_MS,
|
||||
"--axis", "hl_signal.fast.length=2,4",
|
||||
"--axis", "hl_signal.slow.length=8",
|
||||
])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||
let lines: Vec<&str> = stdout.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "one member line per grid point: {stdout}");
|
||||
for line in &lines {
|
||||
let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON");
|
||||
assert!(
|
||||
v["report"]["manifest"].is_object(),
|
||||
"each member carries a real report, not a source-mismatch fault: {line}"
|
||||
);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&fixture_dir);
|
||||
}
|
||||
|
||||
/// Property (#210 c0110 fieldtest, "the wrapped-name refusal mangles the axis
|
||||
/// the user typed"): the dissolved real-data blueprint sweep types `--axis`
|
||||
/// against the WRAPPED probe namespace (`sma_signal.fast.length`, what
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Acceptance pin for the harness-input-binding spec (#231): a MULTI-COLUMN
|
||||
//! blueprint (open/high/low/close input roles) composes from blueprint data
|
||||
//! and runs over four inline `VecSource` columns to hand-computed values —
|
||||
//! pinning the role-declaration/merge-order contract (role i is fed by source
|
||||
//! i; same-timestamp ties break by source index, i.e. canonical column order —
|
||||
//! C4) without any archive. Mirrors `rsi_from_blueprint_data.rs` (the same
|
||||
//! public seam: a JSON document + `blueprint_from_json` + an injected
|
||||
//! vocabulary, C24). Pure engine machinery — no CLI, no binding module.
|
||||
|
||||
use std::sync::mpsc;
|
||||
|
||||
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
|
||||
use aura_engine::{blueprint_from_json, BlueprintNode, Composite, Edge, Role, Target, VecSource};
|
||||
use aura_std::{std_vocabulary, Recorder};
|
||||
|
||||
// value = (high - low) + (close - open): every column is load-bearing, so any
|
||||
// mis-pairing of roles to sources (or a wrong merge tie-break) changes the
|
||||
// recorded rows. Node indices: 0 Sub(range = high - low) ·
|
||||
// 1 Sub(body = close - open) · 2 Add(range + body).
|
||||
const OHLC_BLUEPRINT_JSON: &str = r#"{
|
||||
"format_version": 1,
|
||||
"blueprint": {
|
||||
"name": "ohlc_probe",
|
||||
"nodes": [
|
||||
{"primitive":{"type":"Sub"}},
|
||||
{"primitive":{"type":"Sub"}},
|
||||
{"primitive":{"type":"Add"}}
|
||||
],
|
||||
"edges": [
|
||||
{"from":0,"to":2,"slot":0,"from_field":0},
|
||||
{"from":1,"to":2,"slot":1,"from_field":0}
|
||||
],
|
||||
"input_roles": [
|
||||
{"name":"open","targets":[{"node":1,"slot":1}]},
|
||||
{"name":"high","targets":[{"node":0,"slot":0}]},
|
||||
{"name":"low","targets":[{"node":0,"slot":1}]},
|
||||
{"name":"close","targets":[{"node":1,"slot":0}]}
|
||||
],
|
||||
"output": [{"node":2,"field":0,"name":"value"}]
|
||||
}
|
||||
}"#;
|
||||
|
||||
// Nest the loaded signal under a Rust root that declares the four source roles
|
||||
// in canonical column order (open, high, low, close) and records the single
|
||||
// output (the rsi_from_blueprint_data idiom — sinks are outside the std
|
||||
// vocabulary, so the recorder is added in Rust). Root role slot i targets the
|
||||
// nested composite's role i (input_roles order).
|
||||
fn run_recording(
|
||||
signal: Composite,
|
||||
columns: [Vec<(Timestamp, Scalar)>; 4],
|
||||
) -> Vec<(Timestamp, Vec<Scalar>)> {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let roles: Vec<Role> = ["open", "high", "low", "close"]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(slot, name)| Role {
|
||||
name: (*name).to_string(),
|
||||
targets: vec![Target { node: 0, slot }],
|
||||
source: Some(ScalarKind::F64),
|
||||
})
|
||||
.collect();
|
||||
let root = Composite::new(
|
||||
"h",
|
||||
vec![
|
||||
BlueprintNode::Composite(signal),
|
||||
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx).into(),
|
||||
],
|
||||
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
|
||||
roles,
|
||||
vec![],
|
||||
);
|
||||
let mut h = root.bootstrap_with_params(vec![]).expect("bootstraps (no open params)");
|
||||
let sources: Vec<Box<dyn aura_engine::Source>> = columns
|
||||
.into_iter()
|
||||
.map(|c| Box::new(VecSource::new(c)) as Box<dyn aura_engine::Source>)
|
||||
.collect();
|
||||
h.run(sources);
|
||||
rx.try_iter().collect()
|
||||
}
|
||||
|
||||
fn col(values: &[f64]) -> Vec<(Timestamp, Scalar)> {
|
||||
values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &v)| (Timestamp(i as i64 + 1), Scalar::f64(v)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Hand-computed contract. The four sources at one bar timestamp arrive in
|
||||
/// FOUR consecutive engine cycles (the k-way merge breaks the timestamp tie
|
||||
/// by source index = canonical column order: open, high, low, close); every
|
||||
/// `Firing::Any` node re-evaluates per fresh input over the newest value of
|
||||
/// each leg (sample-and-hold on the stale leg). Bars (o,h,l,c):
|
||||
/// (1,10,5,3), (2,20,8,6), (4,40,16,12).
|
||||
///
|
||||
/// Bar 1: open=1 -> body fires, close leg cold -> None; high=10 -> range
|
||||
/// fires, low leg cold -> None; low=5 -> range = 10-5 = 5, add fires, body
|
||||
/// leg cold -> None; close=3 -> body = 3-1 = 2, add = 5+2 = 7 -> (t1, 7).
|
||||
/// Bar 2: open=2 -> body = 3-2 = 1 (close held at 3), add = 5+1 = 6;
|
||||
/// high=20 -> range = 20-5 = 15 (low held), add = 15+1 = 16;
|
||||
/// low=8 -> range = 20-8 = 12, add = 12+1 = 13;
|
||||
/// close=6 -> body = 6-2 = 4, add = 12+4 = 16.
|
||||
/// Bar 3: open=4 -> body = 6-4 = 2, add = 12+2 = 14; high=40 -> range =
|
||||
/// 40-8 = 32, add = 32+2 = 34; low=16 -> range = 40-16 = 24, add = 26;
|
||||
/// close=12 -> body = 12-4 = 8, add = 24+8 = 32.
|
||||
#[test]
|
||||
fn ohlc_blueprint_runs_over_four_columns_in_canonical_order() {
|
||||
let signal = blueprint_from_json(OHLC_BLUEPRINT_JSON, &|t| std_vocabulary(t))
|
||||
.expect("loads through the std vocabulary");
|
||||
let trace = run_recording(
|
||||
signal,
|
||||
[
|
||||
col(&[1.0, 2.0, 4.0]), // open
|
||||
col(&[10.0, 20.0, 40.0]), // high
|
||||
col(&[5.0, 8.0, 16.0]), // low
|
||||
col(&[3.0, 6.0, 12.0]), // close
|
||||
],
|
||||
);
|
||||
let want: Vec<(Timestamp, Vec<Scalar>)> = [
|
||||
(1, 7.0),
|
||||
(2, 6.0),
|
||||
(2, 16.0),
|
||||
(2, 13.0),
|
||||
(2, 16.0),
|
||||
(3, 14.0),
|
||||
(3, 34.0),
|
||||
(3, 26.0),
|
||||
(3, 32.0),
|
||||
]
|
||||
.iter()
|
||||
.map(|&(t, v)| (Timestamp(t), vec![Scalar::f64(v)]))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
trace, want,
|
||||
"role i must be fed by source i in canonical column order",
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user