From 2968fbef40f317e57bcfcf58bcf2e366fc9e8596 Mon Sep 17 00:00:00 2001 From: Brummel Date: Fri, 3 Jul 2026 22:44:26 +0200 Subject: [PATCH] feat(registry,cli): referential tier requires full open-param coverage; bind refusal speaks the raw namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/aura-cli/src/campaign_run.rs | 15 ++++- crates/aura-cli/src/research_docs.rs | 3 + crates/aura-cli/tests/research_docs.rs | 3 +- crates/aura-registry/src/lib.rs | 91 ++++++++++++++++++++++++++ 4 files changed, 109 insertions(+), 3 deletions(-) diff --git a/crates/aura-cli/src/campaign_run.rs b/crates/aura-cli/src/campaign_run.rs index e1fb2af..f103304 100644 --- a/crates/aura-cli/src/campaign_run.rs +++ b/crates/aura-cli/src/campaign_run.rs @@ -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", ¶ms).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}" + ); } } diff --git a/crates/aura-cli/src/research_docs.rs b/crates/aura-cli/src/research_docs.rs index 9b93e7b..2e30feb 100644 --- a/crates/aura-cli/src/research_docs.rs +++ b/crates/aura-cli/src/research_docs.rs @@ -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") + } } } diff --git a/crates/aura-cli/tests/research_docs.rs b/crates/aura-cli/tests/research_docs.rs index 618f0a1..d6be012 100644 --- a/crates/aura-cli/tests/research_docs.rs +++ b/crates/aura-cli/tests/research_docs.rs @@ -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"] }} diff --git a/crates/aura-registry/src/lib.rs b/crates/aura-registry/src/lib.rs index dc7dc6e..05356f5 100644 --- a/crates/aura-registry/src/lib.rs +++ b/crates/aura-registry/src/lib.rs @@ -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());