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
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
# 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:
|
||||
|
||||
```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.
|
||||
Reference in New Issue
Block a user