From 595c98b264a3671b9cf35c7fd51f265806daf9d7 Mon Sep 17 00:00:00 2001 From: Brummel Date: Sat, 11 Jul 2026 00:49:22 +0200 Subject: [PATCH] feat(cli,ingest): binding module + generalized column openers (#231 tasks 1-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit crates/aura-cli/src/binding.rs: the closed column vocabulary (open/high/ low/close/spread/volume, price as the close alias) with ResolvedBinding — one ordered plan both halves of the single-price weld will share. resolve_binding applies override-wins-over-name-default, refuses unknown roles / bad override values / the price+close double-alias / a rebound reserved close role, synthesizes the broker-only close entry, and sorts into canonical M1Field order (the C4 merge tie-break contract). 12 exact-prose unit tests. Module-wide dead_code allow is scaffolding until the wrap seam consumes it next. aura-ingest: open_columns / open_columns_window open any field set in the given order (None if any field lacks an overlapping archive file); open_ohlc now delegates with the fixed OHLC order — behaviour-identical, still guarded by the open_ohlc_seam bit-identity test. Post-review polish: RESERVED_CLOSE_ROLE constant couples the synthesized close's two sites; dead_code note reworded condition-based. Verified: binding 12/12, ingest absence test green, full workspace suite green, clippy -D warnings clean; independent quality review approved. refs #231 --- crates/aura-cli/src/binding.rs | 366 +++++++++++++++++++++++++++++++++ crates/aura-cli/src/main.rs | 1 + crates/aura-ingest/src/lib.rs | 77 ++++++- 3 files changed, 439 insertions(+), 5 deletions(-) create mode 100644 crates/aura-cli/src/binding.rs diff --git a/crates/aura-cli/src/binding.rs b/crates/aura-cli/src/binding.rs new file mode 100644 index 0000000..49cad3b --- /dev/null +++ b/crates/aura-cli/src/binding.rs @@ -0,0 +1,366 @@ +//! 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 " + ); + } +} diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 0652a8c..b898ac6 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -3,6 +3,7 @@ //! (or the `--strategy r-sma` sugar) and print canonical JSON metrics/manifests; //! this binary authors no built-in harness. +mod binding; mod render; mod graph_construct; mod project; diff --git a/crates/aura-ingest/src/lib.rs b/crates/aura-ingest/src/lib.rs index c9f6fd6..1746dfc 100644 --- a/crates/aura-ingest/src/lib.rs +++ b/crates/aura-ingest/src/lib.rs @@ -312,6 +312,53 @@ impl aura_engine::Source for M1FieldSource { } } +/// Open one real [`M1FieldSource`] per requested field for `symbol` over +/// `[from_ms, to_ms]` (inclusive Unix-ms, `None` = open-ended), in the GIVEN +/// field order — callers pass the canonical `M1Field` declaration order (the +/// C4 merge tie-break order, #92), so source index i is field i. All sources +/// share the one `Arc` (one `FileCache`), so a window's bars are +/// parsed once and reused across the field decodes (C12). +/// +/// Returns `None` if any field has no archived file overlapping the window +/// (the [`open_ohlc`] contract — file-level absence; since every archive file +/// carries all fields, one absent field means all are). A window that +/// overlaps a file but holds zero matching bars yields sources whose first +/// `peek` is `None`, not a `None` here. +pub fn open_columns( + server: &Arc, + symbol: &str, + from_ms: Option, + to_ms: Option, + fields: &[M1Field], +) -> Option>> { + fields + .iter() + .map(|f| { + M1FieldSource::open(server, symbol, from_ms, to_ms, *f) + .map(|s| Box::new(s) as Box) + }) + .collect() +} + +/// [`open_columns`] over an epoch-ns [`Timestamp`] window — the engine-side +/// mirror, exactly as [`M1FieldSource::open_window`] mirrors +/// [`M1FieldSource::open`]; the ns→ms crossing happens once, here. +pub fn open_columns_window( + server: &Arc, + symbol: &str, + from: Option, + to: Option, + fields: &[M1Field], +) -> Option>> { + open_columns( + server, + symbol, + from.map(epoch_ns_to_unix_ms), + to.map(epoch_ns_to_unix_ms), + fields, + ) +} + /// Open the four real OHLC [`M1FieldSource`]s for `symbol` over the closed /// epoch-ns window `[from, to]`, in the FIXED order open, high, low, close — the /// C4 merge tie-break order a resampler's `Barrier(0)` group depends on (#92). @@ -330,11 +377,13 @@ pub fn open_ohlc( from: Timestamp, to: Timestamp, ) -> Option>> { - let open = M1FieldSource::open_window(server, symbol, Some(from), Some(to), M1Field::Open)?; - let high = M1FieldSource::open_window(server, symbol, Some(from), Some(to), M1Field::High)?; - let low = M1FieldSource::open_window(server, symbol, Some(from), Some(to), M1Field::Low)?; - let close = M1FieldSource::open_window(server, symbol, Some(from), Some(to), M1Field::Close)?; - Some(vec![Box::new(open), Box::new(high), Box::new(low), Box::new(close)]) + open_columns_window( + server, + symbol, + Some(from), + Some(to), + &[M1Field::Open, M1Field::High, M1Field::Low, M1Field::Close], + ) } /// Construct the default data-server over the local archive at @@ -398,6 +447,24 @@ mod tests { } } + #[test] + fn open_columns_propagates_file_level_absence_as_none() { + // The open_ohlc contract, generalized: file-level absence (unknown + // symbol / no overlapping archive file) is None for the whole set — + // hermetic (a bogus path has no archive whether or not local data + // exists), like instrument_geometry_none_for_unknown_symbol above. + let server = Arc::new(DataServer::new("/nonexistent-archive-path")); + assert!(open_columns(&server, "NOSYM", None, None, &[M1Field::Close]).is_none()); + assert!(open_columns_window( + &server, + "NOSYM", + Some(Timestamp(0)), + Some(Timestamp(1)), + &[M1Field::Open, M1Field::Close] + ) + .is_none()); + } + /// A hand-built M1 bar with distinct per-field values so a transpose test /// can tell the columns apart. fn full_bar(time_ms: i64) -> M1Parsed {