Two tasks: (1) the cost_graph(Vec<PrimitiveBuilder>) -> Composite builder in aura-composites + the GEOMETRY_WIDTH re-export from aura-std + two unit tests pinning the exposed input-role set and the 3-field output; (2) the aura-cli stage1_r_graph rewire onto cost_graph, deleting MAX_RUN_COST_NODES and COST_SUM_PORTS. Runtime port names .leak()'d (the risk_executor precedent). Behaviour-preserving: the cycle-3 net_expectancy_r goldens are the regression net. refs #148
18 KiB
Cost-graph composite-builder — Implementation Plan
Parent spec:
docs/specs/0084-cost-graph-composite.mdFor agentic workers: REQUIRED SUB-SKILL: use the
implementskill to run this plan. Steps use- [ ]checkboxes for tracking.
Goal: Replace the CLI's manual slot-indexed cost-wiring with a reusable
cost_graph(Vec<PrimitiveBuilder>) -> Composite builder in aura-composites,
behaviour-preserving at the value level.
Architecture: cost_graph fans the 4 PM-geometry inputs to N cost nodes,
surfaces each node's extra inputs (discovered past GEOMETRY_WIDTH via schema
introspection) as cost[k].<port> roles, sums them via CostSum, and exposes
the 3-field aggregate. The composite inlines at bootstrap (C11) to the same flat
fan-in the hand-wired CLI block produced, so the cycle-3 net_expectancy_r
goldens stay byte-identical.
Tech Stack: aura-composites (the new builder), aura-std (a one-symbol
re-export), aura-cli (the consumer rewire). All grounding-ratified (spec PASS).
Files this plan creates or modifies:
- Modify:
crates/aura-std/src/lib.rs:53— appendGEOMETRY_WIDTHto the cost re-export. - Modify:
crates/aura-composites/src/lib.rs— imports (:18,:20-22) + newpub fn cost_graph(append after:138). - Create:
crates/aura-composites/tests/cost_graph.rs— the twocost_graphunit tests. - Modify:
crates/aura-cli/src/main.rs— import (:18), deleteMAX_RUN_COST_NODES(:2574-2576) +COST_SUM_PORTS(:2578-2590), rewire the cost block (:2750-2806), prune unused imports (:32,34).
Task 1: The cost_graph composite-builder + the GEOMETRY_WIDTH re-export + two unit tests
Files:
-
Modify:
crates/aura-std/src/lib.rs:53 -
Modify:
crates/aura-composites/src/lib.rs:18,:20-22, append after:138 -
Test:
crates/aura-composites/tests/cost_graph.rs -
Step 1: Re-export
GEOMETRY_WIDTHfrom aura-std
In crates/aura-std/src/lib.rs, line 53 is currently:
pub use cost::{cost_node_builder, CostNode, CostRunner, COST_FIELD_NAMES, COST_WIDTH};
Replace it with (append , GEOMETRY_WIDTH):
pub use cost::{cost_node_builder, CostNode, CostRunner, COST_FIELD_NAMES, COST_WIDTH, GEOMETRY_WIDTH};
(No cost.rs change — GEOMETRY_WIDTH is already pub const … = 4 at cost.rs:28.)
- Step 2: Write the two failing unit tests
Create crates/aura-composites/tests/cost_graph.rs with exactly:
//! `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"]);
}
- Step 3: Run the tests to verify they fail
Run: cargo test -p aura-composites --test cost_graph 2>&1 | tail -20
Expected: FAIL — compile error cannot find function 'cost_graph' in crate 'aura_composites' (the symbol does not exist yet).
- Step 4: Extend the aura-composites imports
In crates/aura-composites/src/lib.rs, line 18 is use aura_core::Scalar; — replace with:
use aura_core::{PrimitiveBuilder, Scalar};
Lines 20-22 are:
use aura_std::{
Delay, Ema, FixedStop, LinComb, Mul, PositionManagement, Sizer, Sqrt, Sub, PM_FIELD_NAMES,
};
Replace with (add CostSum, COST_FIELD_NAMES, GEOMETRY_WIDTH):
use aura_std::{
CostSum, Delay, Ema, FixedStop, LinComb, Mul, PositionManagement, Sizer, Sqrt, Sub,
COST_FIELD_NAMES, GEOMETRY_WIDTH, PM_FIELD_NAMES,
};
(Composite, GraphBuilder, NodeHandle are already imported at :19.)
- Step 5: Add the
cost_graphbuilder
Append to crates/aura-composites/src/lib.rs (after the current EOF, line 138):
/// 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")
}
- Step 6: Run the tests to verify they pass
Run: cargo test -p aura-composites --test cost_graph 2>&1 | tail -20
Expected: PASS — test result: ok. 2 passed; 0 failed.
- Step 7: Build + clippy the touched crates
Run: cargo build -p aura-composites -p aura-std 2>&1 | tail -5
Expected: Finished — 0 errors.
Run: cargo clippy -p aura-composites -p aura-std --all-targets -- -D warnings 2>&1 | tail -5
Expected: Finished — no warnings.
Task 2: Rewire the CLI cost block to use cost_graph
Files:
-
Modify:
crates/aura-cli/src/main.rs:18(import),:2574-2590(delete consts),:2750-2806(rewire block),:32,34(prune imports) -
Step 1: Add the
cost_graphimport
In crates/aura-cli/src/main.rs, line 18 is:
use aura_composites::{risk_executor, risk_executor_vol_open, StopRule};
Replace with (add cost_graph):
use aura_composites::{cost_graph, risk_executor, risk_executor_vol_open, StopRule};
- Step 2: Delete the now-obsolete cap + slot-name table
Delete the MAX_RUN_COST_NODES const and its doc comment (lines 2574-2576):
/// Upper bound on cost nodes wired on a single run-path cost graph (constant +
/// vol slippage). Sizes the interned `CostSum` input-port names below.
const MAX_RUN_COST_NODES: usize = 2;
And delete the entire COST_SUM_PORTS static and its doc comment (lines 2578-2590):
/// Interned `cost[k].<field>` `CostSum` input-port names, built once. Same
/// … (doc continues) …
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
});
(LazyLock stays imported — still used by COL_PORTS at :2624.)
- Step 3: Rewire the cost block
In fn stage1_r_graph (:2646), the cost block currently spans lines 2750-2806:
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;
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;
}
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;
}
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(
LinComb::builder(4)
.bind("weights[0]", Scalar::f64(1.0))
.bind("weights[1]", Scalar::f64(1.0))
.bind("weights[2]", Scalar::f64(-1.0))
.bind("weights[3]", Scalar::f64(-1.0)),
);
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]"));
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]"));
// The aggregate cost record summarize_r folds (col 0 per-close, col 2 window-end).
let cost_rec = g.add(Recorder::builder(
vec![ScalarKind::F64, ScalarKind::F64, ScalarKind::F64],
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]"));
}
Replace that entire block (lines 2750-2806) with:
if let Some((cfg, tx_net, tx_cost)) = cost {
// 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 {
cost_nodes.push(ConstantCost::builder().bind("cost_per_trade", Scalar::f64(cpt)));
}
if let Some(svm) = cfg.slip_vol_mult {
vol_slot = Some(cost_nodes.len());
cost_nodes.push(VolSlippageCost::builder().bind("slip_vol_mult", Scalar::f64(svm)));
}
// One composite replaces the manual CostSum slot-loop + the arity cap.
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));
}
// net_r_equity = cum_realized_r + unrealized_r - Σcum_cost_in_r - Σopen_cost_in_r
let net_eq = g.add(
LinComb::builder(4)
.bind("weights[0]", Scalar::f64(1.0))
.bind("weights[1]", Scalar::f64(1.0))
.bind("weights[2]", Scalar::f64(-1.0))
.bind("weights[3]", Scalar::f64(-1.0)),
);
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(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]"));
// The aggregate cost record summarize_r folds (col 0 per-close, col 2 window-end).
let cost_rec = g.add(Recorder::builder(
vec![ScalarKind::F64, ScalarKind::F64, ScalarKind::F64],
Firing::Any,
tx_cost,
));
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]"));
}
- Step 4: Prune the now-unused aura-std imports
In crates/aura-cli/src/main.rs, the import block at lines 31-35 is:
use aura_std::{
Add, Bias, ConstantCost, CostSum, 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,
};
CostSum, COST_FIELD_NAMES, and COST_WIDTH are no longer referenced after the
rewrite (the composite owns the summation; the block now uses literal field
names). Remove all three:
use aura_std::{
Add, Bias, ConstantCost, Delay, Ema, GatedRecorder, Gt, Latch, LinComb, LongOnly, Mul,
Recorder, RollingMax, RollingMin, SeriesReducer, SimBroker, Sma, Sqrt, Sub, VolSlippageCost,
PM_FIELD_NAMES, PM_RECORD_KINDS,
};
(ConstantCost and VolSlippageCost stay — the rewrite still constructs them.)
- Step 5: Build aura-cli
Run: cargo build -p aura-cli 2>&1 | tail -8
Expected: Finished — 0 errors, no unused import warning (the prune in Step 4 is complete; if the compiler flags any remaining unused import, remove exactly what it names).
- Step 6: Run the cost goldens — byte-identity gate
Run: cargo test -p aura-cli --test cli_run 2>&1 | tail -20
Expected: PASS — all cli_run tests green, in particular
stage1_r_flat_cost_net_expectancy_r_golden (pins -614.3134020253314),
stage1_r_composed_cost_net_expectancy_r_golden (pins -615.0304388396047),
stage1_r_single_run_output_golden (the C18 no-cost golden),
stage1_r_cost_run_persists_net_r_equity_and_charges_cost, and
stage1_r_both_costs_compose_net_below_each_alone. A shifted net_expectancy_r
fails the gate — the refactor was not behaviour-preserving.
- Step 7: Full-workspace regression + lint
Run: cargo test --workspace 2>&1 | tail -15
Expected: test result: ok across all crates — 0 failures.
Run: cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -5
Expected: Finished — no warnings.