From 3f4c9478bf5e1ffffae19b7090f127c734fd721b Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 13 Jul 2026 02:03:14 +0200 Subject: [PATCH] feat(cli): sweep axes override bound params on the blueprint verb paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 3-4 of the bound-override cycle. The family boundary derives the override set from the invocation's axes (override_paths: an axis in the open space passes through, one naming a bound param re-opens it in strategy coordinates, one matching neither is refused with a message pointing at --list-axes) and threads it through the axis probe and every per-member reload (blueprint_axis_probe_reopened / reopen_all), so probe and members stay slot-identical. The 'fully bound; nothing to sweep' refusal retires; identity stays the authored document (the topology_hash probe reload is never re-opened). --list-axes now prints the bound surface after the open knobs — one '..: default=' line per bound param, unconditionally (a bound param is an equally re-openable axis regardless of how many knobs are open beside it). Walk-forward (blueprint_sweep_over, blueprint_walkforward_family, run_oos_blueprint) and reproduce (reproduce_family_in plus the campaign-side trace re-run) derive the same set silently from recorded manifest param names (bound_overrides_of), so a family that swept an overridden bound param re-derives bit-identically. refs #246 --- crates/aura-cli/src/campaign_run.rs | 23 +- crates/aura-cli/src/main.rs | 277 +++++++++++++----- crates/aura-cli/tests/cli_run.rs | 179 +++++++++-- .../aura-cli/tests/project_sweep_campaign.rs | 19 +- 4 files changed, 401 insertions(+), 97 deletions(-) diff --git a/crates/aura-cli/src/campaign_run.rs b/crates/aura-cli/src/campaign_run.rs index 5493005..795b451 100644 --- a/crates/aura-cli/src/campaign_run.rs +++ b/crates/aura-cli/src/campaign_run.rs @@ -839,11 +839,25 @@ pub(crate) fn persist_campaign_traces( cell_rec.strategy ) })?; + // The #246 override set (silent variant, `reproduce_family_in`'s + // recipe): re-derived from the cell's own recorded manifest param + // names, against a raw probe space + raw strategy load — the members + // of one cell share the same knob NAMES (only values differ), so this + // is safe to compute once, cell-invariant, from the first member. + let raw_space = crate::blueprint_axis_probe(blueprint_json, env).param_space(); + let raw_signal = blueprint_from_json(blueprint_json, &|t| env.resolve(t)) + .expect("stored blueprint passed the referential gate; reload is infallible"); + let recorded: Vec = members + .first() + .map(|(_, r)| r.manifest.params.iter().map(|(n, _)| n.clone()).collect()) + .unwrap_or_default(); + let overrides = crate::bound_overrides_of(&recorded, &raw_space, &raw_signal); // Cell-invariant across every member of this cell (the wrapped param // space depends only on the cell's blueprint; the resolved geometry // only on the cell's instrument) — computed once here rather than // redundantly inside the member loop below. - let space = crate::blueprint_axis_probe(blueprint_json, env).param_space(); + let space = + crate::blueprint_axis_probe_reopened(blueprint_json, env, &overrides).param_space(); // Same resolved pip the member ran at (`CliMemberRunner::run_member`'s // `geo.pip_size`), else the C1 drift-alarm equality below would trip // spuriously on a re-run computed at a different (default) pip. @@ -872,8 +886,11 @@ pub(crate) fn persist_campaign_traces( cell_rec.instrument, from.0, to.0 ) }; - let signal = blueprint_from_json(blueprint_json, &|t| env.resolve(t)) - .expect("stored blueprint passed the referential gate; reload is infallible"); + let signal = crate::reopen_all( + blueprint_from_json(blueprint_json, &|t| env.resolve(t)) + .expect("stored blueprint passed the referential gate; reload is infallible"), + &overrides, + ); // Campaign data.bindings overrides win over name defaults — the // SAME resolution the member ran under, so the C1 drift alarm // compares like with like. diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 6e6c41b..e97b569 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -1222,14 +1222,32 @@ fn reproduce_family_in( eprintln!("aura: blueprint {hash} missing from store"); std::process::exit(1); }); + // The #246 override set (silent variant): re-derived from the RECORDED + // manifest param names, against a raw probe space + raw strategy load — a + // name matching neither space is simply not an override (falls through to + // `point_from_params`'s existing missing-knob refusal below), unlike the + // sweep boundary's `override_paths`, which errors on it. + let recorded: Vec = + stored.manifest.params.iter().map(|(n, _)| n.clone()).collect(); + let raw_space = blueprint_axis_probe(&doc, env).param_space(); + let raw_signal = blueprint_from_json(&doc, &|t| env.resolve(t)).unwrap_or_else(|e| { + eprintln!("aura: stored blueprint {hash} does not parse: {e:?}"); + std::process::exit(1); + }); + let overrides = bound_overrides_of(&recorded, &raw_space, &raw_signal); // Reload the stored blueprint per use: a Composite is !Clone, and both the // param-space probe (below) and the re-run each consume one. The doc was - // canonical-serialized at store time, so every reload is infallible. + // canonical-serialized at store time, so every reload is infallible. Every + // reload re-opens the same override set derived above, so the recorded + // param names resolve against the space they were minted under (#246). let reload = || { - blueprint_from_json(&doc, &|t| env.resolve(t)).unwrap_or_else(|e| { - eprintln!("aura: stored blueprint {hash} does not parse: {e:?}"); - std::process::exit(1); - }) + reopen_all( + blueprint_from_json(&doc, &|t| env.resolve(t)).unwrap_or_else(|e| { + eprintln!("aura: stored blueprint {hash} does not parse: {e:?}"); + std::process::exit(1); + }), + &overrides, + ) }; // The param_space of the WRAPPED signal — its knobs carry the `r_sma` // wrapper's `sma_signal.` node-path prefix, exactly the names the manifest @@ -1792,8 +1810,17 @@ fn run_blueprint_member( /// boundary before this runs, so a malformed doc has already exited 2 and /// never reaches the `.expect`. fn blueprint_axis_probe(doc: &str, env: &project::Env) -> Composite { + blueprint_axis_probe_reopened(doc, env, &[]) +} + +/// The axis probe with a #246 override set re-opened on the strategy BEFORE +/// wrapping — probe and per-member reloads must re-open identically so points +/// resolve against one space. The empty-set form is byte-equal to the old +/// probe (mc/run closed-checks and the open `--list-axes` lines read that). +fn blueprint_axis_probe_reopened(doc: &str, env: &project::Env, overrides: &[String]) -> Composite { let signal = blueprint_from_json(doc, &|t| env.resolve(t)) .expect("doc parse-validated at the dispatch boundary; reload is infallible"); + let signal = reopen_all(signal, overrides); // The PROBE binding is lenient (unresolvable roles fall back to close), // like the probe's synthetic pip: the wrap is built for its param_space // only, never run over data — strict resolution lives on the run paths. @@ -1805,13 +1832,94 @@ fn blueprint_axis_probe(doc: &str, env: &project::Env) -> Composite { wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, true, SYNTHETIC_PIP_SIZE, &probe, None) } +/// The override subset of `names` (#246): every name missing the un-reopened +/// wrapped OPEN space but naming a BOUND param of the strategy — returned in +/// STRATEGY coordinates (the wrap prefix `.` stripped) for +/// `Composite::reopen`. Names matching neither space are skipped here; the +/// caller decides whether that is an error (sweep boundary) or falls through +/// to its existing resolution errors. The silent variant `reproduce_family_in` +/// uses over the RECORDED manifest param names (the sweep boundary uses the +/// stricter `override_paths`, which errors on an unmatched name instead). +fn bound_overrides_of( + names: &[String], + open_space: &[ParamSpec], + signal: &Composite, +) -> Vec { + let open: HashSet<&str> = open_space.iter().map(|p| p.name.as_str()).collect(); + let prefix = format!("{}.", signal.name()); + let bound: HashSet = signal + .bound_param_space() + .into_iter() + .map(|b| format!("{prefix}{}", b.name)) + .collect(); + names + .iter() + .filter(|n| !open.contains(n.as_str()) && bound.contains(*n)) + .map(|n| n[prefix.len()..].to_string()) + .collect() +} + +/// The sweep-boundary variant (#246): like `bound_overrides_of`, but an axis +/// matching NEITHER the open nor the bound space is the error — the honest +/// replacement of the retired "fully bound; nothing to sweep" refusal. +fn override_paths( + axes: &[(String, Vec)], + open_space: &[ParamSpec], + signal: &Composite, +) -> Result, String> { + let open: HashSet<&str> = open_space.iter().map(|p| p.name.as_str()).collect(); + let prefix = format!("{}.", signal.name()); + let bound: HashSet = signal + .bound_param_space() + .into_iter() + .map(|b| format!("{prefix}{}", b.name)) + .collect(); + let mut overrides = Vec::new(); + for (name, _) in axes { + if open.contains(name.as_str()) { + continue; + } + if bound.contains(name) { + overrides.push(name[prefix.len()..].to_string()); + } else { + return Err(format!( + "axis {name}: names no param of this blueprint (open or bound) — \ + see `aura sweep --list-axes`" + )); + } + } + Ok(overrides) +} + +/// Apply a validated override set to a freshly loaded strategy (#246). +/// Infallible by contract: the set was derived against this same document at +/// the family boundary. +fn reopen_all(signal: Composite, overrides: &[String]) -> Composite { + overrides.iter().fold(signal, |s, p| { + s.reopen(p).expect("override set validated at the family boundary") + }) +} + /// `aura sweep --list-axes`: one `:` line per open -/// sweepable knob, in `param_space()` order; a closed blueprint prints nothing. -/// Names are exactly what `--axis` binds (same probe as the sweep terminal). +/// sweepable knob, in `param_space()` order (byte-identical to before #246), +/// followed by one `: default=` line per BOUND param — +/// every bound param IS a sweepable default, re-openable by naming it as an +/// `--axis` (#246), on EVERY blueprint shape (fully open, partially open, or +/// fully closed) — not only the fully-closed case: `--axis`/`override_paths` +/// accepts a bound name regardless of how many knobs happen to be open +/// alongside it, so the discovery surface must list it regardless too. Names +/// are exactly what `--axis` binds. fn list_blueprint_axes(doc: &str, env: &project::Env) { - for p in blueprint_axis_probe(doc, env).param_space() { + let space = blueprint_axis_probe(doc, env).param_space(); + for p in &space { println!("{}:{:?}", p.name, p.kind); // ScalarKind Debug -> I64/F64/Bool/Timestamp } + let signal = blueprint_from_json(doc, &|t| env.resolve(t)) + .expect("doc parse-validated at the dispatch boundary"); + let bp = signal.name().to_string(); + for b in signal.bound_param_space() { + println!("{bp}.{}:{:?} default={}", b.name, b.kind, render_value(&b.value)); + } } /// Sweep a serialized signal `doc` over user-named param-space axes. Structurally it @@ -1823,11 +1931,9 @@ fn list_blueprint_axes(doc: &str, env: &project::Env) { /// The axes are taken verbatim BY NAME (not the four suffix-resolved r-sma knobs): /// each `(name, vals)` is fed straight to the `SweepBinder`, so an unknown name or a /// kind mismatch surfaces as the sweep terminal's [`BindError`], rendered to a message -/// string — a named error, never a panic. A FULLY BOUND (closed) blueprint has an empty -/// `param_space` — nothing to sweep — and is refused up front with a clear message -/// (the symmetric inverse of [`blueprint_mc_family`]'s closed-blueprint requirement), -/// pre-empting the misleading `UnknownKnob()` the per-axis resolve would emit for a -/// knob that is not unknown but bound out. Every member manifest carries the shared +/// string — a named error, never a panic. An axis naming a bound param re-opens it +/// (#246: bound value = default); an axis matching neither space is refused by +/// `override_paths` before any run. Every member manifest carries the shared /// `topology_hash` of the loaded signal; reduce-mode fold, identical to the retired /// mirror's default (no-trace) arm. fn blueprint_sweep_family( @@ -1836,14 +1942,11 @@ fn blueprint_sweep_family( data: &DataSource, env: &project::Env, ) -> Result { - // The doc is parse-validated at the dispatch boundary (with file-path context), - // so every reload here is infallible: the builder has a single error contract — - // the `BindError` returned by the sweep terminal — and no hidden process exit. - let reload = |d: &str| { - blueprint_from_json(d, &|t| env.resolve(t)) - .expect("doc parse-validated at the dispatch boundary; reload is infallible") - }; - let probe_signal = reload(doc); + // Identity + binding read the AUTHORED doc, raw (no override re-open): + // topology and the resolved role plan are properties of the document, not + // of any one sweep's axis choices. + let probe_signal = blueprint_from_json(doc, &|t| env.resolve(t)) + .expect("doc parse-validated at the dispatch boundary; reload is infallible"); let topo = topology_hash(&probe_signal); // Strict binding resolution (name defaults — the verb path carries no // campaign overrides): the family's open plan and wrap plan in one value. @@ -1853,23 +1956,26 @@ fn blueprint_sweep_family( } let pip = data.pip_size(); let window = data.full_window(env); - // a single throwaway floated build, only to resolve param_space (borrow) then seed - // the named axes (move) — its taps are never drained. The wrapped probe is the one - // `blueprint_axis_probe` single-sources (identical `false, true, None` wrap). - let probe = blueprint_axis_probe(doc, env); + // The un-reopened wrapped OPEN space (#246): derives the override set (which + // named axes re-open a bound param) before the real, reopened probe is built — + // probe and per-member reloads must re-open identically so points resolve + // against one space. A name matching neither space is the error here. + let raw_space = blueprint_axis_probe(doc, env).param_space(); + let overrides = override_paths(axes, &raw_space, &probe_signal)?; + let probe = blueprint_axis_probe_reopened(doc, env, &overrides); let space = probe.param_space(); - // A fully bound blueprint has no open knob — there is nothing to sweep. Refuse it up - // front (mirroring blueprint_mc_family's closed-blueprint guard, inverted) BEFORE the - // per-axis resolve, which would otherwise return `UnknownKnob()` — misleading, as - // the named knob is not unknown but bound out. Exit-free: the message is returned for - // the IO wrapper to render + exit 2, so the precondition is unit-testable. - if space.is_empty() { - return Err( - "this blueprint is fully bound; nothing to sweep (use `aura sweep --list-axes` \ - to confirm there are no open knobs)" - .to_string(), - ); - } + // The doc is parse-validated at the dispatch boundary (with file-path context), + // so every reload here is infallible: the builder has a single error contract — + // the `BindError` returned by the sweep terminal — and no hidden process exit. + // Member reloads re-open the SAME override set derived above, so every member + // resolves its axes against the identical (reopened) param space the probe used. + let reload = |d: &str| { + reopen_all( + blueprint_from_json(d, &|t| env.resolve(t)) + .expect("doc parse-validated at the dispatch boundary; reload is infallible"), + &overrides, + ) + }; // seed the named axes verbatim: the first via Composite::axis (consumes the probe), // the rest via SweepBinder::axis. resolve_axes name- and kind-checks them at the // sweep terminal, so an UnknownKnob / KindMismatch is returned, not panicked. @@ -1894,7 +2000,12 @@ fn blueprint_sweep_family( /// `[from,to]` — the windowed, lattice-carrying twin of /// `blueprint_sweep_family`. `sweep_with_lattice` gives the grid lattice `--select /// plateau` needs. An unknown/kind-mismatched axis surfaces as `BindError` at the -/// sweep terminal (no panic, no hidden exit) for the caller to render. +/// sweep terminal (no panic, no hidden exit) for the caller to render. An axis +/// naming a bound param re-opens it (#246: bound value = default, same +/// `override_paths`/`reopen_all` recipe as `blueprint_sweep_family` — this is +/// its walk-forward in-sample twin); an axis matching neither space is refused +/// (wrapped as `BindError::UnknownKnob`, the honest replacement for the retired +/// "fully bound; nothing to sweep" refusal) before any member runs. fn blueprint_sweep_over( doc: &str, axes: &[(String, Vec)], from: Timestamp, to: Timestamp, data: &DataSource, env: &project::Env, binding: &binding::ResolvedBinding, @@ -1904,8 +2015,16 @@ fn blueprint_sweep_over( .expect("doc parse-validated at the dispatch boundary; reload is infallible") }; let pip = data.pip_size(); - let topo = topology_hash(&reload(doc)); - let probe = blueprint_axis_probe(doc, env); + let probe_signal = reload(doc); + let topo = topology_hash(&probe_signal); + // The un-reopened wrapped OPEN space (#246), against a raw probe + raw strategy + // load: derives the override set (which named axes re-open a bound param) before + // the reopened probe is built — probe and per-member reloads must re-open + // identically so points resolve against one space, exactly like + // `blueprint_sweep_family`. + let raw_space = blueprint_axis_probe(doc, env).param_space(); + let overrides = override_paths(axes, &raw_space, &probe_signal).map_err(BindError::UnknownKnob)?; + let probe = blueprint_axis_probe_reopened(doc, env, &overrides); let space = probe.param_space(); let mut iter = axes.iter(); let (first_name, first_vals) = iter.next().expect("a blueprint walk-forward declares >= 1 axis"); @@ -1916,21 +2035,29 @@ fn blueprint_sweep_over( binder.sweep_with_lattice(|point| { let sources = data.windowed_sources(from, to, env, &binding.columns()); let window = window_of(&sources).expect("non-empty in-sample window"); - run_blueprint_member(reload(doc), point, &space, sources, window, 0, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, binding, &[]) + run_blueprint_member(reopen_all(reload(doc), &overrides), point, &space, sources, window, 0, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, binding, &[]) }) } /// Run the winner params over an out-of-sample window `[from,to]` on the loaded /// blueprint. The reduce-mode member /// (`run_blueprint_member`) retains R-metrics, not a raw pip curve, so the stitching -/// segment is empty (an empty segment leaves the stitched curve unbroken). +/// segment is empty (an empty segment leaves the stitched curve unbroken). `overrides` +/// (#246) is the SAME family-wide set `blueprint_walkforward_family` derived once and +/// resolved `space`/`params` against — the OOS reload must re-open it too, or a +/// bound-param axis's winner point (kind-checked against the REOPENED space) fails +/// `bootstrap_with_cells`'s arity check against this still-closed reload. #[allow(clippy::too_many_arguments)] fn run_oos_blueprint( doc: &str, params: &[Cell], space: &[ParamSpec], from: Timestamp, to: Timestamp, topo: &str, data: &DataSource, env: &project::Env, binding: &binding::ResolvedBinding, + overrides: &[String], ) -> (Vec<(Timestamp, f64)>, RunReport) { - let reload = blueprint_from_json(doc, &|t| env.resolve(t)) - .expect("doc parse-validated at the dispatch boundary; reload is infallible"); + let reload = reopen_all( + blueprint_from_json(doc, &|t| env.resolve(t)) + .expect("doc parse-validated at the dispatch boundary; reload is infallible"), + overrides, + ); let pip = data.pip_size(); let sources = data.windowed_sources(from, to, env, &binding.columns()); let window = window_of(&sources).expect("non-empty out-of-sample window"); @@ -1956,10 +2083,22 @@ fn blueprint_walkforward_family( std::process::exit(2); } }; - let space = blueprint_axis_probe(doc, env).param_space(); let probe_signal = blueprint_from_json(doc, &|t| env.resolve(t)) .expect("doc parse-validated at the dispatch boundary; reload is infallible"); let topo = topology_hash(&probe_signal); + // The un-reopened wrapped OPEN space (#246), against a raw probe + raw strategy + // load: derives the override set ONCE for the whole family — every per-window + // sweep AND the OOS reload re-open the SAME set, mirroring + // `blueprint_sweep_family`/`blueprint_sweep_over`. The SILENT variant + // (`bound_overrides_of`, not the validating `override_paths`): an axis matching + // neither space is simply not an override here — the strict check + its + // established error message stay single-sourced in `blueprint_sweep_over`'s own + // pre-flight call below, so this derivation cannot double-validate with a + // differently-worded rejection. + let axis_names: Vec = axes.iter().map(|(n, _)| n.clone()).collect(); + let raw_space = blueprint_axis_probe(doc, env).param_space(); + let overrides = bound_overrides_of(&axis_names, &raw_space, &probe_signal); + let space = blueprint_axis_probe_reopened(doc, env, &overrides).param_space(); // Strict binding resolution, once per family; refusal is the established // `aura: ` + exit-1 register (the roller's usage refusals stay exit 2). let binding = binding::resolve_binding(probe_signal.name(), probe_signal.input_roles(), &BTreeMap::new()) @@ -1995,7 +2134,7 @@ fn blueprint_walkforward_family( Err(msg) => { eprintln!("aura: {msg}"); std::process::exit(2); } }; let (oos_equity, mut oos_report) = - run_oos_blueprint(doc, &best.params, &space, w.oos.0, w.oos.1, &topo, data, env, &binding); + run_oos_blueprint(doc, &best.params, &space, w.oos.0, w.oos.1, &topo, data, env, &binding, &overrides); oos_report.manifest.selection = Some(selection); WindowRun { chosen_params: best.params, oos_equity, oos_report } }) @@ -5311,36 +5450,34 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - /// Property: sweeping a FULLY BOUND (closed) blueprint is refused up front with a - /// clear "fully bound / nothing to sweep" message — NOT the terse `UnknownKnob()` - /// the per-axis resolve would otherwise emit (misleading: the named knob is not unknown, - /// it is bound out, so there is simply nothing to sweep). Symmetric inverse of - /// `blueprint_mc_family_rejects_an_open_blueprint` (mc requires a closed blueprint; a - /// sweep requires >= 1 open knob). Type-agnostic on the error (renders it via Debug), so - /// the RED phase compiles whether the builder returns a `BindError` or a `String`. + /// #246: a fully-bound (closed) blueprint IS sweepable — an axis naming a + /// bound param re-opens it (the bound value is the default), so the retired + /// "fully bound; nothing to sweep" refusal must not resurface. Inverse + /// guard: an axis naming NEITHER an open nor a bound param gets the one + /// clear boundary message (not a terse `UnknownKnob` debug leak). #[test] - fn blueprint_sweep_family_rejects_a_fully_bound_blueprint() { + fn blueprint_sweep_family_overrides_a_bound_param_and_names_unknown_axes() { let env = project::Env::std(); - // both SMA knobs bound -> empty param_space + // same fixture the retired test swept: both SMA knobs bound. let closed = load_closed_r_sma(); let doc = blueprint_to_json(&closed).expect("serializes"); - // an axis naming a bound-out knob: the pre-fix path returns UnknownKnob for it. + + // (a) override axis: two members, no refusal let axes = vec![( - "sma_signal.slow.length".to_string(), - vec![Scalar::i64(4), Scalar::i64(6)], + "sma_signal.fast.length".to_string(), + vec![Scalar::i64(2), Scalar::i64(4)], )]; - let err = match blueprint_sweep_family(&doc, &axes, &DataSource::Synthetic, &env) { - Ok(_) => panic!("a fully-bound blueprint has nothing to sweep"), - Err(e) => format!("{e:?}"), - }; - assert!( - err.contains("fully bound") || err.contains("nothing to sweep"), - "names the fully-bound / nothing-to-sweep condition: {err}" - ); - assert!( - !err.contains("UnknownKnob"), - "must not leak the misleading UnknownKnob: {err}" - ); + let fam = blueprint_sweep_family(&doc, &axes, &DataSource::Synthetic, &env) + .expect("a bound param is a default — the axis overrides it"); + assert_eq!(fam.points.len(), 2); + + // (b) unknown axis: the boundary message, no UnknownKnob leak + let bad = vec![("sma_signal.nope".to_string(), vec![Scalar::i64(1)])]; + let err = blueprint_sweep_family(&doc, &bad, &DataSource::Synthetic, &env) + .expect_err("an axis matching neither space is refused"); + assert!(err.contains("names no param"), "boundary message, got: {err}"); + assert!(err.contains("--list-axes"), "must point at --list-axes: {err}"); + assert!(!err.contains("UnknownKnob"), "must not leak the debug render: {err}"); } /// Property: an MC family's `aura reproduce` lines carry the member's own `seed=` diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 5a05413..64f9bc6 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -1401,9 +1401,12 @@ fn sweep_real_ohlc_channel_members_run_and_reproduce() { .output() .unwrap(); assert_eq!( - String::from_utf8_lossy(&axes.stdout).trim(), - "hl_channel.channel_length:I64", - "the ganged channel knob is the one sweepable axis; stderr: {}", + String::from_utf8_lossy(&axes.stdout), + "hl_channel.channel_length:I64\n\ + hl_channel.prev_high.lag:I64 default=1\n\ + hl_channel.prev_low.lag:I64 default=1\n", + "the ganged channel knob is the one open axis, alongside the fixture's \ + bound Delay lags as #246 defaults; stderr: {}", String::from_utf8_lossy(&axes.stderr) ); let out = std::process::Command::new(BIN) @@ -1813,6 +1816,105 @@ fn reproduce_real_sweep_family_re_derives_bit_identically() { ); } +/// Property (C18/C1 reproduce guarantee, #246): the same reproduce guarantee holds +/// when the swept axis names a BOUND param of a CLOSED starter blueprint, not an +/// already-open one — `r_sma.json` (both `sma_signal.fast.length` and +/// `sma_signal.slow.length` bound), the closed counterpart of the OPEN +/// `r_sma_open.json` the walk-forward reproduce sibling +/// (`aura_walkforward_over_a_blueprint_reproduces_bit_identically`) sweeps. +/// Modelled on that sibling (synthetic in-process walk-forward — no `--real`), +/// NOT on `reproduce_real_sweep_family_re_derives_bit_identically`: a real-data +/// mint routes through the campaign executor (`CliMemberRunner::run_member` + +/// `validate_and_register_axes`), neither of which carries any #246 override +/// threading — a gap outside this task's file scope (`blueprint_sweep_over`, +/// `reproduce_family_in`) and spanning all four dispatch verbs, so exercising +/// it here would silently expand into unrelated, unreviewed surface. The +/// synthetic walk-forward path exercises exactly the threaded functions: +/// `blueprint_sweep_over` (via `blueprint_walkforward_family`) re-opens +/// `sma_signal.fast.length` for the per-window IS re-fit, and +/// `reproduce_family_in` re-opens it again from the recorded manifest params +/// so the re-derivation resolves against the same space the mint used. +#[test] +fn reproduce_family_with_overridden_bound_param_re_derives() { + let cwd = temp_cwd("walkforward-reproduce-override"); + let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); + let run = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["walkforward", &bp, "--axis", "sma_signal.fast.length=2,3", "--name", "wfo"]) + .current_dir(&cwd) + .output() + .expect("spawn aura walkforward"); + assert_eq!( + run.status.code(), + Some(0), + "walk-forward over a bound-param axis must succeed (#246 re-open), stderr: {}", + String::from_utf8_lossy(&run.stderr) + ); + let repro = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["reproduce", "wfo-0"]) + .current_dir(&cwd) + .output() + .expect("spawn aura reproduce"); + assert_eq!( + repro.status.code(), + Some(0), + "reproduce stderr: {}", + String::from_utf8_lossy(&repro.stderr) + ); + let out = String::from_utf8(repro.stdout).expect("utf-8"); + assert!( + out.contains("reproduced 3/3 members bit-identically"), + "every OOS member of a deterministic walk-forward over a bound-param axis \ + re-derives bit-identically: {out}" + ); + let _ = std::fs::remove_dir_all(&cwd); +} + +/// Property (#246, distinct code path from `reproduce_family_with_overridden_bound_param_re_derives`): +/// `reproduce_family_in`'s override derivation (`bound_overrides_of`, computed once +/// per family from the FIRST member's recorded params) applies uniformly across +/// `FamilyKind`s, not only `WalkForward` — a plain `aura sweep` family minted by +/// overriding a bound param on the CLOSED `r_sma.json` fixture re-derives +/// bit-identically too. The sibling test exercises `FamilyKind::WalkForward`'s +/// branch (per-window OOS reload via `run_oos_blueprint`); this one exercises the +/// `Sweep`-kind branch (`data.run_sources`/`data.full_window`, no per-window +/// re-fit) of the same shared dispatch in `reproduce_family_in`. A regression that +/// threaded the #246 override set through one branch but not the other would pass +/// the walk-forward sibling while failing here. +#[test] +fn aura_reproduce_re_derives_a_sweep_family_minted_over_a_bound_param_override() { + let cwd = temp_cwd("sweep-reproduce-bound-override"); + let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); + let sweep = Command::new(BIN) + .args(["sweep", &bp, "--axis", "sma_signal.fast.length=2,3", "--name", "swo"]) + .current_dir(&cwd) + .output() + .expect("spawn aura sweep over a bound-param axis"); + assert_eq!( + sweep.status.code(), + Some(0), + "sweeping a bound param of a closed blueprint must succeed (#246 re-open), stderr: {}", + String::from_utf8_lossy(&sweep.stderr) + ); + let repro = Command::new(BIN) + .args(["reproduce", "swo-0"]) + .current_dir(&cwd) + .output() + .expect("spawn aura reproduce"); + assert_eq!( + repro.status.code(), + Some(0), + "reproduce stderr: {}", + String::from_utf8_lossy(&repro.stderr) + ); + let out = String::from_utf8(repro.stdout).expect("utf-8"); + assert!( + out.contains("reproduced 2/2 members bit-identically"), + "every member of a plain sweep minted over a bound-param override \ + re-derives bit-identically: {out}" + ); + let _ = std::fs::remove_dir_all(&cwd); +} + /// Property: `aura reproduce` yields a handled outcome — a reproduction verdict /// or a clean `aura:` refusal — for a real-data WalkForward family, and NEVER an /// internal panic. A WalkForward member's OOS window is stored as real epoch-ns @@ -4385,10 +4487,14 @@ fn aura_reproduce_rejects_a_missing_stored_blueprint() { let _ = std::fs::remove_dir_all(&cwd); } -/// E2E (#169): `aura sweep --list-axes` prints one -/// `:` line per open sweepable knob, in `param_space()` order, and -/// exits 0. The names are prefixed by the wrapping (sma_signal.*) — exactly -/// the strings `--axis` binds — so a user can discover then sweep without guessing. +/// E2E (#169/#246): `aura sweep --list-axes` prints one +/// `:` line per open sweepable knob, in `param_space()` order, +/// followed by one `: default=` line per BOUND param (the +/// fixture's `bias.scale` stays bound even though the two SMA lengths are +/// open — #246's discovery surface lists bound params on EVERY blueprint +/// shape, not only a fully-closed one), and exits 0. The names are prefixed +/// by the wrapping (sma_signal.*) — exactly the strings `--axis` binds — so a +/// user can discover then sweep or re-open without guessing. #[test] fn aura_sweep_list_axes_prints_prefixed_names() { let bp = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); @@ -4398,20 +4504,35 @@ fn aura_sweep_list_axes_prints_prefixed_names() { .expect("spawn aura sweep --list-axes"); assert_eq!(out.status.code(), Some(0)); let stdout = String::from_utf8(out.stdout).expect("utf-8"); - assert_eq!(stdout, "sma_signal.fast.length:I64\nsma_signal.slow.length:I64\n"); + assert_eq!( + stdout, + "sma_signal.fast.length:I64\n\ + sma_signal.slow.length:I64\n\ + sma_signal.bias.scale:F64 default=0.5\n" + ); } -/// E2E (#169): `--list-axes` on a CLOSED blueprint (all knobs bound) prints -/// nothing and exits 0 — an empty axis set is the honest answer, not an error. +/// E2E (#246): `--list-axes` on a CLOSED blueprint (all knobs bound) prints one +/// `..: default=` line per bound param — the +/// bound value IS the default, overridable by naming the same path as an +/// `--axis` (#246's re-open contract) — and exits 0. The sibling pin above +/// (`aura_sweep_list_axes_prints_prefixed_names`) proves the same bound-param +/// lines also appear on a PARTIALLY open blueprint, after its open lines. #[test] -fn aura_sweep_list_axes_closed_is_empty() { +fn aura_sweep_list_axes_closed_prints_bound_defaults() { let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args(["sweep", &bp, "--list-axes"]) .output() .expect("spawn aura sweep --list-axes (closed)"); assert_eq!(out.status.code(), Some(0)); - assert!(out.stdout.is_empty(), "a closed blueprint has no open axes"); + let stdout = String::from_utf8(out.stdout).expect("utf-8"); + assert_eq!( + stdout, + "sma_signal.fast.length:I64 default=2\n\ + sma_signal.slow.length:I64 default=4\n\ + sma_signal.bias.scale:F64 default=0.5\n" + ); } /// E2E (#169): `--list-axes` is a standalone query — combining it with a real @@ -4510,26 +4631,48 @@ fn aura_sweep_list_axes_names_round_trip_into_a_working_sweep() { let cwd = temp_cwd("blueprint-list-axes-roundtrip"); let bp = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); - // 1) discover the axis names (do NOT hardcode them) — this is what a user reads. + // 1) discover the axis names + kinds (do NOT hardcode them) — this is what a user + // reads. #246: the fixture now lists a BOUND param (`bias.scale`, F64) alongside + // the two open I64 lengths, so the CSV below must be typed per discovered kind, + // not assumed integer. let list = Command::new(BIN) .args(["sweep", &bp, "--list-axes"]) .output() .expect("spawn aura sweep --list-axes"); assert_eq!(list.status.code(), Some(0)); let listed = String::from_utf8(list.stdout).expect("utf-8"); - let names: Vec = listed + let names: Vec<(String, String)> = listed .lines() - .map(|l| l.split(':').next().expect("a `name:kind` line").to_string()) + .map(|l| { + let mut parts = l.splitn(2, ':'); + let name = parts.next().expect("a `name:kind` line").to_string(); + let kind = parts + .next() + .expect("kind after ':'") + .split_whitespace() + .next() + .expect("a kind token") + .to_string(); + (name, kind) + }) .collect(); assert!(!names.is_empty(), "the open fixture must list >= 1 axis"); // 2) feed the DISCOVERED names straight back into a real sweep. Values are // position-assigned (first axis small, rest large) to stay in a non-degenerate - // SMA regime without re-hardcoding the names; each axis takes two values. + // SMA regime without re-hardcoding the names; each axis takes two values, typed + // per its discovered kind so a bound F64 default (e.g. `bias.scale`) resolves + // instead of tripping `KindMismatch` on an integer-shaped token. let mut args: Vec = vec!["sweep".into(), bp.clone()]; - for (i, name) in names.iter().enumerate() { + for (i, (name, kind)) in names.iter().enumerate() { + let vals: &str = match (i == 0, kind.as_str()) { + (true, "F64") => "2.0,4.0", + (true, _) => "2,4", + (false, "F64") => "8.0,16.0", + (false, _) => "8,16", + }; args.push("--axis".into()); - args.push(format!("{name}={}", if i == 0 { "2,4" } else { "8,16" })); + args.push(format!("{name}={vals}")); } let sweep = Command::new(BIN) .args(&args) diff --git a/crates/aura-cli/tests/project_sweep_campaign.rs b/crates/aura-cli/tests/project_sweep_campaign.rs index 941cda1..1ac77d8 100644 --- a/crates/aura-cli/tests/project_sweep_campaign.rs +++ b/crates/aura-cli/tests/project_sweep_campaign.rs @@ -152,9 +152,12 @@ fn fresh_project() -> (&'static PathBuf, (RunsCleanup, MutexGuard<'static, ()>)) fn project_node_open_param_sweeps_and_reproduces() { let (dir, _g) = fresh_project(); - // (a) `--list-axes` discovers the PROJECT node's own open knob. NOT gated: - // enumerating axes needs no data, so the discoverability half runs on every - // host. The wrapped name is `..`. + // (a) `--list-axes` discovers the PROJECT node's own open knob, followed by + // the fixture's three BOUND params as `default=`-lines (#246: every bound + // param is an equally re-openable `--axis`, listed regardless of how many + // knobs happen to be open alongside it). NOT gated: enumerating axes needs + // no data, so the discoverability half runs on every host. The wrapped + // name is `..`. let axes = aura_in(dir, &["sweep", "blueprints/scaled_open.json", "--list-axes"]); assert!( axes.status.success(), @@ -162,9 +165,13 @@ fn project_node_open_param_sweeps_and_reproduces() { String::from_utf8_lossy(&axes.stderr) ); assert_eq!( - String::from_utf8_lossy(&axes.stdout).trim(), - "scaled_signal.gain.factor:F64", - "the project node's own param is the one discoverable sweep axis; stderr: {}", + String::from_utf8_lossy(&axes.stdout), + "scaled_signal.gain.factor:F64\n\ + scaled_signal.fast.length:I64 default=2\n\ + scaled_signal.slow.length:I64 default=4\n\ + scaled_signal.bias.scale:F64 default=0.5\n", + "the project node's own OPEN param is the one open axis, alongside the \ + fixture's bound defaults; stderr: {}", String::from_utf8_lossy(&axes.stderr) );