//! Harness input binding — the one place strategy input-role NAMES meet archive //! COLUMNS (docs/specs/harness-input-binding.md, #231). A root input role whose //! name is in the closed column vocabulary (`open`/`high`/`low`/`close`/ //! `spread`/`volume`, plus `price` as the backward-compatible alias of `close`) //! binds to that column of the run's instrument by default; a campaign //! document's `data.bindings` block (role -> column) overrides the name default //! per campaign. The resolved plan (`ResolvedBinding`) is consumed twice — to //! OPEN the columns (`aura_ingest::open_columns`) and to DECLARE the wrapped //! root roles (`wrap_r`) — in the same canonical `M1Field` declaration order, //! so the two halves of the old single-price weld can no longer drift apart //! (C4 merge tie-break = role declaration order = source open order). //! //! `dead_code` is allowed module-wide only while nothing outside this //! module consumes it; the allow is scaffolding to REMOVE with the first //! consumer (the wrap seam), at which point genuinely unused items must //! surface again. #![allow(dead_code)] use std::collections::BTreeMap; use aura_core::ScalarKind; use aura_engine::Role; use aura_ingest::M1Field; /// The reserved name of the synthesized broker/executor close entry — /// `finish` creates it under this name and `resolve_binding`'s collision /// guard defends it; one constant couples the two sites. const RESERVED_CLOSE_ROLE: &str = "close"; /// The vocabulary line every binding refusal names (single source for prose). pub(crate) const COLUMN_VOCABULARY: &str = "open, high, low, close (alias: price), spread, volume"; /// The closed role-name -> archive-column map (`price` aliases `close`). pub(crate) fn column_for_role(name: &str) -> Option { match name { "open" => Some(M1Field::Open), "high" => Some(M1Field::High), "low" => Some(M1Field::Low), "close" | "price" => Some(M1Field::Close), "spread" => Some(M1Field::Spread), "volume" => Some(M1Field::Volume), _ => None, } } /// The canonical column name (refusal prose; the alias-free inverse of /// [`column_for_role`]). pub(crate) fn column_name(column: M1Field) -> &'static str { match column { M1Field::Open => "open", M1Field::High => "high", M1Field::Low => "low", M1Field::Close => "close", M1Field::Spread => "spread", M1Field::Volume => "volume", } } /// The scalar kind a column streams (mirrors `aura_ingest::decode`: Volume is /// the one `i64` column; everything else is `f64`). pub(crate) fn column_kind(column: M1Field) -> ScalarKind { match column { M1Field::Volume => ScalarKind::I64, _ => ScalarKind::F64, } } /// Canonical rank = `M1Field` declaration order — the C4 merge tie-break /// order `open_ohlc` (#92) fixed and `open_columns` opens in. fn rank(column: M1Field) -> usize { match column { M1Field::Open => 0, M1Field::High => 1, M1Field::Low => 2, M1Field::Close => 3, M1Field::Spread => 4, M1Field::Volume => 5, } } /// One resolved root role: the role name to declare (= the signal's port /// name), the archive column it binds, and whether it feeds the signal /// (`false` only for the synthesized broker/executor-only close entry). #[derive(Debug)] pub(crate) struct BindingEntry { pub role: String, pub column: M1Field, pub feeds_signal: bool, } /// The ordered binding plan `wrap_r` (role declaration) and the real-data /// openers (column opening) share: entries in canonical column order, with a /// close entry guaranteed (the broker/executor price feed). #[derive(Debug)] pub(crate) struct ResolvedBinding { entries: Vec, } impl ResolvedBinding { pub(crate) fn entries(&self) -> &[BindingEntry] { &self.entries } /// The columns to open, one per entry, in canonical order — exactly the /// order the entries' roles are declared in, so source index i always /// feeds role i. pub(crate) fn columns(&self) -> Vec { self.entries.iter().map(|e| e.column).collect() } /// True iff the strategy consumes the close column only — the one shape /// the synthetic walk (a single close series) can drive. pub(crate) fn close_only(&self) -> bool { self.columns() == [M1Field::Close] } } /// Guarantee a close entry (the broker/executor pair always feeds from /// close), synthesizing a signal-blind one when the strategy consumes none, /// then sort into canonical column order (stable: same-column entries keep /// their declared order). Shared tail of strict and probe resolution. fn finish(mut entries: Vec) -> ResolvedBinding { if !entries.iter().any(|e| e.column == M1Field::Close) { entries.push(BindingEntry { role: RESERVED_CLOSE_ROLE.to_string(), column: M1Field::Close, feeds_signal: false, }); } entries.sort_by_key(|e| rank(e.column)); ResolvedBinding { entries } } /// Resolve a strategy's input roles against the campaign overrides: an /// override wins over the name default; a role that is neither /// vocabulary-named nor overridden is a refusal naming the vocabulary and the /// override remedy. pub(crate) fn resolve_binding( strategy: &str, roles: &[Role], overrides: &BTreeMap, ) -> Result { // `price` is the alias of `close`: both present is ambiguous (which one // is the broker feed?) — refuse rather than guess. if roles.iter().any(|r| r.name == "price") && roles.iter().any(|r| r.name == "close") { return Err(format!( "strategy \"{strategy}\" declares both \"price\" and \"close\" input roles — \ \"price\" is the alias of \"close\", so the pair is ambiguous; rename one role" )); } let mut entries: Vec = Vec::with_capacity(roles.len() + 1); for role in roles { let column = match overrides.get(&role.name) { Some(value) => column_for_role(value).ok_or_else(|| { format!( "binding for role \"{}\" of strategy \"{strategy}\" names no archive \ column: \"{value}\" — bindable columns: {COLUMN_VOCABULARY}", role.name ) })?, None => column_for_role(&role.name).ok_or_else(|| { format!( "input role \"{}\" of strategy \"{strategy}\" binds to no archive column \ — bindable columns: {COLUMN_VOCABULARY}; or bind it explicitly in the \ campaign data.bindings block", role.name ) })?, }; // A role literally named "close" bound away from the close column // would collide with `finish`'s synthesized close entry (two // entries named "close") — refuse rather than double-name. if role.name == RESERVED_CLOSE_ROLE && column != M1Field::Close { return Err(format!( "binding override renames role \"close\" to column \"{}\" — \"close\" is the \ reserved name of the synthesized broker/executor entry; rename the role", column_name(column) )); } entries.push(BindingEntry { role: role.name.clone(), column, feeds_signal: true }); } Ok(finish(entries)) } /// The lenient PROBE binding for the throwaway param-space wrap /// (`blueprint_axis_probe`): every unresolvable role falls back to the close /// column. The probe composite is built but never run over data — the /// fallback is a placeholder exactly like the probe's synthetic pip, so /// probing never refuses a blueprint the (strictly resolved) run path may /// still bind via campaign overrides. pub(crate) fn probe_binding(roles: &[Role]) -> ResolvedBinding { let entries: Vec = roles .iter() .map(|role| BindingEntry { role: role.name.clone(), column: column_for_role(&role.name).unwrap_or(M1Field::Close), feeds_signal: true, }) .collect(); finish(entries) } /// The refusal for a multi-column binding on synthetic data (which generates /// a close series only). Callers invoke it only when `!binding.close_only()`. pub(crate) fn synthetic_refusal(strategy: &str, binding: &ResolvedBinding) -> String { let beyond: Vec<&str> = binding .entries() .iter() .filter(|e| e.column != M1Field::Close) .map(|e| column_name(e.column)) .collect(); format!( "strategy \"{strategy}\" consumes columns beyond close ({}) — synthetic data \ generates a close series only; run with --real ", beyond.join(", ") ) } #[cfg(test)] mod tests { use super::*; fn role(name: &str) -> Role { Role { name: name.to_string(), targets: vec![], source: Some(ScalarKind::F64) } } /// The closed name map: six columns plus the `price` alias of close. #[test] fn column_for_role_maps_the_vocabulary_and_the_price_alias() { assert_eq!(column_for_role("open"), Some(M1Field::Open)); assert_eq!(column_for_role("high"), Some(M1Field::High)); assert_eq!(column_for_role("low"), Some(M1Field::Low)); assert_eq!(column_for_role("close"), Some(M1Field::Close)); assert_eq!(column_for_role("price"), Some(M1Field::Close)); assert_eq!(column_for_role("spread"), Some(M1Field::Spread)); assert_eq!(column_for_role("volume"), Some(M1Field::Volume)); assert_eq!(column_for_role("sentiment"), None); } /// Volume is the one i64 column (mirrors `aura_ingest::decode`). #[test] fn column_kind_is_i64_for_volume_and_f64_otherwise() { assert_eq!(column_kind(M1Field::Volume), ScalarKind::I64); assert_eq!(column_kind(M1Field::Close), ScalarKind::F64); assert_eq!(column_kind(M1Field::Open), ScalarKind::F64); } /// The compatibility anchor: a single `price` role resolves to exactly /// today's weld — one close-bound entry, no synthesized extra. #[test] fn single_price_role_resolves_to_exactly_todays_weld() { let b = resolve_binding("s", &[role("price")], &BTreeMap::new()).expect("resolves"); assert_eq!(b.entries().len(), 1); assert_eq!(b.entries()[0].role, "price"); assert_eq!(b.entries()[0].column, M1Field::Close); assert!(b.entries()[0].feeds_signal); assert!(b.close_only()); } /// An override wins over the name default; rebinding `price` away from /// close synthesizes the broker-only close entry. #[test] fn override_wins_and_synthesizes_the_broker_close_entry() { let overrides = BTreeMap::from([("price".to_string(), "open".to_string())]); let b = resolve_binding("s", &[role("price")], &overrides).expect("resolves"); assert_eq!(b.columns(), vec![M1Field::Open, M1Field::Close]); assert_eq!(b.entries()[0].role, "price"); assert!(b.entries()[0].feeds_signal); assert_eq!(b.entries()[1].role, "close"); assert!(!b.entries()[1].feeds_signal, "synthesized close feeds broker/executor only"); assert!(!b.close_only()); } /// A high/low/close strategy shares its close entry with the broker — /// nothing synthesized. #[test] fn consumed_close_is_shared_not_duplicated() { let roles = [role("high"), role("low"), role("close")]; let b = resolve_binding("s", &roles, &BTreeMap::new()).expect("resolves"); assert_eq!(b.columns(), vec![M1Field::High, M1Field::Low, M1Field::Close]); assert!(b.entries().iter().all(|e| e.feeds_signal)); } /// Canonical ordering: declaration order in the blueprint is irrelevant — /// entries sort into `M1Field` declaration order (the C4 tie-break order). #[test] fn entries_sort_into_canonical_column_order() { let roles = [role("volume"), role("close"), role("open")]; let b = resolve_binding("s", &roles, &BTreeMap::new()).expect("resolves"); assert_eq!(b.columns(), vec![M1Field::Open, M1Field::Close, M1Field::Volume]); } /// The unknown-role refusal names the vocabulary AND the override remedy /// (exact prose — the spec's error contract). #[test] fn unknown_role_refuses_naming_vocabulary_and_remedy() { let err = resolve_binding("x", &[role("sentiment")], &BTreeMap::new()).unwrap_err(); assert_eq!( err, "input role \"sentiment\" of strategy \"x\" binds to no archive column — \ bindable columns: open, high, low, close (alias: price), spread, volume; \ or bind it explicitly in the campaign data.bindings block" ); } /// An override VALUE outside the vocabulary refuses naming the vocabulary. #[test] fn bad_override_value_refuses_naming_the_vocabulary() { let overrides = BTreeMap::from([("price".to_string(), "hl2".to_string())]); let err = resolve_binding("x", &[role("price")], &overrides).unwrap_err(); assert_eq!( err, "binding for role \"price\" of strategy \"x\" names no archive column: \"hl2\" \ — bindable columns: open, high, low, close (alias: price), spread, volume" ); } /// `price` + `close` both declared is the alias ambiguity — refused. #[test] fn price_and_close_together_refuse_as_ambiguous() { let err = resolve_binding("x", &[role("price"), role("close")], &BTreeMap::new()).unwrap_err(); assert_eq!( err, "strategy \"x\" declares both \"price\" and \"close\" input roles — \"price\" \ is the alias of \"close\", so the pair is ambiguous; rename one role" ); } /// A role literally named "close" overridden away from the close column /// would otherwise collide with `finish`'s synthesized close entry (two /// entries both named "close") — refused instead of double-naming. #[test] fn role_named_close_overridden_away_refuses_the_reserved_name() { let overrides = BTreeMap::from([("close".to_string(), "open".to_string())]); let err = resolve_binding("x", &[role("close")], &overrides).unwrap_err(); assert_eq!( err, "binding override renames role \"close\" to column \"open\" — \"close\" is the \ reserved name of the synthesized broker/executor entry; rename the role" ); } /// The lenient probe binding never refuses: unresolvable roles fall back /// to close (probe-only, like the probe's synthetic pip). #[test] fn probe_binding_falls_back_to_close_for_unknown_roles() { let b = probe_binding(&[role("sentiment"), role("high")]); assert_eq!(b.columns(), vec![M1Field::High, M1Field::Close]); assert_eq!(b.entries()[1].role, "sentiment"); assert!(b.entries()[1].feeds_signal); } /// The synthetic refusal prose names the beyond-close columns + remedy. #[test] fn synthetic_refusal_names_columns_and_remedy() { let roles = [role("high"), role("low"), role("close")]; let b = resolve_binding("hl_channel", &roles, &BTreeMap::new()).expect("resolves"); assert_eq!( synthetic_refusal("hl_channel", &b), "strategy \"hl_channel\" consumes columns beyond close (high, low) — synthetic \ data generates a close series only; run with --real " ); } }