face1879a6
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
207 lines
11 KiB
Rust
207 lines
11 KiB
Rust
//! `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")
|
||
}
|