Files
Aura/docs/specs/0084-cost-graph-composite.md
T
Brummel 9fb3d8b8ee spec: 0084 cost-graph composite-builder (boss-signed)
Cycle 4 of milestone #148 (Cost-model graph in R), decision E — the cost-graph
composite-builder, recorded across cycles 2-3 as the natural next.

Adds a `cost_graph(Vec<PrimitiveBuilder>) -> Composite` builder in aura-composites
that fans the 4 PM-geometry inputs to N cost nodes, surfaces each node's extra
inputs as `cost[k].<port>` roles (schema introspection past GEOMETRY_WIDTH),
sums them via CostSum, and exposes the 3-field aggregate. Replaces the CLI's
manual slot-indexed cost-wiring + the hardcoded MAX_RUN_COST_NODES=2 cap with
one principled composite of arbitrary arity. Re-exports GEOMETRY_WIDTH from
aura-std (first cross-crate consumer). Behaviour-preserving at the value level:
the composite inlines at bootstrap (C11) to the same flat computation, so the
two cycle-3 net_expectancy_r goldens, the C18 no-cost golden, and the full suite
stay green verbatim.

Routed spec-driven (specify direct-entry): settled in #148's scope + the mature
aura-composites idiom + the CostNode contract. All four forks (composite API
shape, extra-input namespacing, GEOMETRY_WIDTH visibility, CLI consumption) are
derived decisions logged on #148; none hangs on pure user preference.

Auto-signed under /boss on the Step-5 grounding-check PASS (every load-bearing
current-behaviour assumption tied to a named green test or current source; no
with-cost graph label/shape pin contradicts the C23-permitted label drift).

refs #148
2026-06-28 21:10:07 +02:00

14 KiB
Raw Blame History

Cost-graph composite-builder — Design Spec

Date: 2026-06-28 Status: Draft — awaiting user spec review Authors: orchestrator + Claude

Cycle 4 of milestone Cost-model graph (in R) (#148). Parent rework: ledger C10 (29cdc8c, #116). Decision E ("cost-graph composite-builder"), recorded across cycles 23 as the natural next. Fork decisions logged on #148.

Goal

Give the cost-model graph a real authoring primitive: a cost_graph(...) composite-builder in aura-composites that takes any number of cost nodes and wires them through CostSum into the single 3-field cost-in-R stream the net-R seam already consumes. This replaces the CLI's manual, slot-indexed cost-wiring block (CostSum::builder(n) + a slot counter + COST_SUM_PORTS[slot * COST_WIDTH + f] + a hardcoded MAX_RUN_COST_NODES = 2 cap) with one principled composite that handles arbitrary arity. Behaviour-preserving: the composite inlines at bootstrap (C11) to the same flat computation, so every value output is byte-identical.

This is the aura-composites charter realised for cost: the crate is "the one place where the engine's builder and the standard nodes are wired together" (its module doc), and cost-node fan-in is exactly such wiring. It is not a new cost behaviour, a new node, or a new CLI surface — it is the composition layer the milestone's title ("composable cost nodes") names.

Architecture

A cost-model graph is, structurally, N cost nodes → CostSum → one 3-field output. Today that fan-in lives inline in aura-cli::stage1_r_graph, wired by hand against the interned cost[k].<field> slot names and capped at two nodes. This cycle lifts that fan-in into a composite:

cost_graph(cost_nodes) : Composite
  open input roles : closed, open, entry_price, stop_price   (the 4 geometry inputs, fanned to every cost node)
                     cost[k].<port>                          (each cost node's extra inputs, namespaced by index)
  internal         : cost_node[0] ┐
                     cost_node[1] ┤→ CostSum(n)
                     …            ┘
  exposed output   : cost_in_r, cum_cost_in_r, open_cost_in_r  (CostSum's aggregate, = COST_FIELD_NAMES)

The composite owns only the summation wiring. State a cost node depends on but does not own — the realized-vol proxy feeding VolSlippageCost — stays outside the composite (it is shared with the stop rule), surfaced as a cost[k].volatility input role the caller feeds. This keeps the composite the cost-summation layer, nothing more.

Heterogeneity is handled by schema introspection, not a closure: every cost node is a PrimitiveBuilder (built via cost_node_builder), and the builder contract guarantees the 4 geometry ports first, then the factor's extra ports (slot GEOMETRY_WIDTH onward — tested by cost_node_builder_assembles_geometry_prefix_then_extras). So cost_graph reads each node's schema().inputs[GEOMETRY_WIDTH..] to discover its extras and exposes them as cost[k].<port> roles. (risk_executor's closure pattern is unnecessary here: its stop arms are heterogeneous constructs, a primitive vs a composite; cost nodes are uniformly primitives.)

Concrete code shapes

The worked author / consumer example (the acceptance evidence)

The program a cost-model author writes — the CLI's stage1_r_graph is the first consumer, and it is exactly the code a project author composing a cost model would write:

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

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

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

// 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-exportGEOMETRY_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 adds (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.