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
+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