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:
+27
-52
@@ -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")
|
||||
|
||||
@@ -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].<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`
|
||||
/// (the `risk_executor` precedent), a bounded one-time cost at blueprint
|
||||
/// construction, off the hot path.
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -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].<port>` 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"]);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user