Files
Aura/docs/plans/0083-cost-node-trait.md
T
Brummel c787488e64 plan: 0083 cost-node trait + shared cost-record contract
6 tasks: (1) new cost.rs — CostNode trait + CostRunner<F> + cost_node_builder +
shared COST_FIELD_NAMES/COST_WIDTH/GEOMETRY_WIDTH + runner tests + author doctest;
(2-3) migrate ConstantCost/VolSlippageCost to thin factors; (4) CostSum reads the
shared contract; (5) main.rs reads aura_std::COST_FIELD_NAMES; (6) workspace gate.
Behaviour-preserving — the existing suite is the regression net.

refs #148
2026-06-28 18:21:12 +02:00

28 KiB

CostNode trait + shared cost-record contract — Implementation Plan

Parent spec: docs/specs/0083-cost-node-trait.md

For agentic workers: REQUIRED SUB-SKILL: use the implement skill to run this plan. Steps use - [ ] checkboxes for tracking.

Goal: Lift the duplicated cost-node skeleton into a CostNode factor trait + a generic CostRunner<F> adapter with one source of truth for the 3-field cost record, migrating both shipped cost nodes — behaviour-preserving (byte-identical output, unchanged schemas/wiring).

Architecture: A new aura-std/src/cost.rs owns the contract (COST_WIDTH, COST_FIELD_NAMES, GEOMETRY_WIDTH), the CostNode trait (one hook: cost_numerator), the CostRunner<F> node (holds cum/out, the co-temporality skeleton), and a cost_node_builder schema assembler. ConstantCost and VolSlippageCost become thin factors whose new() returns CostRunner<Self>. CostSum and main.rs drop their local triple consts for the shared source.

Tech Stack: aura-std (node lib), aura-core (Node/Ctx/Cell/PrimitiveBuilder), aura-cli (the cost-block wiring). No new dependencies.

Files this plan creates or modifies:

  • Create: crates/aura-std/src/cost.rs — contract + CostNode + CostRunner<F> + cost_node_builder + tests + author doctest
  • Modify: crates/aura-std/src/lib.rs:18-76mod cost; + pub use re-exports
  • Modify: crates/aura-std/src/constant_cost.rs:1-89 — strip to a CostNode factor (tests at 91-189 kept; test use line gains Cell)
  • Modify: crates/aura-std/src/vol_slippage_cost.rs:1-100 — strip to a CostNode factor (tests at 102-222 kept; test use line gains Cell)
  • Modify: crates/aura-std/src/cost_sum.rs:9-75 — read shared COST_FIELD_NAMES/COST_WIDTH, drop local consts
  • Modify: crates/aura-cli/src/main.rs:31-34,2580,2589,2765,2779 — read aura_std::COST_FIELD_NAMES, drop local const

Task 1: The cost-record contract, trait, runner, and builder (new cost.rs)

Files:

  • Create: crates/aura-std/src/cost.rs

  • Modify: crates/aura-std/src/lib.rs:18-76

  • Step 1: Write crates/aura-std/src/cost.rs in full

