diff --git a/docs/plans/0083-cost-node-trait.md b/docs/plans/0083-cost-node-trait.md new file mode 100644 index 0000000..b673bcd --- /dev/null +++ b/docs/plans/0083-cost-node-trait.md @@ -0,0 +1,741 @@ +# 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` 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` 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`. +`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` + `cost_node_builder` + tests + author doctest +- Modify: `crates/aura-std/src/lib.rs:18-76` — `mod 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** + +```rust +//! The cost-model-graph node contract (C10): the `CostNode` factor trait and the +//! `CostRunner` 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 { + 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 { + 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 { +/// 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 { + 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 { + factor: F, + cum: f64, + out: [Cell; COST_WIDTH], +} + +impl CostRunner { + pub fn new(factor: F) -> Self { + Self { factor, cum: 0.0, out: [Cell::from_f64(0.0); COST_WIDTH] } + } +} + +impl Node for CostRunner { + fn lookbacks(&self) -> Vec { + 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, + params: Vec, + build: impl Fn(&[Cell]) -> Box + '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 { + 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 { + 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 = 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 = + ConstantCost::builder().schema().output.iter().map(|f| f.name.clone()).collect(); + assert_eq!(prod, COST_FIELD_NAMES.to_vec()); + let agg_out: Vec = + 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 = + CostSum::builder(1).schema().inputs.iter().map(|p| p.name.clone()).collect(); + let expected: Vec = + 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): + +```rust +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): + +```rust +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** + +```rust +//! `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 { + 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`), 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** + +```rust +//! `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 { + 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 { + 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)** + +```rust +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: + +```rust +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: + +```rust +/// 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_FIELDS` → `for field in COST_FIELD_NAMES`. +In `builder`'s output (line 55): `output: COST_FIELDS` → `output: COST_FIELD_NAMES`. +In the eval-accumulator comment (line 72): `COST_FIELDS order` → `COST_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: + +```rust +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_FIELDS` → `for 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.