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"] }}