Files
Aura/docs/specs/0083-cost-node-trait.md
T
Brummel 16f4cbb548 spec: 0083 cost-node trait + shared cost-record contract (boss-signed)
Cycle 3 of the cost-model-graph milestone: lift the duplicated cost-node
skeleton (shared verbatim by ConstantCost/VolSlippageCost, locked by convention
against CostSum) into a CostNode factor trait + a generic CostRunner<F> adapter,
with one source of truth for the 3-field cost record. Behaviour-preserving: same
schemas, same wiring, byte-identical output; the existing suite is the regression
net. Cost-graph composite-builder deferred (decision E).

Grounding-check PASS (7/7 assumption groups against named green tests).
Fork decisions logged on the reference issue.

refs #148
2026-06-28 18:11:55 +02:00

19 KiB

CostNode trait + shared cost-record contract — Design Spec

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

Goal

Lift the duplicated cost-node skeleton — shared verbatim by ConstantCost and VolSlippageCost and locked-in by convention against CostSum — into one abstraction: a CostNode factor trait carrying the single per-node difference (the price-unit cost numerator) and a generic CostRunner<F> that owns the shared co-temporality skeleton (geometry gating, R-normalization, the closed/open charge, the running cum, the 3-field emit). One source of truth for the 3-field cost record (COST_FIELD_NAMES/COST_WIDTH) replaces the cycle-2 audit-flagged by-convention lockstep with a structural single-source contract.

Hard constraint: behaviour-preserving. Every shipped cost node emits byte-identical output; the node builders produce unchanged schemas (same port names), so the main.rs wiring, the net_r_equity seam, and summarize_r are untouched and every existing test stays green verbatim. The deliverable is an authoring-surface and a maintainability win, not a behaviour change.