//! The cost-model-graph node contract (C10): the `CostNode` factor trait and the
//! `CostRunner<F>` adapter that wraps a factor into an engine `Node`.
//!
//! A cost node's only per-node difference is the **price-unit cost numerator** the
//! runner divides by the latched 1R distance. Everything else — gating on the PM
//! geometry (the co-temporality contract: the cost stream stays 1:1 with the
//! executor's record), the closed/open charge, the running `cum`, the 3-field
//! emit — is the runner's, written once. The 3-field cost record
//! (`COST_FIELD_NAMES`) is one source of truth, read by both the producer side
//! (`cost_node_builder`) and the `CostSum` aggregator; this mirrors the
//! `position_management::{FIELD_NAMES, WIDTH}` precedent and replaces what was a
//! by-convention triple lockstep.

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 for the producer schema and the `CostSum` aggregator.
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 (`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). The only thing a cost node differs
/// in; the co-temporality skeleton is [`CostRunner`]'s, shared.
///
/// # Authoring a cost node
///
/// ```
/// use aura_core::Ctx;
/// use aura_std::{CostNode, CostRunner};
///
/// 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 the latched 1R distance
///     }
/// }
///
/// let _node = HalfSpreadCost::new(0.5); // a ready-to-wire cost node
/// ```
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.
    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 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 — `cum` and the output
/// buffer — so a factor impl stays pure.
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): the cost stream stays 1:1
        // with the executor's record. A factor's not-yet-warm input contributes 0
        // (handled in `cost_numerator`), it does not withhold the row.
        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. 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)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{ConstantCost, CostSum};
    use aura_core::{AnyColumn, Scalar, Timestamp};

    /// A test-only factor: a constant numerator, no extra inputs.
    struct StubCost(f64);
    impl CostNode for StubCost {
        fn name(&self) -> &'static str {
            "StubCost"
        }
        fn label(&self) -> String {
            format!("StubCost({})", self.0)
        }
        fn cost_numerator(&mut self, _ctx: &Ctx<'_>) -> f64 {
            self.0
        }
    }

    /// A test-only factor with one extra f64 input, read at `GEOMETRY_WIDTH`.
    struct StubExtra(f64);
    impl CostNode for StubExtra {
        fn name(&self) -> &'static str {
            "StubExtra"
        }
        fn label(&self) -> String {
            "StubExtra".into()
        }
        fn extra_inputs(&self) -> Vec<PortSpec> {
            vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "x".into() }]
        }
        fn cost_numerator(&mut self, ctx: &Ctx<'_>) -> f64 {
            let w = ctx.f64_in(GEOMETRY_WIDTH);
            let x = if w.is_empty() { 0.0 } else { w[0] };
            self.0 * x
        }
    }

    fn geom_cols() -> Vec<AnyColumn> {
        vec![
            AnyColumn::with_capacity(ScalarKind::Bool, 1), // closed
            AnyColumn::with_capacity(ScalarKind::Bool, 1), // open
            AnyColumn::with_capacity(ScalarKind::F64, 1),  // entry
            AnyColumn::with_capacity(ScalarKind::F64, 1),  // stop
        ]
    }

    #[test]
    fn withholds_until_geometry_present() {
        let mut r = CostRunner::new(StubCost(2.0));
        let inputs = geom_cols(); // empty
        assert_eq!(r.eval(Ctx::new(&inputs, Timestamp(0))), None);
    }

    #[test]
    fn charges_numerator_over_latched_on_close() {
        let mut r = CostRunner::new(StubCost(2.0));
        let mut inputs = geom_cols();
        inputs[0].push(Scalar::bool(true)).unwrap();
        inputs[1].push(Scalar::bool(false)).unwrap();
        inputs[2].push(Scalar::f64(100.0)).unwrap();
        inputs[3].push(Scalar::f64(96.0)).unwrap(); // latched 4 -> 2/4 = 0.5
        assert_eq!(
            r.eval(Ctx::new(&inputs, Timestamp(0))),
            Some([Cell::from_f64(0.5), Cell::from_f64(0.5), Cell::from_f64(0.0)].as_slice())
        );
    }

    #[test]
    fn open_emits_would_be_cost_not_in_cum() {
        let mut r = CostRunner::new(StubCost(2.0));
        let mut inputs = geom_cols();
        inputs[0].push(Scalar::bool(false)).unwrap();
        inputs[1].push(Scalar::bool(true)).unwrap(); // open
        inputs[2].push(Scalar::f64(100.0)).unwrap();
        inputs[3].push(Scalar::f64(96.0)).unwrap();
        assert_eq!(
            r.eval(Ctx::new(&inputs, Timestamp(0))),
            Some([Cell::from_f64(0.0), Cell::from_f64(0.0), Cell::from_f64(0.5)].as_slice())
        );
    }

    #[test]
    fn zero_latched_no_cost() {
        let mut r = CostRunner::new(StubCost(2.0));
        let mut inputs = geom_cols();
        inputs[0].push(Scalar::bool(true)).unwrap();
        inputs[1].push(Scalar::bool(false)).unwrap();
        inputs[2].push(Scalar::f64(100.0)).unwrap();
        inputs[3].push(Scalar::f64(100.0)).unwrap(); // latched 0
        assert_eq!(
            r.eval(Ctx::new(&inputs, Timestamp(0))),
            Some([Cell::from_f64(0.0), Cell::from_f64(0.0), Cell::from_f64(0.0)].as_slice())
        );
    }

    #[test]
    fn cum_accumulates() {
        let mut r = CostRunner::new(StubCost(2.0));
        let mut a = geom_cols();
        a[0].push(Scalar::bool(true)).unwrap();
        a[1].push(Scalar::bool(false)).unwrap();
        a[2].push(Scalar::f64(100.0)).unwrap();
        a[3].push(Scalar::f64(96.0)).unwrap(); // 0.5
        let _ = r.eval(Ctx::new(&a, Timestamp(0)));
        let mut b = geom_cols();
        b[0].push(Scalar::bool(true)).unwrap();
        b[1].push(Scalar::bool(false)).unwrap();
        b[2].push(Scalar::f64(100.0)).unwrap();
        b[3].push(Scalar::f64(98.0)).unwrap(); // latched 2 -> 1.0; cum 1.5
        assert_eq!(
            r.eval(Ctx::new(&b, Timestamp(1))),
            Some([Cell::from_f64(1.0), Cell::from_f64(1.5), Cell::from_f64(0.0)].as_slice())
        );
    }

    #[test]
    fn extra_input_cold_contributes_zero_but_row_emits() {
        // Co-temporality: a not-yet-warm factor input -> 0 cost, but a row IS emitted.
        let mut r = CostRunner::new(StubExtra(0.5));
        let mut inputs = geom_cols();
        inputs.push(AnyColumn::with_capacity(ScalarKind::F64, 1)); // x, empty
        inputs[0].push(Scalar::bool(true)).unwrap();
        inputs[1].push(Scalar::bool(false)).unwrap();
        inputs[2].push(Scalar::f64(100.0)).unwrap();
        inputs[3].push(Scalar::f64(96.0)).unwrap();
        assert_eq!(
            r.eval(Ctx::new(&inputs, Timestamp(0))),
            Some([Cell::from_f64(0.0), Cell::from_f64(0.0), Cell::from_f64(0.0)].as_slice())
        );
    }

    #[test]
    fn extra_input_warm_scales_numerator() {
        let mut r = CostRunner::new(StubExtra(0.5));
        let mut inputs = geom_cols();
        inputs.push(AnyColumn::with_capacity(ScalarKind::F64, 1));
        inputs[0].push(Scalar::bool(true)).unwrap();
        inputs[1].push(Scalar::bool(false)).unwrap();
        inputs[2].push(Scalar::f64(100.0)).unwrap();
        inputs[3].push(Scalar::f64(96.0)).unwrap(); // latched 4
        inputs[4].push(Scalar::f64(3.0)).unwrap(); // x=3 -> 0.5*3=1.5 -> 1.5/4 = 0.375
        assert_eq!(
            r.eval(Ctx::new(&inputs, Timestamp(0))),
            Some([Cell::from_f64(0.375), Cell::from_f64(0.375), Cell::from_f64(0.0)].as_slice())
        );
    }

    #[test]
    fn lookbacks_count_geometry_plus_extra() {
        assert_eq!(CostRunner::new(StubCost(1.0)).lookbacks(), vec![1; GEOMETRY_WIDTH]);
        assert_eq!(CostRunner::new(StubExtra(1.0)).lookbacks(), vec![1; GEOMETRY_WIDTH + 1]);
    }

    #[test]
    fn runner_label_delegates_to_factor() {
        assert_eq!(CostRunner::new(StubCost(2.0)).label(), "StubCost(2)");
    }

    #[test]
    fn geometry_width_matches_port_count() {
        assert_eq!(GEOMETRY_WIDTH, geometry_input_ports().len());
    }

    #[test]
    fn cost_output_fields_are_the_triple() {
        let names: Vec<String> = cost_output_fields().into_iter().map(|f| f.name).collect();
        assert_eq!(names, COST_FIELD_NAMES.to_vec());
    }

    #[test]
    fn producer_and_aggregator_share_the_triple() {
        // The structural lockstep: producer output and aggregator output/inputs all
        // read COST_FIELD_NAMES (one source), replacing the by-convention triple.
        let prod: Vec<String> =
            ConstantCost::builder().schema().output.iter().map(|f| f.name.clone()).collect();
        assert_eq!(prod, COST_FIELD_NAMES.to_vec());
        let agg_out: Vec<String> =
            CostSum::builder(1).schema().output.iter().map(|f| f.name.clone()).collect();
        assert_eq!(agg_out, COST_FIELD_NAMES.to_vec());
        let agg_in: Vec<String> =
            CostSum::builder(1).schema().inputs.iter().map(|p| p.name.clone()).collect();
        let expected: Vec<String> =
            COST_FIELD_NAMES.iter().map(|f| format!("cost[0].{f}")).collect();
        assert_eq!(agg_in, expected);
    }
}
  • Step 2: Wire the module into crates/aura-std/src/lib.rs

