From 550d5ce947007dbe2564625f2c2233567f41f1d7 Mon Sep 17 00:00:00 2001 From: Brummel Date: Sun, 28 Jun 2026 21:22:26 +0200 Subject: [PATCH] plan: 0084 cost-graph composite-builder Two tasks: (1) the cost_graph(Vec) -> 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 --- docs/plans/0084-cost-graph-composite.md | 434 ++++++++++++++++++++++++ 1 file changed, 434 insertions(+) create mode 100644 docs/plans/0084-cost-graph-composite.md diff --git a/docs/plans/0084-cost-graph-composite.md b/docs/plans/0084-cost-graph-composite.md new file mode 100644 index 0000000..4ce3b41 --- /dev/null +++ b/docs/plans/0084-cost-graph-composite.md @@ -0,0 +1,434 @@ +# 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.