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
+11 -4
View File
@@ -1831,8 +1831,8 @@ const R_BREAKOUT_CHANNEL: i64 = 3;
/// as blueprint data (#159 cut 2). The signal computation matches the retired fused
/// builder's leg (same nodes, same wiring); the pip/R harness is the generic
/// `wrap_r` wrapper, not part of the signal. `channel = Some(n)` binds both rolling
/// nodes (closed); `None` leaves them open (two sweep axes — per-node exposure;
/// ganging one knob into both is the separate #61). This carve has no production
/// nodes (closed); `None` leaves them open, ganged into the single `channel_length`
/// knob (#61) — the channel is structurally ONE parameter. This carve has no production
/// (non-test) caller — it is exercised only by the regeneration + proof tests below;
/// production code loads the shipped JSON, not this builder — hence `#[cfg(test)]`.
#[cfg(test)]
@@ -1847,6 +1847,9 @@ fn r_breakout_signal(channel: Option<i64>) -> Composite {
}
let mx = g.add(mx_b);
let mn = g.add(mn_b);
if channel.is_none() {
g.gang("channel_length", [mx.param("length"), mn.param("length")]);
}
let gt_up = g.add(Gt::builder());
let gt_down = g.add(Gt::builder());
let up_latch = g.add(Latch::builder());
@@ -1869,8 +1872,9 @@ fn r_breakout_signal(channel: Option<i64>) -> Composite {
}
/// The EWMA window for the canonical closed r_meanrev example (ganged mean/var Ema
/// length). Single source for the emitter and the proof tests that must all agree
/// with the baked `examples/r_meanrev.json`.
/// length; the open form gangs them structurally via the single `window` knob, #61).
/// Single source for the emitter and the proof tests that must all agree with the
/// baked `examples/r_meanrev.json`.
#[cfg(test)]
const R_MEANREV_WINDOW: i64 = 3;
@@ -1898,6 +1902,9 @@ fn r_meanrev_signal(window: Option<i64>, band_k: Option<f64>) -> Composite {
let dev = g.add(Sub::builder()); // price - mean
let sq = g.add(Mul::builder()); // dev * dev
let var = g.add(var_b); // EWMA variance
if window.is_none() {
g.gang("window", [mean.param("length"), var.param("length")]);
}
let sigma = g.add(Sqrt::builder());
let mut band_b = Scale::builder().named("band");
if let Some(k) = band_k {