5c2ac982bc
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
250 lines
12 KiB
Rust
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", ¶ms).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", ¶ms).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", ¶ms).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", ¶ms).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(¶m_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"
|
|
);
|
|
}
|
|
}
|