Insert mod cost; between mod constant_cost; (line 21) and mod cost_sum; (line 22):

mod constant_cost;
mod cost;
mod cost_sum;

Insert the re-export between pub use constant_cost::ConstantCost; (line 49) and pub use cost_sum::CostSum; (line 50):

pub use constant_cost::ConstantCost;
pub use cost::{cost_node_builder, CostNode, CostRunner, COST_FIELD_NAMES, COST_WIDTH};
pub use cost_sum::CostSum;

(GEOMETRY_WIDTH is NOT re-exported — it is consumed intra-crate only, via crate::cost::GEOMETRY_WIDTH.)

  • Step 3: Build and run the new module's tests

Run: cargo test -p aura-std Expected: PASS — all existing aura-std tests still green, plus the new cost::tests (13 tests incl. producer_and_aggregator_share_the_triple, geometry_width_matches_port_count) and the CostNode doctest (HalfSpreadCost).

  • Step 4: Clippy-clean the new file

Run: cargo clippy -p aura-std --all-targets -- -D warnings Expected: clean (no unused imports; every use aura_core::{...} symbol in cost.rs is used).


Task 2: Migrate ConstantCost to a CostNode factor

Files:

  • Modify: crates/aura-std/src/constant_cost.rs:1-89 (replace), :103 (test use line)

  • Step 1: Replace lines 1-89 (module doc through impl Node) with the factor

