diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 3f16f26..0666fd1 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -15,7 +15,7 @@ mod render; use render::{ChartData, ChartMeta, ChartMode, ReduceKind, Series}; use aura_core::{zip_params, Cell, Firing, ParamSpec, Scalar, ScalarKind, Timestamp}; -use aura_composites::{risk_executor, risk_executor_vol_open, StopRule}; +use aura_composites::{cost_graph, risk_executor, risk_executor_vol_open, StopRule}; use aura_engine::{ f64_field, join_on_ts, monte_carlo, param_stability, r_bootstrap, r_metrics_from_rs, summarize, summarize_r, walk_forward, window_of, ColumnarTrace, Composite, Edge, FamilySelection, FlatGraph, @@ -29,9 +29,9 @@ use aura_registry::{ FamilyMember, Generalization, NameKind, PlateauMode, Registry, RunTraces, TraceStore, WriteKind, }; use aura_std::{ - Add, Bias, ConstantCost, CostSum, Delay, Ema, GatedRecorder, Gt, Latch, LinComb, LongOnly, Mul, + Add, Bias, ConstantCost, Delay, Ema, GatedRecorder, Gt, Latch, LinComb, LongOnly, Mul, Recorder, RollingMax, RollingMin, SeriesReducer, SimBroker, Sma, Sqrt, Sub, VolSlippageCost, - COST_FIELD_NAMES, COST_WIDTH, PM_FIELD_NAMES, PM_RECORD_KINDS, + PM_FIELD_NAMES, PM_RECORD_KINDS, }; use std::sync::mpsc::{self, Receiver}; use std::sync::LazyLock; @@ -2571,24 +2571,6 @@ const STAGE1_R_STOP_K: f64 = 2.0; /// within the synthetic smoke fixture so the run path exercises non-zero slippage. const SLIP_VOL_LENGTH: i64 = 5; -/// The maximum number of cost nodes the run-path cost graph wires (flat cost + -/// vol slippage). Sizes the interned `CostSum` input-port names below. -const MAX_RUN_COST_NODES: usize = 2; - -/// Interned `cost[k].` `CostSum` input-port names, built once. Same -/// `&'static str`-from-a-static rationale as `COL_PORTS`: `GraphBuilder::input` -/// wants `&'static str`, so the names live in a `static` rather than being -/// `format!(...).leak()`ed per build. -static COST_SUM_PORTS: LazyLock> = LazyLock::new(|| { - let mut v = Vec::with_capacity(MAX_RUN_COST_NODES * COST_WIDTH); - for k in 0..MAX_RUN_COST_NODES { - for field in COST_FIELD_NAMES { - v.push(format!("cost[{k}].{field}")); - } - } - v -}); - /// Which cost nodes the run-path cost graph builds. At least one field is `Some` /// (the carrier `Option` is `None` when no cost flag was given). struct CostConfig { @@ -2748,36 +2730,29 @@ fn stage1_r_graph( g.connect(exec.output("unrealized_r"), r_equity.input("term[1]")); g.connect(r_equity.output("value"), req.input("col[0]")); if let Some((cfg, tx_net, tx_cost)) = cost { - let n = cfg.const_cost.is_some() as usize + cfg.slip_vol_mult.is_some() as usize; - let agg = g.add(CostSum::builder(n)); - let mut slot = 0usize; - + // Build the active cost nodes (same conditional order), tracking the vol + // node's index so its `cost[k].volatility` role can be fed below. + let mut cost_nodes = Vec::new(); + let mut vol_slot = None; if let Some(cpt) = cfg.const_cost { - let cc = g.add(ConstantCost::builder().bind("cost_per_trade", Scalar::f64(cpt))); - g.connect(exec.output("closed_this_cycle"), cc.input("closed")); - g.connect(exec.output("open"), cc.input("open")); - g.connect(exec.output("entry_price"), cc.input("entry_price")); - g.connect(exec.output("stop_price"), cc.input("stop_price")); - for (f, field) in COST_FIELD_NAMES.iter().copied().enumerate() { - g.connect(cc.output(field), agg.input(COST_SUM_PORTS[slot * COST_WIDTH + f].as_str())); - } - slot += 1; + cost_nodes.push(ConstantCost::builder().bind("cost_per_trade", Scalar::f64(cpt))); } - if let Some(svm) = cfg.slip_vol_mult { - let (_, _, vrange) = vol_proxy.expect("vol proxy is built whenever slip_vol_mult is set"); - let vs = g.add(VolSlippageCost::builder().bind("slip_vol_mult", Scalar::f64(svm))); - g.connect(exec.output("closed_this_cycle"), vs.input("closed")); - g.connect(exec.output("open"), vs.input("open")); - g.connect(exec.output("entry_price"), vs.input("entry_price")); - g.connect(exec.output("stop_price"), vs.input("stop_price")); - g.connect(vrange.output("value"), vs.input("volatility")); - for (f, field) in COST_FIELD_NAMES.iter().copied().enumerate() { - g.connect(vs.output(field), agg.input(COST_SUM_PORTS[slot * COST_WIDTH + f].as_str())); - } - slot += 1; + vol_slot = Some(cost_nodes.len()); + cost_nodes.push(VolSlippageCost::builder().bind("slip_vol_mult", Scalar::f64(svm))); + } + // A single cost_graph composite fans the shared PM-geometry into the active + // cost nodes and sums their per-field charges into the run's cost streams. + let cg = g.add(cost_graph(cost_nodes)); + g.connect(exec.output("closed_this_cycle"), cg.input("closed")); + g.connect(exec.output("open"), cg.input("open")); + g.connect(exec.output("entry_price"), cg.input("entry_price")); + g.connect(exec.output("stop_price"), cg.input("stop_price")); + if let Some(k) = vol_slot { + let (_, _, vrange) = vol_proxy.expect("vol proxy is built whenever slip_vol_mult is set"); + let role: &'static str = format!("cost[{k}].volatility").leak(); + g.connect(vrange.output("value"), cg.input(role)); } - debug_assert_eq!(slot, n); // net_r_equity = cum_realized_r + unrealized_r - Σcum_cost_in_r - Σopen_cost_in_r let net_eq = g.add( @@ -2789,8 +2764,8 @@ fn stage1_r_graph( ); g.connect(exec.output("cum_realized_r"), net_eq.input("term[0]")); g.connect(exec.output("unrealized_r"), net_eq.input("term[1]")); - g.connect(agg.output("cum_cost_in_r"), net_eq.input("term[2]")); - g.connect(agg.output("open_cost_in_r"), net_eq.input("term[3]")); + g.connect(cg.output("cum_cost_in_r"), net_eq.input("term[2]")); + g.connect(cg.output("open_cost_in_r"), net_eq.input("term[3]")); let net_rec = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_net)); g.connect(net_eq.output("value"), net_rec.input("col[0]")); @@ -2800,9 +2775,9 @@ fn stage1_r_graph( Firing::Any, tx_cost, )); - g.connect(agg.output("cost_in_r"), cost_rec.input("col[0]")); - g.connect(agg.output("cum_cost_in_r"), cost_rec.input("col[1]")); - g.connect(agg.output("open_cost_in_r"), cost_rec.input("col[2]")); + g.connect(cg.output("cost_in_r"), cost_rec.input("col[0]")); + g.connect(cg.output("cum_cost_in_r"), cost_rec.input("col[1]")); + g.connect(cg.output("open_cost_in_r"), cost_rec.input("col[2]")); } } g.build().expect("stage1_r wiring resolves") diff --git a/crates/aura-composites/src/lib.rs b/crates/aura-composites/src/lib.rs index 2a93626..6058232 100644 --- a/crates/aura-composites/src/lib.rs +++ b/crates/aura-composites/src/lib.rs @@ -15,10 +15,11 @@ //! //! 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::Scalar; +use aura_core::{PrimitiveBuilder, Scalar}; use aura_engine::{Composite, GraphBuilder, NodeHandle}; use aura_std::{ - Delay, Ema, FixedStop, LinComb, Mul, PositionManagement, Sizer, Sqrt, Sub, PM_FIELD_NAMES, + 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: @@ -136,3 +137,68 @@ pub fn risk_executor_vol_open(risk_budget: f64) -> Composite { // 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].` 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].` — mirroring `CostSum`'s own `cost[k].*` +/// input vocabulary. Runtime-computed port names are `.leak()`ed to `&'static str` +/// (the `risk_executor` precedent), a bounded one-time cost at blueprint +/// construction, off the hot path. +pub fn cost_graph(cost_nodes: Vec) -> 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 = + 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].` 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].` 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") +} diff --git a/crates/aura-composites/tests/cost_graph.rs b/crates/aura-composites/tests/cost_graph.rs new file mode 100644 index 0000000..5a4feea --- /dev/null +++ b/crates/aura-composites/tests/cost_graph.rs @@ -0,0 +1,95 @@ +//! `cost_graph` composite-builder: it fans the 4 PM-geometry inputs to N cost +//! nodes, surfaces each node's extra inputs as `cost[k].` roles, and exposes +//! `CostSum`'s 3-field aggregate. These tests pin the exposed wiring contract (the +//! input-role set + the output triple); value byte-identity is the CLI goldens' job. + +use aura_composites::cost_graph; +use aura_core::Scalar; +use aura_std::{ConstantCost, VolSlippageCost}; + +/// Two heterogeneous cost nodes: ConstantCost (no extras, index 0) and +/// VolSlippageCost (one extra `volatility`, index 1). The composite exposes the 4 +/// geometry roles + the namespaced `cost[1].volatility`, and the 3-field aggregate. +#[test] +fn cost_graph_exposes_geometry_and_namespaced_extras() { + let cg = cost_graph(vec![ + ConstantCost::builder().bind("cost_per_trade", Scalar::f64(2.0)), + VolSlippageCost::builder().bind("slip_vol_mult", Scalar::f64(0.5)), + ]); + let roles: Vec<&str> = cg.input_roles().iter().map(|r| r.name.as_str()).collect(); + assert_eq!( + roles, + vec!["closed", "open", "entry_price", "stop_price", "cost[1].volatility"] + ); + let outs: Vec<&str> = cg.output().iter().map(|o| o.name.as_str()).collect(); + assert_eq!(outs, vec!["cost_in_r", "cum_cost_in_r", "open_cost_in_r"]); +} + +/// A lone cost node with no extras exposes only the 4 geometry roles (the n=1 +/// shape), plus the 3-field aggregate. +#[test] +fn cost_graph_single_node_has_no_extra_roles() { + let cg = cost_graph(vec![ + ConstantCost::builder().bind("cost_per_trade", Scalar::f64(2.0)), + ]); + let roles: Vec<&str> = cg.input_roles().iter().map(|r| r.name.as_str()).collect(); + assert_eq!(roles, vec!["closed", "open", "entry_price", "stop_price"]); + let outs: Vec<&str> = cg.output().iter().map(|o| o.name.as_str()).collect(); + assert_eq!(outs, vec!["cost_in_r", "cum_cost_in_r", "open_cost_in_r"]); +} + +/// Property: `cost_graph` composes an ARBITRARY number of cost nodes — the +/// cycle-0084 headline that retired the hardcoded `MAX_RUN_COST_NODES = 2` cap — +/// and namespaces each extra-bearing node's inputs by the node index, so two +/// same-typed factors never collide. Three nodes (ConstantCost at idx 0 with no +/// extras, then two VolSlippageCost at idx 1 and 2, each carrying a `volatility` +/// extra) must expose the 4 SHARED geometry roles plus the two DISTINCT +/// `cost[1].volatility` / `cost[2].volatility` roles, and still collapse to the +/// single 3-field aggregate output regardless of arity. The shipped n=2 fixture +/// has only one extra-bearing node, so it cannot witness a cross-node collision: +/// a mis-indexed `k`, or extras flattened to a bare `volatility`, would alias the +/// two nodes' inputs into one role and silently mis-feed the cost graph — this +/// n=3 two-extra fixture fails loudly on that class while the n=1/n=2 fixtures +/// stay green. +#[test] +fn cost_graph_arbitrary_arity_namespaces_each_extra_by_node_index() { + let cg = cost_graph(vec![ + ConstantCost::builder().bind("cost_per_trade", Scalar::f64(2.0)), + VolSlippageCost::builder().bind("slip_vol_mult", Scalar::f64(0.5)), + VolSlippageCost::builder().bind("slip_vol_mult", Scalar::f64(0.25)), + ]); + let roles: Vec<&str> = cg.input_roles().iter().map(|r| r.name.as_str()).collect(); + assert_eq!( + roles, + vec![ + "closed", + "open", + "entry_price", + "stop_price", + "cost[1].volatility", + "cost[2].volatility", + ] + ); + let outs: Vec<&str> = cg.output().iter().map(|o| o.name.as_str()).collect(); + assert_eq!(outs, vec!["cost_in_r", "cum_cost_in_r", "open_cost_in_r"]); +} + +/// Property: the 4 PM-geometry inputs are FANNED (shared once) across every cost +/// node, never duplicated per node — so N extra-free cost nodes expose exactly the +/// 4 geometry roles and ZERO `cost[k]` roles, independent of N. Two extra-free +/// ConstantCost nodes must expose only `[closed, open, entry_price, stop_price]` +/// (and the 3-field aggregate). The shipped single-node fixture has N=1, so it +/// cannot catch a regression that per-node-duplicated the geometry into +/// `cost[k].closed`-style roles, or that leaked a spurious `cost[k]` role for an +/// extra-free node; this multi-node extra-free fixture pins the fan-once contract. +#[test] +fn cost_graph_fans_shared_geometry_across_multiple_extra_free_nodes() { + let cg = cost_graph(vec![ + ConstantCost::builder().bind("cost_per_trade", Scalar::f64(2.0)), + ConstantCost::builder().bind("cost_per_trade", Scalar::f64(1.0)), + ]); + let roles: Vec<&str> = cg.input_roles().iter().map(|r| r.name.as_str()).collect(); + assert_eq!(roles, vec!["closed", "open", "entry_price", "stop_price"]); + let outs: Vec<&str> = cg.output().iter().map(|o| o.name.as_str()).collect(); + assert_eq!(outs, vec!["cost_in_r", "cum_cost_in_r", "open_cost_in_r"]); +} diff --git a/crates/aura-std/src/lib.rs b/crates/aura-std/src/lib.rs index 64e9acc..44ef9da 100644 --- a/crates/aura-std/src/lib.rs +++ b/crates/aura-std/src/lib.rs @@ -50,7 +50,7 @@ pub use add::Add; pub use and::And; pub use bias::Bias; pub use constant_cost::ConstantCost; -pub use cost::{cost_node_builder, CostNode, CostRunner, COST_FIELD_NAMES, COST_WIDTH}; +pub use cost::{cost_node_builder, CostNode, CostRunner, COST_FIELD_NAMES, COST_WIDTH, GEOMETRY_WIDTH}; pub use cost_sum::CostSum; pub use delay::Delay; pub use ema::Ema;