This is cycle 3 of the "Cost-model graph (in R)" milestone (#148). The cost-graph composite-builder is deferred (decision E on #148).

Architecture

Three layers replace the two near-identical monolith nodes:

  1. The cost-record contract (aura-std/src/cost.rs): COST_WIDTH = 3 and COST_FIELD_NAMES = ["cost_in_r", "cum_cost_in_r", "open_cost_in_r"], plus GEOMETRY_WIDTH = 4 (the PM-geometry input prefix). Mirrors the position_management::{WIDTH, FIELD_NAMES} precedent (aura-std). Both the producer side (the runner's schema helper) and the consumer side (CostSum) read these — the lockstep is now structural.

  2. The CostNode factor trait (aura-std/src/cost.rs): the per-node hook — cost_numerator(&mut self, &Ctx) -> f64 (the round-trip cost in price units, before R-normalization and before the closed/open gate), extra_inputs() -> Vec<PortSpec> (default none), name, label. A factor is pure data + one numerator; it holds no running state.

  3. The CostRunner<F: CostNode> adapter (aura-std/src/cost.rs): a concrete generic node holding the shared mutable state (cum, out: [Cell; COST_WIDTH]) and implementing Node. Its eval is the co-temporality skeleton, written once. It gates only on the 4 PM-geometry inputs (so a not-yet-warm factor input contributes 0 cost but the row still emits, 1:1 with the PM record), divides the factor's numerator by the latched 1R distance, charges on close / would-be-charges on open, accumulates cum, and emits the triple.

ConstantCost and VolSlippageCost become thin CostNode factors; their new() returns CostRunner<Self> (a ready-to-wire Node), preserving every existing call site and unit test. A cost-node PrimitiveBuilder is assembled by a shared cost_node_builder helper that prepends the geometry inputs and appends the standard 3-field output.

Not a blanket impl. impl<T: CostNode> Node for T is rejected (decision A on #148): the runner must hold cum/out, which a trait cannot add; and the recon found zero blanket impls in crates/ — the norm is concrete types implementing Node. impl<F: CostNode> Node for CostRunner<F> is the idiomatic generic wrapper and keeps the numerator call inlinable (no vtable on the hot path).

Concrete code shapes

Author-facing: writing a new cost node is now one method

A cost-node author writes only the numerator — the entire co-temporality skeleton is the runner's. (Illustrative third node, NOT shipped this cycle; it is the worked example the acceptance criterion is judged against.)

use aura_core::Ctx;
use aura_std::{CostNode, CostRunner};

/// A flat half-spread cost per round trip, in price units.
pub struct HalfSpreadCost {
    half_spread: f64,
}

impl HalfSpreadCost {
    pub fn new(half_spread: f64) -> CostRunner<HalfSpreadCost> {
        assert!(half_spread >= 0.0, "HalfSpreadCost half_spread must be >= 0");
        CostRunner::new(HalfSpreadCost { half_spread })
    }
}

impl CostNode for HalfSpreadCost {
    fn name(&self) -> &'static str {
        "HalfSpreadCost"
    }
    fn label(&self) -> String {
        format!("HalfSpreadCost({})", self.half_spread)
    }
    fn cost_numerator(&mut self, _ctx: &Ctx<'_>) -> f64 {
        self.half_spread // price units; the runner divides by latched 1R distance
    }
}

That is the whole node: ~16 lines, one numerator method, no eval, no cum, no geometry gating, no 3-field schema. Compare the ~90-line per-node skeleton this cycle removes from each of the two shipped nodes.

The contract + trait + runner (new aura-std/src/cost.rs)

use aura_core::{
    Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
    ScalarKind,
};

/// The 3-field cost-in-R record every cost node emits, in slot order — one source
/// of truth, read by both the producer schema (`cost_node_builder`) and the
/// `CostSum` aggregator. Mirrors `position_management::{FIELD_NAMES, WIDTH}`.
pub const COST_WIDTH: usize = 3;
pub const COST_FIELD_NAMES: [&str; COST_WIDTH] =
    ["cost_in_r", "cum_cost_in_r", "open_cost_in_r"];

/// The PM-geometry input prefix every cost node gates on (the co-temporality
/// contract): `closed`, `open`, `entry_price`, `stop_price`. A factor's own extra
/// inputs are appended after these, beginning at slot `GEOMETRY_WIDTH`.
pub const GEOMETRY_WIDTH: usize = 4;

fn geometry_input_ports() -> Vec<PortSpec> {
    vec![
        PortSpec { kind: ScalarKind::Bool, firing: Firing::Any, name: "closed".into() },
        PortSpec { kind: ScalarKind::Bool, firing: Firing::Any, name: "open".into() },
        PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "entry_price".into() },
        PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "stop_price".into() },
    ]
}

fn cost_output_fields() -> Vec<FieldSpec> {
    COST_FIELD_NAMES
        .iter()
        .map(|n| FieldSpec { name: (*n).into(), kind: ScalarKind::F64 })
        .collect()
}

/// A cost factor: the per-round-trip cost in *price units* (the numerator the
/// runner divides by the latched 1R distance). This is the only thing a cost node
/// differs in; the co-temporality skeleton is `CostRunner`'s, shared.
pub trait CostNode: 'static {
    /// Static node-type name (a non-load-bearing debug symbol, C23).
    fn name(&self) -> &'static str;
    /// One-line render label, carrying the identifying param (C23).
    fn label(&self) -> String;
    /// Extra input ports beyond the 4 geometry inputs, appended at slot
    /// `GEOMETRY_WIDTH`. Default: none (a stateless cost like a flat fee).
    fn extra_inputs(&self) -> Vec<PortSpec> {
        Vec::new()
    }
    /// The round-trip cost in price units this cycle, BEFORE R-normalization and
    /// BEFORE the closed/open gate. Reads its extra inputs from `ctx` at
    /// `GEOMETRY_WIDTH + i`; an empty window during warm-up means the factor
    /// contributes 0 (the runner still emits the row — co-temporality).
    fn cost_numerator(&mut self, ctx: &Ctx<'_>) -> f64;
}

/// The shared co-temporality skeleton, wrapping any [`CostNode`] factor into a
/// `Node`. Holds the only running state a cost node needs — the `cum` and the
/// output buffer — so a factor impl stays pure. One home for the contract that
/// was duplicated across `ConstantCost`/`VolSlippageCost` and locked by convention
/// against `CostSum`.
pub struct CostRunner<F: CostNode> {
    factor: F,
    cum: f64,
    out: [Cell; COST_WIDTH],
}