//! `ConstantCost` — a flat round-trip cost charged once per closed trade, in R.
//! The simplest cost node of the C10 cost-model graph: a stateless [`CostNode`]
//! factor whose price-unit numerator is a flat `cost_per_trade`. The shared
//! [`CostRunner`] supplies the co-temporality skeleton (geometry gating, the
//! `cost_per_trade / |entry - stop|` R-normalization, the closed/open charge, the
//! running `cum`, the 3-field emit). R-pure: notional cancels (C10).

use aura_core::{Ctx, ParamSpec, PrimitiveBuilder, ScalarKind};

use crate::cost::{cost_node_builder, CostNode, CostRunner};

/// A flat per-trade cost in price units (`cost_per_trade`), emitted in R via the
/// shared [`CostRunner`].
pub struct ConstantCost {
    cost_per_trade: f64,
}

impl ConstantCost {
    /// A flat per-trade cost node: the factor wrapped in the shared [`CostRunner`].
    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 })
    }

    /// The param-generic recipe: one `cost_per_trade` F64 knob, no extra inputs.
    pub fn builder() -> PrimitiveBuilder {
        cost_node_builder(
            "ConstantCost",
            Vec::new(),
            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
    }
}
  • Step 2: Amend the test module's use line (now line ~63 after the rewrite)

The kept test bodies use Cell::from_f64, which previously reached them via use super::* from the parent's Cell import (now removed — the non-test code no longer names Cell). Add Cell to the test module's import:

