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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user