impl<F: CostNode> CostRunner<F> {
    pub fn new(factor: F) -> Self {
        Self { factor, cum: 0.0, out: [Cell::from_f64(0.0); COST_WIDTH] }
    }
}

impl<F: CostNode> Node for CostRunner<F> {
    fn lookbacks(&self) -> Vec<usize> {
        vec![1; GEOMETRY_WIDTH + self.factor.extra_inputs().len()]
    }

    fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
        // Gate ONLY on the PM geometry (co-temporality contract): the cost stream
        // stays 1:1 with the executor's PM record. A factor's own not-yet-warm
        // input contributes 0 (handled in `cost_numerator`), it does not withhold.
        let closed_w = ctx.bool_in(0);
        let open_w = ctx.bool_in(1);
        let entry_w = ctx.f64_in(2);
        let stop_w = ctx.f64_in(3);
        if closed_w.is_empty() || open_w.is_empty() || entry_w.is_empty() || stop_w.is_empty() {
            return None;
        }
        let closed = closed_w[0];
        let open = open_w[0];
        let latched = (entry_w[0] - stop_w[0]).abs();
        let numerator = self.factor.cost_numerator(&ctx);
        // Zero latched distance = no valid 1R denominator -> no cost (matches
        // summarize_r's guard). The `numerator / latched` token form is preserved
        // verbatim from the pre-migration nodes for byte-identity (IEEE-754).
        let per = if latched > 0.0 { numerator / latched } else { 0.0 };
        let cost_in_r = if closed { per } else { 0.0 };
        let open_cost_in_r = if open { per } else { 0.0 };
        self.cum += cost_in_r;
        self.out = [
            Cell::from_f64(cost_in_r),
            Cell::from_f64(self.cum),
            Cell::from_f64(open_cost_in_r),
        ];
        Some(&self.out)
    }

    fn label(&self) -> String {
        self.factor.label()
    }
}

/// Assemble a cost-node `PrimitiveBuilder`: the 4 geometry inputs ++ the factor's
/// extra inputs, the standard 3-field cost output, the given params, and a build
/// closure. The single home for the cost-node schema shape.
pub fn cost_node_builder(
    name: &'static str,
    extra_inputs: Vec<PortSpec>,
    params: Vec<ParamSpec>,
    build: impl Fn(&[Cell]) -> Box<dyn Node> + 'static,
) -> PrimitiveBuilder {
    let mut inputs = geometry_input_ports();
    inputs.extend(extra_inputs);
    PrimitiveBuilder::new(name, NodeSchema { inputs, output: cost_output_fields(), params }, build)
}

Before → after: ConstantCost (the stateless factor)

// AFTER — aura-std/src/constant_cost.rs (skeleton removed; factor + thin new/builder)
use aura_core::{Cell, Ctx, ParamSpec, PrimitiveBuilder, ScalarKind};
use crate::cost::{cost_node_builder, CostNode, CostRunner};

pub struct ConstantCost {
    cost_per_trade: f64,
}

impl ConstantCost {
    /// A flat per-trade cost node (the factor wrapped in the shared runner).
    pub fn new(cost_per_trade: f64) -> CostRunner<ConstantCost> {
        assert!(cost_per_trade >= 0.0, "ConstantCost cost_per_trade must be >= 0");
        CostRunner::new(ConstantCost { cost_per_trade })
    }

    pub fn builder() -> PrimitiveBuilder {
        cost_node_builder(
            "ConstantCost",
            Vec::new(), // no extra inputs beyond geometry
            vec![ParamSpec { name: "cost_per_trade".into(), kind: ScalarKind::F64 }],
            |p| Box::new(ConstantCost::new(p[0].f64())),
        )
    }
}

impl CostNode for ConstantCost {
    fn name(&self) -> &'static str {
        "ConstantCost"
    }
    fn label(&self) -> String {
        format!("ConstantCost({})", self.cost_per_trade)
    }
    fn cost_numerator(&mut self, _ctx: &Ctx<'_>) -> f64 {
        self.cost_per_trade
    }
}

