diff --git a/crates/aura-campaign/src/lib.rs b/crates/aura-campaign/src/lib.rs index 35ce75c..3c43a68 100644 --- a/crates/aura-campaign/src/lib.rs +++ b/crates/aura-campaign/src/lib.rs @@ -475,6 +475,7 @@ mod tests { data: DataSection { instruments: vec!["EURUSD".to_string()], windows: vec![Window { from_ms: 0, to_ms: 10_000 }], + bindings: BTreeMap::new(), }, risk: vec![], strategies: vec![StrategyEntry { @@ -901,6 +902,7 @@ mod wf_tests { data: DataSection { instruments: vec!["SYNTH".to_string()], windows: vec![Window { from_ms: window.0, to_ms: window.1 }], + bindings: BTreeMap::new(), }, risk: vec![], strategies: vec![StrategyEntry { diff --git a/crates/aura-campaign/tests/execute.rs b/crates/aura-campaign/tests/execute.rs index da3800d..c28ae7c 100644 --- a/crates/aura-campaign/tests/execute.rs +++ b/crates/aura-campaign/tests/execute.rs @@ -149,6 +149,7 @@ fn campaign(instruments: &[&str]) -> CampaignDoc { data: DataSection { instruments: instruments.iter().map(|s| s.to_string()).collect(), windows: vec![Window { from_ms: 1_000, to_ms: 9_000 }], + bindings: BTreeMap::new(), }, risk: vec![], strategies: vec![StrategyEntry { diff --git a/crates/aura-cli/src/binding.rs b/crates/aura-cli/src/binding.rs index f75adf8..ec24a28 100644 --- a/crates/aura-cli/src/binding.rs +++ b/crates/aura-cli/src/binding.rs @@ -255,6 +255,19 @@ mod tests { assert_eq!(column_for_role("sentiment"), None); } + /// The doc-tier value vocabulary (aura-research) and this module's + /// role->column map agree exactly — the #160-style two-surface pin, so + /// the campaign-validate tier and the bind tier cannot drift. + #[test] + fn research_vocabulary_agrees_with_column_for_role() { + let vocab = aura_research::binding_column_vocabulary(); + assert_eq!(vocab.len(), 7, "six columns + the price alias"); + for token in vocab { + assert!(column_for_role(token).is_some(), "doc-tier token {token} must resolve"); + } + assert!(column_for_role("hl2").is_none(), "non-vocabulary tokens must not resolve"); + } + /// Volume is the one i64 column (mirrors `aura_ingest::decode`). #[test] fn column_kind_is_i64_for_volume_and_f64_otherwise() { diff --git a/crates/aura-cli/src/campaign_run.rs b/crates/aura-cli/src/campaign_run.rs index 4e0e3cf..0f26ca2 100644 --- a/crates/aura-cli/src/campaign_run.rs +++ b/crates/aura-cli/src/campaign_run.rs @@ -228,6 +228,10 @@ pub(crate) fn bind_axes( struct CliMemberRunner<'a> { env: &'a Env, server: Arc, + /// The campaign's `data.bindings` overrides (role -> column), threaded + /// into per-member binding resolution (#231: rebind per campaign without + /// touching the blueprint's content id). + bindings: BTreeMap, } impl MemberRunner for CliMemberRunner<'_> { @@ -242,13 +246,13 @@ impl MemberRunner for CliMemberRunner<'_> { 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 (name defaults; - // the campaign's data.bindings overrides thread through in the - // DataSection task). A refusal is a member fault, never a process exit. + // 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. let signal = blueprint_from_json(&cell.blueprint_json, &|t| self.env.resolve(t)) .expect("stored blueprint passed the referential gate; reload is infallible"); let binding = - crate::binding::resolve_binding(&cell.strategy_id, signal.input_roles(), &BTreeMap::new()) + crate::binding::resolve_binding(&cell.strategy_id, signal.input_roles(), &self.bindings) .map_err(MemberFault::Bind)?; // Real windowed data — geometry BEFORE bar data (the shipped pre-data @@ -477,6 +481,7 @@ pub(crate) fn run_campaign_returning( let runner = CliMemberRunner { env, server: Arc::new(data_server::DataServer::new(env.data_path())), + bindings: campaign.data.bindings.clone(), }; let outcome = aura_campaign::execute( campaign_id, @@ -828,10 +833,14 @@ pub(crate) fn persist_campaign_traces( }; let signal = blueprint_from_json(blueprint_json, &|t| env.resolve(t)) .expect("stored blueprint passed the referential gate; reload is infallible"); - // Same name-default binding the member ran under (campaign - // overrides thread through in the DataSection task). - let binding = - crate::binding::resolve_binding(&cell_rec.strategy, signal.input_roles(), &BTreeMap::new())?; + // Campaign data.bindings overrides win over name defaults — the + // SAME resolution the member ran under, so the C1 drift alarm + // compares like with like. + let binding = crate::binding::resolve_binding( + &cell_rec.strategy, + signal.input_roles(), + &campaign.data.bindings, + )?; let sources = open_columns_window( server, &cell_rec.instrument, diff --git a/crates/aura-cli/src/research_docs.rs b/crates/aura-cli/src/research_docs.rs index 14637cc..07a53a0 100644 --- a/crates/aura-cli/src/research_docs.rs +++ b/crates/aura-cli/src/research_docs.rs @@ -6,10 +6,10 @@ use std::path::PathBuf; use aura_research::{ - campaign_to_json, campaign_vocabulary, describe_block, open_slots_campaign, - open_slots_process, parse_campaign, parse_process, process_to_json, process_vocabulary, - slot_kind_label, tap_vocabulary, validate_campaign, validate_process, CampaignDoc, DocError, - DocFault, DocKind, ProcessDoc, StageBlock, + binding_column_vocabulary_display, campaign_to_json, campaign_vocabulary, describe_block, + open_slots_campaign, open_slots_process, parse_campaign, parse_process, process_to_json, + process_vocabulary, slot_kind_label, tap_vocabulary, validate_campaign, validate_process, + CampaignDoc, DocError, DocFault, DocKind, ProcessDoc, StageBlock, }; use aura_registry::RefFault; @@ -154,6 +154,10 @@ pub(crate) fn doc_fault_prose(f: &DocFault) -> String { "presentation.persist_taps[{index}]: unknown tap \"{tap}\" (taps: {})", tap_vocabulary().join(" | ") ), + DocFault::UnknownBindingColumn { role, column } => format!( + "data.bindings.{role}: \"{column}\" names no archive column (columns: {})", + binding_column_vocabulary_display() + ), DocFault::ProcessRefMustBeContentId => { "process.ref: a process is referenced by content id in this version".into() } @@ -331,6 +335,10 @@ pub(crate) fn ref_fault_prose(f: &RefFault) -> String { RefFault::ParamNotCovered { strategy, param } => { format!("strategy {strategy}: open param \"{param}\" is bound by no campaign axis") } + RefFault::BindingRoleUnknown { role, roles } => format!( + "data.bindings: key \"{role}\" names no input role of any campaign strategy (roles: {})", + roles.join(", ") + ), } } @@ -537,6 +545,22 @@ fn introspect_campaign(cmd: &DocIntrospectCmd) -> Result<(), String> { mod tests { use super::*; + /// Exact prose pin for the binding-key referential refusal (#231): the + /// message is path-addressed, names the offending key, and lists the + /// strategies' actual roles. + #[test] + fn binding_role_unknown_prose_names_key_and_roles() { + let msg = ref_fault_prose(&RefFault::BindingRoleUnknown { + role: "sentiment".into(), + roles: vec!["price".into(), "high".into()], + }); + assert_eq!( + msg, + "data.bindings: key \"sentiment\" names no input role of any campaign \ + strategy (roles: price, high)" + ); + } + #[test] fn ref_fault_prose_is_debug_free() { let cases = [ diff --git a/crates/aura-cli/src/verb_sugar.rs b/crates/aura-cli/src/verb_sugar.rs index e0b8ae5..a5a5ef5 100644 --- a/crates/aura-cli/src/verb_sugar.rs +++ b/crates/aura-cli/src/verb_sugar.rs @@ -148,6 +148,9 @@ pub(crate) fn translate_sweep(inv: &SugarInvocation) -> Result serde_json::Value { + out.lines() + .find(|l| l.starts_with("{\"family_id\":")) + .map(|l| serde_json::from_str(l).expect("member line parses")) + .expect("a family_table member line") + }; + let close_metrics = first_member(&out_close)["report"]["metrics"].clone(); + let open_metrics = first_member(&out_open)["report"]["metrics"].clone(); + assert_ne!( + close_metrics, open_metrics, + "rebinding price<-open must change the realized member metrics" + ); +} + /// Property (#210 T3, Task 4 Step 9 — the regime->stop map): a campaign /// document's non-default risk regime (`risk: [{ "vol": { length, k } }]`) /// reaches the real `CliMemberRunner::run_member` path and IS the stop actually diff --git a/crates/aura-registry/src/lib.rs b/crates/aura-registry/src/lib.rs index c42c4ab..20afd81 100644 --- a/crates/aura-registry/src/lib.rs +++ b/crates/aura-registry/src/lib.rs @@ -218,6 +218,10 @@ pub enum RefFault { /// time so "valid" means "runnable". Carries the RAW `param_space()` /// path (never the wrapped bind-time path). ParamNotCovered { strategy: String, param: String }, + /// A campaign `data.bindings` key that names no input role of any + /// strategy in the campaign — the override would silently bind nothing + /// (#231). Carries the strategies' actual role names for the prose. + BindingRoleUnknown { role: String, roles: Vec }, } impl Registry { @@ -231,6 +235,7 @@ impl Registry { resolve: &dyn Fn(&str) -> Option, ) -> Result, RegistryError> { let mut faults = Vec::new(); + let mut known_roles: std::collections::BTreeSet = std::collections::BTreeSet::new(); if let DocRef::ContentId(id) = &doc.process.r#ref && self.get_process(id)?.is_none() @@ -266,6 +271,9 @@ impl Registry { continue; } }; + for role in composite.input_roles() { + known_roles.insert(role.name.clone()); + } let space = composite.param_space(); for (axis, ax) in &entry.axes { match space.iter().find(|p| &p.name == axis) { @@ -294,6 +302,18 @@ impl Registry { } } } + // Binding KEYS (the 6b overrides): each must name an input role of at + // least one campaign strategy — checked in this tier (not the + // intrinsic one) because only the resolver sees the loaded blueprints' + // roles. Binding VALUES are the intrinsic tier's concern. + for role in doc.data.bindings.keys() { + if !known_roles.contains(role) { + faults.push(RefFault::BindingRoleUnknown { + role: role.clone(), + roles: known_roles.iter().cloned().collect(), + }); + } + } Ok(faults) } @@ -1693,6 +1713,72 @@ mod tests { ); } + /// Property (#231): the referential tier checks campaign `data.bindings` + /// KEYS against the resolved strategies' input roles (the 6b override + /// seam) — a key naming no role of any strategy faults with the actual + /// roles listed; a key naming a real role passes. The sibling of the + /// unknown-axis check above; values are the intrinsic tier's concern. + #[test] + fn referential_tier_checks_binding_keys_against_strategy_roles() { + use aura_engine::{blueprint_to_json, Composite, Role, Target}; + + let reg = Registry::open(temp_family_dir("binding_keys")); + let resolve = |t: &str| aura_std::std_vocabulary(t); + + // The referential fixture composite (see + // referential_tier_resolves_refs_and_checks_axes), plus ONE input + // role so a binding key has something to name. + let composite = Composite::new( + "fixture", + vec![aura_std::Bias::builder().named("b").into()], + vec![], + vec![Role { + name: "price".to_string(), + targets: vec![Target { node: 0, slot: 0 }], + source: Some(aura_core::ScalarKind::F64), + }], + vec![], + ); + let space = composite.param_space(); + let axis = space.first().expect("fixture has an open param").name.clone(); + 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 campaign_text = format!( + concat!( + r#"{{"format_version":1,"kind":"campaign","name":"c","#, + r#""data":{{"bindings":{{"price":"open"}},"instruments":["GER40"],"windows":[{{"from_ms":1,"to_ms":2}}]}},"#, + r#""strategies":[{{"ref":{{"content_id":"{bp}"}},"#, + r#""axes":{{"{axis}":{{"kind":"F64","values":[1.5]}}}}}}],"#, + r#""process":{{"ref":{{"content_id":"{proc}"}}}},"#, + r#""seed":1,"presentation":{{"persist_taps":[],"emit":["family_table"]}}}}"# + ), + bp = bp_id, + axis = axis, + 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 binding key naming a real strategy role is fault-free", + ); + + let mut bad = doc.clone(); + bad.data.bindings.insert("nope".to_string(), "close".to_string()); + let faults = reg.validate_campaign_refs(&bad, &resolve).expect("io ok"); + assert!( + faults.iter().any(|f| matches!( + f, + RefFault::BindingRoleUnknown { role, roles } + if role == "nope" && roles == &vec!["price".to_string()] + )), + "a key naming no strategy role must fault, listing the actual roles; got {faults:?}", + ); + } + #[test] fn check_r_metric_accepts_r_and_refuses_pip() { assert!(check_r_metric("expectancy_r").is_ok()); diff --git a/crates/aura-research/src/lib.rs b/crates/aura-research/src/lib.rs index 62b4b63..57eaa4c 100644 --- a/crates/aura-research/src/lib.rs +++ b/crates/aura-research/src/lib.rs @@ -442,12 +442,19 @@ pub struct CampaignDoc { pub presentation: Presentation, } -/// Structural data axes: which instruments over which windows. +/// Structural data axes: which instruments over which windows, plus the +/// optional per-campaign input-binding overrides (role name -> column name, +/// #231 — the campaign rebind seam: re-aim a strategy's input roles without +/// touching its blueprint's content identity). Additive on the +/// `description`/`risk` precedent: absent-serializes, so binding-less +/// documents keep their content ids. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct DataSection { pub instruments: Vec, pub windows: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub bindings: BTreeMap, } /// One data window, inclusive epoch-ms bounds (the CLI --from/--to form). @@ -665,6 +672,21 @@ pub fn tap_vocabulary() -> &'static [&'static str] { &["equity", "exposure", "r_equity", "net_r_equity"] } +/// The closed archive-column vocabulary a campaign `data.bindings` VALUE may +/// name — the six M1 columns plus `price`, the alias of close. Doc-tier +/// mirror of the CLI binding module's role->column map (the `tap_vocabulary` +/// precedent for doc-tier string tables); the CLI pins the two surfaces +/// against each other in `binding::tests`. +pub fn binding_column_vocabulary() -> &'static [&'static str] { + &["open", "high", "low", "close", "price", "spread", "volume"] +} + +/// The vocabulary in the refusal-prose register — the alias annotated +/// instead of listed as a flat column (mirrors the CLI binding module). +pub fn binding_column_vocabulary_display() -> &'static str { + "open | high | low | close (alias: price) | spread | volume" +} + /// Intrinsic-validation findings. By-identifier and Display-free. #[derive(Clone, Debug, PartialEq)] pub enum DocFault { @@ -685,6 +707,7 @@ pub enum DocFault { EmptyAxis { strategy: usize, axis: String }, UnknownEmitKind(String), UnknownTap { index: usize, tap: String }, + UnknownBindingColumn { role: String, column: String }, ProcessRefMustBeContentId, } @@ -769,6 +792,17 @@ pub fn validate_campaign(doc: &CampaignDoc) -> Vec { faults.push(DocFault::BadWindow { index: i }); } } + // Binding VALUES parse against the closed column vocabulary here (the + // structural tier — no blueprint access); binding KEYS are checked against + // the strategies' input roles in the resolver tier (validate_campaign_refs). + for (role, column) in &doc.data.bindings { + if !binding_column_vocabulary().contains(&column.as_str()) { + faults.push(DocFault::UnknownBindingColumn { + role: role.clone(), + column: column.clone(), + }); + } + } for (i, r) in doc.risk.iter().enumerate() { let RiskRegime::Vol { length, k } = r; // length < 1 catches 0 and negatives; `k <= 0.0 || k.is_nan()` catches @@ -1580,6 +1614,52 @@ mod tests { assert_eq!(validate_campaign(&all), Vec::new()); } + /// The campaign `data.bindings` block (#231): serde-ADDITIVE (a + /// binding-less document serializes with NO "bindings" key — every stored + /// campaign's content id is byte-stable — and legacy text parses to an + /// empty map), round-trips when present, and value-checks against the + /// closed column vocabulary at the intrinsic tier. + #[test] + fn bindings_are_serde_additive_and_value_checked() { + let legacy = 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":"{id}"}},"axes":{{"len":{{"kind":"I64","values":[2]}}}}}}],"#, + r#""process":{{"ref":{{"content_id":"{id}"}}}},"#, + r#""seed":1,"presentation":{{"persist_taps":[],"emit":[]}}}}"# + ), + id = "a".repeat(64), + ); + let doc = parse_campaign(&legacy).expect("legacy campaign parses"); + assert!(doc.data.bindings.is_empty()); + assert!(!campaign_to_json(&doc).contains("bindings")); + assert!(validate_campaign(&doc).is_empty()); + + let with = legacy.replace( + r#""data":{"instruments""#, + r#""data":{"bindings":{"price":"open"},"instruments""#, + ); + assert_ne!(with, legacy, "the fixture splice must hit"); + let doc = parse_campaign(&with).expect("bindings campaign parses"); + assert_eq!(doc.data.bindings.get("price").map(String::as_str), Some("open")); + assert!(campaign_to_json(&doc).contains(r#""bindings":{"price":"open"}"#)); + assert!(validate_campaign(&doc).is_empty()); + + let bad = legacy.replace( + r#""data":{"instruments""#, + r#""data":{"bindings":{"price":"hl2"},"instruments""#, + ); + let doc = parse_campaign(&bad).expect("parses (values are validate-tier)"); + assert_eq!( + validate_campaign(&doc), + vec![DocFault::UnknownBindingColumn { + role: "price".to_string(), + column: "hl2".to_string() + }] + ); + } + /// The `std::presentation` persist_taps slot advertises the closed tap /// vocabulary through the describe path (`describe_block` + /// `slot_kind_label`); the label is cross-pinned against `tap_vocabulary()`