Files
Aura/crates/aura-composites/src/lib.rs
T
Brummel face1879a6 audit(0084): cycle close — drift-clean (code); C10 cycle-0084 note + doc fixes
Cycle 4 of milestone #148 (cost-graph composite-builder). Architect drift review
over fc52b4f..d5c44dd: drift_found, every item dispositioned.

What holds (architect, evidence-of-review): C16/C9 — cost_graph lives in
aura-composites, the factors in aura-std, aura-engine stays domain-free; the
composite is ordinary downstream nodes. C11 behaviour-preservation is structural,
not just asserted — the composite inlines to the same flat fan-in as the deleted
CLI block (identical node set/order, cost[k].field slot map, geometry fan, net_eq
terms/signs, cost_rec cols), so the cycle-0083 net_expectancy_r goldens stay
byte-identical. C23 — role/port names non-load-bearing; 4 fixtures pin the role-set
+ output triple.

Resolved this commit:
- [high] ledger C10: the cycle-0083 note's "Still deferred (decision E)" is removed
  (E shipped this cycle) and a cycle-0084 realization note added.
- [medium] cost_graph .leak() provenance (aura-composites/src/lib.rs): the
  "risk_executor precedent" was imprecise — that .leak() lives in risk_executor's
  TEST (one-shot, leak-safe), not its production builder. Doc corrected: the leak is
  fine for one-shot run-path construction but must be interned (the COL_PORTS
  production pattern) before cost reaches the sweep path.
- [low] cli_run.rs golden docstring cited the deleted `slot * COST_WIDTH + f` CLI
  wiring; updated to the cost_graph composite's per-node cost[k].<port> wiring.

Deferred (tracked, not pending) -> #152:
- [medium] the cost[k].<port> index-namespacing restated across CostSum / cost_graph
  / the CLI (a build-validated lockstep — a divergent name fails loudly at g.build(),
  NOT the silent positional kind 0083 collapsed), AND the per-build .leak() that
  becomes an allocation regression on the per-member sweep path. Both are harmless on
  the current run-only cost path; the proper fix is one sweep-safe single-source
  cost-port-name contract (interned), best built with the deferred sweep-cost cycle.

Regression gate: cargo test --workspace (0 failures), clippy --workspace --all-targets
-- -D warnings clean, cargo doc clean — the architect is the primary gate (no dedicated
regression script). Ephemera (spec 0084 + plan 0084) git rm'd.

Milestone "Cost-model graph (in R)" remains OPEN.

refs #148
2026-06-28 22:06:06 +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 Stage-1 composite-builders: 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 `stage1_r_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")
}