The existing ConstantCost unit-test module is kept unchanged: every test calls ConstantCost::new(x) (now a CostRunner<ConstantCost>) and .eval(...), which produces byte-identical output; new_panics_on_negative_cost still panics in new. The tests become the migration's verbatim regression guard.

Before → after: VolSlippageCost (the state-dependent factor)

// AFTER — aura-std/src/vol_slippage_cost.rs
use crate::cost::{cost_node_builder, CostNode, CostRunner, GEOMETRY_WIDTH};

pub struct VolSlippageCost {
    slip_vol_mult: f64,
}

impl VolSlippageCost {
    pub fn new(slip_vol_mult: f64) -> CostRunner<VolSlippageCost> {
        assert!(slip_vol_mult >= 0.0, "VolSlippageCost slip_vol_mult must be >= 0");
        CostRunner::new(VolSlippageCost { slip_vol_mult })
    }

    pub fn builder() -> PrimitiveBuilder {
        cost_node_builder(
            "VolSlippageCost",
            vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "volatility".into() }],
            vec![ParamSpec { name: "slip_vol_mult".into(), kind: ScalarKind::F64 }],
            |p| Box::new(VolSlippageCost::new(p[0].f64())),
        )
    }
}

impl CostNode for VolSlippageCost {
    fn name(&self) -> &'static str {
        "VolSlippageCost"
    }
    fn label(&self) -> String {
        format!("VolSlippageCost({})", self.slip_vol_mult)
    }
    fn extra_inputs(&self) -> Vec<PortSpec> {
        vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "volatility".into() }]
    }
    fn cost_numerator(&mut self, ctx: &Ctx<'_>) -> f64 {
        // Extra input slot 0 (after the 4 geometry inputs).
        let vol_w = ctx.f64_in(GEOMETRY_WIDTH);
        let vol = if vol_w.is_empty() { 0.0 } else { vol_w[0] }; // 0 during proxy warm-up
        self.slip_vol_mult * vol
    }
}

Byte-identity: the runner computes numerator / latched. For ConstantCost, numerator = cost_per_trade, giving cost_per_trade / latched (identical to the original). For VolSlippageCost, numerator = slip_vol_mult * vol, giving (slip_vol_mult * vol) / latched — left-associative-identical to the original slip_vol_mult * vol / latched. The intermediate multiply rounds to the same f64 whether stored or inlined, so the divide sees the same bits.

extra_inputs() is declared in both the trait impl and consulted by builder(); the duplication between builder()'s volatility PortSpec and extra_inputs() is acceptable (the builder is the param-generic recipe, the trait method is the runtime lookback driver) — alternatively builder() may call Self::new(0.0) .extra_inputs()-style, left to the planner if it reads cleaner.

Before → after: CostSum reads the shared contract

// AFTER — aura-std/src/cost_sum.rs (local const removed, shared contract used)
use crate::cost::{COST_FIELD_NAMES, COST_WIDTH};
// ...the local `const COST_FIELDS` / `const COST_WIDTH` are deleted; every prior
// reference to `COST_FIELDS` now reads `COST_FIELD_NAMES`. Behaviour and the
// `cost[k].<field>` port names are unchanged (identical string values).

main.rs: the third triple duplicate is unified (behaviour-neutral)

aura-cli/src/main.rs defines its own const COST_FIELDS: [&str; 3] (driving COST_SUM_PORTS interning and the cost-block wiring loops). It is replaced by the re-exported aura_std::COST_FIELD_NAMES (identical values), removing the third copy of the triple. The wiring, port names, and the C18 golden are unchanged.

