feat(std,cli): add the Scale node, retire the r-meanrev demo to data (#159 cut 3)

Cut 3 of the hard-wired demo retirement. Unlike r-sma/r-breakout, r-meanrev could
not round-trip as data: its band computes band_k*sigma (a constant times a stream)
and the closed 22-node std_vocabulary had no way to express it (EqConst is i64->bool,
Bias is clamp(signal/scale,±1), Mul is binary, LinComb is excluded as a
construction-arg node; no Div/Const/constant-emitter). Standard operators are missing
by chance, not by design, so this adds the one r-meanrev needs.

Scale node (aura-std): out = input * factor — one f64 input, one f64 `factor` param,
stateless, warm-up-filtered (the EqConst/Bias shape). A zero-input `Const` emitter was
rejected: the per-cycle eval loop gates every node through an input-firing test
(harness.rs `fires()`), so a node with no input never fires without an engine-core
change; `Scale` fits the existing model and, by IEEE-754 commutativity, reproduces the
retiring LinComb's band byte-for-byte. Rostered (`"Scale" => Scale`); the two
vocabulary count-pins bump 22 -> 23.

r-meanrev migration: r_meanrev_signal(window, band_k) is carved out of the fused
r_meanrev_graph as a #[cfg(test)] price->bias Composite whose band is `Scale` in place
of `LinComb(1)`; production loads the shipped JSON (examples/r_meanrev{,_open}.json),
never a builder. Before the fused builder was deleted, an equivalence test proved the
carved Scale-band signal reproduces its grade byte-for-byte on the synthetic stream
(window=3, band_k=2). Durable survivors: the byte-identity of the examples to the
carve, the loaded-grades-identically-to-carve proof, the closed/open introspect
anchors (mean_window.length, var_window.length, band.factor — band_k is now a
first-class sweepable param), and — re-pointed onto the carve rather than dropped —
the fades-short-above/long-below behavioural test, which becomes the durable
carve-correctness gate the deleted equivalence test used to be.

Deletions + the dead-code cascade the retirement opened: r_meanrev_graph,
r_meanrev_sweep_family, Strategy::RMeanRev (+ all arms). r-meanrev was the last reader
of run_sweep's grid, so the whole vestigial built-in-sweep grid apparatus retires with
it: run_sweep's `grid` param, the sweep call-site grid build, and SweepCmd's
--fast/--slow/--stop-length/--stop-k/--window/--band-k (sma/momentum sweeps build their
own blueprints and ignored these; the fast/slow/stop-* had been vestigial since r-sma's
cut 1b). RGrid the struct SURVIVES — the dissolved `generalize` verb resolves its
candidate from its fast/slow/stop_length/stop_k via generalize_args_from — so only its
window/band_k fields go; its stale doc-comment (r-sma-sweep / persist_traces_r / CLI
flags, all now gone) is rewritten to its true generalize-only role. Also retired as
transitively dead: persist_traces_r and the metrics_object test helper (their only
callers were the deleted families/tests); Add/Gt/Latch/Mul/Sqrt imports are now
#[cfg(test)] (their last production caller was r_meanrev_graph).

Test surface: the two built-in --strategy r-meanrev sweep tests drop; a new negative
pins that --strategy r-meanrev on both sweep and walkforward now falls into the generic
usage error; the grid-flag stray-positional negative is re-pointed to a surviving flag.

Verification (own, not the workflow's report): full `cargo test --workspace` green
(0 failed across 62 result lines); `cargo clippy --workspace --all-targets -- -D
warnings` clean, no dead-code residue; the r-sma + r-breakout anchors and the
dissolved-verb real goldens (generalize still reads RGrid) stay green. Bundled as one
commit (the Scale node's count-pin and the r-meanrev anchors share graph_construct.rs,
so a clean two-commit split was not path-separable); the implement-loop ran all three
plan tasks in one pass and the tree was verified by hand.

refs #159
This commit is contained in:
2026-07-07 22:02:50 +02:00
parent 5856cadadd
commit 257ab0b9f2
8 changed files with 343 additions and 433 deletions
+56 -122
View File
@@ -2080,126 +2080,6 @@ fn run_unknown_harness_exits_two() {
assert_eq!(out.status.code(), Some(2));
}
/// Extract the balanced `"metrics":{...}` object substring from one stdout member
/// line. The `family_id` prefix legitimately differs between a no-trace sweep
/// (`sweep-0`) and a `--trace n` sweep (`n-0`), so an equivalence check must
/// compare only the `metrics` block (the reduced/raw output under test), not the
/// whole line. Brace-balanced scan, not a JSON parser (no serde dep in this test
/// crate); the member lines are flat enough for this to be exact.
fn metrics_object(line: &str) -> &str {
let key = "\"metrics\":";
let start = line.find(key).expect("member line has a metrics block") + key.len();
let bytes = line.as_bytes();
assert_eq!(bytes[start], b'{', "metrics value must be an object: {line}");
let mut depth = 0usize;
for (i, &b) in bytes[start..].iter().enumerate() {
match b {
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
return &line[start..start + i + 1];
}
}
_ => {}
}
}
panic!("unbalanced metrics object: {line}")
}
/// The mean-reversion strategy reaches the CLI seam: `aura sweep --strategy
/// r-meanrev` emits an R-bearing member, and the folded no-trace path equals
/// the raw --trace path byte-for-byte (parity with the r-sma / breakout
/// fold-vs-raw guard). window 3 warms up on the synthetic stream; one window ×
/// one band_k × the single default stop = one member.
#[test]
fn sweep_strategy_r_meanrev_folded_no_trace_metrics_equal_raw_trace_metrics() {
let cwd = temp_cwd("sweep-r-meanrev-fold-vs-raw");
let folded = Command::new(BIN)
.args(["sweep", "--strategy", "r-meanrev", "--window", "3"])
.current_dir(&cwd)
.output()
.expect("spawn folded (no-trace) r-meanrev sweep");
assert!(
folded.status.success(),
"folded no-trace meanrev sweep exit: {:?}; stderr: {}",
folded.status,
String::from_utf8_lossy(&folded.stderr)
);
let folded_out = String::from_utf8(folded.stdout).expect("utf-8");
let raw = Command::new(BIN)
.args(["sweep", "--strategy", "r-meanrev", "--window", "3", "--trace", "m1"])
.current_dir(&cwd)
.output()
.expect("spawn raw (--trace) r-meanrev sweep");
assert!(
raw.status.success(),
"raw --trace meanrev sweep exit: {:?}; stderr: {}",
raw.status,
String::from_utf8_lossy(&raw.stderr)
);
let raw_out = String::from_utf8(raw.stdout).expect("utf-8");
let folded_lines: Vec<&str> = folded_out.lines().collect();
let raw_lines: Vec<&str> = raw_out.lines().collect();
assert_eq!(folded_lines.len(), 1, "folded meanrev sweep must print 1 member: {folded_out:?}");
assert_eq!(
raw_lines.len(),
folded_lines.len(),
"member count must match: folded {} vs raw {}",
folded_lines.len(),
raw_lines.len()
);
for (i, (f, r)) in folded_lines.iter().zip(raw_lines.iter()).enumerate() {
let fm = metrics_object(f);
let rm = metrics_object(r);
assert!(fm.contains("\"r\":{"), "folded meanrev member {i} must carry an r block: {fm}");
assert_eq!(
fm, rm,
"folded vs raw meanrev metrics diverge at member {i}\n folded: {fm}\n raw: {rm}"
);
}
let _ = std::fs::remove_dir_all(&cwd);
}
/// Property: the meanrev family is the cartesian product of its grid axes — a
/// multi-value `--window` list yields exactly one member per window value, each
/// carrying its own bound window length in the manifest params. Guards the manual
/// cartesian loop in `r_meanrev_sweep_family`. windows 3 and 4 both warm up
/// on the 18-bar synthetic stream, so two members must appear, in grid order.
#[test]
fn sweep_strategy_r_meanrev_grids_one_member_per_window() {
let cwd = temp_cwd("sweep-r-meanrev-window-grid");
let out = Command::new(BIN)
.args(["sweep", "--strategy", "r-meanrev", "--window", "3,4"])
.current_dir(&cwd)
.output()
.expect("spawn 2-window r-meanrev sweep");
assert!(
out.status.success(),
"window-grid meanrev sweep exit: {:?}; stderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).expect("utf-8");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines.len(), 2, "2-window grid must print 2 members: {stdout:?}");
assert!(
lines[0].contains("[\"window\",{\"I64\":3}]"),
"first member must bind window=3: {}",
lines[0]
);
assert!(
lines[1].contains("[\"window\",{\"I64\":4}]"),
"second member must bind window=4: {}",
lines[1]
);
let _ = std::fs::remove_dir_all(&cwd);
}
/// Property: the bare `aura walkforward` (default SMA-cross) summary is preserved
/// byte-for-byte by the new `--strategy`/grid plumbing — it still carries exactly
/// `windows`, `stitched_total_pips`, `param_stability` and NOT the r-sma-only
@@ -2292,6 +2172,26 @@ fn sweep_and_walkforward_reject_retired_r_breakout_token_as_unrecognized() {
}
}
/// Property (#159 cut 3): `--strategy r-meanrev` is a fully retired token on both
/// sweep and walkforward — the generic usage error, no special-casing.
#[test]
fn sweep_and_walkforward_reject_retired_r_meanrev_token_as_unrecognized() {
for (args, verb) in [
(&["sweep", "--strategy", "r-meanrev"][..], "sweep"),
(&["walkforward", "--strategy", "r-meanrev"][..], "walkforward"),
] {
let cwd = temp_cwd("retired-r-meanrev-token");
let out = Command::new(BIN).args(args).current_dir(&cwd).output()
.unwrap_or_else(|e| panic!("spawn aura {args:?}: {e}"));
assert_eq!(out.status.code(), Some(2), "`aura {args:?}` must exit 2; status: {:?}", out.status);
let stderr = String::from_utf8_lossy(&out.stderr);
let want = format!("aura: Usage: aura {verb} ");
assert!(stderr.starts_with(&want), "generic usage, not special-cased: {stderr:?}");
assert!(out.stdout.is_empty(), "no summary on the rejected path: {:?}", out.stdout);
let _ = std::fs::remove_dir_all(&cwd);
}
}
/// Property (#159 cut 2): `--channel` is genuinely removed from the sweep grammar,
/// not merely unused — clap rejects it as a structurally unknown argument (exit 2)
/// rather than silently accepting and dropping it. A regression that left the flag
@@ -2301,7 +2201,7 @@ fn sweep_and_walkforward_reject_retired_r_breakout_token_as_unrecognized() {
fn sweep_channel_flag_is_removed_from_the_grammar() {
let cwd = temp_cwd("sweep-channel-flag-retired");
let out = Command::new(BIN)
.args(["sweep", "--strategy", "r-meanrev", "--channel", "3"])
.args(["sweep", "--strategy", "sma", "--channel", "3"])
.current_dir(&cwd)
.output()
.expect("spawn aura sweep --channel");
@@ -3925,7 +3825,7 @@ fn dual_grammar_stray_positional_is_a_usage_error_not_swallowed() {
// is_file() discriminator sends every case down the built-in branch.
for argv in [
&["run", "bogus"][..],
&["sweep", "bogus", "--fast", "3"][..],
&["sweep", "bogus", "--name", "x"][..],
&["walkforward", "bogus", "--strategy", "sma"][..],
&["mc", "bogus"][..],
] {
@@ -4254,3 +4154,37 @@ fn shipped_r_sma_example_reproduces_the_builtin_grade() {
assert!(stdout.contains("\"total_pips\":0.34185000000002036"), "{stdout}");
assert!(stdout.contains("\"n_trades\":3"), "{stdout}");
}
/// `aura run` on the shipped closed r-meanrev example (#159 cut 3) actually loads
/// and grades through the real, separately-linked `aura` binary — not merely
/// in-process, as `r_meanrev_example_loaded_runs_identically_to_the_carved_signal`
/// (main.rs) and this test's own grade-equivalence check both do by calling
/// `run_signal_r`/`blueprint_from_json` inside the *test* binary. This is the
/// CLI-seam companion `shipped_r_sma_example_reproduces_the_builtin_grade` has for
/// r-sma: it pins that the `Scale`-based band, freshly added to the closed
/// vocabulary this iteration, resolves and runs cleanly through the production
/// vocabulary lookup + arg-parsing path a real invocation takes, not just through
/// the test harness's direct function calls. The synthetic stream never crosses
/// the band at this window/k, so the grade is genuinely all-zero (no trade) —
/// still a deterministic, meaningful pin: a regression that broke `Scale`'s CLI
/// wiring (e.g. a roster/registration mismatch) would surface as a non-zero exit
/// or a load error here, not as a metrics drift.
#[test]
fn shipped_r_meanrev_example_runs_end_to_end_via_aura_run() {
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["run", "examples/r_meanrev.json"])
.output()
.expect("spawn aura run example");
assert_eq!(
out.status.code(),
Some(0),
"exit: {:?} stderr={}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).expect("utf-8");
assert!(stdout.contains("\"topology_hash\":\""), "manifest carries the topology hash: {stdout}");
assert!(stdout.contains("\"expectancy_r\":0.0"), "{stdout}");
assert!(stdout.contains("\"n_trades\":0"), "{stdout}");
assert!(stdout.contains("\"total_pips\":0.0"), "{stdout}");
}