Files
Aura/crates/aura-runner/src/axes.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

250 lines
12 KiB
Rust

//! Axis / risk-regime conventions.
//!
//! The RAW campaign-axis namespace (a campaign document's own, #203) and the
//! WRAPPED param-space namespace (`blueprint_axis_probe`'s output, one node
//! segment prefixed) are two coordinate systems over the same open params;
//! this module owns the one translation between them (`wrapped_to_raw_axis`
//! <-> `raw_matches_wrapped`) and the pure axis-binding resolution
//! (`bind_axes`) built on it, plus the content-id shape predicate
//! (`is_content_id`) shared by every FILE-or-id CLI surface.
use std::collections::HashSet;
use aura_core::{Cell, ParamSpec, Scalar};
use aura_campaign::MemberFault;
use aura_engine::Composite;
/// A bare store address: exactly 64 lowercase hex chars (the content-id key
/// shape). Shared by every FILE-or-id CLI surface (`aura campaign run`'s
/// target resolution, `graph_construct`'s `--params <FILE|ID>` resolution)
/// so the two cannot drift apart on the id shape a second time.
pub fn is_content_id(s: &str) -> bool {
s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
}
/// Strip the wrapper's exactly-one leading node segment from a WRAPPED param
/// path, yielding the RAW campaign-axis name the doc speaks (#203): the bind
/// convention prefixes a strategy's own param paths with one root-composite
/// segment before they enter the runnable (wrapped) param space, so the raw
/// axis is everything after that segment's dot. Returns `name` unchanged if
/// there is no dot (defensive; every wrapped param space produced by
/// `blueprint_axis_probe` has one). This is the single definition of "the
/// wrapper segment" — [`raw_matches_wrapped`] is built on it so a wrap-depth
/// change cannot desync the two directions of the #203 convention.
pub fn wrapped_to_raw_axis(name: &str) -> &str {
name.split_once('.').map(|(_, rest)| rest).unwrap_or(name)
}
/// Does a RAW campaign-axis name (the doc's own namespace) address this ONE
/// wrapped param path? True for an exact match (an unwrapped param, if one
/// ever exists) or when stripping the wrapper's one node segment
/// ([`wrapped_to_raw_axis`]) yields exactly `raw`. Inverse of
/// `wrapped_to_raw_axis`, co-located and built on it (#203): the two are the
/// only two places the wrapper-segment convention is expressed.
pub fn raw_matches_wrapped(raw: &str, wrapped: &str) -> bool {
wrapped == raw || wrapped_to_raw_axis(wrapped) == raw
}
/// Suffix-join each raw campaign axis onto exactly one wrapped param
/// ([`raw_matches_wrapped`] — wrapped == raw, or stripping the wrapper's one
/// node segment yields raw), then require every wrapped slot to be covered.
/// Pure and independent of the env/data seam so the three fault arms are
/// unit-testable without a project, a store, or a loaded blueprint.
pub fn bind_axes(
space: &[ParamSpec],
strategy_id: &str,
params: &[(String, Scalar)],
) -> Result<Vec<Cell>, MemberFault> {
let mut slots: Vec<Option<Scalar>> = vec![None; space.len()];
for (raw, value) in params {
let hits: Vec<usize> = space
.iter()
.enumerate()
.filter(|(_, p)| raw_matches_wrapped(raw, &p.name))
.map(|(i, _)| i)
.collect();
match hits.as_slice() {
[i] => slots[*i] = Some(*value),
[] => {
return Err(MemberFault::Bind(format!(
"axis \"{raw}\" matches no open param of the wrapped strategy {strategy_id}"
)));
}
_ => {
let names: Vec<&str> = hits.iter().map(|&i| space[i].name.as_str()).collect();
return Err(MemberFault::Bind(format!(
"axis \"{raw}\" is ambiguous in the wrapped param space of \
strategy {strategy_id}: matches {}",
names.join(", ")
)));
}
}
}
let mut point: Vec<Cell> = Vec::with_capacity(space.len());
for (spec, slot) in space.iter().zip(&slots) {
match slot {
Some(v) => point.push(v.cell()),
None => {
// Speak the RAW campaign-axis namespace (the doc's own): the
// wrapped space prefixes the signal's params with one node
// segment, so the raw path is everything after it (#203).
let raw = wrapped_to_raw_axis(&spec.name);
return Err(MemberFault::Bind(format!(
"open param \"{raw}\" of strategy {strategy_id} is bound by no \
campaign axis (wrapped path: {})",
spec.name
)));
}
}
}
Ok(point)
}
/// The #246 override subset of one member's campaign-axis names (already RAW
/// — a campaign document speaks the raw namespace, #203, hence the `raw_`
/// name prefix distinguishing this from `member`'s WRAPPED-input siblings):
/// every name that matches no entry of the un-reopened wrapped OPEN
/// `open_space` but names a BOUND param of `raw_signal` (`bound_param_space()`'s
/// `.name` is already path-qualified in strategy/RAW coordinates, exactly
/// like a raw campaign axis — no wrap-prefix stripping needed, unlike
/// `member::wrapped_bound_overrides_of`/`member::override_paths`, which start
/// from WRAPPED CLI axis names). Silent like `member::wrapped_bound_overrides_of`
/// (skips a name matching neither space), NOT the validating `member::override_paths`:
/// `run_member` runs inside a sweep worker and must never call
/// `std::process::exit` — an unmatched name still surfaces as `bind_axes`'s
/// own named `MemberFault::Bind` once the (reopened) space below is built.
pub fn raw_bound_overrides_of(
param_names: &[String],
open_space: &[ParamSpec],
raw_signal: &Composite,
) -> Vec<String> {
let bound: HashSet<String> =
raw_signal.bound_param_space().into_iter().map(|b| b.name).collect();
param_names
.iter()
.filter(|raw| {
!open_space.iter().any(|p| raw_matches_wrapped(raw, &p.name))
&& bound.contains(raw.as_str())
})
.cloned()
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::ScalarKind;
fn spec(name: &str) -> ParamSpec {
ParamSpec { name: name.to_string(), kind: ScalarKind::I64 }
}
#[test]
/// The only shape treated as a direct store address is a bare 64-char
/// lowercase-hex token; anything else (wrong length, uppercase, non-hex)
/// falls through to `run_campaign`'s file-path branch instead.
fn is_content_id_accepts_only_64_lowercase_hex() {
assert!(is_content_id(&"a".repeat(64)));
assert!(!is_content_id(&"A".repeat(64)));
assert!(!is_content_id(&"a".repeat(63)));
assert!(!is_content_id(&format!("{}g", "a".repeat(63))));
}
#[test]
/// #203: `wrapped_to_raw_axis` and `raw_matches_wrapped` are the inverse
/// pair of one wrapper-segment convention — stripping the wrapper off a
/// wrapped name must satisfy the match predicate against that SAME
/// wrapped name, and a raw name from a DIFFERENT wrapped param must not.
fn raw_matches_wrapped_and_wrapped_to_raw_axis_are_inverses() {
let wrapped = "sma_signal.fast.length";
assert_eq!(wrapped_to_raw_axis(wrapped), "fast.length");
assert!(raw_matches_wrapped(wrapped_to_raw_axis(wrapped), wrapped));
assert!(!raw_matches_wrapped("fast.length", "sma_signal.slow.length"));
}
#[test]
/// bind_axes resolves a raw axis name against the ONE wrapped param whose
/// path ends with ".{raw}" (or equals it), producing a co-indexed point.
fn bind_axes_resolves_a_unique_suffix_match() {
let space = vec![spec("sma.length")];
let params = vec![("length".to_string(), Scalar::i64(14))];
let point = bind_axes(&space, "strat", &params).unwrap();
assert_eq!(point, vec![Scalar::i64(14).cell()]);
}
#[test]
/// A raw campaign axis that matches no open param of the wrapped
/// strategy is a named Bind fault naming the axis, never a silently
/// dropped binding.
fn bind_axes_refuses_an_unmatched_axis() {
let space = vec![spec("sma.length")];
let params = vec![("period".to_string(), Scalar::i64(14))];
let err = bind_axes(&space, "strat", &params).unwrap_err();
let MemberFault::Bind(m) = err else { panic!("expected Bind, got {err:?}") };
assert!(m.contains("period") && m.contains("matches no open param"));
}
#[test]
/// A raw axis matching MORE THAN ONE wrapped param is a named Bind fault
/// that lists every ambiguous candidate, never a silent first-match pick.
fn bind_axes_refuses_an_ambiguous_axis() {
let space = vec![spec("fast.length"), spec("slow.length")];
let params = vec![("length".to_string(), Scalar::i64(14))];
let err = bind_axes(&space, "strat", &params).unwrap_err();
let MemberFault::Bind(m) = err else { panic!("expected Bind, got {err:?}") };
assert!(m.contains("ambiguous") && m.contains("fast.length") && m.contains("slow.length"));
}
#[test]
/// An open wrapped param left unbound by any campaign axis is a named
/// Bind fault naming the param, not an implicit default.
fn bind_axes_refuses_an_uncovered_param() {
let space = vec![spec("sma.length"), spec("sma.threshold")];
let params = vec![("length".to_string(), Scalar::i64(14))];
let err = bind_axes(&space, "strat", &params).unwrap_err();
let MemberFault::Bind(m) = err else { panic!("expected Bind, got {err:?}") };
// The refusal speaks the RAW campaign-axis namespace (what the doc
// must say), with the wrapped bind-time path as a parenthetical (#203).
assert!(
m.contains("open param \"threshold\"") && m.contains("(wrapped path: sma.threshold)"),
"raw-namespace prose expected: {m}"
);
}
#[test]
/// #246: `raw_bound_overrides_of` names exactly the raw axis names that
/// miss the un-reopened wrapped OPEN space but match a BOUND param of the
/// raw signal (in strategy/RAW coordinates, per its own doc comment) — an
/// axis already covered by the open space is not re-flagged as an
/// override, and an axis matching neither space is silently skipped here
/// (bind_axes surfaces THAT case as its own named fault once the
/// reopened space is built downstream).
fn raw_bound_overrides_of_selects_axes_naming_a_bound_param() {
use aura_strategy::Bias;
use aura_std::Sma;
let raw_signal = Composite::new(
"fixture",
vec![
Sma::builder().named("fast").bind("length", Scalar::i64(2)).into(),
Bias::builder().named("exp").into(),
],
vec![],
vec![],
vec![],
);
// The un-reopened wrapped OPEN space carries one extra wrap segment
// (the CLI's own outer wrap, mirroring `blueprint_axis_probe`'s
// output shape) ahead of the composite's own "exp.scale" path.
let open_space = vec![ParamSpec { name: "wrap.exp.scale".to_string(), kind: ScalarKind::F64 }];
let param_names =
vec!["exp.scale".to_string(), "fast.length".to_string(), "nope.thing".to_string()];
let overrides = raw_bound_overrides_of(&param_names, &open_space, &raw_signal);
assert_eq!(
overrides,
vec!["fast.length".to_string()],
"only the bound-param axis is selected; the already-open axis and the \
unmatched axis are excluded"
);
}
}