Components

  • aura-std/src/cost.rs (new): COST_WIDTH, COST_FIELD_NAMES, GEOMETRY_WIDTH, geometry_input_ports, cost_output_fields, the CostNode trait, CostRunner<F>, cost_node_builder. Owns the co-temporality contract and its unit tests (via a test-only stub factor).
  • aura-std/src/lib.rs: mod cost; + pub use cost::{CostNode, CostRunner, COST_FIELD_NAMES, COST_WIDTH}; (and GEOMETRY_WIDTH if a downstream test needs it). cost_node_builder stays pub for cross-module node authors.
  • aura-std/src/constant_cost.rs: stripped to a CostNode factor + thin new/builder; unit tests unchanged.
  • aura-std/src/vol_slippage_cost.rs: same; unit tests unchanged.
  • aura-std/src/cost_sum.rs: local triple const removed, reads crate::cost::{COST_FIELD_NAMES, COST_WIDTH}; tests unchanged.
  • aura-cli/src/main.rs: local COST_FIELDS const replaced by aura_std::COST_FIELD_NAMES; wiring unchanged.

No changes to aura-engine, aura-analysis, or the composites crate (they consume the unchanged schemas). The aura-engine E2E helpers call ConstantCost::new(c) / VolSlippageCost::new(k) without a type annotation, so they bind the CostRunner<_> return transparently and keep passing.

Data flow

Unchanged from cycle 2. Per cycle: the executor (exec) emits PM geometry (closed_this_cycle, open, entry_price, stop_price); each cost node's CostRunner reads those 4 (slots 0-3) plus its factor's extra inputs (slot 4+, e.g. volatility from the vol proxy); it emits the 3-field cost record; CostSum sums N records per-field; summarize_r and the net_r_equity LinComb read the aggregate. The graph topology, the edges, and the recorded traces are identical.

Error handling

  • Negative paramassert! panic in each node's new (unchanged messages, unchanged tests).
  • Zero latched distance → 0 cost (the runner's guard, preserved verbatim).
  • Factor input not warm → 0 numerator, row still emitted (the co-temporality contract, now enforced once in the runner rather than per-node).
  • CostSum leg missingNone (unchanged; mode-A as-of join).
  • GEOMETRY_WIDTH vs geometry_input_ports().len() drift → guarded by a unit test (geometry_width_matches_port_count).

Testing strategy

RED-first where a new assertion is introduced; the migration's safety net is the existing suite staying green verbatim.

  1. Regression net (unchanged, must stay green): all ConstantCost, VolSlippageCost, CostSum unit tests; the aura-engine composition E2E (cost_sum_composes_constant_and_vol_slippage_exactly, aggregate_net_r_equity_final_sample_agrees_with_summarize_r_net_total, cost_sum_of_one_is_identity_for_vol_slippage); the aura-cli C18 golden (stage1_r_single_run_output_golden) byte-identical; the both-costs-compose CLI test. These prove behaviour preservation.
  2. New — cost.rs runner skeleton tests (via a test-only StubCost(f64) factor and a StubExtra factor with one extra input): withholds on missing geometry; charges numerator/latched on close; would-be cost on open not charged to cum; cum accumulates across closes; zero-latched → 0; a factor whose extra input is empty contributes 0 but the row still emits.
  3. New — structural-lockstep guard: assert ConstantCost::builder().schema().output field names == COST_FIELD_NAMES, and that CostSum's per-cost input field stems derive from COST_FIELD_NAMES — proving producer and consumer read one source.
  4. New — geometry_width_matches_port_count: GEOMETRY_WIDTH == geometry_input_ports().len().
  5. Doc-example compile: the HalfSpreadCost author example compiles (a cost.rs doctest or a tests/ fixture), proving the author surface is real.

Acceptance criteria

  • The codebase improves measurably (removes ~90 lines of duplicated skeleton per shipped node and three copies of the 3-field triple, collapsed to one source) — the C10 "one home for cost" spirit, made structural.
  • A new cost node is authored as impl CostNode with one cost_numerator method (the HalfSpreadCost worked example), no eval/cum/schema boilerplate.
  • No failure class is reintroduced: the cost stream stays co-temporal with the PM record (the contract is now enforced once, in the runner), determinism and causality are untouched, and every existing test passes verbatim — the C18 golden byte-identical, the composition identity exact.
  • cargo build --workspace, cargo test --workspace, and cargo clippy --workspace --all-targets -- -D warnings are clean.