Change use aura_core::{AnyColumn, Scalar, Timestamp}; to use aura_core::{AnyColumn, Cell, Scalar, Timestamp};

Everything else in the #[cfg(test)] mod tests block stays verbatim.

  • Step 3: Build and run the (unchanged) ConstantCost tests through the runner

Run: cargo test -p aura-std Expected: PASS — the 7 existing constant_cost tests pass verbatim (now exercising CostRunner<ConstantCost>), byte-identical output (0.5, 1.5), the negative-param panic still fires in new.

  • Step 4: Clippy-clean

Run: cargo clippy -p aura-std --all-targets -- -D warnings Expected: clean.


Task 3: Migrate VolSlippageCost to a CostNode factor

Files:

  • Modify: crates/aura-std/src/vol_slippage_cost.rs:1-100 (replace), :105 (test use line)

  • Step 1: Replace lines 1-100 (module doc through impl Node) with the factor

//! `VolSlippageCost` — a slippage cost that scales with a measured volatility
//! input, charged once per closed trade, in R. The first *state-dependent*
//! [`CostNode`] factor: its price-unit numerator is `slip_vol_mult · volatility`
//! instead of a flat constant, so the cost-in-R varies trade-to-trade. The vol is
//! supplied as an extra input (an upstream realized-range estimator), kept
//! independent of the stop's own vol — scaling by the stop's vol would collapse
//! cost-in-R to a constant. R-pure: `slip_vol_mult · vol / |entry - stop|` (C10).
//!
//! Co-temporality is the shared [`CostRunner`]'s contract: it gates only on the PM
//! geometry, so a not-yet-warm `volatility` input makes this factor's numerator 0
//! that cycle (handled below) rather than withholding and desyncing the stream.

use aura_core::{Ctx, Firing, ParamSpec, PortSpec, PrimitiveBuilder, ScalarKind};

use crate::cost::{cost_node_builder, CostNode, CostRunner, GEOMETRY_WIDTH};

/// A volatility-scaled per-trade slippage, emitted in R via the shared
/// [`CostRunner`]. One extra input beyond the geometry: a `volatility` stream
/// (price units), read at slot `GEOMETRY_WIDTH`.
pub struct VolSlippageCost {
    slip_vol_mult: f64,
}

impl VolSlippageCost {
    /// A volatility-scaled slippage cost node (the factor wrapped in the runner).
    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 })
    }

    /// The param-generic recipe: one `slip_vol_mult` F64 knob; one extra
    /// `volatility` input appended after the geometry.
    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
    }
}
  • Step 2: Amend the test module's use line

Change use aura_core::{AnyColumn, Scalar, Timestamp}; to use aura_core::{AnyColumn, Cell, Scalar, Timestamp};

(The kept test bodies — including vol_not_yet_warm_emits_zero_cost_co_temporally — use Cell::from_f64, previously reached via use super::*.) Everything else in the test block stays verbatim.

  • Step 3: Build and run the (unchanged) VolSlippageCost tests through the runner

Run: cargo test -p aura-std Expected: PASS — the 8 existing vol_slippage_cost tests pass verbatim, byte-identical output (0.375, 1.375), the co-temporality 0-cost-row test green, the negative-param panic fires.

  • Step 4: Clippy-clean

Run: cargo clippy -p aura-std --all-targets -- -D warnings Expected: clean.


Task 4: CostSum reads the shared cost-record contract

Files:

  • Modify: crates/aura-std/src/cost_sum.rs:9-75

  • Step 1: Add the shared-contract import after the aura_core use block (line 11)

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

use crate::cost::{COST_FIELD_NAMES, COST_WIDTH};
  • Step 2: Delete the local triple consts (lines 19-20) and update the doc comment

Remove:

const COST_FIELDS: [&str; 3] = ["cost_in_r", "cum_cost_in_r", "open_cost_in_r"];
const COST_WIDTH: usize = COST_FIELDS.len();

Replace the doc comment above them (lines 13-18) so it points at the shared source rather than declaring the triple locally:

