Files
Aura/crates/aura-runner/src/binding.rs
T
claude 5c2ac982bc refactor: extract the member-run recipe into library crates (#295 part 1)
The shell no longer defines what an aura backtest is. Tasks 1-9 of the
shell-boundary cycle — structural extraction, behaviour byte-identical:

- aura-runner (new; the C28 assembly position): input binding (the C26
  module, moved whole), the C1-load-bearing param<->config translators,
  harness assembly (wrap_r / run_signal_r / run_blueprint_member and the
  probe/reopen cluster), axis + risk-regime conventions (bind_axes et
  al.), the campaign family builders + MC guards, reproduce
  (process::exit -> RunnerError, shell remaps to identical bytes), the
  measurement-run orchestration, project loading (Env / cdylib load /
  charter / staleness, moved whole), the coverage gap-walk (deduplicated
  onto interior_gap_months), and DefaultMemberRunner — the public
  implementation of aura_campaign::MemberRunner (ex CliMemberRunner).
  The MemberRunner trait stays in aura-campaign; the column still
  imports no harness/data-binding machinery.
- aura-measurement (new; seeds C28 rung 3): the IcMetrics/IcKey
  vocabulary + information_coefficient, verbatim incl. serde derives
  (#294 duplicate-timestamp semantics move as-is, unresolved).
- aura-backtest: the pure per-run scaffold (point_from_params,
  wf_ms_sizes / fit_wf_ms_sizes, intersect_shared_window).
- shell residue: main.rs / campaign_run.rs keep argv/dispatch,
  argv->document translation, and presentation only;
  walkforward_summary_json_from_reports now calls the public
  aura_engine::param_stability instead of its inline twin.
- worked example (crates/aura-runner/examples/world_member_run.rs): a
  World program runs a member backtest through the library alone.

Held quality findings, adjudicated: the plan's literal pub-visibility
list for binding.rs kept (cosmetic); ~20 refusal sites inside
aura-runner (family/member/measure/translate) still process::exit —
behaviour-identical today, conversion to RunnerError is filed forward
(refs #297); rustfmt line-width drift on re-pathed call sites left for
a repo-wide fmt decision.

Verification: cargo test --workspace green (1471 passed, 0 failed);
cargo clippy --workspace --all-targets -D warnings clean. Byte-identity
is pinned by the untouched shell E2E suites (cli_run, measure_ic,
run_measurement, research_docs, run_refuses_unrunnable_blueprint,
tap_recording — zero assertion edits).

Remaining in this cycle: full-workspace c28_layering + shell-content
check, ledger amendments (C28 assembly position, C25/C14 control-surface
consequence, C26 realization note).

refs #295
2026-07-21 05:20:27 +02:00

428 lines
18 KiB
Rust

//! 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).
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 `&'static str` form of a vocabulary role name, when it is one — the
/// common case at the wrap seam, so declaring a port for a vocabulary-named
/// role needs no per-build leak; only an override-renamed (non-vocabulary)
/// role falls back to leaking its name.
pub fn static_role_name(name: &str) -> Option<&'static str> {
match name {
"open" => Some("open"),
"high" => Some("high"),
"low" => Some("low"),
"close" => Some("close"),
"price" => Some("price"),
"spread" => Some("spread"),
"volume" => Some("volume"),
_ => None,
}
}
/// The closed role-name -> archive-column map (`price` aliases `close`).
pub(crate) fn column_for_role(name: &str) -> Option<M1Field> {
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 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 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 struct ResolvedBinding {
entries: Vec<BindingEntry>,
}
impl ResolvedBinding {
pub 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 fn columns(&self) -> Vec<M1Field> {
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 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<BindingEntry>) -> 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 fn resolve_binding(
strategy: &str,
roles: &[Role],
overrides: &BTreeMap<String, String>,
) -> Result<ResolvedBinding, String> {
// `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"
));
}
// Duplicate role names would lower to duplicate keyed feeds, which the by-name
// bind (`run_bound`) rejects as `DuplicateFeed`. The blueprint path does not
// enforce role-name uniqueness (only the construction op-script path does), so
// refuse here — a named refusal on every CLI run path, not a panic behind the
// `.expect()` the single-binding invariant relies on.
if let Some(dup) = first_duplicate_role(roles) {
return Err(format!(
"strategy \"{strategy}\" declares input role \"{dup}\" more than once — \
input role names must be unique"
));
}
let mut entries: Vec<BindingEntry> = 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 first role name that appears more than once in `roles`, in declaration
/// order, or `None` if every name is unique.
fn first_duplicate_role(roles: &[Role]) -> Option<&str> {
let mut seen = std::collections::BTreeSet::new();
for r in roles {
if !seen.insert(r.name.as_str()) {
return Some(r.name.as_str());
}
}
None
}
/// 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 fn probe_binding(roles: &[Role]) -> ResolvedBinding {
let entries: Vec<BindingEntry> = 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 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 <SYMBOL>",
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);
}
/// 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() {
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"
);
}
/// Two input roles with the same name would lower to a duplicate keyed feed;
/// `resolve_binding` refuses at the binding boundary (a named refusal, not a
/// `run_bound` `DuplicateFeed` panic behind the CLI's `.expect()`).
#[test]
fn duplicate_role_name_refuses() {
let err =
resolve_binding("x", &[role("high"), role("high")], &BTreeMap::new()).unwrap_err();
assert_eq!(
err,
"strategy \"x\" declares input role \"high\" more than once — \
input role names must be unique"
);
}
/// 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 <SYMBOL>"
);
}
}