diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 16e2864..2171f12 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -1707,7 +1707,7 @@ fn stage1_r_flat_cost_net_expectancy_r_golden() { /// contract (Task 4) are VERIFIED byte-preserving end to end. The sibling /// `stage1_r_both_costs_compose_net_below_each_alone` only asserts the RELATION /// `net_both < net_flat`; a drift in the vol-scaled numerator, the CostSum field -/// order, or the geometry-prefix slot offsets (`slot * COST_WIDTH + f`) would keep +/// order, or the `cost_graph` composite's per-node `cost[k].` wiring would keep /// that relation true and pass silently while shifting this value. Deterministic /// over the fixed synthetic stream (C1). #[test] diff --git a/crates/aura-composites/src/lib.rs b/crates/aura-composites/src/lib.rs index 6058232..2de8ac4 100644 --- a/crates/aura-composites/src/lib.rs +++ b/crates/aura-composites/src/lib.rs @@ -150,8 +150,10 @@ pub fn risk_executor_vol_open(risk_budget: f64) -> Composite { /// onward); `cost_graph` reads `schema().inputs[GEOMETRY_WIDTH..]` to discover the /// extras and names them `cost[k].` — 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. +/// to satisfy the `NodeHandle::input`/`output` bound — fine while cost is run-path +/// only (the graph is built once, a one-shot leak), but to be interned (the +/// `COL_PORTS` production pattern) before cost reaches the per-member sweep path, +/// where a per-build leak would accumulate. See #152. pub fn cost_graph(cost_nodes: Vec) -> Composite { assert!(!cost_nodes.is_empty(), "cost_graph needs at least one cost node"); let n = cost_nodes.len(); diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index b804f4a..37730ff 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -776,9 +776,27 @@ remains the non-load-bearing symbol). **Behaviour-preserving**: the builders emi unchanged schemas, so the wiring / `net_r_equity` seam / `summarize_r` are untouched and the `numerator / latched` token form is byte-identical; the existing suite passes verbatim and two new CLI characterization goldens pin the exact flat/composed -`net_expectancy_r` (the prior tests only asserted `net < gross`). **Still deferred** -(decision E): the multi-node cost-graph composite-builder (wiring sugar over the -already-working edge-wired seam). Decision log: #148. +`net_expectancy_r` (the prior tests only asserted `net < gross`). Decision log: #148. + +**Realization (cycle 0084 — cost-graph composite-builder, cycle 4, #148).** Decision E +ships. A new `cost_graph(Vec) -> Composite` in `aura-composites` (the +C16 layer that couples the engine builder + `aura-std` nodes) is the cost-model graph's +**authoring primitive**: it fans the 4 PM-geometry inputs to `N` cost nodes, surfaces +each node's extra inputs (discovered via `schema().inputs[GEOMETRY_WIDTH..]`, +`GEOMETRY_WIDTH` now re-exported from `aura-std`) as `cost[k].` composite roles, +sums them through `CostSum`, and exposes the 3-field aggregate. The CLI's manual +slot-indexed cost-wiring + the hardcoded `MAX_RUN_COST_NODES = 2` cap are deleted — the +composite handles arbitrary arity. **Behaviour-preserving** (C11): the composite inlines +at bootstrap to the same flat fan-in, so the cycle-0083 `net_expectancy_r` goldens are +byte-identical (four `aura-composites` unit tests pin the exposed role-set + output +triple, incl. arbitrary-arity per-node namespacing). Honours C9 (ordinary downstream +nodes), C16 (wiring stays out of `aura-engine`), C23 (role/port names are +non-load-bearing). **Carried debt (#152, for the deferred sweep-cost cycle):** the +`cost[k].` index-namespacing is restated across `CostSum` / `cost_graph` / the CLI +(a build-validated lockstep, not the silent positional kind 0083 collapsed), and +`cost_graph` `.leak()`s runtime port names per build — fine for one-shot run-path +construction, but to be interned (the `COL_PORTS` production pattern, not the test-only +`.leak()`) before cost reaches the per-member sweep path. Decision log: #148. **Reframe (2026-06-23, #117 — exposure → bias, R as the signal-quality unit).** [HISTORY — its R spine survives into the 2026-06-28 contract; its Stage-2 currency / diff --git a/docs/plans/0084-cost-graph-composite.md b/docs/plans/0084-cost-graph-composite.md deleted file mode 100644 index 4ce3b41..0000000 --- a/docs/plans/0084-cost-graph-composite.md +++ /dev/null @@ -1,434 +0,0 @@ -# Cost-graph composite-builder — Implementation Plan - -> **Parent spec:** `docs/specs/0084-cost-graph-composite.md` -> -> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill 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) -> 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].` 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` — append `GEOMETRY_WIDTH` to the cost re-export. -- Modify: `crates/aura-composites/src/lib.rs` — imports (`:18`, `:20-22`) + new `pub fn cost_graph` (append after `:138`). -- Create: `crates/aura-composites/tests/cost_graph.rs` — the two `cost_graph` unit tests. -- Modify: `crates/aura-cli/src/main.rs` — import (`:18`), delete `MAX_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_WIDTH` from aura-std** - -In `crates/aura-std/src/lib.rs`, line 53 is currently: - -```rust -pub use cost::{cost_node_builder, CostNode, CostRunner, COST_FIELD_NAMES, COST_WIDTH}; -``` - -Replace it with (append `, GEOMETRY_WIDTH`): - -```rust -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: - -```rust -//! `cost_graph` composite-builder: it fans the 4 PM-geometry inputs to N cost -//! nodes, surfaces each node's extra inputs as `cost[k].` 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: - -```rust -use aura_core::{PrimitiveBuilder, Scalar}; -``` - -Lines 20-22 are: - -```rust -use aura_std::{ - Delay, Ema, FixedStop, LinComb, Mul, PositionManagement, Sizer, Sqrt, Sub, PM_FIELD_NAMES, -}; -``` - -Replace with (add `CostSum, COST_FIELD_NAMES, GEOMETRY_WIDTH`): - -```rust -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_graph` builder** - -Append to `crates/aura-composites/src/lib.rs` (after the current EOF, line 138): - -```rust - -/// 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].` 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].` — 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) -> 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 = - 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].` 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].` 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_graph` import** - -In `crates/aura-cli/src/main.rs`, line 18 is: - -```rust -use aura_composites::{risk_executor, risk_executor_vol_open, StopRule}; -``` - -Replace with (add `cost_graph`): - -```rust -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): - -```rust -/// 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): - -```rust -/// Interned `cost[k].` `CostSum` input-port names, built once. Same -/// … (doc continues) … -static COST_SUM_PORTS: LazyLock> = 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: - -```rust - 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: - -```rust - 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: - -```rust -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: - -```rust -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. diff --git a/docs/specs/0084-cost-graph-composite.md b/docs/specs/0084-cost-graph-composite.md deleted file mode 100644 index ae4c9d2..0000000 --- a/docs/specs/0084-cost-graph-composite.md +++ /dev/null @@ -1,290 +0,0 @@ -# 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].` 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]. (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].` 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].` 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) -> 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 = 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].` 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].` 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> = LazyLock::new(|| { /* cost[k]. 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) -> Composite`** - — new. The only deliverable node-graph builder. Asserts `n >= 1`. Exposes the - 4 geometry roles + each node's `cost[k].` 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].` 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].` 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].` 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.