Cycle 4 of milestone #148 (Cost-model graph in R), decision E — the cost-graph composite-builder, recorded across cycles 2-3 as the natural next. Adds a `cost_graph(Vec<PrimitiveBuilder>) -> Composite` builder in aura-composites that fans the 4 PM-geometry inputs to N cost nodes, surfaces each node's extra inputs as `cost[k].<port>` roles (schema introspection past GEOMETRY_WIDTH), sums them via 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. Re-exports GEOMETRY_WIDTH from aura-std (first cross-crate consumer). Behaviour-preserving at the value level: the composite inlines at bootstrap (C11) to the same flat computation, so the two cycle-3 net_expectancy_r goldens, the C18 no-cost golden, and the full suite stay green verbatim. Routed spec-driven (specify direct-entry): settled in #148's scope + the mature aura-composites idiom + the CostNode contract. All four forks (composite API shape, extra-input namespacing, GEOMETRY_WIDTH visibility, CLI consumption) are derived decisions logged on #148; none hangs on pure user preference. Auto-signed under /boss on the Step-5 grounding-check PASS (every load-bearing current-behaviour assumption tied to a named green test or current source; no with-cost graph label/shape pin contradicts the C23-permitted label drift). refs #148
14 KiB
Cost-graph composite-builder — Design Spec
Date: 2026-06-28 Status: Draft — awaiting user spec review Authors: orchestrator + Claude
Cycle 4 of milestone Cost-model graph (in R) (#148). Parent rework: ledger C10 (
29cdc8c, #116). Decision E ("cost-graph composite-builder"), recorded across cycles 2–3 as the natural next. Fork decisions logged on #148.
Goal
Give the cost-model graph a real authoring primitive: a cost_graph(...)
composite-builder in aura-composites that takes any number of cost nodes and
wires them through CostSum into the single 3-field cost-in-R stream the net-R
seam already consumes. This replaces the CLI's manual, slot-indexed cost-wiring
block (CostSum::builder(n) + a slot counter + COST_SUM_PORTS[slot * COST_WIDTH + f] + a hardcoded MAX_RUN_COST_NODES = 2 cap) with one principled
composite that handles arbitrary arity. Behaviour-preserving: the composite
inlines at bootstrap (C11) to the same flat computation, so every value output
is byte-identical.
This is the aura-composites charter realised for cost: the crate is "the one
place where the engine's builder and the standard nodes are wired together"
(its module doc), and cost-node fan-in is exactly such wiring. It is not a
new cost behaviour, a new node, or a new CLI surface — it is the composition
layer the milestone's title ("composable cost nodes") names.
Architecture
A cost-model graph is, structurally, N cost nodes → CostSum → one 3-field
output. Today that fan-in lives inline in aura-cli::stage1_r_graph, wired by
hand against the interned cost[k].<field> slot names and capped at two nodes.
This cycle lifts that fan-in into a composite:
cost_graph(cost_nodes) : Composite
open input roles : closed, open, entry_price, stop_price (the 4 geometry inputs, fanned to every cost node)
cost[k].<port> (each cost node's extra inputs, namespaced by index)
internal : cost_node[0] ┐
cost_node[1] ┤→ CostSum(n)
… ┘
exposed output : cost_in_r, cum_cost_in_r, open_cost_in_r (CostSum's aggregate, = COST_FIELD_NAMES)
The composite owns only the summation wiring. State a cost node depends on but
does not own — the realized-vol proxy feeding VolSlippageCost — stays outside
the composite (it is shared with the stop rule), surfaced as a cost[k].volatility
input role the caller feeds. This keeps the composite the cost-summation layer,
nothing more.
Heterogeneity is handled by schema introspection, not a closure: every cost
node is a PrimitiveBuilder (built via cost_node_builder), and the builder
contract guarantees the 4 geometry ports first, then the factor's extra ports
(slot GEOMETRY_WIDTH onward — tested by
cost_node_builder_assembles_geometry_prefix_then_extras). So cost_graph reads
each node's schema().inputs[GEOMETRY_WIDTH..] to discover its extras and
exposes them as cost[k].<port> roles. (risk_executor's closure pattern is
unnecessary here: its stop arms are heterogeneous constructs, a primitive vs a
composite; cost nodes are uniformly primitives.)
Concrete code shapes
The worked author / consumer example (the acceptance evidence)
The program a cost-model author writes — the CLI's stage1_r_graph is the first
consumer, and it is exactly the code a project author composing a cost model
would write:
// Build the active cost nodes (same conditional order as today), as PrimitiveBuilders.
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()); // remember this node's index, to feed its vol input
cost_nodes.push(VolSlippageCost::builder().bind("slip_vol_mult", Scalar::f64(svm)));
}
// One composite replaces the manual CostSum slot-loop + the MAX_RUN_COST_NODES cap.
let cg = g.add(cost_graph(cost_nodes));
// Geometry: the executor's PM-geometry outputs fan in via the composite's 4 roles.
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"));
// Extra inputs: feed the shared vol proxy to the vol node's namespaced role.
if let Some(k) = vol_slot {
let (_, _, vrange) = vol_proxy.expect("vol proxy built whenever slip_vol_mult is set");
g.connect(vrange.output("value"), cg.input(&format!("cost[{k}].volatility")));
}
// The composite's aggregate output feeds net_r_equity + the cost recorder, unchanged.
// net_eq term[2] <- cg.output("cum_cost_in_r") (weight -1)
// net_eq term[3] <- cg.output("open_cost_in_r") (weight -1)
// cost recorder <- cg.output(field) for field in COST_FIELD_NAMES
The composite-builder (the cycle's deliverable)
// crates/aura-composites/src/lib.rs
use aura_std::{CostSum, COST_FIELD_NAMES, GEOMETRY_WIDTH};
use aura_core::PrimitiveBuilder;
/// 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.
/// Inlines at bootstrap (C11) to the same flat fan-in the hand-wired CLI block
/// produced. Requires `n >= 1` (mirrors `CostSum::new`).
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 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 fan targets, collected across all nodes, fed once per role.
let (mut c_t, mut o_t, mut e_t, mut s_t) = (vec![], vec![], vec![], vec![]);
for (k, node) in cost_nodes.into_iter().enumerate() {
// Discover this node's extra ports (everything past the geometry prefix).
let extras: Vec<PortSpec> = node.schema().inputs[GEOMETRY_WIDTH..].to_vec();
let h = g.add(node);
c_t.push(h.input("closed"));
o_t.push(h.input("open"));
e_t.push(h.input("entry_price"));
s_t.push(h.input("stop_price"));
// Each extra becomes a `cost[k].<port>` composite role.
for p in &extras {
let role = g.input_role(&format!("cost[{k}].{}", p.name));
g.feed(role, [h.input(&p.name)]);
}
// The node's 3 cost fields → CostSum's `cost[k].<field>` inputs.
for field in COST_FIELD_NAMES {
g.connect(h.output(field), agg.input(&format!("cost[{k}].{field}")));
}
}
g.feed(closed, c_t);
g.feed(open, o_t);
g.feed(entry, e_t);
g.feed(stop, s_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")
}
The supporting re-export (before → after)
GEOMETRY_WIDTH is already pub in cost.rs but not re-exported; add it to the
existing cost re-export so the cross-crate consumer can slice the extra ports:
// crates/aura-std/src/lib.rs — before
pub use cost::{cost_node_builder, CostNode, CostRunner, COST_FIELD_NAMES, COST_WIDTH};
// after
pub use cost::{cost_node_builder, CostNode, CostRunner, COST_FIELD_NAMES, COST_WIDTH, GEOMETRY_WIDTH};
The CLI block being removed (before → after)
// crates/aura-cli/src/main.rs — REMOVED: the hardcoded cap + the interned slot table
const MAX_RUN_COST_NODES: usize = 2;
static COST_SUM_PORTS: LazyLock<Vec<String>> = LazyLock::new(|| { /* cost[k].<field> names */ });
// REMOVED: the inline `CostSum::builder(n)` + the per-node slot-loop wiring
// (g.add(CostSum…), the `slot` counter, COST_SUM_PORTS[slot * COST_WIDTH + f])
// REPLACED BY: the `cost_graph(cost_nodes)` add + role wiring shown above.
Components
aura_composites::cost_graph(cost_nodes: Vec<PrimitiveBuilder>) -> Composite— new. The only deliverable node-graph builder. Assertsn >= 1. Exposes the 4 geometry roles + each node'scost[k].<port>extra roles + the 3-field aggregate output.aura_stdre-export —GEOMETRY_WIDTHadded to thecost::re-export line inlib.rs. No code change incost.rs.aura_cli::stage1_r_graph— the cost block (if let Some((cfg, tx_net, tx_cost)) = cost { … }) rewired to callcost_graph.MAX_RUN_COST_NODESandCOST_SUM_PORTSdeleted.net_r_equity(the 4-term LinComb) and the cost recorder read the composite's exposed outputs instead of the inlineagghandle — same field names, same weights.
Sequencing note (planner/implementer). The cost_graph body above is shown
in its natural one-pass form (create a cost[k].<port> role inside the per-node
loop). If GraphBuilder requires every input_role to precede node adds (the
risk_executor/vol_stop precedent creates all roles up front), restructure to
two passes: pass 1 borrows each node's schema() to collect its extras
(schema() borrows, it does not consume, so this is legal before add) and
creates all geometry + cost[k].<port> roles; pass 2 consumes the nodes
(g.add) and wires geometry fan + extra feeds + the CostSum connects. The
topology (roles, edges, exposed output) is identical either way; only the
statement order differs. Confirm the ordering constraint against the
GraphBuilder API during plan-recon.
Data flow
Unchanged from cycle 2 at the value level. Per cycle: the executor emits its
PM-geometry record; the 4 geometry roles fan it to every cost node; each cost
node (via CostRunner) emits its 3-field cost-in-R record co-temporally;
CostSum sums them per-field; the aggregate feeds net_r_equity (cum and
open legs, weight −1 each) and the cost recorder. summarize_r folds the same
co-temporal cost stream. The only change is where the fan-in lives (a composite
that inlines, vs. inline CLI code) — the flat graph the bootstrap produces is the
same computation.
Error handling
cost_graphwith an emptycost_nodesvec →assert!panic ("cost_graph needs at least one cost node"), mirroringCostSum::new's zero-arity panic. The CLI only callscost_graphwhen ≥1 cost flag is set, so the live path never hits it.- A cost node whose schema has fewer than
GEOMETRY_WIDTHinputs would panic on the slice — impossible for acost_node_builder-built node (the geometry prefix is always present); not defended beyond the slice's own bounds check, since every cost node is built through the one builder. - Unknown role / port names in
g.feed/g.connectare caught byg.build()'s wiring validation (the.expect("cost_graph wires")), exactly as the existing composites rely on it.
Testing strategy
aura-compositesunit test — the wiring contract. Buildcost_graph(vec![ConstantCost::builder().bind("cost_per_trade", …), VolSlippageCost::builder().bind("slip_vol_mult", …)])and assert: the composite builds; its exposed input roles are exactly{closed, open, entry_price, stop_price, cost[1].volatility}(cost[0] = ConstantCost has no extras; cost[1] = VolSlippageCost surfacesvolatility); its exposed output field names areCOST_FIELD_NAMES. This pins the geometry-fan + thecost[k].<port>namespacing + the aggregate output.aura-compositesunit test — single-node identity.cost_graph(vec![ConstantCost::builder().bind(…)])exposes{closed, open, entry_price, stop_price}(no extra roles) and the 3-field output — then = 1shape, confirming a lone cost node needs no extra roles.- Behaviour-preservation regression net (the load-bearing gate). After the
CLI rewrite, the existing suite stays green verbatim, in particular:
stage1_r_flat_cost_net_expectancy_r_golden(--cost-per-trade 2) — exactnet_expectancy_rbyte-identity;stage1_r_composed_cost_net_expectancy_r_golden(--cost-per-trade 2 --slip-vol-mult 0.5) — exact composednet_expectancy_rbyte-identity;- the C18 no-cost golden (cost =
None, composite not built) — untouched; - the full
aura-std/aura-engine/aura-clisuites. These two value goldens are the regression net: a wiring change that altered the computation would shiftnet_expectancy_rand fail them.
- No new value golden is needed — cycle 3 already pinned the exact flat and
composed
net_expectancy_r; this cycle's job is to keep them byte-identical through the refactor, which those goldens already enforce.
Acceptance criteria
Applying aura's feature-acceptance criterion (removes redundancy / an author naturally reaches for it / reintroduces no failure class the core constraints forbid):
- Removes redundancy / a hardcoded limit. The manual slot-loop, the interned
COST_SUM_PORTStable, and the arbitraryMAX_RUN_COST_NODES = 2cap are deleted; the composite handles any arity. This is concrete redundancy removal, not speculative generality — the consumer (stage1_r_graph) exists and is rewired in the same cycle. - An author naturally reaches for it. The worked example above is the code
a cost-model author writes:
cost_graph(vec![…])+ four geometry connects, vs. hand-managingCostSumslot indices. The CLI consumer is the empirical evidence. - Reintroduces no failure class. Behaviour-preserving at the value level
(C11 inlining → same flat computation): the two
net_expectancy_rgoldens, the C18 no-cost golden, and the full suite stay green verbatim. Honours C9 (a composite of cost nodes is still ordinary downstream nodes), C16 (the wiring lives inaura-composites, neveraura-engine), C23 (node labels are non-load-bearing debug symbols — label drift undercost_graphnesting is permitted; if a test pins a with-cost graph label/shape it is a ratified C23 change, confirmed absent or updated by grounding/audit). - Scope is one iteration. One new
pub fn+ one re-export + the CLI rewire + two composite unit tests. Deferred (recorded on #148): data-grounded nodes, per-cycle-held accrual, the conviction R-aggregation axis, sweep-path/OOS cost.