Files
Aura/crates/aura-composites/src/lib.rs
T
Brummel 4de6d5cbad rename: retire the stage1-* family for the r-family (r-sma / r-breakout / r-meanrev)
The stage1 ordinal has been dead vocabulary since the C10 reframe dropped
the two-stage research model: a 1 structurally implies a 2 that no longer
exists. The family is renamed by its live discriminator - the R yardstick -
with members named by their signal, uniform with their signal-named
siblings (sma, macd, momentum):

- selectors: stage1-r -> r-sma, stage1-breakout -> r-breakout,
  stage1-meanrev -> r-meanrev (old tokens are usage errors, exit 2 - no
  silent alias)
- identifiers: Strategy::RSma/RBreakout/RMeanRev, HarnessKind::RSma,
  r_sma_*/r_breakout_*/r_meanrev_*, wrap_r, run_signal_r, RGrid, R_SMA_*
- persisted identity: the sma_signal composite (param prefix
  sma_signal.fast.length / .slow.length; fixtures regenerated; content-ids
  shift - no test pins a literal hash, the registry parses no record names)
- e2e test files git-mv'd to r_sma_e2e / r_breakout_e2e / r_meanrev_e2e
- dead Stage-1/Stage-2 prose reworded to post-C10 vocabulary across
  rustdoc, Cargo.tomls, ledger live lines, and the glossary (historical
  entries stay; fieldtests corpus untouched)
- CLAUDE.md invariant 7 rewritten to record the ratified C10 reframe
  faithfully (the token-swap alone would have laundered the retired
  gated-currency/realistic-broker design into unmarked live prose); the
  unbacked account-mode clause dropped

Verification: cargo build/test --workspace green (51 targets), clippy
-D warnings clean, doc build clean, acceptance grep gate leaves exactly
the one resampling-stage false positive (harness.rs), smoke: --harness
r-sma runs, --harness stage1-r exits 2 with the new usage line.

Decision log: forks and rationale recorded on the issue (reconciliation
+ implementation-phase comments).

closes #174
2026-07-02 12:03:09 +02:00

