feat(cli,docs): ship the ganged open examples + document the gang construct (#61 tasks 7-8)

The two open examples now expose their author-intended single knobs — the
closed builders always bound these pairs to one value by hand; the open forms
falsely offered them as independent axes:
- r_breakout_open: channel_hi.length + channel_lo.length -> channel_length
  (the Donchian channel is structurally ONE parameter)
- r_meanrev_open: mean_window.length + var_window.length -> window; the band
  factor stays an independent axis
Regenerated via the emitters (never hand-edited); the closed examples are
byte-unchanged. The carve builders gang in the open branch only.

Test migration: the two axis-namespace pins, five --real e2e invocations
(single gang axis, the 10,20 diagonal for the campaign pair — the mismatched
20,40/10,20 grid was exactly the configuration space the gang retires), and
the param_stability row counts (4 -> 3). The r-sma walkforward golden anchor
is untouched (it never swept a ganged pair).

Docs: authoring-guide gains the seventh op + the third param state
(open/bound/ganged) + the gang wrap note; README op list + Axis concept;
glossary gang entry; ledger C24 records the gang verb and the pre-ship
Tier-2 dormancy (no format-version bump while no out-of-repo reader exists).

Verified: full workspace suite 1104/0 (--real e2e included, local data
present), clippy -D warnings clean, cargo doc clean; live acceptance:
introspect --params prints channel_length:I64 alone / window:I64 +
band.factor:F64.

closes #61
This commit is contained in:
2026-07-10 14:07:22 +02:00
parent bc88e18247
commit fc9cf23b87
9 changed files with 149 additions and 37 deletions
+82 -5
View File
@@ -744,7 +744,8 @@ fn shipped_r_breakout_example_is_genuinely_closed() {
}
/// Property (#159 cut 2): the shipped open example (`examples/r_breakout_open.json`)
/// lists its two channel axes, in lowering order (per-node exposure; ganging is #61).
/// lists its ONE ganged channel axis (#61: the two rolling windows are structurally
/// one knob).
#[test]
fn shipped_r_breakout_open_example_lists_its_axis_namespace() {
let dir = temp_cwd("r-breakout-example-open-params");
@@ -752,8 +753,47 @@ fn shipped_r_breakout_open_example_lists_its_axis_namespace() {
run_in(&dir, &["graph", "introspect", "--params", &example("r_breakout_open.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
assert_eq!(
stdout, "channel_hi.length:I64\nchannel_lo.length:I64\n",
"the raw open params, in order, from the public gallery copy"
stdout, "channel_length:I64\n",
"the ganged channel knob — one public axis for the two rolling windows (#61)"
);
}
/// Property (#61 + #159 cut 2): binding the shipped open example's ganged
/// `channel_length` axis through the REAL `aura sweep` CLI pipeline (argv ->
/// `parse_axes` -> `compile_with_cells`'s gang expansion) reproduces the exact
/// metrics of running the shipped CLOSED example, whose `channel_hi`/
/// `channel_lo` are separately hardwired to the same value. The equivalence
/// between a gang and its member-bound twin is already pinned at the builder/
/// engine level (aura-engine's `gang_e2e.rs`, and this crate's
/// `r_breakout_example_loaded_runs_identically_to_the_carved_signal`, which
/// calls `run_signal_r` directly); this instead drives the actual public
/// binary end-to-end on the actual shipped files, so a regression in the
/// CLI's own axis-name resolution or grid-point binding (e.g. silently
/// treating the gang as an unbound param, or fanning the value to only one
/// member) would be caught here even if the internal builder-level
/// equivalence still holds.
#[test]
fn shipped_r_breakout_open_gang_axis_matches_the_closed_example() {
let closed_dir = temp_cwd("r-breakout-gang-axis-closed");
let (closed_stdout, closed_stderr, closed_code) = run_in(&closed_dir, &["run", &example("r_breakout.json")]);
assert_eq!(closed_code, Some(0), "closed run stderr: {closed_stderr}");
let closed: serde_json::Value =
serde_json::from_str(closed_stdout.trim()).expect("closed run report parses as JSON");
let sweep_dir = temp_cwd("r-breakout-gang-axis-sweep");
let (sweep_stdout, sweep_stderr, sweep_code) = run_in(
&sweep_dir,
&["sweep", &example("r_breakout_open.json"), "--axis", "r_breakout_signal.channel_length=3"],
);
assert_eq!(sweep_code, Some(0), "sweep stderr: {sweep_stderr}");
let lines: Vec<&str> = sweep_stdout.lines().collect();
assert_eq!(lines.len(), 1, "one grid point for a single-value axis: {sweep_stdout}");
let member: serde_json::Value = serde_json::from_str(lines[0]).expect("member line parses as JSON");
assert_eq!(
member["report"]["metrics"], closed["metrics"],
"channel_length=3, bound via the real sweep CLI, must fan out to both channel_hi \
and channel_lo identically to the closed example's hardwired channel=3"
);
}
@@ -777,8 +817,45 @@ fn shipped_r_meanrev_open_example_lists_its_axis_namespace() {
run_in(&dir, &["graph", "introspect", "--params", &example("r_meanrev_open.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
assert_eq!(
stdout, "mean_window.length:I64\nvar_window.length:I64\nband.factor:F64\n",
"the raw open params, in lowering order"
stdout, "window:I64\nband.factor:F64\n",
"the ganged EWMA window + the independent band knob, in lowering order"
);
}
/// Property (#61 + #159 cut 3): a sweep over TWO axes at once — the ganged
/// `window` knob (fusing `mean_window.length`/`var_window.length`) alongside
/// the independent, un-ganged `band.factor` knob — binds both correctly
/// through the real CLI grid, matching the shipped closed example's hardwired
/// window=3/band=2.0. This is a distinct risk from the r_breakout gang test
/// (a lone gang axis): a mis-scoped gang-expansion map that shifts sibling
/// param positions could silently mis-bind the co-present un-ganged axis
/// instead (or vice-versa) even though each axis alone still resolves.
#[test]
fn shipped_r_meanrev_open_gang_plus_plain_axis_matches_the_closed_example() {
let closed_dir = temp_cwd("r-meanrev-gang-axis-closed");
let (closed_stdout, closed_stderr, closed_code) = run_in(&closed_dir, &["run", &example("r_meanrev.json")]);
assert_eq!(closed_code, Some(0), "closed run stderr: {closed_stderr}");
let closed: serde_json::Value =
serde_json::from_str(closed_stdout.trim()).expect("closed run report parses as JSON");
let sweep_dir = temp_cwd("r-meanrev-gang-axis-sweep");
let (sweep_stdout, sweep_stderr, sweep_code) = run_in(
&sweep_dir,
&[
"sweep", &example("r_meanrev_open.json"),
"--axis", "r_meanrev_signal.window=3",
"--axis", "r_meanrev_signal.band.factor=2.0",
],
);
assert_eq!(sweep_code, Some(0), "sweep stderr: {sweep_stderr}");
let lines: Vec<&str> = sweep_stdout.lines().collect();
assert_eq!(lines.len(), 1, "one grid point for two single-value axes: {sweep_stdout}");
let member: serde_json::Value = serde_json::from_str(lines[0]).expect("member line parses as JSON");
assert_eq!(
member["report"]["metrics"], closed["metrics"],
"window=3 (ganged) + band.factor=2.0 (plain), bound via the real sweep CLI, must \
match the closed example's hardwired window=3/band=2.0"
);
}