feat(momentum): bool-param demo strategy + aura sweep --strategy selector
Proves the #105 generic portable member key end-to-end: a demo strategy whose params have nothing in common with the SMA-cross demo, swept by the same machinery, with a bool axis surfacing — conformant — in the member dir names. - momentum strategy: price -> Ema(length:i64) -> Sub(price-ema) -> Exposure(scale:f64) -> LongOnly(enabled:bool) -> SimBroker. Three swept params of three kinds incl. a bool; the exposure sink taps the post-gate exposure so the bool's effect is in the trace. - aura sweep gains --strategy <sma|momentum> via a pure, unit-tested parse_sweep_args (mirroring parse_real_args); default = SMA-cross, so the existing bare / --name / --trace forms are unchanged. - `aura sweep --strategy momentum --trace mom` persists 8 member dirs named e.g. ema.length-5_exposure.scale-0.5_longonly.enabled-true — the bool in the dir name, every name matching [A-Za-z0-9._-], each chartable. - ledger (docs/design/INDEX.md): the C22/#101 member-key note's sweep clause amended to the generalised portable form (MC seed{N} / walk-forward oos{ns} unchanged). Self-verified: cargo build --workspace, cargo test --workspace (all green incl. momentum_param_space_is_ema_exposure_longonly, the 8-point determinism test, the parse_sweep_args grammar test, and the momentum_sweep_trace integration test asserting 8 portable bool-bearing dirs + a chartable member), cargo clippy --workspace --all-targets -D warnings. refs #105
This commit is contained in:
+171
-11
@@ -25,7 +25,7 @@ use aura_registry::{
|
||||
group_families, mc_member_reports, optimize, rank_by, sweep_member_reports,
|
||||
walkforward_member_reports, FamilyKind, Registry, RunTraces, TraceStore, TraceStoreError,
|
||||
};
|
||||
use aura_std::{Ema, Exposure, LinComb, Recorder, SimBroker, Sma, Sub};
|
||||
use aura_std::{Ema, Exposure, LinComb, LongOnly, Recorder, SimBroker, Sma, Sub};
|
||||
use std::sync::mpsc::{self, Receiver};
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -540,6 +540,82 @@ fn sweep_family(trace: Option<&str>) -> SweepFamily {
|
||||
.expect("the built-in named grid matches the sample param-space")
|
||||
}
|
||||
|
||||
/// The EMA-distance momentum demo strategy with its two recording sinks reachable.
|
||||
/// The signal is how far price sits above/below its own EMA (`price - ema`), sized
|
||||
/// by `Exposure`, then passed through the long-only `LongOnly` gate. Three swept
|
||||
/// knobs of three kinds — `ema.length` (i64), `exposure.scale` (f64),
|
||||
/// `longonly.enabled` (bool) — with nothing in common with the SMA-cross demo: the
|
||||
/// generic-sweep proof. The exposure sink taps the FINAL (gated) exposure so the
|
||||
/// bool's effect is visible in the trace.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn momentum_blueprint_with_sinks() -> (
|
||||
Composite,
|
||||
Receiver<(Timestamp, Vec<Scalar>)>,
|
||||
Receiver<(Timestamp, Vec<Scalar>)>,
|
||||
) {
|
||||
let (tx_eq, rx_eq) = mpsc::channel();
|
||||
let (tx_ex, rx_ex) = mpsc::channel();
|
||||
let mut g = GraphBuilder::new("momentum");
|
||||
// Name the param-bearing nodes explicitly so the swept paths are guaranteed
|
||||
// ema.length / exposure.scale / longonly.enabled regardless of default-name
|
||||
// derivation (the member-key examples + the param-space test depend on these).
|
||||
let ema = g.add(Ema::builder().named("ema")); // ema.length
|
||||
let dist = g.add(Sub::builder()); // momentum = price - ema
|
||||
let expo = g.add(Exposure::builder().named("exposure")); // exposure.scale
|
||||
let gate = g.add(LongOnly::builder().named("longonly")); // longonly.enabled (the bool param)
|
||||
let broker = g.add(SimBroker::builder(SYNTHETIC_PIP_SIZE));
|
||||
let eq = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq));
|
||||
let ex = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex));
|
||||
let price = g.source_role("price", ScalarKind::F64);
|
||||
g.feed(price, [ema.input("series"), dist.input("lhs"), broker.input("price")]);
|
||||
g.connect(ema.output("value"), dist.input("rhs")); // momentum = price - ema
|
||||
g.connect(dist.output("value"), expo.input("signal"));
|
||||
g.connect(expo.output("exposure"), gate.input("exposure"));
|
||||
g.connect(gate.output("exposure"), broker.input("exposure")); // gated exposure -> broker
|
||||
g.connect(broker.output("equity"), eq.input("col[0]")); // equity -> sink
|
||||
g.connect(gate.output("exposure"), ex.input("col[0]")); // gated exposure -> sink
|
||||
let bp = g.build().expect("momentum blueprint wiring resolves");
|
||||
(bp, rx_eq, rx_ex)
|
||||
}
|
||||
|
||||
/// Run the momentum strategy over its built-in grid — `ema.length ∈ {5,10}` ×
|
||||
/// `exposure.scale ∈ {0.5,1.0}` × `longonly.enabled ∈ {true,false}` = 8 points,
|
||||
/// all three axes varying. Mirrors `sweep_family`: capture the binder's varying
|
||||
/// axes, key each member via the generic portable `member_key`. With `--trace`,
|
||||
/// persist each member under `runs/traces/<name>/<member_key>/`.
|
||||
fn momentum_sweep_family(trace: Option<&str>) -> SweepFamily {
|
||||
let bp = momentum_blueprint_with_sinks().0;
|
||||
let space = bp.param_space();
|
||||
let binder = bp
|
||||
.axis("ema.length", [5, 10])
|
||||
.axis("exposure.scale", [0.5, 1.0])
|
||||
.axis("longonly.enabled", [true, false]);
|
||||
let varying: HashSet<String> = binder.varying_axes().into_iter().collect();
|
||||
binder
|
||||
.sweep(|point| {
|
||||
let (bp, rx_eq, rx_ex) = momentum_blueprint_with_sinks();
|
||||
let mut h = bp
|
||||
.bootstrap_with_cells(point)
|
||||
.expect("grid points are kind-checked against param_space");
|
||||
let sources: Vec<Box<dyn aura_engine::Source>> =
|
||||
vec![Box::new(VecSource::new(showcase_prices()))];
|
||||
let window = window_of(&sources).expect("non-empty showcase stream");
|
||||
h.run(sources);
|
||||
let eq_rows = rx_eq.try_iter().collect::<Vec<_>>();
|
||||
let ex_rows = rx_ex.try_iter().collect::<Vec<_>>();
|
||||
let named = zip_params(&space, point);
|
||||
let key = member_key(&named, &varying);
|
||||
let manifest = sim_optimal_manifest(named, window, 0, SYNTHETIC_PIP_SIZE);
|
||||
if let Some(name) = trace {
|
||||
persist_traces(&format!("{name}/{key}"), &manifest, &eq_rows, &ex_rows);
|
||||
}
|
||||
let equity = f64_field(&eq_rows, 0);
|
||||
let exposure = f64_field(&ex_rows, 0);
|
||||
RunReport { manifest, metrics: summarize(&equity, &exposure) }
|
||||
})
|
||||
.expect("the momentum named grid matches the momentum param-space")
|
||||
}
|
||||
|
||||
/// Render a sweep family as one `RunReport` JSON line per point. Test helper:
|
||||
/// production (`run_sweep`) renders *and* persists per point.
|
||||
#[cfg(test)]
|
||||
@@ -559,13 +635,55 @@ fn default_registry() -> Registry {
|
||||
Registry::open("runs/runs.jsonl")
|
||||
}
|
||||
|
||||
/// `aura sweep [--name <n>|--trace <n>]`: run the built-in sweep, persist it as a
|
||||
/// *family* (related records sharing one `family_id`, C18/C21) via `append_family`,
|
||||
/// and print each point's record line carrying the assigned id. With `--trace`,
|
||||
/// also persist each member's streams under `runs/traces/<n>/<member_key>/` (opt-in).
|
||||
fn run_sweep(name: &str, persist: bool) {
|
||||
/// Which built-in strategy `aura sweep` runs. Default (today's behaviour) is the
|
||||
/// SMA-cross sample; `momentum` is the bool-param demo.
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
enum Strategy {
|
||||
SmaCross,
|
||||
Momentum,
|
||||
}
|
||||
|
||||
/// Parse the `sweep` tail: `[--strategy <sma|momentum>] [--name <n> | --trace <n>]`.
|
||||
/// Defaults: SMA-cross, name "sweep", no persist (today's bare `aura sweep`).
|
||||
/// `--name` and `--trace` are mutually exclusive. Pure (no I/O / exit) so the
|
||||
/// grammar is unit-testable; `main` does the side effects. An unknown token, a
|
||||
/// flag without its value, an unknown strategy, or both name flags rejects.
|
||||
fn parse_sweep_args(rest: &[&str]) -> Result<(Strategy, String, bool), String> {
|
||||
let usage = || "sweep [--strategy <sma|momentum>] [--name <n> | --trace <n>]".to_string();
|
||||
let mut strategy = Strategy::SmaCross;
|
||||
let mut name: Option<(String, bool)> = None; // (name, persist)
|
||||
let mut tail = rest;
|
||||
while let Some((flag, t)) = tail.split_first() {
|
||||
let (value, t) = t.split_first().ok_or_else(usage)?;
|
||||
match *flag {
|
||||
"--strategy" => {
|
||||
strategy = match *value {
|
||||
"sma" => Strategy::SmaCross,
|
||||
"momentum" => Strategy::Momentum,
|
||||
_ => return Err(usage()),
|
||||
};
|
||||
}
|
||||
"--name" if name.is_none() => name = Some(((*value).to_string(), false)),
|
||||
"--trace" if name.is_none() => name = Some(((*value).to_string(), true)),
|
||||
_ => return Err(usage()),
|
||||
}
|
||||
tail = t;
|
||||
}
|
||||
let (name, persist) = name.unwrap_or_else(|| ("sweep".to_string(), false));
|
||||
Ok((strategy, name, persist))
|
||||
}
|
||||
|
||||
/// `aura sweep [--strategy <sma|momentum>] [--name <n>|--trace <n>]`: run the
|
||||
/// selected built-in sweep, persist it as a *family* (related records sharing one
|
||||
/// `family_id`, C18/C21) via `append_family`, and print each point's record line
|
||||
/// carrying the assigned id. With `--trace`, also persist each member's streams
|
||||
/// under `runs/traces/<n>/<member_key>/` (opt-in).
|
||||
fn run_sweep(strategy: Strategy, name: &str, persist: bool) {
|
||||
let reg = default_registry();
|
||||
let family = sweep_family(persist.then_some(name));
|
||||
let family = match strategy {
|
||||
Strategy::SmaCross => sweep_family(persist.then_some(name)),
|
||||
Strategy::Momentum => momentum_sweep_family(persist.then_some(name)),
|
||||
};
|
||||
let id = match reg.append_family(name, FamilyKind::Sweep, &sweep_member_reports(&family)) {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
@@ -1005,7 +1123,7 @@ fn run_macd(trace: Option<&str>) -> RunReport {
|
||||
}
|
||||
|
||||
const USAGE: &str =
|
||||
"usage: aura run [--macd] [--trace <name>] | aura run --real <SYMBOL> [--from <ms>] [--to <ms>] [--trace <name>] | aura chart <name> [--panels] | aura graph | aura sweep [--name <n>|--trace <n>] | aura mc [--name <n>|--trace <n>] | aura walkforward [--name <n>|--trace <n>] | aura runs families | aura runs family <id> [rank <metric>]";
|
||||
"usage: aura run [--macd] [--trace <name>] | aura run --real <SYMBOL> [--from <ms>] [--to <ms>] [--trace <name>] | aura chart <name> [--panels] | aura graph | aura sweep [--strategy <sma|momentum>] [--name <n>|--trace <n>] | aura mc [--name <n>|--trace <n>] | aura walkforward [--name <n>|--trace <n>] | aura runs families | aura runs family <id> [rank <metric>]";
|
||||
|
||||
fn main() {
|
||||
// Collect argv and match the whole vector: every accepted form is exhaustive,
|
||||
@@ -1029,9 +1147,13 @@ fn main() {
|
||||
["chart", name] => emit_chart(name, ChartMode::Overlay),
|
||||
["chart", name, "--panels"] => emit_chart(name, ChartMode::Panels),
|
||||
["graph"] => print!("{}", render::render_html(&sample_blueprint())),
|
||||
["sweep"] => run_sweep("sweep", false),
|
||||
["sweep", "--name", n] => run_sweep(n, false),
|
||||
["sweep", "--trace", n] => run_sweep(n, true),
|
||||
["sweep", rest @ ..] => match parse_sweep_args(rest) {
|
||||
Ok((strategy, name, persist)) => run_sweep(strategy, &name, persist),
|
||||
Err(msg) => {
|
||||
eprintln!("aura: {msg}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
},
|
||||
["walkforward"] => run_walkforward("walkforward", false),
|
||||
["walkforward", "--name", n] => run_walkforward(n, false),
|
||||
["walkforward", "--trace", n] => run_walkforward(n, true),
|
||||
@@ -1529,4 +1651,42 @@ mod tests {
|
||||
let unique: std::collections::HashSet<&String> = keys.iter().collect();
|
||||
assert_eq!(unique.len(), 4, "distinct points must yield distinct keys: {keys:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn momentum_param_space_is_ema_exposure_longonly() {
|
||||
// pins the default node-name path segments (ema / exposure / longonly) and
|
||||
// the param order/kinds the member key + sweep depend on.
|
||||
let names: Vec<String> =
|
||||
momentum_blueprint_with_sinks().0.param_space().into_iter().map(|p| p.name).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![
|
||||
"ema.length".to_string(),
|
||||
"exposure.scale".to_string(),
|
||||
"longonly.enabled".to_string(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn momentum_sweep_is_deterministic_and_has_eight_points() {
|
||||
let a = momentum_sweep_family(None);
|
||||
let b = momentum_sweep_family(None);
|
||||
assert_eq!(a.points.len(), 8, "2x2x2 grid = 8 points");
|
||||
assert_eq!(a, b, "C1: the momentum family is a pure function of the build");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_sweep_args_defaults_selects_and_rejects() {
|
||||
assert_eq!(parse_sweep_args(&[]), Ok((Strategy::SmaCross, "sweep".to_string(), false)));
|
||||
assert_eq!(parse_sweep_args(&["--trace", "swp"]), Ok((Strategy::SmaCross, "swp".to_string(), true)));
|
||||
assert_eq!(parse_sweep_args(&["--name", "s"]), Ok((Strategy::SmaCross, "s".to_string(), false)));
|
||||
assert_eq!(
|
||||
parse_sweep_args(&["--strategy", "momentum", "--trace", "mom"]),
|
||||
Ok((Strategy::Momentum, "mom".to_string(), true))
|
||||
);
|
||||
assert!(parse_sweep_args(&["--strategy", "bogus"]).is_err());
|
||||
assert!(parse_sweep_args(&["--name", "a", "--trace", "b"]).is_err()); // mutually exclusive
|
||||
assert!(parse_sweep_args(&["--trace"]).is_err()); // flag missing its value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -838,3 +838,172 @@ fn sweep_trace_is_byte_deterministic_across_runs() {
|
||||
// here too — this pins run-to-run determinism, C1).
|
||||
assert_eq!(first, second, "a traced member's bytes drifted across two runs (C1 violation)");
|
||||
}
|
||||
|
||||
/// Property (#105 generic key, the bool-param proof): `aura sweep --strategy
|
||||
/// momentum --trace <name>` sweeps the momentum strategy — a totally different
|
||||
/// param surface incl. a bool — and persists one portable member dir per grid
|
||||
/// point. Every member-dir name is filesystem-conformant (`[A-Za-z0-9._-]`), the
|
||||
/// bool axis shows up (both `longonly.enabled-true` and `-false`), and one member
|
||||
/// is chartable.
|
||||
#[test]
|
||||
fn momentum_sweep_trace_persists_portable_member_dirs_with_the_bool() {
|
||||
let cwd = temp_cwd("momentum-trace");
|
||||
|
||||
let traced = Command::new(BIN)
|
||||
.args(["sweep", "--strategy", "momentum", "--trace", "mom"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura sweep --strategy momentum --trace");
|
||||
assert!(
|
||||
traced.status.success(),
|
||||
"momentum sweep exit: {:?}; stderr: {}",
|
||||
traced.status,
|
||||
String::from_utf8_lossy(&traced.stderr)
|
||||
);
|
||||
|
||||
let base = cwd.join("runs/traces/mom");
|
||||
let members: Vec<String> = std::fs::read_dir(&base)
|
||||
.expect("read mom trace dir")
|
||||
.map(|e| e.expect("dir entry").file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert_eq!(members.len(), 8, "2x2x2 momentum grid = 8 member dirs; got {members:?}");
|
||||
for m in &members {
|
||||
assert!(
|
||||
m.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')),
|
||||
"non-portable member dir name: {m}"
|
||||
);
|
||||
}
|
||||
assert!(members.iter().any(|m| m.contains("longonly.enabled-true")), "missing bool=true: {members:?}");
|
||||
assert!(members.iter().any(|m| m.contains("longonly.enabled-false")), "missing bool=false: {members:?}");
|
||||
|
||||
// one member is chartable (the bool sits inside the dir name passed through).
|
||||
let one = &members[0];
|
||||
let chart = Command::new(BIN)
|
||||
.args(["chart", &format!("mom/{one}")])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura chart mom/<member>");
|
||||
assert!(chart.status.success(), "chart exit: {:?}", chart.status);
|
||||
assert!(
|
||||
String::from_utf8_lossy(&chart.stdout).contains("uPlot"),
|
||||
"expected a self-contained uPlot page"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// Property (#105, the `--strategy` selector at the stdout boundary): `aura sweep
|
||||
/// --strategy momentum` SWITCHES the swept surface — it prints exactly the
|
||||
/// 8-point momentum family (vs the default SMA arm's 4 points), and every line is
|
||||
/// a `RunReport` over the momentum param surface, carrying the bool axis as a
|
||||
/// TYPED `{"Bool":...}` param (both `true` and `false` appear across the grid).
|
||||
/// The disk-trace test pins the member-dir names; this pins the binary's actual
|
||||
/// stdout. A selector miswiring (`momentum`→`SmaCross`) would leave that test
|
||||
/// green — the dirs would still differ — while silently printing SMA reports
|
||||
/// here; nothing else catches it. Asserts on structure + the typed bool token,
|
||||
/// never the volatile commit value or the metric floats, so it stays
|
||||
/// deterministic. Run in a fresh temp cwd so the family-store ordinal is stable.
|
||||
#[test]
|
||||
fn sweep_strategy_momentum_prints_eight_typed_bool_member_lines() {
|
||||
let cwd = temp_cwd("sweep-strategy-momentum");
|
||||
let out = Command::new(BIN)
|
||||
.args(["sweep", "--strategy", "momentum"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura sweep --strategy momentum");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"momentum sweep exit: {:?}; stderr: {}",
|
||||
out.status,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
|
||||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||||
let lines: Vec<&str> = stdout.lines().collect();
|
||||
// 2x2x2 momentum grid = 8 member lines (vs the default SMA arm's 4): the
|
||||
// selector switched the swept surface, observable at stdout.
|
||||
assert_eq!(lines.len(), 8, "momentum sweep must print 8 family lines: {stdout:?}");
|
||||
|
||||
// Every line is a family member: assigned id + a nested RunReport over the
|
||||
// MOMENTUM param surface (the SMA arm carries `signals.trend.*`, never
|
||||
// `ema.length`/`longonly.enabled`).
|
||||
for line in &lines {
|
||||
assert!(line.starts_with("{\"family_id\":\"sweep-0\",\"report\":{\"manifest\":{"), "got: {line}");
|
||||
assert!(line.contains("[\"ema.length\","), "momentum surface missing ema.length: {line}");
|
||||
assert!(!line.contains("signals.trend.fast.length"), "must not be the SMA surface: {line}");
|
||||
}
|
||||
|
||||
// The bool axis is carried as a TYPED `{"Bool":...}` param, both values
|
||||
// present across the 8-point grid — the bool-param proof at the stdout edge.
|
||||
assert!(
|
||||
stdout.contains("[\"longonly.enabled\",{\"Bool\":true}]"),
|
||||
"missing typed Bool=true param: {stdout}"
|
||||
);
|
||||
assert!(
|
||||
stdout.contains("[\"longonly.enabled\",{\"Bool\":false}]"),
|
||||
"missing typed Bool=false param: {stdout}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// Property (#105, the bool actually gates — observable on disk): the
|
||||
/// `longonly.enabled` param is genuinely WIRED into the momentum harness, not a
|
||||
/// no-op knob. For one grid point's two bool members (same `ema.length` +
|
||||
/// `exposure.scale`, differing only in the bool), the `enabled=true` member
|
||||
/// records a long-only exposure stream — every recorded value `>= 0` — while the
|
||||
/// otherwise-identical `enabled=false` member records at least one NEGATIVE
|
||||
/// exposure over the same deterministic showcase stream. This pins the gate's
|
||||
/// effect on the persisted C7 exposure tap end-to-end: the LongOnly unit test
|
||||
/// proves `max(x,0)` in isolation, the dir-name test proves the bool reaches the
|
||||
/// key, but only this proves the bool changes the OBSERVED run output. A
|
||||
/// regression that dropped the gate from the wiring (passing exposure straight to
|
||||
/// the broker) would make the two members' exposure taps identical — this fails
|
||||
/// loudly. Asserts on the sign (>=0 / <0), never exact floats, so it is
|
||||
/// deterministic across builds.
|
||||
#[test]
|
||||
fn momentum_longonly_true_clamps_exposure_nonnegative_false_keeps_negatives() {
|
||||
let cwd = temp_cwd("momentum-longonly-gate");
|
||||
let traced = Command::new(BIN)
|
||||
.args(["sweep", "--strategy", "momentum", "--trace", "gate"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura sweep --strategy momentum --trace");
|
||||
assert!(
|
||||
traced.status.success(),
|
||||
"momentum --trace exit: {:?}; stderr: {}",
|
||||
traced.status,
|
||||
String::from_utf8_lossy(&traced.stderr)
|
||||
);
|
||||
|
||||
// One grid point, its two bool members (identical but for the bool).
|
||||
let base = cwd.join("runs/traces/gate");
|
||||
let enabled = base.join("ema.length-5_exposure.scale-1_longonly.enabled-true");
|
||||
let disabled = base.join("ema.length-5_exposure.scale-1_longonly.enabled-false");
|
||||
|
||||
let exposures = |dir: &std::path::Path| -> Vec<f64> {
|
||||
let body = std::fs::read_to_string(dir.join("exposure.json")).expect("read exposure.json");
|
||||
// the single f64 column's comma-separated values (substring parse, no serde).
|
||||
json_array_body(&body, "\"columns\":[[")
|
||||
.split(',')
|
||||
.map(|t| t.trim().parse::<f64>().expect("exposure value is an f64"))
|
||||
.collect()
|
||||
};
|
||||
|
||||
let enabled_vals = exposures(&enabled);
|
||||
let disabled_vals = exposures(&disabled);
|
||||
|
||||
// enabled=true is a long-only gate: NO recorded exposure is negative.
|
||||
assert!(
|
||||
enabled_vals.iter().all(|&x| x >= 0.0),
|
||||
"long-only (enabled=true) must clamp short exposure to >= 0; got: {enabled_vals:?}"
|
||||
);
|
||||
// enabled=false passes through: at least one negative survives over the same
|
||||
// showcase stream — proof the bool genuinely changes the run, not a no-op.
|
||||
assert!(
|
||||
disabled_vals.iter().any(|&x| x < 0.0),
|
||||
"pass-through (enabled=false) must keep a short exposure over this stream; got: {disabled_vals:?}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
@@ -1030,7 +1030,10 @@ to family runs: `aura sweep|mc|walkforward --trace <name>` persists *each member
|
||||
a nested standalone run-dir `runs/traces/<name>/<member_key>/`, reusing
|
||||
`persist_traces`/`TraceStore` verbatim (engine untouched — persistence is a
|
||||
side-effect inside each per-member closure). The `member_key` is content-derived and
|
||||
deterministic — sweep `f{fast}s{slow}`, MC `seed{N}`, walk-forward `oos{ns}` — never
|
||||
deterministic — sweep: the *varying* axes rendered as a filesystem-portable
|
||||
directory component (`<axis-name>-<value>` tokens joined by `_`, charset
|
||||
`[A-Za-z0-9._-]`, case-less values, length-capped with an FNV fallback), MC
|
||||
`seed{N}`, walk-forward `oos{ns}` — never
|
||||
a runtime ordinal, because members run in parallel under the engine's `Fn + Sync`
|
||||
HOFs, where a counter would be schedule-dependent (C1); concurrent `TraceStore::write`
|
||||
targets disjoint member dirs, so it is lock-free. Opt-in: without `--trace`,
|
||||
|
||||
Reference in New Issue
Block a user