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:
2026-07-13 03:37:54 +02:00
parent 3f4c9478bf
commit e902a0f31d
5 changed files with 334 additions and 62 deletions
+92 -12
View File
@@ -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(&param_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(&param_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
View File
@@ -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);
+19 -5
View File
@@ -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(&param_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(|_| ())
+55 -1
View File
@@ -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
+115 -7
View File
@@ -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