feat(research,registry,cli): campaign data.bindings — the 6b rebind seam (#231 task 5)
DataSection gains an additive bindings block (role -> column; serde default + skip-if-empty, so binding-less documents keep their content ids — pinned). Validation is two-tier along the existing line: the intrinsic tier checks binding VALUES against the column vocabulary (DocFault::UnknownBindingColumn, alias-annotated display register); the resolver tier checks binding KEYS against the loaded strategies' input_roles() (RefFault::BindingRoleUnknown — a key must name a role of at least one campaign strategy). A cross-surface pin keeps the doc-tier vocabulary and the CLI binding module from drifting. CliMemberRunner threads the campaign overrides into resolve_binding on BOTH halves (column opening and wrap), keeping the C1 drift alarm comparing like-with-like; the verb sugar passes no bindings (name defaults rule). Proof: an archive-gated campaign e2e rebinds price -> open and yields different realized metrics from the close-bound run; refusal prose exact-pinned at both tiers. Verified: full workspace suite green, clippy -D warnings clean; independent quality review, all findings repaired. refs #231
This commit is contained in:
@@ -475,6 +475,7 @@ mod tests {
|
|||||||
data: DataSection {
|
data: DataSection {
|
||||||
instruments: vec!["EURUSD".to_string()],
|
instruments: vec!["EURUSD".to_string()],
|
||||||
windows: vec![Window { from_ms: 0, to_ms: 10_000 }],
|
windows: vec![Window { from_ms: 0, to_ms: 10_000 }],
|
||||||
|
bindings: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
risk: vec![],
|
risk: vec![],
|
||||||
strategies: vec![StrategyEntry {
|
strategies: vec![StrategyEntry {
|
||||||
@@ -901,6 +902,7 @@ mod wf_tests {
|
|||||||
data: DataSection {
|
data: DataSection {
|
||||||
instruments: vec!["SYNTH".to_string()],
|
instruments: vec!["SYNTH".to_string()],
|
||||||
windows: vec![Window { from_ms: window.0, to_ms: window.1 }],
|
windows: vec![Window { from_ms: window.0, to_ms: window.1 }],
|
||||||
|
bindings: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
risk: vec![],
|
risk: vec![],
|
||||||
strategies: vec![StrategyEntry {
|
strategies: vec![StrategyEntry {
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ fn campaign(instruments: &[&str]) -> CampaignDoc {
|
|||||||
data: DataSection {
|
data: DataSection {
|
||||||
instruments: instruments.iter().map(|s| s.to_string()).collect(),
|
instruments: instruments.iter().map(|s| s.to_string()).collect(),
|
||||||
windows: vec![Window { from_ms: 1_000, to_ms: 9_000 }],
|
windows: vec![Window { from_ms: 1_000, to_ms: 9_000 }],
|
||||||
|
bindings: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
risk: vec![],
|
risk: vec![],
|
||||||
strategies: vec![StrategyEntry {
|
strategies: vec![StrategyEntry {
|
||||||
|
|||||||
@@ -255,6 +255,19 @@ mod tests {
|
|||||||
assert_eq!(column_for_role("sentiment"), None);
|
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`).
|
/// Volume is the one i64 column (mirrors `aura_ingest::decode`).
|
||||||
#[test]
|
#[test]
|
||||||
fn column_kind_is_i64_for_volume_and_f64_otherwise() {
|
fn column_kind_is_i64_for_volume_and_f64_otherwise() {
|
||||||
|
|||||||
@@ -228,6 +228,10 @@ pub(crate) fn bind_axes(
|
|||||||
struct CliMemberRunner<'a> {
|
struct CliMemberRunner<'a> {
|
||||||
env: &'a Env,
|
env: &'a Env,
|
||||||
server: Arc<data_server::DataServer>,
|
server: Arc<data_server::DataServer>,
|
||||||
|
/// 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<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemberRunner for CliMemberRunner<'_> {
|
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 space = crate::blueprint_axis_probe(&cell.blueprint_json, self.env).param_space();
|
||||||
let point = bind_axes(&space, &cell.strategy_id, params)?;
|
let point = bind_axes(&space, &cell.strategy_id, params)?;
|
||||||
|
|
||||||
// The member's blueprint + its resolved input binding (name defaults;
|
// The member's blueprint + its resolved input binding (campaign
|
||||||
// the campaign's data.bindings overrides thread through in the
|
// data.bindings overrides win over name defaults). A refusal is a
|
||||||
// DataSection task). A refusal is a member fault, never a process exit.
|
// member fault, never a process exit.
|
||||||
let signal = blueprint_from_json(&cell.blueprint_json, &|t| self.env.resolve(t))
|
let signal = blueprint_from_json(&cell.blueprint_json, &|t| self.env.resolve(t))
|
||||||
.expect("stored blueprint passed the referential gate; reload is infallible");
|
.expect("stored blueprint passed the referential gate; reload is infallible");
|
||||||
let binding =
|
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)?;
|
.map_err(MemberFault::Bind)?;
|
||||||
|
|
||||||
// Real windowed data — geometry BEFORE bar data (the shipped pre-data
|
// Real windowed data — geometry BEFORE bar data (the shipped pre-data
|
||||||
@@ -477,6 +481,7 @@ pub(crate) fn run_campaign_returning(
|
|||||||
let runner = CliMemberRunner {
|
let runner = CliMemberRunner {
|
||||||
env,
|
env,
|
||||||
server: Arc::new(data_server::DataServer::new(env.data_path())),
|
server: Arc::new(data_server::DataServer::new(env.data_path())),
|
||||||
|
bindings: campaign.data.bindings.clone(),
|
||||||
};
|
};
|
||||||
let outcome = aura_campaign::execute(
|
let outcome = aura_campaign::execute(
|
||||||
campaign_id,
|
campaign_id,
|
||||||
@@ -828,10 +833,14 @@ pub(crate) fn persist_campaign_traces(
|
|||||||
};
|
};
|
||||||
let signal = blueprint_from_json(blueprint_json, &|t| env.resolve(t))
|
let signal = blueprint_from_json(blueprint_json, &|t| env.resolve(t))
|
||||||
.expect("stored blueprint passed the referential gate; reload is infallible");
|
.expect("stored blueprint passed the referential gate; reload is infallible");
|
||||||
// Same name-default binding the member ran under (campaign
|
// Campaign data.bindings overrides win over name defaults — the
|
||||||
// overrides thread through in the DataSection task).
|
// SAME resolution the member ran under, so the C1 drift alarm
|
||||||
let binding =
|
// compares like with like.
|
||||||
crate::binding::resolve_binding(&cell_rec.strategy, signal.input_roles(), &BTreeMap::new())?;
|
let binding = crate::binding::resolve_binding(
|
||||||
|
&cell_rec.strategy,
|
||||||
|
signal.input_roles(),
|
||||||
|
&campaign.data.bindings,
|
||||||
|
)?;
|
||||||
let sources = open_columns_window(
|
let sources = open_columns_window(
|
||||||
server,
|
server,
|
||||||
&cell_rec.instrument,
|
&cell_rec.instrument,
|
||||||
|
|||||||
@@ -6,10 +6,10 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use aura_research::{
|
use aura_research::{
|
||||||
campaign_to_json, campaign_vocabulary, describe_block, open_slots_campaign,
|
binding_column_vocabulary_display, campaign_to_json, campaign_vocabulary, describe_block,
|
||||||
open_slots_process, parse_campaign, parse_process, process_to_json, process_vocabulary,
|
open_slots_campaign, open_slots_process, parse_campaign, parse_process, process_to_json,
|
||||||
slot_kind_label, tap_vocabulary, validate_campaign, validate_process, CampaignDoc, DocError,
|
process_vocabulary, slot_kind_label, tap_vocabulary, validate_campaign, validate_process,
|
||||||
DocFault, DocKind, ProcessDoc, StageBlock,
|
CampaignDoc, DocError, DocFault, DocKind, ProcessDoc, StageBlock,
|
||||||
};
|
};
|
||||||
use aura_registry::RefFault;
|
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: {})",
|
"presentation.persist_taps[{index}]: unknown tap \"{tap}\" (taps: {})",
|
||||||
tap_vocabulary().join(" | ")
|
tap_vocabulary().join(" | ")
|
||||||
),
|
),
|
||||||
|
DocFault::UnknownBindingColumn { role, column } => format!(
|
||||||
|
"data.bindings.{role}: \"{column}\" names no archive column (columns: {})",
|
||||||
|
binding_column_vocabulary_display()
|
||||||
|
),
|
||||||
DocFault::ProcessRefMustBeContentId => {
|
DocFault::ProcessRefMustBeContentId => {
|
||||||
"process.ref: a process is referenced by content id in this version".into()
|
"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 } => {
|
RefFault::ParamNotCovered { strategy, param } => {
|
||||||
format!("strategy {strategy}: open param \"{param}\" is bound by no campaign axis")
|
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 {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn ref_fault_prose_is_debug_free() {
|
fn ref_fault_prose_is_debug_free() {
|
||||||
let cases = [
|
let cases = [
|
||||||
|
|||||||
@@ -148,6 +148,9 @@ pub(crate) fn translate_sweep(inv: &SugarInvocation) -> Result<GeneratedSweep, S
|
|||||||
data: DataSection {
|
data: DataSection {
|
||||||
instruments: inv.symbols.clone(),
|
instruments: inv.symbols.clone(),
|
||||||
windows: vec![Window { from_ms: inv.from_ms, to_ms: inv.to_ms }],
|
windows: vec![Window { from_ms: inv.from_ms, to_ms: inv.to_ms }],
|
||||||
|
// The verb sugar passes no bindings: name defaults rule (#231);
|
||||||
|
// authored campaign documents may set them.
|
||||||
|
bindings: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
strategies: vec![StrategyEntry {
|
strategies: vec![StrategyEntry {
|
||||||
r#ref: DocRef::ContentId(content_id_of(inv.blueprint_canonical)),
|
r#ref: DocRef::ContentId(content_id_of(inv.blueprint_canonical)),
|
||||||
@@ -226,6 +229,9 @@ pub(crate) fn translate_generalize(
|
|||||||
data: DataSection {
|
data: DataSection {
|
||||||
instruments: inv.symbols.clone(),
|
instruments: inv.symbols.clone(),
|
||||||
windows: vec![Window { from_ms: inv.from_ms, to_ms: inv.to_ms }],
|
windows: vec![Window { from_ms: inv.from_ms, to_ms: inv.to_ms }],
|
||||||
|
// The verb sugar passes no bindings: name defaults rule (#231);
|
||||||
|
// authored campaign documents may set them.
|
||||||
|
bindings: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
strategies: vec![StrategyEntry {
|
strategies: vec![StrategyEntry {
|
||||||
r#ref: DocRef::ContentId(content_id_of(inv.blueprint_canonical)),
|
r#ref: DocRef::ContentId(content_id_of(inv.blueprint_canonical)),
|
||||||
@@ -425,6 +431,9 @@ pub(crate) fn translate_walkforward(
|
|||||||
data: DataSection {
|
data: DataSection {
|
||||||
instruments: inv.symbols.clone(),
|
instruments: inv.symbols.clone(),
|
||||||
windows: vec![Window { from_ms: inv.from_ms, to_ms: inv.to_ms }],
|
windows: vec![Window { from_ms: inv.from_ms, to_ms: inv.to_ms }],
|
||||||
|
// The verb sugar passes no bindings: name defaults rule (#231);
|
||||||
|
// authored campaign documents may set them.
|
||||||
|
bindings: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
strategies: vec![StrategyEntry {
|
strategies: vec![StrategyEntry {
|
||||||
r#ref: DocRef::ContentId(content_id_of(inv.blueprint_canonical)),
|
r#ref: DocRef::ContentId(content_id_of(inv.blueprint_canonical)),
|
||||||
@@ -577,6 +586,9 @@ pub(crate) fn translate_mc(
|
|||||||
data: DataSection {
|
data: DataSection {
|
||||||
instruments: inv.symbols.clone(),
|
instruments: inv.symbols.clone(),
|
||||||
windows: vec![Window { from_ms: inv.from_ms, to_ms: inv.to_ms }],
|
windows: vec![Window { from_ms: inv.from_ms, to_ms: inv.to_ms }],
|
||||||
|
// The verb sugar passes no bindings: name defaults rule (#231);
|
||||||
|
// authored campaign documents may set them.
|
||||||
|
bindings: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
strategies: vec![StrategyEntry {
|
strategies: vec![StrategyEntry {
|
||||||
r#ref: DocRef::ContentId(content_id_of(inv.blueprint_canonical)),
|
r#ref: DocRef::ContentId(content_id_of(inv.blueprint_canonical)),
|
||||||
|
|||||||
@@ -746,6 +746,29 @@ fn campaign_introspect_unwired_reports_placeholder_refs() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The intrinsic tier value-checks `data.bindings` (no project, no store,
|
||||||
|
/// #231): a value outside the closed column vocabulary refuses
|
||||||
|
/// path-addressed, naming the vocabulary. NOT gated.
|
||||||
|
#[test]
|
||||||
|
fn campaign_validate_refuses_an_unknown_binding_column() {
|
||||||
|
let dir = temp_cwd("campaign-validate-bad-binding");
|
||||||
|
let doc = r#"{ "format_version": 1, "kind": "campaign", "name": "bad-binding",
|
||||||
|
"data": { "bindings": { "price": "hl2" },
|
||||||
|
"instruments": ["GER40"], "windows": [ { "from_ms": 1, "to_ms": 2 } ] },
|
||||||
|
"strategies": [ { "ref": { "content_id": "9f3a" },
|
||||||
|
"axes": { "slow": { "kind": "I64", "values": [10] } } } ],
|
||||||
|
"process": { "ref": { "content_id": "9f3a" } },
|
||||||
|
"seed": 1,
|
||||||
|
"presentation": { "persist_taps": [], "emit": [] } }"#;
|
||||||
|
write_doc(&dir, "bad.campaign.json", doc);
|
||||||
|
let (out, code) = run_code_in(&dir, &["campaign", "validate", "bad.campaign.json"]);
|
||||||
|
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||||
|
assert!(
|
||||||
|
out.contains(r#"data.bindings.price: "hl2" names no archive column"#),
|
||||||
|
"the refusal is path-addressed and names the vocabulary: {out}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The campaign twin of `process_introspect_vocabulary_block_and_content_id`'s
|
/// The campaign twin of `process_introspect_vocabulary_block_and_content_id`'s
|
||||||
/// `--content-id` branch: pins that `campaign introspect --content-id` wires
|
/// `--content-id` branch: pins that `campaign introspect --content-id` wires
|
||||||
/// `parse_valid_campaign` + `campaign_to_json` + `content_id_of` end to end
|
/// `parse_valid_campaign` + `campaign_to_json` + `content_id_of` end to end
|
||||||
@@ -1991,6 +2014,66 @@ fn campaign_run_real_e2e_sweep_gate_walkforward() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The campaign `data.bindings` override end to end (#231, 6b): the same
|
||||||
|
/// seeded strategy, window, and seed run twice — bare (price<-close default)
|
||||||
|
/// and with `price` rebound to the open column. Both exit 0; the realized
|
||||||
|
/// member metrics differ (the open series is not the close series); the
|
||||||
|
/// strategy blueprint is untouched (both docs reference the same content id).
|
||||||
|
/// Gated on the local GER40 archive; skips cleanly when absent.
|
||||||
|
#[test]
|
||||||
|
fn campaign_run_real_e2e_bindings_override_rebinds_price_to_open() {
|
||||||
|
let _fixture = project_lock();
|
||||||
|
let dir = built_project();
|
||||||
|
let runs_dir = dir.join("runs");
|
||||||
|
std::fs::remove_dir_all(&runs_dir).ok();
|
||||||
|
let _cleanup = ScratchGuard(vec![
|
||||||
|
ScratchPath::Dir(runs_dir.clone()),
|
||||||
|
ScratchPath::File(dir.join("sweep.process.json")),
|
||||||
|
ScratchPath::File(dir.join("close.campaign.json")),
|
||||||
|
ScratchPath::File(dir.join("open.campaign.json")),
|
||||||
|
]);
|
||||||
|
let bp_id = seed_blueprint(dir, "binding-override-seed");
|
||||||
|
let proc_id = register_process_doc(dir, "sweep.process.json", SWEEP_ONLY_PROCESS_DOC);
|
||||||
|
let base = campaign_doc_json(
|
||||||
|
&bp_id,
|
||||||
|
&proc_id,
|
||||||
|
(1725148800000, 1727740799999),
|
||||||
|
"",
|
||||||
|
"\"family_table\"",
|
||||||
|
);
|
||||||
|
let rebound = base.replace(
|
||||||
|
r#""data": { "instruments": ["GER40"],"#,
|
||||||
|
r#""data": { "bindings": { "price": "open" }, "instruments": ["GER40"],"#,
|
||||||
|
);
|
||||||
|
assert_ne!(rebound, base, "the override splice must hit the data section");
|
||||||
|
write_doc(dir, "close.campaign.json", &base);
|
||||||
|
write_doc(dir, "open.campaign.json", &rebound);
|
||||||
|
|
||||||
|
let (out_close, code_close) = run_code_in(dir, &["campaign", "run", "close.campaign.json"]);
|
||||||
|
if code_close == Some(1)
|
||||||
|
&& (out_close.contains("no recorded geometry") || out_close.contains("no data for instrument"))
|
||||||
|
{
|
||||||
|
eprintln!("skip: no local GER40 data for the binding-override e2e");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assert_eq!(code_close, Some(0), "stdout/stderr: {out_close}");
|
||||||
|
let (out_open, code_open) = run_code_in(dir, &["campaign", "run", "open.campaign.json"]);
|
||||||
|
assert_eq!(code_open, Some(0), "stdout/stderr: {out_open}");
|
||||||
|
|
||||||
|
let first_member = |out: &str| -> 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
|
/// Property (#210 T3, Task 4 Step 9 — the regime->stop map): a campaign
|
||||||
/// document's non-default risk regime (`risk: [{ "vol": { length, k } }]`)
|
/// document's non-default risk regime (`risk: [{ "vol": { length, k } }]`)
|
||||||
/// reaches the real `CliMemberRunner::run_member` path and IS the stop actually
|
/// reaches the real `CliMemberRunner::run_member` path and IS the stop actually
|
||||||
|
|||||||
@@ -218,6 +218,10 @@ pub enum RefFault {
|
|||||||
/// time so "valid" means "runnable". Carries the RAW `param_space()`
|
/// time so "valid" means "runnable". Carries the RAW `param_space()`
|
||||||
/// path (never the wrapped bind-time path).
|
/// path (never the wrapped bind-time path).
|
||||||
ParamNotCovered { strategy: String, param: String },
|
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<String> },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Registry {
|
impl Registry {
|
||||||
@@ -231,6 +235,7 @@ impl Registry {
|
|||||||
resolve: &dyn Fn(&str) -> Option<PrimitiveBuilder>,
|
resolve: &dyn Fn(&str) -> Option<PrimitiveBuilder>,
|
||||||
) -> Result<Vec<RefFault>, RegistryError> {
|
) -> Result<Vec<RefFault>, RegistryError> {
|
||||||
let mut faults = Vec::new();
|
let mut faults = Vec::new();
|
||||||
|
let mut known_roles: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
|
||||||
|
|
||||||
if let DocRef::ContentId(id) = &doc.process.r#ref
|
if let DocRef::ContentId(id) = &doc.process.r#ref
|
||||||
&& self.get_process(id)?.is_none()
|
&& self.get_process(id)?.is_none()
|
||||||
@@ -266,6 +271,9 @@ impl Registry {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
for role in composite.input_roles() {
|
||||||
|
known_roles.insert(role.name.clone());
|
||||||
|
}
|
||||||
let space = composite.param_space();
|
let space = composite.param_space();
|
||||||
for (axis, ax) in &entry.axes {
|
for (axis, ax) in &entry.axes {
|
||||||
match space.iter().find(|p| &p.name == axis) {
|
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)
|
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]
|
#[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());
|
||||||
|
|||||||
@@ -442,12 +442,19 @@ pub struct CampaignDoc {
|
|||||||
pub presentation: Presentation,
|
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)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct DataSection {
|
pub struct DataSection {
|
||||||
pub instruments: Vec<String>,
|
pub instruments: Vec<String>,
|
||||||
pub windows: Vec<Window>,
|
pub windows: Vec<Window>,
|
||||||
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
|
pub bindings: BTreeMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One data window, inclusive epoch-ms bounds (the CLI --from/--to form).
|
/// 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"]
|
&["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.
|
/// Intrinsic-validation findings. By-identifier and Display-free.
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub enum DocFault {
|
pub enum DocFault {
|
||||||
@@ -685,6 +707,7 @@ pub enum DocFault {
|
|||||||
EmptyAxis { strategy: usize, axis: String },
|
EmptyAxis { strategy: usize, axis: String },
|
||||||
UnknownEmitKind(String),
|
UnknownEmitKind(String),
|
||||||
UnknownTap { index: usize, tap: String },
|
UnknownTap { index: usize, tap: String },
|
||||||
|
UnknownBindingColumn { role: String, column: String },
|
||||||
ProcessRefMustBeContentId,
|
ProcessRefMustBeContentId,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -769,6 +792,17 @@ pub fn validate_campaign(doc: &CampaignDoc) -> Vec<DocFault> {
|
|||||||
faults.push(DocFault::BadWindow { index: i });
|
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() {
|
for (i, r) in doc.risk.iter().enumerate() {
|
||||||
let RiskRegime::Vol { length, k } = r;
|
let RiskRegime::Vol { length, k } = r;
|
||||||
// length < 1 catches 0 and negatives; `k <= 0.0 || k.is_nan()` catches
|
// 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());
|
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
|
/// The `std::presentation` persist_taps slot advertises the closed tap
|
||||||
/// vocabulary through the describe path (`describe_block` +
|
/// vocabulary through the describe path (`describe_block` +
|
||||||
/// `slot_kind_label`); the label is cross-pinned against `tap_vocabulary()`
|
/// `slot_kind_label`); the label is cross-pinned against `tap_vocabulary()`
|
||||||
|
|||||||
Reference in New Issue
Block a user