Files
Aura/docs/specs/0084-cost-graph-composite.md
T
Brummel 9fb3d8b8ee spec: 0084 cost-graph composite-builder (boss-signed)
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
2026-06-28 21:10:07 +02:00

291 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 23 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:
```rust
// 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)
```rust
// 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:
```rust
// 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)
```rust
// 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. Asserts `n >= 1`. Exposes the
4 geometry roles + each node's `cost[k].<port>` extra roles + the 3-field
aggregate output.
- **`aura_std` re-export** — `GEOMETRY_WIDTH` added to the `cost::` re-export
line in `lib.rs`. No code change in `cost.rs`.
- **`aura_cli::stage1_r_graph`** — the cost block (`if let Some((cfg, tx_net,
tx_cost)) = cost { … }`) rewired to call `cost_graph`. `MAX_RUN_COST_NODES`
and `COST_SUM_PORTS` deleted. `net_r_equity` (the 4-term LinComb) and the cost
recorder read the composite's exposed outputs instead of the inline `agg`
handle — 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 `add`s (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_graph` with an empty `cost_nodes` vec → `assert!` panic ("cost_graph
needs at least one cost node"), mirroring `CostSum::new`'s zero-arity panic.
The CLI only calls `cost_graph` when ≥1 cost flag is set, so the live path
never hits it.
- A cost node whose schema has fewer than `GEOMETRY_WIDTH` inputs would panic on
the slice — impossible for a `cost_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.connect` are caught by `g.build()`'s
wiring validation (the `.expect("cost_graph wires")`), exactly as the existing
composites rely on it.
## Testing strategy
1. **`aura-composites` unit test — the wiring contract.** Build
`cost_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 surfaces `volatility`);
its exposed **output** field names are `COST_FIELD_NAMES`. This pins the
geometry-fan + the `cost[k].<port>` namespacing + the aggregate output.
2. **`aura-composites` unit 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 — the `n = 1` shape, confirming a lone cost node needs no extra roles.
3. **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`) — exact
`net_expectancy_r` byte-identity;
- `stage1_r_composed_cost_net_expectancy_r_golden` (`--cost-per-trade 2
--slip-vol-mult 0.5`) — exact composed `net_expectancy_r` byte-identity;
- the C18 no-cost golden (cost = `None`, composite not built) — untouched;
- the full `aura-std` / `aura-engine` / `aura-cli` suites.
These two value goldens are the regression net: a wiring change that altered
the computation would shift `net_expectancy_r` and fail them.
4. **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_PORTS` table, and the arbitrary `MAX_RUN_COST_NODES = 2` cap 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-managing `CostSum` slot 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_r` goldens, 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 in `aura-composites`, never `aura-engine`), C23 (node labels are
non-load-bearing debug symbols — label drift under `cost_graph` nesting 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.