feat(0084): cost-graph composite-builder

Cycle 4 of milestone #148 (Cost-model graph in R), decision E.

New `cost_graph(Vec<PrimitiveBuilder>) -> Composite` in aura-composites: it fans
the 4 PM-geometry inputs to N cost nodes, surfaces each node's extra inputs
(discovered via schema introspection past GEOMETRY_WIDTH) as `cost[k].<port>`
roles, sums them through CostSum, and exposes the 3-field aggregate. Replaces the
CLI's manual slot-indexed cost-wiring + the hardcoded MAX_RUN_COST_NODES=2 cap
with one principled composite of arbitrary arity. GEOMETRY_WIDTH re-exported from
aura-std (first cross-crate consumer). Runtime port names .leak()'d (the
risk_executor precedent), a bounded one-time cost at blueprint construction.

Behaviour-preserving at the value level: the composite inlines at bootstrap (C11)
to the same flat fan-in, so the two cycle-3 net_expectancy_r goldens
(-614.3134020253314 flat, -615.0304388396047 composed), the C18 no-cost golden,
and the full suite stay green verbatim. Honours C9 (a composite of cost nodes is
ordinary downstream nodes), C16 (wiring lives in aura-composites, not aura-engine),
C23 (label drift under cost_graph nesting is permitted; no with-cost graph
label/shape golden exists).

Four aura-composites unit tests pin the exposed contract: the two planned (n=2
heterogeneous role set; n=1 no-extra shape) plus two the implementer added that
strengthen the headline — arbitrary-arity per-node namespacing (n=3, two
VolSlippageCost -> distinct cost[1]/cost[2].volatility, the property that retires
the 2-node cap) and geometry fan-once (n=2 extra-free -> only the 4 geometry roles).

Accepted nit (held, non-gating): the CLI reconstructs the `cost[k].volatility`
role name to feed the vol node's extra input — a string coupling to the composite's
public role-naming convention. Kept: role-based composite wiring addresses roles by
name (as the CLI already does for "closed"/"bias"/"price"); the convention is tested
+ build-validated; a decoupling accessor would widen this cycle's scope.

Verified: cargo test --workspace (0 failures), clippy --workspace --all-targets
-D warnings clean, cargo doc -p aura-composites clean.

refs #148
This commit is contained in:
2026-06-28 21:52:06 +02:00
parent 550d5ce947
commit d5c44dd1ea
4 changed files with 191 additions and 55 deletions
+27 -52
View File
@@ -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].<field>` `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<Vec<String>> = 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")