feat(cli,registry): bound-param axis overrides reach the campaign/--real trunk
Task 5 of the bound-override cycle. The campaign executor and its gates accept an axis naming a bound param everywhere the open surface was already accepted: CliMemberRunner::run_member re-opens the override set on the cell's probe space and raw reload (raw_bound_overrides_of — the campaign document's axis names live in strategy coordinates, matched against bound_param_space directly), the P3 preflight in validate_before_register threads the same set so the executable-shape check passes, and aura-registry's validate_campaign_refs treats a bound name as a valid axis (open space wins the arm order; the kind check still faults a mismatched axis over a bound path). The CLI-side helper family is named by coordinate system now: wrapped_bound_overrides_of (wrap-prefixed, sweep/wf/reproduce) beside raw_bound_overrides_of (campaign). A fresh post-block quality review found no correctness defect; its two stale-comment findings (renamed helpers cited under their old names) are fixed in this commit. refs #246
This commit is contained in:
@@ -14,15 +14,15 @@
|
||||
//! submodule idiom: main.rs is the crate root, so its private items are
|
||||
//! visible to child modules without promotion.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{mpsc, Arc};
|
||||
|
||||
use aura_campaign::{CampaignOutcome, CellOutcome, CellSpec, ExecFault, MemberFault, MemberRunner};
|
||||
use aura_core::{Cell, ParamSpec, PrimitiveBuilder, Scalar, ScalarKind, Timestamp};
|
||||
use aura_engine::{
|
||||
blueprint_from_json, f64_field, summarize, summarize_r, ColumnarTrace, FamilySelection,
|
||||
RunReport,
|
||||
blueprint_from_json, f64_field, summarize, summarize_r, ColumnarTrace, Composite,
|
||||
FamilySelection, RunReport,
|
||||
};
|
||||
use aura_ingest::{instrument_geometry, open_columns_window, unix_ms_to_epoch_ns};
|
||||
use aura_registry::{CampaignRunRecord, WriteKind};
|
||||
@@ -254,6 +254,36 @@ pub(crate) fn bind_axes(
|
||||
Ok(point)
|
||||
}
|
||||
|
||||
/// The #246 override subset of one member's campaign-axis names (already RAW
|
||||
/// — a campaign document speaks the raw namespace, #203, hence the `raw_`
|
||||
/// name prefix distinguishing this from main.rs's WRAPPED-input siblings):
|
||||
/// every name that matches no entry of the un-reopened wrapped OPEN
|
||||
/// `open_space` but names a BOUND param of `raw_signal` (`bound_param_space()`'s
|
||||
/// `.name` is already path-qualified in strategy/RAW coordinates, exactly
|
||||
/// like a raw campaign axis — no wrap-prefix stripping needed, unlike
|
||||
/// `wrapped_bound_overrides_of`/`override_paths` in main.rs, which start from
|
||||
/// WRAPPED CLI axis names). Silent like `wrapped_bound_overrides_of` (skips a
|
||||
/// name matching neither space), NOT the validating `override_paths`:
|
||||
/// `run_member` runs inside a sweep worker and must never call
|
||||
/// `std::process::exit` — an unmatched name still surfaces as `bind_axes`'s
|
||||
/// own named `MemberFault::Bind` once the (reopened) space below is built.
|
||||
pub(crate) fn raw_bound_overrides_of(
|
||||
param_names: &[String],
|
||||
open_space: &[ParamSpec],
|
||||
raw_signal: &Composite,
|
||||
) -> Vec<String> {
|
||||
let bound: HashSet<String> =
|
||||
raw_signal.bound_param_space().into_iter().map(|b| b.name).collect();
|
||||
param_names
|
||||
.iter()
|
||||
.filter(|raw| {
|
||||
!open_space.iter().any(|p| raw_matches_wrapped(raw, &p.name))
|
||||
&& bound.contains(raw.as_str())
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The CLI's harness/data binding seam for `aura_campaign::execute`: members
|
||||
/// run through the shipped loaded-blueprint machinery (`wrap_r` reduce-mode
|
||||
/// via `crate::run_blueprint_member`) over windowed real M1 close bars
|
||||
@@ -279,16 +309,31 @@ impl MemberRunner for CliMemberRunner<'_> {
|
||||
params: &[(String, Scalar)],
|
||||
window_ms: (i64, i64),
|
||||
) -> Result<RunReport, MemberFault> {
|
||||
// The wrapped axis namespace — the SAME probe the sweep verbs resolve
|
||||
// against (single-sourced in main.rs; identical `false, true, None` wrap).
|
||||
let space = crate::blueprint_axis_probe(&cell.blueprint_json, self.env).param_space();
|
||||
let point = bind_axes(&space, &cell.strategy_id, params)?;
|
||||
|
||||
// The member's blueprint + its resolved input binding (campaign
|
||||
// data.bindings overrides win over name defaults). A refusal is a
|
||||
// member fault, never a process exit.
|
||||
// The member's blueprint, loaded RAW (no re-open yet) — the SAME
|
||||
// reload the probe below wraps, so `bound_param_space()` here and
|
||||
// the probe's `param_space()` after `blueprint_axis_probe_reopened`
|
||||
// resolve against one topology.
|
||||
let signal = blueprint_from_json(&cell.blueprint_json, &|t| self.env.resolve(t))
|
||||
.expect("stored blueprint passed the referential gate; reload is infallible");
|
||||
|
||||
// The un-reopened wrapped OPEN space (#246) — the SAME probe the
|
||||
// sweep verbs resolve against (single-sourced in main.rs; identical
|
||||
// `false, true, None` wrap) — derives which of this cell's RAW axis
|
||||
// names re-open a bound param before the real, reopened probe/reload
|
||||
// are built: probe and member reload must re-open identically so
|
||||
// `params` resolves against one space.
|
||||
let raw_space = crate::blueprint_axis_probe(&cell.blueprint_json, self.env).param_space();
|
||||
let param_names: Vec<String> = params.iter().map(|(n, _)| n.clone()).collect();
|
||||
let overrides = raw_bound_overrides_of(¶m_names, &raw_space, &signal);
|
||||
let space =
|
||||
crate::blueprint_axis_probe_reopened(&cell.blueprint_json, self.env, &overrides)
|
||||
.param_space();
|
||||
let point = bind_axes(&space, &cell.strategy_id, params)?;
|
||||
let signal = crate::reopen_all(signal, &overrides);
|
||||
|
||||
// The member's resolved input binding (campaign data.bindings
|
||||
// overrides win over name defaults). A refusal is a member fault,
|
||||
// never a process exit.
|
||||
let binding =
|
||||
crate::binding::resolve_binding(&cell.strategy_id, signal.input_roles(), &self.bindings)
|
||||
.map_err(MemberFault::Bind)?;
|
||||
@@ -851,7 +896,7 @@ pub(crate) fn persist_campaign_traces(
|
||||
.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);
|
||||
let overrides = crate::wrapped_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
|
||||
@@ -1172,6 +1217,41 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// #246: `raw_bound_overrides_of` names exactly the raw axis names that
|
||||
/// miss the un-reopened wrapped OPEN space but match a BOUND param of the
|
||||
/// raw signal (in strategy/RAW coordinates, per its own doc comment) — an
|
||||
/// axis already covered by the open space is not re-flagged as an
|
||||
/// override, and an axis matching neither space is silently skipped here
|
||||
/// (bind_axes surfaces THAT case as its own named fault once the
|
||||
/// reopened space is built downstream).
|
||||
fn raw_bound_overrides_of_selects_axes_naming_a_bound_param() {
|
||||
use aura_std::{Bias, Sma};
|
||||
let raw_signal = Composite::new(
|
||||
"fixture",
|
||||
vec![
|
||||
Sma::builder().named("fast").bind("length", Scalar::i64(2)).into(),
|
||||
Bias::builder().named("exp").into(),
|
||||
],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
);
|
||||
// The un-reopened wrapped OPEN space carries one extra wrap segment
|
||||
// (the CLI's own outer wrap, mirroring `blueprint_axis_probe`'s
|
||||
// output shape) ahead of the composite's own "exp.scale" path.
|
||||
let open_space = vec![ParamSpec { name: "wrap.exp.scale".to_string(), kind: ScalarKind::F64 }];
|
||||
let param_names =
|
||||
vec!["exp.scale".to_string(), "fast.length".to_string(), "nope.thing".to_string()];
|
||||
let overrides = raw_bound_overrides_of(¶m_names, &open_space, &raw_signal);
|
||||
assert_eq!(
|
||||
overrides,
|
||||
vec!["fast.length".to_string()],
|
||||
"only the bound-param axis is selected; the already-open axis and the \
|
||||
unmatched axis are excluded"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// 0109: the campaign cell key is content-derived (the `member_key`
|
||||
/// discipline — never a runtime ordinal): strategy content-id prefix +
|
||||
|
||||
+53
-37
@@ -1234,7 +1234,7 @@ fn reproduce_family_in(
|
||||
eprintln!("aura: stored blueprint {hash} does not parse: {e:?}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
let overrides = bound_overrides_of(&recorded, &raw_space, &raw_signal);
|
||||
let overrides = wrapped_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. Every
|
||||
@@ -1832,26 +1832,40 @@ fn blueprint_axis_probe_reopened(doc: &str, env: &project::Env, overrides: &[Str
|
||||
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 `<signal.name()>.` 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(
|
||||
/// The WRAPPED-coordinate BOUND param name set of `signal` (#246): every
|
||||
/// `bound_param_space()` entry prefixed with `<signal.name()>.` — the same
|
||||
/// coordinate `param_space()`'s OPEN entries live in on the wrapped graph.
|
||||
/// Extracted because `wrapped_bound_overrides_of`, `override_paths`, and
|
||||
/// `validate_and_register_axes` each independently rebuilt this exact set
|
||||
/// (the rule-of-three the neighbouring doc comment itself cites).
|
||||
fn wrapped_bound_names(signal: &Composite) -> HashSet<String> {
|
||||
let prefix = format!("{}.", signal.name());
|
||||
signal
|
||||
.bound_param_space()
|
||||
.into_iter()
|
||||
.map(|b| format!("{prefix}{}", b.name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The override subset of `names` (#246): every WRAPPED-coordinate name
|
||||
/// missing the un-reopened wrapped OPEN space but naming a BOUND param of the
|
||||
/// strategy — returned in STRATEGY coordinates (the wrap prefix
|
||||
/// `<signal.name()>.` 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). Contrast
|
||||
/// `campaign_run::raw_bound_overrides_of`, whose `names` are already RAW
|
||||
/// (a campaign document's own namespace, #203) and needs no such stripping.
|
||||
fn wrapped_bound_overrides_of(
|
||||
names: &[String],
|
||||
open_space: &[ParamSpec],
|
||||
signal: &Composite,
|
||||
) -> Vec<String> {
|
||||
let open: HashSet<&str> = open_space.iter().map(|p| p.name.as_str()).collect();
|
||||
let prefix = format!("{}.", signal.name());
|
||||
let bound: HashSet<String> = signal
|
||||
.bound_param_space()
|
||||
.into_iter()
|
||||
.map(|b| format!("{prefix}{}", b.name))
|
||||
.collect();
|
||||
let bound = wrapped_bound_names(signal);
|
||||
names
|
||||
.iter()
|
||||
.filter(|n| !open.contains(n.as_str()) && bound.contains(*n))
|
||||
@@ -1859,9 +1873,9 @@ fn bound_overrides_of(
|
||||
.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.
|
||||
/// The sweep-boundary variant (#246): like `wrapped_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<Scalar>)],
|
||||
open_space: &[ParamSpec],
|
||||
@@ -1869,11 +1883,7 @@ fn override_paths(
|
||||
) -> Result<Vec<String>, String> {
|
||||
let open: HashSet<&str> = open_space.iter().map(|p| p.name.as_str()).collect();
|
||||
let prefix = format!("{}.", signal.name());
|
||||
let bound: HashSet<String> = signal
|
||||
.bound_param_space()
|
||||
.into_iter()
|
||||
.map(|b| format!("{prefix}{}", b.name))
|
||||
.collect();
|
||||
let bound = wrapped_bound_names(signal);
|
||||
let mut overrides = Vec::new();
|
||||
for (name, _) in axes {
|
||||
if open.contains(name.as_str()) {
|
||||
@@ -2090,14 +2100,14 @@ fn blueprint_walkforward_family(
|
||||
// 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
|
||||
// (`wrapped_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<String> = 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 overrides = wrapped_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).
|
||||
@@ -3160,14 +3170,19 @@ enum AxisRegisterError {
|
||||
}
|
||||
|
||||
/// Validate every `--axis` name against the blueprint's WRAPPED probe namespace
|
||||
/// (`blueprint_axis_probe`/`--list-axes`), then canonicalize + register the
|
||||
/// blueprint by topology hash and strip every axis name to the RAW campaign
|
||||
/// namespace (the sweep sequence, #210 c0110). A raw-form or fat-fingered axis
|
||||
/// name is refused here, echoing exactly what the user typed, before the
|
||||
/// archive is touched or the blueprint is registered — shared by every
|
||||
/// campaign-path dispatcher (sweep/generalize/walkforward/mc all landed the
|
||||
/// identical block; #220 slice-1 deferred this dedup to "once wf/mc land the
|
||||
/// same block", rule-of-three now exceeded 4x).
|
||||
/// (`blueprint_axis_probe`/`--list-axes`) — an axis naming a BOUND param (#246:
|
||||
/// re-openable, same as every already-open knob) passes exactly like an open
|
||||
/// one; only a name matching NEITHER space is refused — then canonicalize +
|
||||
/// register the blueprint by topology hash and strip every axis name to the RAW
|
||||
/// campaign namespace (the sweep sequence, #210 c0110). A raw-form or
|
||||
/// fat-fingered axis name is refused here, echoing exactly what the user typed,
|
||||
/// before the archive is touched or the blueprint is registered — shared by
|
||||
/// every campaign-path dispatcher (sweep/generalize/walkforward/mc all landed
|
||||
/// the identical block; #220 slice-1 deferred this dedup to "once wf/mc land
|
||||
/// the same block", rule-of-three now exceeded 4x). The override set itself is
|
||||
/// re-derived downstream (`CliMemberRunner::run_member`,
|
||||
/// `verb_sugar::validate_before_register`) rather than threaded through this
|
||||
/// return value — this preflight only decides go/no-go on the NAME.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn validate_and_register_axes(
|
||||
verb: &str,
|
||||
@@ -3176,8 +3191,11 @@ fn validate_and_register_axes(
|
||||
env: &project::Env,
|
||||
) -> Result<(String, Vec<(String, Vec<Scalar>)>), AxisRegisterError> {
|
||||
let space = blueprint_axis_probe(doc, env).param_space();
|
||||
let blueprint = blueprint_from_json(doc, &|t| env.resolve(t))
|
||||
.expect("doc parse-validated at the dispatch boundary");
|
||||
let bound = wrapped_bound_names(&blueprint);
|
||||
for (n, _) in axes {
|
||||
if !space.iter().any(|p| &p.name == n) {
|
||||
if !space.iter().any(|p| &p.name == n) && !bound.contains(n) {
|
||||
return Err(AxisRegisterError::UnknownAxis(format!(
|
||||
"axis \"{n}\" is not one of this blueprint's \
|
||||
sweepable axes — run 'aura sweep <bp> --list-axes' \
|
||||
@@ -3194,8 +3212,6 @@ fn validate_and_register_axes(
|
||||
store and vocabulary (no Aura.toml found up from {cwd})"
|
||||
)));
|
||||
}
|
||||
let blueprint = blueprint_from_json(doc, &|t| env.resolve(t))
|
||||
.expect("doc parse-validated at the dispatch boundary");
|
||||
let canonical = blueprint_to_json(&blueprint).expect("a loaded blueprint re-serializes");
|
||||
let reg = env.registry();
|
||||
let topo = topology_hash(&blueprint);
|
||||
|
||||
@@ -284,10 +284,15 @@ pub(crate) fn register_generated_g(
|
||||
/// blueprint's own (wrapped) open param space via the pure `bind_axes` seam
|
||||
/// (campaign_run.rs) — the referential shape the dispatch-boundary name
|
||||
/// preflight (main.rs) does not catch: a subset of axes that leaves an open
|
||||
/// param unbound (probe P3). `probe_params` is any one grid value per axis
|
||||
/// (checks NAME coverage/uniqueness, never the bound value). Shared by both
|
||||
/// dissolved-verb sugars: one seam enforcing the store-litter invariant,
|
||||
/// rather than two drift-prone copies.
|
||||
/// param unbound (probe P3). Probe P3's space is the REOPENED one (#246): the
|
||||
/// same `raw_bound_overrides_of` derivation `CliMemberRunner::run_member` uses
|
||||
/// per member (campaign_run.rs) — a bound-param axis passes this
|
||||
/// executable-shape preflight exactly like an already-open one; an axis
|
||||
/// matching neither space still fails `bind_axes`'s own named check below.
|
||||
/// `probe_params` is any one grid value per axis (checks NAME
|
||||
/// coverage/uniqueness, never the bound value). Shared by both dissolved-verb
|
||||
/// sugars: one seam enforcing the store-litter invariant, rather than two
|
||||
/// drift-prone copies.
|
||||
fn validate_before_register(
|
||||
process: &ProcessDoc,
|
||||
campaign: &CampaignDoc,
|
||||
@@ -311,7 +316,16 @@ fn validate_before_register(
|
||||
}
|
||||
aura_campaign::preflight(process, campaign).map_err(|f| crate::campaign_run::exec_fault_prose(&f))?;
|
||||
|
||||
let space = crate::blueprint_axis_probe(blueprint_canonical, env).param_space();
|
||||
// The un-reopened wrapped OPEN space (#246): derive which of the probe's
|
||||
// RAW axis names re-open a bound param of the strategy, against a raw
|
||||
// (un-reopened) reload of the same canonical bytes — infallible, the
|
||||
// strategy ref's own content.
|
||||
let raw_space = crate::blueprint_axis_probe(blueprint_canonical, env).param_space();
|
||||
let raw_signal = aura_engine::blueprint_from_json(blueprint_canonical, &|t| env.resolve(t))
|
||||
.expect("a generated sugar campaign's strategy ref reloads its own canonical bytes");
|
||||
let param_names: Vec<String> = probe_params.iter().map(|(n, _)| n.clone()).collect();
|
||||
let overrides = crate::campaign_run::raw_bound_overrides_of(¶m_names, &raw_space, &raw_signal);
|
||||
let space = crate::blueprint_axis_probe_reopened(blueprint_canonical, env, &overrides).param_space();
|
||||
let strategy_id = content_id_of(blueprint_canonical);
|
||||
crate::campaign_run::bind_axes(&space, &strategy_id, probe_params)
|
||||
.map(|_| ())
|
||||
|
||||
@@ -1870,7 +1870,7 @@ fn reproduce_family_with_overridden_bound_param_re_derives() {
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// `reproduce_family_in`'s override derivation (`wrapped_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
|
||||
@@ -2215,6 +2215,60 @@ fn aura_sweep_real_blueprint_subset_axes_refuses_without_store_litter() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Property (issue #246, task 5): the dissolved real-data `aura sweep`
|
||||
/// campaign path accepts an `--axis` naming a BOUND param of a CLOSED
|
||||
/// blueprint, not only an already-open one. Tasks 3-4 threaded this override
|
||||
/// convention (bound value = default, `Composite::reopen`) through the
|
||||
/// SYNTHETIC blueprint verb paths (`blueprint_sweep_family`/
|
||||
/// `blueprint_sweep_over`, `reproduce_family_with_overridden_bound_param_re_derives`);
|
||||
/// the real-data dissolved path routes through a distinct seam
|
||||
/// (`CliMemberRunner::run_member` + `verb_sugar::validate_before_register`'s
|
||||
/// P3 preflight) that carried no such threading at all, the explicit gap
|
||||
/// named on `reproduce_family_with_overridden_bound_param_re_derives`'s doc
|
||||
/// comment. Modelled on `sweep_real_blueprint_member_lines_pin_the_dissolved_contract`
|
||||
/// (the dissolved-sweep e2e family `walkforward_dissolved_refuses_an_unknown_axis_name`
|
||||
/// belongs to): same store-reading assertions (`aura runs families`, one
|
||||
/// persisted `Sweep` family, member count), but over the CLOSED `r_sma.json`
|
||||
/// fixture (`sma_signal.fast.length`/`sma_signal.slow.length` both bound)
|
||||
/// instead of the OPEN `r_sma_open.json` every other real-data sweep pin uses.
|
||||
#[test]
|
||||
fn sweep_dissolved_accepts_an_axis_over_a_bound_param() {
|
||||
if !local_data_present() {
|
||||
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
|
||||
return;
|
||||
}
|
||||
let (dir, _g) = fresh_project();
|
||||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let out = Command::new(BIN)
|
||||
.args([
|
||||
"sweep", &fixture,
|
||||
"--real", "GER40",
|
||||
"--from", GER40_SEPT2024_FROM_MS,
|
||||
"--to", GER40_SEPT2024_TO_MS,
|
||||
"--axis", "sma_signal.fast.length=2,3",
|
||||
"--name", "bound-override",
|
||||
])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("spawn aura sweep over a bound-param axis");
|
||||
assert_eq!(
|
||||
out.status.code(),
|
||||
Some(0),
|
||||
"a bound-param axis must re-open (#246) on the dissolved real-data campaign \
|
||||
path too, stderr: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let fams = Command::new(BIN).args(["runs", "families"]).current_dir(dir).output().unwrap();
|
||||
assert!(fams.status.success(), "families exit: {:?}", fams.status);
|
||||
let fams_out = String::from_utf8_lossy(&fams.stdout).into_owned();
|
||||
assert_eq!(fams_out.lines().count(), 1, "exactly one family persisted: {fams_out}");
|
||||
assert!(fams_out.contains("\"kind\":\"Sweep\""), "families: {fams_out}");
|
||||
assert!(
|
||||
fams_out.contains("\"members\":2"),
|
||||
"the two-value bound-param axis grid yields two members: {fams_out}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The synthetic seed-resweep `mc` is undefined over real bars (one realization ->
|
||||
/// identical members), so a bare `aura mc --real EURUSD` — with no R candidate — is
|
||||
/// REJECTED (usage on stderr, exit 2) at the binary boundary. The real path is
|
||||
|
||||
@@ -275,20 +275,37 @@ impl Registry {
|
||||
known_roles.insert(role.name.clone());
|
||||
}
|
||||
let space = composite.param_space();
|
||||
// #246: an axis naming a BOUND param re-opens it (bound value =
|
||||
// default) — an equally valid axis alongside the open surface,
|
||||
// not a param-space miss. `composite` here is the RAW strategy
|
||||
// (no CLI-side wrap), so a campaign axis and `bound_param_space()`
|
||||
// names live in the same (strategy-coordinate) namespace already
|
||||
// — no wrap-prefix stripping needed, unlike the CLI's
|
||||
// `wrapped_bound_overrides_of`/`override_paths`/`raw_bound_overrides_of`.
|
||||
let bound = composite.bound_param_space();
|
||||
for (axis, ax) in &entry.axes {
|
||||
match space.iter().find(|p| &p.name == axis) {
|
||||
None => faults.push(RefFault::AxisNotInParamSpace {
|
||||
strategy: label.clone(),
|
||||
axis: axis.clone(),
|
||||
}),
|
||||
Some(spec) => {
|
||||
if ax.kind != spec.kind {
|
||||
// Open space wins over bound on a (defensively impossible)
|
||||
// name collision, mirroring the original arm order; only the
|
||||
// kind SOURCE differs between the two found-cases, so one
|
||||
// shared mismatch push replaces the former duplicate arms.
|
||||
let kind = space
|
||||
.iter()
|
||||
.find(|p| &p.name == axis)
|
||||
.map(|p| p.kind)
|
||||
.or_else(|| bound.iter().find(|b| &b.name == axis).map(|b| b.kind));
|
||||
match kind {
|
||||
Some(k) => {
|
||||
if ax.kind != k {
|
||||
faults.push(RefFault::AxisKindMismatch {
|
||||
strategy: label.clone(),
|
||||
axis: axis.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
None => faults.push(RefFault::AxisNotInParamSpace {
|
||||
strategy: label.clone(),
|
||||
axis: axis.clone(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
// The reverse direction: every open param must be bound by some
|
||||
@@ -1637,6 +1654,97 @@ mod tests {
|
||||
assert_eq!(reg.validate_campaign_refs(&by_identity, &resolve).expect("io ok"), Vec::new());
|
||||
}
|
||||
|
||||
/// Property (#246): a campaign axis naming a BOUND param (not an open
|
||||
/// one) is checked exactly like an open-param axis — a kind-correct axis
|
||||
/// over the bound path is fault-free, and a kind-mismatched one over that
|
||||
/// SAME bound path still yields `AxisKindMismatch` via the bound branch
|
||||
/// of `validate_campaign_refs`'s match, never silently accepted. Pure and
|
||||
/// archive/env-free, unlike the `sweep_dissolved_accepts_an_axis_over_a_
|
||||
/// bound_param` e2e sibling, which only exercises this path when local
|
||||
/// data is present.
|
||||
#[test]
|
||||
fn referential_tier_accepts_a_kind_correct_axis_over_a_bound_param() {
|
||||
use aura_engine::{blueprint_to_json, Composite};
|
||||
use aura_research::Axis;
|
||||
use aura_std::Sma;
|
||||
|
||||
let reg = Registry::open(temp_family_dir("bound_axis_tier"));
|
||||
let resolve = |t: &str| aura_std::std_vocabulary(t);
|
||||
|
||||
// A fixture composite with ONE bound param and NO open param, so the
|
||||
// reverse full-coverage check (every OPEN param must be bound by an
|
||||
// axis) is vacuously satisfied regardless of the axis under test.
|
||||
let composite = Composite::new(
|
||||
"fixture",
|
||||
vec![Sma::builder().named("s").bind("length", Scalar::i64(3)).into()],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
);
|
||||
assert!(composite.param_space().is_empty(), "fixture is fully bound");
|
||||
let bound = composite.bound_param_space();
|
||||
let bound_param = bound.first().expect("fixture has one bound param");
|
||||
let bound_name = bound_param.name.clone();
|
||||
let bound_kind = bound_param.kind;
|
||||
|
||||
let blueprint_json = blueprint_to_json(&composite).expect("serializes");
|
||||
let bp_id = aura_research::content_id_of(&blueprint_json);
|
||||
reg.put_blueprint(&bp_id, &blueprint_json).expect("seed blueprint");
|
||||
let process = r#"{"format_version":1,"kind":"process","name":"p","pipeline":[{"block":"std::generalize","metric":"sqn"}]}"#;
|
||||
let proc_id = reg.put_process(process).expect("seed process");
|
||||
|
||||
let bare = match bound_kind {
|
||||
aura_core::ScalarKind::I64 => "4",
|
||||
aura_core::ScalarKind::F64 => "1.5",
|
||||
aura_core::ScalarKind::Bool => "true",
|
||||
aura_core::ScalarKind::Timestamp => "0",
|
||||
};
|
||||
let kind_tag = format!("{bound_kind:?}");
|
||||
let campaign_text = format!(
|
||||
concat!(
|
||||
r#"{{"format_version":1,"kind":"campaign","name":"c","#,
|
||||
r#""data":{{"instruments":["GER40"],"windows":[{{"from_ms":1,"to_ms":2}}]}},"#,
|
||||
r#""strategies":[{{"ref":{{"content_id":"{bp}"}},"#,
|
||||
r#""axes":{{"{axis}":{{"kind":"{kind}","values":[{val}]}}}}}}],"#,
|
||||
r#""process":{{"ref":{{"content_id":"{proc}"}}}},"#,
|
||||
r#""seed":1,"presentation":{{"persist_taps":[],"emit":["family_table"]}}}}"#
|
||||
),
|
||||
bp = bp_id,
|
||||
axis = bound_name,
|
||||
kind = kind_tag,
|
||||
val = bare,
|
||||
proc = proc_id,
|
||||
);
|
||||
let doc = aura_research::parse_campaign(&campaign_text).expect("campaign parses");
|
||||
assert_eq!(
|
||||
reg.validate_campaign_refs(&doc, &resolve).expect("io ok"),
|
||||
Vec::new(),
|
||||
"a kind-correct axis over a bound param must pass validation",
|
||||
);
|
||||
|
||||
// A kind-mismatched axis over the SAME bound path must still fault
|
||||
// via the bound branch — not silently accepted just because it
|
||||
// names no OPEN param.
|
||||
let wrong_kind = if matches!(bound_kind, aura_core::ScalarKind::I64) {
|
||||
aura_core::ScalarKind::F64
|
||||
} else {
|
||||
aura_core::ScalarKind::I64
|
||||
};
|
||||
let mut mismatched = doc.clone();
|
||||
let kept_values = mismatched.strategies[0].axes[&bound_name].values.clone();
|
||||
mismatched.strategies[0].axes.insert(
|
||||
bound_name.clone(),
|
||||
Axis { kind: wrong_kind, values: kept_values },
|
||||
);
|
||||
assert!(
|
||||
reg.validate_campaign_refs(&mismatched, &resolve)
|
||||
.expect("io ok")
|
||||
.iter()
|
||||
.any(|f| matches!(f, RefFault::AxisKindMismatch { axis, .. } if axis == &bound_name)),
|
||||
"a kind-mismatched axis over a bound param must fault via the bound branch",
|
||||
);
|
||||
}
|
||||
|
||||
/// Property: the referential tier requires FULL open-param coverage — every
|
||||
/// open param of a referenced strategy must be bound by a campaign axis, so
|
||||
/// a document `validate` blesses is one `run` can actually bind (the
|
||||
|
||||
Reference in New Issue
Block a user