/// The cost-record field triple and its width come from the shared cost contract
/// (`crate::cost::{COST_FIELD_NAMES, COST_WIDTH}`) — one source of truth, read by
/// both the producer side (`cost_node_builder`) and this aggregator. The input-name
/// loop, the output schema, the lookback vector, and the eval accumulator all read
/// it, so the producer↔aggregator field match is structural, not by-convention.
  • Step 3: Rewrite the COST_FIELDS references to COST_FIELD_NAMES

In builder (line 43): for field in COST_FIELDSfor field in COST_FIELD_NAMES. In builder's output (line 55): output: COST_FIELDSoutput: COST_FIELD_NAMES. In the eval-accumulator comment (line 72): COST_FIELDS orderCOST_FIELD_NAMES order.

All COST_WIDTH references (struct field line 28, new line 34, builder capacity line 41, lookbacks line 68, eval line 72, ctx.f64_in(k * COST_WIDTH + f) line 75) stay as written — they now resolve to the imported COST_WIDTH.

  • Step 4: Build and run the (unchanged) CostSum tests

Run: cargo test -p aura-std Expected: PASS — the 6 existing cost_sum tests pass verbatim (they assert literal port names cost[0].cost_in_r etc., unchanged); input_slots_are_named_cost_index_field green; the whole aura-std suite green.

  • Step 5: Clippy-clean

Run: cargo clippy -p aura-std --all-targets -- -D warnings Expected: clean.


Task 5: main.rs reads the shared COST_FIELD_NAMES

Files:

  • Modify: crates/aura-cli/src/main.rs:31-34,2580,2589,2765,2779

  • Step 1: Add COST_FIELD_NAMES to the aura_std import (lines 31-34)

In the use aura_std::{...} block that already re-exports PM_FIELD_NAMES, PM_RECORD_KINDS (line 34), add COST_FIELD_NAMES (alpha-ordered within the block).

  • Step 2: Delete the local const (line 2580)

Remove:

const COST_FIELDS: [&str; 3] = ["cost_in_r", "cum_cost_in_r", "open_cost_in_r"];
  • Step 3: Rewrite the three COST_FIELDS references to COST_FIELD_NAMES

  • COST_SUM_PORTS builder loop (line 2589): for field in COST_FIELDSfor field in COST_FIELD_NAMES.

  • ConstantCost wiring loop (line 2765): for (f, field) in COST_FIELDS.iter().copied().enumerate()... COST_FIELD_NAMES.iter().copied().enumerate().

  • VolSlippageCost wiring loop (line 2779): same rewrite.

MAX_RUN_COST_NODES (line 2576) and the COST_SUM_PORTS LazyLock structure stay unchanged; only the field-name source changes (identical string values → identical port names → identical wiring).

  • Step 4: Build and verify the C18 golden is byte-identical

Run: cargo test -p aura-cli stage1_r_single_run_output_golden Expected: PASS — the no-cost golden is byte-identical (it pins no cost field by string; the wiring is unchanged).

  • Step 5: Verify the both-costs composition still holds

Run: cargo test -p aura-cli stage1_r_both_costs Expected: PASS — stage1_r_both_costs_compose_net_below_each_alone green (the wiring and the cost streams are unchanged).

  • Step 6: Clippy-clean the cli crate

Run: cargo clippy -p aura-cli --all-targets -- -D warnings Expected: clean (the local const removed; COST_FIELD_NAMES imported and used).


Task 6: Workspace verification (behaviour-preservation gate)

Files: none (verification only)

  • Step 1: Full workspace build

Run: cargo build --workspace --all-targets Expected: clean, 0 errors.

  • Step 2: Full workspace test suite

Run: cargo test --workspace Expected: PASS, 0 failures — including 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, which construct the REAL migrated nodes via ::new() and bind CostRunner<_> transparently) and the C18 golden.

  • Step 3: Workspace clippy gate

Run: cargo clippy --workspace --all-targets -- -D warnings Expected: clean.

  • Step 4: Doc build (no broken intra-doc links from the new cost.rs items)

Run: cargo doc --workspace --no-deps 2>&1 Expected: builds; no warnings about the new CostNode/CostRunner doc links.