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:
2026-06-21 11:32:46 +02:00
parent 27f850dc52
commit 0b73a75d4b
3 changed files with 344 additions and 12 deletions
+169
View File
@@ -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);
}