Files
Aura/docs/plans/0084-cost-graph-composite.md
T
Brummel 550d5ce947 plan: 0084 cost-graph composite-builder
Two tasks: (1) the cost_graph(Vec<PrimitiveBuilder>) -> 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
2026-06-28 21:22:26 +02:00

18 KiB

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<PrimitiveBuilder>) -> 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].<port> 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:

pub use cost::{cost_node_builder, CostNode, CostRunner, COST_FIELD_NAMES, COST_WIDTH};

Replace it with (append , GEOMETRY_WIDTH):

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:

//! `cost_graph` composite-builder: it fans the 4 PM-geometry inputs to N cost
//! nodes, surfaces each node's extra inputs as `cost[k].<port>` 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:

use aura_core::{PrimitiveBuilder, Scalar};

Lines 20-22 are:

use aura_std::{
    Delay, Ema, FixedStop, LinComb, Mul, PositionManagement, Sizer, Sqrt, Sub, PM_FIELD_NAMES,
};

Replace with (add CostSum, COST_FIELD_NAMES, GEOMETRY_WIDTH):

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):


/// 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
/// (`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].<port>` — 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<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 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<String> =
            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].<port>` 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].<field>` 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:

use aura_composites::{risk_executor, risk_executor_vol_open, StopRule};

Replace with (add cost_graph):

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):

/// 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):

/// Interned `cost[k].<field>` `CostSum` input-port names, built once. Same
/// … (doc continues) …
static COST_SUM_PORTS: LazyLock<Vec<String>> = 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:

        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:

        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:

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:

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.