diff --git a/crates/aura-cli/src/campaign_run.rs b/crates/aura-cli/src/campaign_run.rs index 795b451..77929e1 100644 --- a/crates/aura-cli/src/campaign_run.rs +++ b/crates/aura-cli/src/campaign_run.rs @@ -14,15 +14,15 @@ //! submodule idiom: main.rs is the crate root, so its private items are //! visible to child modules without promotion. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::{mpsc, Arc}; use aura_campaign::{CampaignOutcome, CellOutcome, CellSpec, ExecFault, MemberFault, MemberRunner}; use aura_core::{Cell, ParamSpec, PrimitiveBuilder, Scalar, ScalarKind, Timestamp}; use aura_engine::{ - blueprint_from_json, f64_field, summarize, summarize_r, ColumnarTrace, FamilySelection, - RunReport, + blueprint_from_json, f64_field, summarize, summarize_r, ColumnarTrace, Composite, + FamilySelection, RunReport, }; use aura_ingest::{instrument_geometry, open_columns_window, unix_ms_to_epoch_ns}; use aura_registry::{CampaignRunRecord, WriteKind}; @@ -254,6 +254,36 @@ pub(crate) fn bind_axes( Ok(point) } +/// The #246 override subset of one member's campaign-axis names (already RAW +/// — a campaign document speaks the raw namespace, #203, hence the `raw_` +/// name prefix distinguishing this from main.rs's WRAPPED-input siblings): +/// every name that matches no entry of the un-reopened wrapped OPEN +/// `open_space` but names a BOUND param of `raw_signal` (`bound_param_space()`'s +/// `.name` is already path-qualified in strategy/RAW coordinates, exactly +/// like a raw campaign axis — no wrap-prefix stripping needed, unlike +/// `wrapped_bound_overrides_of`/`override_paths` in main.rs, which start from +/// WRAPPED CLI axis names). Silent like `wrapped_bound_overrides_of` (skips a +/// name matching neither space), NOT the validating `override_paths`: +/// `run_member` runs inside a sweep worker and must never call +/// `std::process::exit` — an unmatched name still surfaces as `bind_axes`'s +/// own named `MemberFault::Bind` once the (reopened) space below is built. +pub(crate) fn raw_bound_overrides_of( + param_names: &[String], + open_space: &[ParamSpec], + raw_signal: &Composite, +) -> Vec { + let bound: HashSet = + raw_signal.bound_param_space().into_iter().map(|b| b.name).collect(); + param_names + .iter() + .filter(|raw| { + !open_space.iter().any(|p| raw_matches_wrapped(raw, &p.name)) + && bound.contains(raw.as_str()) + }) + .cloned() + .collect() +} + /// The CLI's harness/data binding seam for `aura_campaign::execute`: members /// run through the shipped loaded-blueprint machinery (`wrap_r` reduce-mode /// via `crate::run_blueprint_member`) over windowed real M1 close bars @@ -279,16 +309,31 @@ impl MemberRunner for CliMemberRunner<'_> { params: &[(String, Scalar)], window_ms: (i64, i64), ) -> Result { - // The wrapped axis namespace — the SAME probe the sweep verbs resolve - // against (single-sourced in main.rs; identical `false, true, None` wrap). - let space = crate::blueprint_axis_probe(&cell.blueprint_json, self.env).param_space(); - let point = bind_axes(&space, &cell.strategy_id, params)?; - - // The member's blueprint + its resolved input binding (campaign - // data.bindings overrides win over name defaults). A refusal is a - // member fault, never a process exit. + // The member's blueprint, loaded RAW (no re-open yet) — the SAME + // reload the probe below wraps, so `bound_param_space()` here and + // the probe's `param_space()` after `blueprint_axis_probe_reopened` + // resolve against one topology. let signal = blueprint_from_json(&cell.blueprint_json, &|t| self.env.resolve(t)) .expect("stored blueprint passed the referential gate; reload is infallible"); + + // The un-reopened wrapped OPEN space (#246) — the SAME probe the + // sweep verbs resolve against (single-sourced in main.rs; identical + // `false, true, None` wrap) — derives which of this cell's RAW axis + // names re-open a bound param before the real, reopened probe/reload + // are built: probe and member reload must re-open identically so + // `params` resolves against one space. + let raw_space = crate::blueprint_axis_probe(&cell.blueprint_json, self.env).param_space(); + let param_names: Vec = params.iter().map(|(n, _)| n.clone()).collect(); + let overrides = raw_bound_overrides_of(¶m_names, &raw_space, &signal); + let space = + crate::blueprint_axis_probe_reopened(&cell.blueprint_json, self.env, &overrides) + .param_space(); + let point = bind_axes(&space, &cell.strategy_id, params)?; + let signal = crate::reopen_all(signal, &overrides); + + // The member's resolved input binding (campaign data.bindings + // overrides win over name defaults). A refusal is a member fault, + // never a process exit. let binding = crate::binding::resolve_binding(&cell.strategy_id, signal.input_roles(), &self.bindings) .map_err(MemberFault::Bind)?; @@ -851,7 +896,7 @@ pub(crate) fn persist_campaign_traces( .first() .map(|(_, r)| r.manifest.params.iter().map(|(n, _)| n.clone()).collect()) .unwrap_or_default(); - let overrides = crate::bound_overrides_of(&recorded, &raw_space, &raw_signal); + let overrides = crate::wrapped_bound_overrides_of(&recorded, &raw_space, &raw_signal); // Cell-invariant across every member of this cell (the wrapped param // space depends only on the cell's blueprint; the resolved geometry // only on the cell's instrument) — computed once here rather than @@ -1172,6 +1217,41 @@ mod tests { ); } + #[test] + /// #246: `raw_bound_overrides_of` names exactly the raw axis names that + /// miss the un-reopened wrapped OPEN space but match a BOUND param of the + /// raw signal (in strategy/RAW coordinates, per its own doc comment) — an + /// axis already covered by the open space is not re-flagged as an + /// override, and an axis matching neither space is silently skipped here + /// (bind_axes surfaces THAT case as its own named fault once the + /// reopened space is built downstream). + fn raw_bound_overrides_of_selects_axes_naming_a_bound_param() { + use aura_std::{Bias, Sma}; + let raw_signal = Composite::new( + "fixture", + vec![ + Sma::builder().named("fast").bind("length", Scalar::i64(2)).into(), + Bias::builder().named("exp").into(), + ], + vec![], + vec![], + vec![], + ); + // The un-reopened wrapped OPEN space carries one extra wrap segment + // (the CLI's own outer wrap, mirroring `blueprint_axis_probe`'s + // output shape) ahead of the composite's own "exp.scale" path. + let open_space = vec![ParamSpec { name: "wrap.exp.scale".to_string(), kind: ScalarKind::F64 }]; + let param_names = + vec!["exp.scale".to_string(), "fast.length".to_string(), "nope.thing".to_string()]; + let overrides = raw_bound_overrides_of(¶m_names, &open_space, &raw_signal); + assert_eq!( + overrides, + vec!["fast.length".to_string()], + "only the bound-param axis is selected; the already-open axis and the \ + unmatched axis are excluded" + ); + } + #[test] /// 0109: the campaign cell key is content-derived (the `member_key` /// discipline — never a runtime ordinal): strategy content-id prefix + diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index e97b569..a121dee 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -1234,7 +1234,7 @@ fn reproduce_family_in( eprintln!("aura: stored blueprint {hash} does not parse: {e:?}"); std::process::exit(1); }); - let overrides = bound_overrides_of(&recorded, &raw_space, &raw_signal); + let overrides = wrapped_bound_overrides_of(&recorded, &raw_space, &raw_signal); // Reload the stored blueprint per use: a Composite is !Clone, and both the // param-space probe (below) and the re-run each consume one. The doc was // canonical-serialized at store time, so every reload is infallible. Every @@ -1832,26 +1832,40 @@ fn blueprint_axis_probe_reopened(doc: &str, env: &project::Env, overrides: &[Str wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, true, SYNTHETIC_PIP_SIZE, &probe, None) } -/// The override subset of `names` (#246): every name missing the un-reopened -/// wrapped OPEN space but naming a BOUND param of the strategy — returned in -/// STRATEGY coordinates (the wrap prefix `.` stripped) for -/// `Composite::reopen`. Names matching neither space are skipped here; the -/// caller decides whether that is an error (sweep boundary) or falls through -/// to its existing resolution errors. The silent variant `reproduce_family_in` -/// uses over the RECORDED manifest param names (the sweep boundary uses the -/// stricter `override_paths`, which errors on an unmatched name instead). -fn bound_overrides_of( +/// The WRAPPED-coordinate BOUND param name set of `signal` (#246): every +/// `bound_param_space()` entry prefixed with `.` — the same +/// coordinate `param_space()`'s OPEN entries live in on the wrapped graph. +/// Extracted because `wrapped_bound_overrides_of`, `override_paths`, and +/// `validate_and_register_axes` each independently rebuilt this exact set +/// (the rule-of-three the neighbouring doc comment itself cites). +fn wrapped_bound_names(signal: &Composite) -> HashSet { + let prefix = format!("{}.", signal.name()); + signal + .bound_param_space() + .into_iter() + .map(|b| format!("{prefix}{}", b.name)) + .collect() +} + +/// The override subset of `names` (#246): every WRAPPED-coordinate name +/// missing the un-reopened wrapped OPEN space but naming a BOUND param of the +/// strategy — returned in STRATEGY coordinates (the wrap prefix +/// `.` stripped) for `Composite::reopen`. Names matching +/// neither space are skipped here; the caller decides whether that is an +/// error (sweep boundary) or falls through to its existing resolution +/// errors. The silent variant `reproduce_family_in` uses over the RECORDED +/// manifest param names (the sweep boundary uses the stricter +/// `override_paths`, which errors on an unmatched name instead). Contrast +/// `campaign_run::raw_bound_overrides_of`, whose `names` are already RAW +/// (a campaign document's own namespace, #203) and needs no such stripping. +fn wrapped_bound_overrides_of( names: &[String], open_space: &[ParamSpec], signal: &Composite, ) -> Vec { let open: HashSet<&str> = open_space.iter().map(|p| p.name.as_str()).collect(); let prefix = format!("{}.", signal.name()); - let bound: HashSet = signal - .bound_param_space() - .into_iter() - .map(|b| format!("{prefix}{}", b.name)) - .collect(); + let bound = wrapped_bound_names(signal); names .iter() .filter(|n| !open.contains(n.as_str()) && bound.contains(*n)) @@ -1859,9 +1873,9 @@ fn bound_overrides_of( .collect() } -/// The sweep-boundary variant (#246): like `bound_overrides_of`, but an axis -/// matching NEITHER the open nor the bound space is the error — the honest -/// replacement of the retired "fully bound; nothing to sweep" refusal. +/// The sweep-boundary variant (#246): like `wrapped_bound_overrides_of`, but +/// an axis matching NEITHER the open nor the bound space is the error — the +/// honest replacement of the retired "fully bound; nothing to sweep" refusal. fn override_paths( axes: &[(String, Vec)], open_space: &[ParamSpec], @@ -1869,11 +1883,7 @@ fn override_paths( ) -> Result, String> { let open: HashSet<&str> = open_space.iter().map(|p| p.name.as_str()).collect(); let prefix = format!("{}.", signal.name()); - let bound: HashSet = signal - .bound_param_space() - .into_iter() - .map(|b| format!("{prefix}{}", b.name)) - .collect(); + let bound = wrapped_bound_names(signal); let mut overrides = Vec::new(); for (name, _) in axes { if open.contains(name.as_str()) { @@ -2090,14 +2100,14 @@ fn blueprint_walkforward_family( // load: derives the override set ONCE for the whole family — every per-window // sweep AND the OOS reload re-open the SAME set, mirroring // `blueprint_sweep_family`/`blueprint_sweep_over`. The SILENT variant - // (`bound_overrides_of`, not the validating `override_paths`): an axis matching - // neither space is simply not an override here — the strict check + its + // (`wrapped_bound_overrides_of`, not the validating `override_paths`): an axis + // matching neither space is simply not an override here — the strict check + its // established error message stay single-sourced in `blueprint_sweep_over`'s own // pre-flight call below, so this derivation cannot double-validate with a // differently-worded rejection. let axis_names: Vec = axes.iter().map(|(n, _)| n.clone()).collect(); let raw_space = blueprint_axis_probe(doc, env).param_space(); - let overrides = bound_overrides_of(&axis_names, &raw_space, &probe_signal); + let overrides = wrapped_bound_overrides_of(&axis_names, &raw_space, &probe_signal); let space = blueprint_axis_probe_reopened(doc, env, &overrides).param_space(); // Strict binding resolution, once per family; refusal is the established // `aura: ` + exit-1 register (the roller's usage refusals stay exit 2). @@ -3160,14 +3170,19 @@ enum AxisRegisterError { } /// Validate every `--axis` name against the blueprint's WRAPPED probe namespace -/// (`blueprint_axis_probe`/`--list-axes`), then canonicalize + register the -/// blueprint by topology hash and strip every axis name to the RAW campaign -/// namespace (the sweep sequence, #210 c0110). A raw-form or fat-fingered axis -/// name is refused here, echoing exactly what the user typed, before the -/// archive is touched or the blueprint is registered — shared by every -/// campaign-path dispatcher (sweep/generalize/walkforward/mc all landed the -/// identical block; #220 slice-1 deferred this dedup to "once wf/mc land the -/// same block", rule-of-three now exceeded 4x). +/// (`blueprint_axis_probe`/`--list-axes`) — an axis naming a BOUND param (#246: +/// re-openable, same as every already-open knob) passes exactly like an open +/// one; only a name matching NEITHER space is refused — then canonicalize + +/// register the blueprint by topology hash and strip every axis name to the RAW +/// campaign namespace (the sweep sequence, #210 c0110). A raw-form or +/// fat-fingered axis name is refused here, echoing exactly what the user typed, +/// before the archive is touched or the blueprint is registered — shared by +/// every campaign-path dispatcher (sweep/generalize/walkforward/mc all landed +/// the identical block; #220 slice-1 deferred this dedup to "once wf/mc land +/// the same block", rule-of-three now exceeded 4x). The override set itself is +/// re-derived downstream (`CliMemberRunner::run_member`, +/// `verb_sugar::validate_before_register`) rather than threaded through this +/// return value — this preflight only decides go/no-go on the NAME. #[allow(clippy::type_complexity)] fn validate_and_register_axes( verb: &str, @@ -3176,8 +3191,11 @@ fn validate_and_register_axes( env: &project::Env, ) -> Result<(String, Vec<(String, Vec)>), AxisRegisterError> { let space = blueprint_axis_probe(doc, env).param_space(); + let blueprint = blueprint_from_json(doc, &|t| env.resolve(t)) + .expect("doc parse-validated at the dispatch boundary"); + let bound = wrapped_bound_names(&blueprint); for (n, _) in axes { - if !space.iter().any(|p| &p.name == n) { + if !space.iter().any(|p| &p.name == n) && !bound.contains(n) { return Err(AxisRegisterError::UnknownAxis(format!( "axis \"{n}\" is not one of this blueprint's \ sweepable axes — run 'aura sweep --list-axes' \ @@ -3194,8 +3212,6 @@ fn validate_and_register_axes( store and vocabulary (no Aura.toml found up from {cwd})" ))); } - let blueprint = blueprint_from_json(doc, &|t| env.resolve(t)) - .expect("doc parse-validated at the dispatch boundary"); let canonical = blueprint_to_json(&blueprint).expect("a loaded blueprint re-serializes"); let reg = env.registry(); let topo = topology_hash(&blueprint); diff --git a/crates/aura-cli/src/verb_sugar.rs b/crates/aura-cli/src/verb_sugar.rs index e88d8e5..cd327cf 100644 --- a/crates/aura-cli/src/verb_sugar.rs +++ b/crates/aura-cli/src/verb_sugar.rs @@ -284,10 +284,15 @@ pub(crate) fn register_generated_g( /// blueprint's own (wrapped) open param space via the pure `bind_axes` seam /// (campaign_run.rs) — the referential shape the dispatch-boundary name /// preflight (main.rs) does not catch: a subset of axes that leaves an open -/// param unbound (probe P3). `probe_params` is any one grid value per axis -/// (checks NAME coverage/uniqueness, never the bound value). Shared by both -/// dissolved-verb sugars: one seam enforcing the store-litter invariant, -/// rather than two drift-prone copies. +/// param unbound (probe P3). Probe P3's space is the REOPENED one (#246): the +/// same `raw_bound_overrides_of` derivation `CliMemberRunner::run_member` uses +/// per member (campaign_run.rs) — a bound-param axis passes this +/// executable-shape preflight exactly like an already-open one; an axis +/// matching neither space still fails `bind_axes`'s own named check below. +/// `probe_params` is any one grid value per axis (checks NAME +/// coverage/uniqueness, never the bound value). Shared by both dissolved-verb +/// sugars: one seam enforcing the store-litter invariant, rather than two +/// drift-prone copies. fn validate_before_register( process: &ProcessDoc, campaign: &CampaignDoc, @@ -311,7 +316,16 @@ fn validate_before_register( } aura_campaign::preflight(process, campaign).map_err(|f| crate::campaign_run::exec_fault_prose(&f))?; - let space = crate::blueprint_axis_probe(blueprint_canonical, env).param_space(); + // The un-reopened wrapped OPEN space (#246): derive which of the probe's + // RAW axis names re-open a bound param of the strategy, against a raw + // (un-reopened) reload of the same canonical bytes — infallible, the + // strategy ref's own content. + let raw_space = crate::blueprint_axis_probe(blueprint_canonical, env).param_space(); + let raw_signal = aura_engine::blueprint_from_json(blueprint_canonical, &|t| env.resolve(t)) + .expect("a generated sugar campaign's strategy ref reloads its own canonical bytes"); + let param_names: Vec = probe_params.iter().map(|(n, _)| n.clone()).collect(); + let overrides = crate::campaign_run::raw_bound_overrides_of(¶m_names, &raw_space, &raw_signal); + let space = crate::blueprint_axis_probe_reopened(blueprint_canonical, env, &overrides).param_space(); let strategy_id = content_id_of(blueprint_canonical); crate::campaign_run::bind_axes(&space, &strategy_id, probe_params) .map(|_| ()) diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 64f9bc6..8ffadad 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -1870,7 +1870,7 @@ fn reproduce_family_with_overridden_bound_param_re_derives() { } /// Property (#246, distinct code path from `reproduce_family_with_overridden_bound_param_re_derives`): -/// `reproduce_family_in`'s override derivation (`bound_overrides_of`, computed once +/// `reproduce_family_in`'s override derivation (`wrapped_bound_overrides_of`, computed once /// per family from the FIRST member's recorded params) applies uniformly across /// `FamilyKind`s, not only `WalkForward` — a plain `aura sweep` family minted by /// overriding a bound param on the CLOSED `r_sma.json` fixture re-derives @@ -2215,6 +2215,60 @@ fn aura_sweep_real_blueprint_subset_axes_refuses_without_store_litter() { ); } +/// Property (issue #246, task 5): the dissolved real-data `aura sweep` +/// campaign path accepts an `--axis` naming a BOUND param of a CLOSED +/// blueprint, not only an already-open one. Tasks 3-4 threaded this override +/// convention (bound value = default, `Composite::reopen`) through the +/// SYNTHETIC blueprint verb paths (`blueprint_sweep_family`/ +/// `blueprint_sweep_over`, `reproduce_family_with_overridden_bound_param_re_derives`); +/// the real-data dissolved path routes through a distinct seam +/// (`CliMemberRunner::run_member` + `verb_sugar::validate_before_register`'s +/// P3 preflight) that carried no such threading at all, the explicit gap +/// named on `reproduce_family_with_overridden_bound_param_re_derives`'s doc +/// comment. Modelled on `sweep_real_blueprint_member_lines_pin_the_dissolved_contract` +/// (the dissolved-sweep e2e family `walkforward_dissolved_refuses_an_unknown_axis_name` +/// belongs to): same store-reading assertions (`aura runs families`, one +/// persisted `Sweep` family, member count), but over the CLOSED `r_sma.json` +/// fixture (`sma_signal.fast.length`/`sma_signal.slow.length` both bound) +/// instead of the OPEN `r_sma_open.json` every other real-data sweep pin uses. +#[test] +fn sweep_dissolved_accepts_an_axis_over_a_bound_param() { + if !local_data_present() { + eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH); + return; + } + let (dir, _g) = fresh_project(); + let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); + let out = Command::new(BIN) + .args([ + "sweep", &fixture, + "--real", "GER40", + "--from", GER40_SEPT2024_FROM_MS, + "--to", GER40_SEPT2024_TO_MS, + "--axis", "sma_signal.fast.length=2,3", + "--name", "bound-override", + ]) + .current_dir(dir) + .output() + .expect("spawn aura sweep over a bound-param axis"); + assert_eq!( + out.status.code(), + Some(0), + "a bound-param axis must re-open (#246) on the dissolved real-data campaign \ + path too, stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let fams = Command::new(BIN).args(["runs", "families"]).current_dir(dir).output().unwrap(); + assert!(fams.status.success(), "families exit: {:?}", fams.status); + let fams_out = String::from_utf8_lossy(&fams.stdout).into_owned(); + assert_eq!(fams_out.lines().count(), 1, "exactly one family persisted: {fams_out}"); + assert!(fams_out.contains("\"kind\":\"Sweep\""), "families: {fams_out}"); + assert!( + fams_out.contains("\"members\":2"), + "the two-value bound-param axis grid yields two members: {fams_out}" + ); +} + /// The synthetic seed-resweep `mc` is undefined over real bars (one realization -> /// identical members), so a bare `aura mc --real EURUSD` — with no R candidate — is /// REJECTED (usage on stderr, exit 2) at the binary boundary. The real path is diff --git a/crates/aura-registry/src/lib.rs b/crates/aura-registry/src/lib.rs index 20afd81..7493f30 100644 --- a/crates/aura-registry/src/lib.rs +++ b/crates/aura-registry/src/lib.rs @@ -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