feat(registry,cli): referential tier requires full open-param coverage; bind refusal speaks the raw namespace

validate == runnable (fieldtest 0107 F7): validate_campaign_refs now
checks the reverse direction — every open param of the resolved
strategy must be bound by some campaign axis — emitting the new
RefFault::ParamNotCovered { strategy, param } with the RAW
param_space() path; ref_fault_prose phrases it ('open param "X" is
bound by no campaign axis'). The executor's every-open-knob-required
rule is thereby mirrored at validate time, and campaign run's
referential gate catches an uncovered knob before any member binds.
The e2e fixture that legitimately under-covered (fast only) gains the
slow.length axis.

Second half, RED-first: the member-bind refusal no longer leaks the
wrapped bind-time path — it names the raw campaign-axis namespace with
the wrapped path as a parenthetical ('open param "threshold" ...
(wrapped path: sma.threshold)').

RED evidence: E0599 on the pinned ParamNotCovered test (tdd-author
handoff), then the flipped bind_axes prose pin observed failing on the
wrapped form.

Gates: workspace 988/0, clippy -D warnings clean.

closes #203
This commit is contained in:
2026-07-03 22:44:26 +02:00
parent 978ba15bd3
commit 2968fbef40
4 changed files with 109 additions and 3 deletions
+13 -2
View File
@@ -147,8 +147,14 @@ fn bind_axes(
match slot {
Some(v) => point.push(v.cell()),
None => {
// Speak the RAW campaign-axis namespace (the doc's own): the
// wrapped space prefixes the signal's params with one node
// segment, so the raw path is everything after it (#203).
let raw =
spec.name.split_once('.').map(|(_, rest)| rest).unwrap_or(&spec.name);
return Err(MemberFault::Bind(format!(
"open param \"{}\" of strategy {strategy_id} is bound by no campaign axis",
"open param \"{raw}\" of strategy {strategy_id} is bound by no \
campaign axis (wrapped path: {})",
spec.name
)));
}
@@ -491,6 +497,11 @@ mod tests {
let params = vec![("length".to_string(), Scalar::i64(14))];
let err = bind_axes(&space, "strat", &params).unwrap_err();
let MemberFault::Bind(m) = err else { panic!("expected Bind, got {err:?}") };
assert!(m.contains("threshold") && m.contains("bound by no campaign axis"));
// The refusal speaks the RAW campaign-axis namespace (what the doc
// must say), with the wrapped bind-time path as a parenthetical (#203).
assert!(
m.contains("open param \"threshold\"") && m.contains("(wrapped path: sma.threshold)"),
"raw-namespace prose expected: {m}"
);
}
}
+3
View File
@@ -263,6 +263,9 @@ pub(crate) fn ref_fault_prose(f: &RefFault) -> String {
RefFault::AxisKindMismatch { strategy, axis } => {
format!("strategy {strategy}: axis \"{axis}\" declares a kind that is not the param's kind")
}
RefFault::ParamNotCovered { strategy, param } => {
format!("strategy {strategy}: open param \"{param}\" is bound by no campaign axis")
}
}
}
+2 -1
View File
@@ -327,7 +327,8 @@ fn campaign_validate_in_project_reports_referential_tier_end_to_end() {
"name": "ref-check",
"data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": 1, "to_ms": 2 }} ] }},
"strategies": [ {{ "ref": {{ "content_id": "{bp}" }},
"axes": {{ "fast.length": {{ "kind": "I64", "values": [2] }} }} }} ],
"axes": {{ "fast.length": {{ "kind": "I64", "values": [2] }},
"slow.length": {{ "kind": "I64", "values": [6] }} }} }} ],
"process": {{ "ref": {{ "content_id": "{proc}" }} }},
"seed": 1,
"presentation": {{ "persist_taps": [], "emit": ["family_table"] }}
+91
View File
@@ -213,6 +213,11 @@ pub enum RefFault {
StrategyUnloadable { id: String, error: String },
AxisNotInParamSpace { strategy: String, axis: String },
AxisKindMismatch { strategy: String, axis: String },
/// An open param of the resolved strategy is bound by no campaign axis —
/// the executor's every-open-knob-required rule, mirrored at validate
/// time so "valid" means "runnable". Carries the RAW `param_space()`
/// path (never the wrapped bind-time path).
ParamNotCovered { strategy: String, param: String },
}
impl Registry {
@@ -278,6 +283,16 @@ impl Registry {
}
}
}
// The reverse direction: every open param must be bound by some
// axis (the executor refuses at member-bind otherwise — #203).
for spec in &space {
if !entry.axes.contains_key(&spec.name) {
faults.push(RefFault::ParamNotCovered {
strategy: label.clone(),
param: spec.name.clone(),
});
}
}
}
Ok(faults)
}
@@ -1595,6 +1610,82 @@ mod tests {
assert_eq!(reg.validate_campaign_refs(&by_identity, &resolve).expect("io ok"), Vec::new());
}
/// 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
/// glossary's "every open knob required, no default" rule, mirrored at
/// validate time — C11). A campaign whose axes omit an open param must yield
/// a `ParamNotCovered` fault carrying the RAW param path (the `param_space()`
/// namespace the axes and `--params` share), while a fully-covering campaign
/// stays fault-free (coverage must not over-reject a covered knob).
#[test]
fn referential_tier_requires_full_open_param_coverage() {
use aura_engine::{blueprint_to_json, Composite};
let reg = Registry::open(temp_family_dir("coverage_tier"));
let resolve = |t: &str| aura_std::std_vocabulary(t);
// A fixture composite with TWO open params, from two zero-arg `Bias`
// nodes so `std_vocabulary` can load it on round-trip (the sibling test
// documents why LinComb composites cannot). Their raw param paths are
// `b1.scale` and `b2.scale`.
let composite = Composite::new(
"fixture",
vec![
aura_std::Bias::builder().named("b1").into(),
aura_std::Bias::builder().named("b2").into(),
],
vec![],
vec![],
vec![],
);
let space = composite.param_space();
assert_eq!(space.len(), 2, "fixture exposes two open params");
let covered = space[0].name.clone(); // e.g. "b1.scale"
let uncovered = space[1].name.clone(); // e.g. "b2.scale"
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");
// A campaign whose axes cover BOTH open params (kind-correct F64 values).
let full_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":{{"{a}":{{"kind":"F64","values":[2.0]}},"{b}":{{"kind":"F64","values":[3.0]}}}}}}],"#,
r#""process":{{"ref":{{"content_id":"{proc}"}}}},"#,
r#""seed":1,"presentation":{{"persist_taps":[],"emit":["family_table"]}}}}"#
),
bp = bp_id,
a = covered,
b = uncovered,
proc = proc_id,
);
let full = aura_research::parse_campaign(&full_text).expect("campaign parses");
assert_eq!(
reg.validate_campaign_refs(&full, &resolve).expect("io ok"),
Vec::new(),
"a fully-covering campaign is fault-free (coverage must not over-reject)",
);
// Drop the axis for `uncovered`: now one open param is bound by no axis.
let mut partial = full.clone();
partial.strategies[0].axes.remove(&uncovered).expect("axis was present");
let faults = reg.validate_campaign_refs(&partial, &resolve).expect("io ok");
assert!(
faults.iter().any(|f| matches!(
f,
RefFault::ParamNotCovered { param, .. } if param == &uncovered
)),
"an open param bound by no axis must fault at validate time, naming the \
RAW param path (not the wrapped bind-time path); got {faults:?}",
);
}
#[test]
fn check_r_metric_accepts_r_and_refuses_pip() {
assert!(check_r_metric("expectancy_r").is_ok());