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:
2026-07-11 04:35:01 +02:00
parent 1d14ebc1cd
commit 43b1c7ff5d
9 changed files with 323 additions and 13 deletions
+13
View File
@@ -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() {
+17 -8
View File
@@ -228,6 +228,10 @@ pub(crate) fn bind_axes(
struct CliMemberRunner<'a> {
env: &'a Env,
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<'_> {
@@ -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,
+28 -4
View File
@@ -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 = [
+12
View File
@@ -148,6 +148,9 @@ pub(crate) fn translate_sweep(inv: &SugarInvocation) -> Result<GeneratedSweep, S
data: DataSection {
instruments: inv.symbols.clone(),
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 {
r#ref: DocRef::ContentId(content_id_of(inv.blueprint_canonical)),
@@ -226,6 +229,9 @@ pub(crate) fn translate_generalize(
data: DataSection {
instruments: inv.symbols.clone(),
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 {
r#ref: DocRef::ContentId(content_id_of(inv.blueprint_canonical)),
@@ -425,6 +431,9 @@ pub(crate) fn translate_walkforward(
data: DataSection {
instruments: inv.symbols.clone(),
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 {
r#ref: DocRef::ContentId(content_id_of(inv.blueprint_canonical)),
@@ -577,6 +586,9 @@ pub(crate) fn translate_mc(
data: DataSection {
instruments: inv.symbols.clone(),
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 {
r#ref: DocRef::ContentId(content_id_of(inv.blueprint_canonical)),
+83
View File
@@ -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
/// `--content-id` branch: pins that `campaign introspect --content-id` wires
/// `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
/// document's non-default risk regime (`risk: [{ "vol": { length, k } }]`)
/// reaches the real `CliMemberRunner::run_member` path and IS the stop actually