207 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `aura-composites` — reusable composite-builders for the feed-forward R loop: the volatility stop and
//! the per-symbol RiskExecutor. Both are `GraphBuilder` compositions (from
//! `aura-engine`) of `aura-std` primitives, so this crate sits *above* both and is
//! the single place where the engine's builder and the standard nodes are wired
//! together.
//!
//! It lives in its own crate on purpose. The engine routes type-erased `Scalar`
//! records and never names a concrete node — `aura_engine::summarize_r` reads the
//! `PositionManagement` record positionally, by column index, without linking
//! `aura-std`. Keeping these node-naming builders out of `aura-engine` preserves
//! that: the engine stays `-> aura-core` only (domain-free), `aura-std` stays the
//! lean node library (`-> aura-core`), and the `aura-engine -> aura-std` edge these
//! builders would otherwise force is dissolved. `aura-composites -> {aura-engine,
//! aura-std}` is the one layer that couples both, and the graph stays acyclic.
//!
//! The Veto is a documented seam, not a node (a pass-through identity is exactly
//! what C19/C23 DCE deletes), so it appears nowhere here.
use aura_core::{PrimitiveBuilder, Scalar};
use aura_engine::{Composite, GraphBuilder, NodeHandle};
use aura_std::{
CostSum, Delay, Ema, FixedStop, LinComb, Mul, PositionManagement, Sizer, Sqrt, Sub,
COST_FIELD_NAMES, GEOMETRY_WIDTH, PM_FIELD_NAMES,
};
/// The volatility stop as a composition of primitives:
/// `stop_distance = k · Sqrt(Ema(Mul(Δ,Δ), length))`, `Δ = price Delay(price, 1)`
/// — a rolling EWMA standard deviation. Role: input `price` → output `stop_distance`.
pub fn vol_stop(length: i64, k: f64) -> Composite {
vol_stop_inner(Some((length, k)))
}
/// The single `vol_stop` topology, with its two knobs either BOUND to constants
/// (`Some((length, k))` — the [`vol_stop`] form, byte-identical to the original
/// build-time bind) or left OPEN (`None` — the gridding form: the EWMA and scale nodes
/// are `named` `stop_length` / `stop_k` so their slots surface in `param_space` under
/// the `.stop_length.length` / `.stop_k.weights[0]` suffixes). One body, so the
/// topology — every node, edge, and exposed role — cannot drift between the two arms;
/// only the two knobs differ (the `Option<i64>`-knob idiom `r_sma_graph` uses).
fn vol_stop_inner(knobs: Option<(i64, f64)>) -> Composite {
let mut g = GraphBuilder::new("vol_stop");
let price = g.input_role("price");
let delay = g.add(Delay::builder().bind("lag", Scalar::i64(1))); // z^-1: prev price
let sub = g.add(Sub::builder()); // Δ = price prev
let sq = g.add(Mul::builder()); // Δ·Δ = Δ²
// EWMA variance: length bound (Some) or open and named `stop_length` (None).
let ema = g.add(match knobs {
Some((length, _)) => Ema::builder().bind("length", Scalar::i64(length)),
None => Ema::builder().named("stop_length"),
});
let sqrt = g.add(Sqrt::builder()); // → σ (price units)
// k·σ: weights[0] bound (Some) or open and named `stop_k` (None).
let scale = g.add(match knobs {
Some((_, k)) => LinComb::builder(1).bind("weights[0]", Scalar::f64(k)),
None => LinComb::builder(1).named("stop_k"),
});
g.feed(price, [delay.input("series"), sub.input("lhs")]);
g.connect(delay.output("value"), sub.input("rhs"));
g.connect(sub.output("value"), sq.input("lhs"));
g.connect(sub.output("value"), sq.input("rhs")); // square: feed Δ to both legs
g.connect(sq.output("value"), ema.input("series"));
g.connect(ema.output("value"), sqrt.input("value"));
g.connect(sqrt.output("value"), scale.input("term[0]"));
g.expose(scale.output("value"), "stop_distance");
g.build().expect("vol_stop composite wires")
}
/// The protective stop-rule axis (C11 structural): a fixed distance, or the
/// volatility-scaled `vol_stop`. Both expose `price → stop_distance`, so the
/// RiskExecutor embeds either by identical downstream wiring.
///
/// Mirrors the sibling axis enum [`RollMode`](aura_engine::RollMode); `Eq` is omitted
/// because the `f64` payloads forbid it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum StopRule {
Fixed(f64),
Vol { length: i64, k: f64 },
}
/// The per-symbol RiskExecutor: open input roles `bias` + `price`, internal
/// `stop-rule → Sizer → PositionManagement`, exposing every field of PM's dense
/// R-record. Price fans to BOTH the stop-rule and PM; bias fans to the Sizer and PM;
/// the Sizer's `size` feeds PM's size slot. The embedded stop is the chosen `StopRule`.
pub fn risk_executor(stop: StopRule, risk_budget: f64) -> Composite {
// Add the chosen stop into the shared body's builder: a FixedStop node directly,
// or the BOUND `vol_stop` composite — byte-identical to the pre-dedup arms.
risk_executor_inner(
Box::new(move |g| match stop {
StopRule::Fixed(d) => g.add(FixedStop::builder().bind("distance", Scalar::f64(d))),
StopRule::Vol { length, k } => g.add(vol_stop(length, k)),
}),
risk_budget,
)
}
/// The shared `risk_executor` body, given a closure that adds the chosen stop node
/// (returning its handle). Open input roles `bias` + `price`, internal
/// `<stop> → Sizer → PositionManagement`, exposing every field of PM's dense R-record;
/// price fans to BOTH the stop and PM, bias to the Sizer and PM, the Sizer's `size`
/// into PM. Every stop exposes `price → stop_distance`, so a fixed stop, a bound
/// vol-stop, or an OPEN vol-stop all embed by this one identical downstream wiring —
/// the single place the fan / Sizer / PM / exposed record live, so [`risk_executor`]
/// and [`risk_executor_vol_open`] cannot drift apart (one body, two stop arms).
fn risk_executor_inner(
add_stop: Box<dyn FnOnce(&mut GraphBuilder) -> NodeHandle + '_>,
risk_budget: f64,
) -> Composite {
let mut g = GraphBuilder::new("risk_executor");
let bias = g.input_role("bias");
let price = g.input_role("price");
let stop = add_stop(&mut g);
let sizer = g.add(Sizer::builder().bind("risk_budget", Scalar::f64(risk_budget)));
let pm = g.add(PositionManagement::builder());
g.feed(price, [stop.input("price"), pm.input("price")]); // price fans to stop + PM
g.feed(bias, [sizer.input("bias"), pm.input("bias")]); // bias fans to sizer + PM
g.connect(stop.output("stop_distance"), sizer.input("stop_distance"));
g.connect(stop.output("stop_distance"), pm.input("stop_distance"));
g.connect(sizer.output("size"), pm.input("size")); // flat-1R size into PM
for field in PM_FIELD_NAMES {
g.expose(pm.output(field), field);
}
g.build().expect("risk_executor wires")
}
/// A `vol_stop`-armed RiskExecutor whose two stop knobs are left **open** (unbound)
/// so they enter `param_space` as sweepable axes — the gridding sibling of
/// [`risk_executor`]`(StopRule::Vol { .. }, ..)`, which binds them to constants. The
/// interior `vol_stop`'s EWMA length and `k`-multiplier nodes are named `stop_length`
/// / `stop_k` so their path-qualified slots are addressable as
/// `<path>.vol_stop.stop_length.length` (I64) and `<path>.vol_stop.stop_k.weights[0]`
/// (F64). Everything else (the price/bias fan, Sizer, PositionManagement, the exposed
/// dense R-record) is identical to [`risk_executor`], so a member built from any
/// `(length, k)` point matches the bound form at the same values byte-for-byte.
pub fn risk_executor_vol_open(risk_budget: f64) -> Composite {
// The same shared body as [`risk_executor`], with the OPEN vol-stop
// (`vol_stop_inner(None)`) as the stop arm — so the price/bias fan, Sizer, PM,
// and exposed dense R-record are guaranteed identical to the bound form, and a
// member built from any `(length, k)` point matches the bound form byte-for-byte.
risk_executor_inner(Box::new(|g| g.add(vol_stop_inner(None))), risk_budget)
}
/// A cost-model graph as a composition: `n` cost nodes fanned the 4 PM-geometry
/// inputs, each node's extra inputs surfaced as `cost[k].<port>` roles, all summed
/// by [`CostSum`] into the single 3-field cost-in-R stream the net-R seam consumes
/// (`summarize_r` + the `net_r_equity` tap stay unchanged). Inlines at bootstrap
/// (C11) to the same flat fan-in the hand-wired CLI block produced, so a cost run's
/// `net_expectancy_r` is byte-identical. Requires `n >= 1` (mirrors `CostSum::new`).
///
/// Each cost node is a `PrimitiveBuilder` (built via `cost_node_builder`), whose
/// schema is geometry-prefix-first then the factor's extras (slot `GEOMETRY_WIDTH`
/// onward); `cost_graph` reads `schema().inputs[GEOMETRY_WIDTH..]` to discover the
/// extras and names them `cost[k].<port>` — mirroring `CostSum`'s own `cost[k].*`
/// input vocabulary. Runtime-computed port names are `.leak()`ed to `&'static str`
/// to satisfy the `NodeHandle::input`/`output` bound — fine while cost is run-path
/// only (the graph is built once, a one-shot leak), but to be interned (the
/// `COL_PORTS` production pattern) before cost reaches the per-member sweep path,
/// where a per-build leak would accumulate. See #152.
pub fn cost_graph(cost_nodes: Vec<PrimitiveBuilder>) -> Composite {
assert!(!cost_nodes.is_empty(), "cost_graph needs at least one cost node");
let n = cost_nodes.len();
let mut g = GraphBuilder::new("cost_graph");
// The 4 PM-geometry input roles, fanned to every cost node's geometry inputs.
let closed = g.input_role("closed");
let open = g.input_role("open");
let entry = g.input_role("entry_price");
let stop = g.input_role("stop_price");
let agg = g.add(CostSum::builder(n));
// Per-role geometry fan targets, collected across all nodes, fed once per role.
let mut closed_t = Vec::with_capacity(n);
let mut open_t = Vec::with_capacity(n);
let mut entry_t = Vec::with_capacity(n);
let mut stop_t = Vec::with_capacity(n);
for (k, node) in cost_nodes.into_iter().enumerate() {
// The factor's extra ports = everything past the 4-wide geometry prefix.
let extra_names: Vec<String> =
node.schema().inputs[GEOMETRY_WIDTH..].iter().map(|p| p.name.clone()).collect();
let h = g.add(node);
closed_t.push(h.input("closed"));
open_t.push(h.input("open"));
entry_t.push(h.input("entry_price"));
stop_t.push(h.input("stop_price"));
// Each extra input becomes a `cost[k].<port>` composite role.
for name in &extra_names {
let role = g.input_role(&format!("cost[{k}].{name}"));
let port: &'static str = name.clone().leak();
g.feed(role, [h.input(port)]);
}
// The node's 3 cost fields -> CostSum's `cost[k].<field>` inputs.
for field in COST_FIELD_NAMES {
let agg_in: &'static str = format!("cost[{k}].{field}").leak();
g.connect(h.output(field), agg.input(agg_in));
}
}
g.feed(closed, closed_t);
g.feed(open, open_t);
g.feed(entry, entry_t);
g.feed(stop, stop_t);
// Expose CostSum's aggregate as the composite's 3-field cost output.
for field in COST_FIELD_NAMES {
g.expose(agg.output(field), field);
}
g.build().expect("cost_graph wires")
}