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:
@@ -147,8 +147,14 @@ fn bind_axes(
|
|||||||
match slot {
|
match slot {
|
||||||
Some(v) => point.push(v.cell()),
|
Some(v) => point.push(v.cell()),
|
||||||
None => {
|
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!(
|
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
|
spec.name
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
@@ -491,6 +497,11 @@ mod tests {
|
|||||||
let params = vec![("length".to_string(), Scalar::i64(14))];
|
let params = vec![("length".to_string(), Scalar::i64(14))];
|
||||||
let err = bind_axes(&space, "strat", ¶ms).unwrap_err();
|
let err = bind_axes(&space, "strat", ¶ms).unwrap_err();
|
||||||
let MemberFault::Bind(m) = err else { panic!("expected Bind, got {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}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -263,6 +263,9 @@ pub(crate) fn ref_fault_prose(f: &RefFault) -> String {
|
|||||||
RefFault::AxisKindMismatch { strategy, axis } => {
|
RefFault::AxisKindMismatch { strategy, axis } => {
|
||||||
format!("strategy {strategy}: axis \"{axis}\" declares a kind that is not the param's kind")
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -327,7 +327,8 @@ fn campaign_validate_in_project_reports_referential_tier_end_to_end() {
|
|||||||
"name": "ref-check",
|
"name": "ref-check",
|
||||||
"data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": 1, "to_ms": 2 }} ] }},
|
"data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": 1, "to_ms": 2 }} ] }},
|
||||||
"strategies": [ {{ "ref": {{ "content_id": "{bp}" }},
|
"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}" }} }},
|
"process": {{ "ref": {{ "content_id": "{proc}" }} }},
|
||||||
"seed": 1,
|
"seed": 1,
|
||||||
"presentation": {{ "persist_taps": [], "emit": ["family_table"] }}
|
"presentation": {{ "persist_taps": [], "emit": ["family_table"] }}
|
||||||
|
|||||||
@@ -213,6 +213,11 @@ pub enum RefFault {
|
|||||||
StrategyUnloadable { id: String, error: String },
|
StrategyUnloadable { id: String, error: String },
|
||||||
AxisNotInParamSpace { strategy: String, axis: String },
|
AxisNotInParamSpace { strategy: String, axis: String },
|
||||||
AxisKindMismatch { 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 {
|
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)
|
Ok(faults)
|
||||||
}
|
}
|
||||||
@@ -1595,6 +1610,82 @@ mod tests {
|
|||||||
assert_eq!(reg.validate_campaign_refs(&by_identity, &resolve).expect("io ok"), Vec::new());
|
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]
|
#[test]
|
||||||
fn check_r_metric_accepts_r_and_refuses_pip() {
|
fn check_r_metric_accepts_r_and_refuses_pip() {
|
||||||
assert!(check_r_metric("expectancy_r").is_ok());
|
assert!(check_r_metric("expectancy_r").is_ok());
|
||||||
|
|||||||
Reference in New Issue
Block a user