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:
@@ -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