diff --git a/crates/aura-std/src/lib.rs b/crates/aura-std/src/lib.rs index 2c51089..64e9acc 100644 --- a/crates/aura-std/src/lib.rs +++ b/crates/aura-std/src/lib.rs @@ -3,7 +3,9 @@ //! The universal, batteries-included building blocks every project gets for //! free: common indicators (SMA, ATR, RSI, …), resamplers, the `SessionNode`, //! standard combinators, the legacy `SimBroker` pip yardstick, and cost-model -//! nodes (`ConstantCost`, …, in R — C10). Depends only on `aura-core` (the shared contract). +//! nodes authored against the [`CostNode`] trait + [`CostRunner`] adapter +//! (`ConstantCost`, `VolSlippageCost`, …, in R — C10). Depends only on `aura-core` +//! (the shared contract). //! //! This is the top tier of the three-tier node-reuse model (contract C16): //! diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index 14fdb85..b804f4a 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -757,6 +757,29 @@ composable with `--cost-per-trade` (their costs sum); sweep / walk-forward / mc + `CostSum` (a compiler-unlinked lockstep) — to be unified by the still-deferred general `CostNode` trait (now justified by two concrete nodes). Decision log: #148. +**Realization (cycle 0083 — CostNode trait + shared cost-record contract, cycle 3, +#148).** The deferred unifier ships. A new `aura-std/src/cost.rs` owns the cost-model +node abstraction: the 3-field cost triple is now **one source of truth** +(`COST_FIELD_NAMES` / `COST_WIDTH`, mirroring `position_management::{FIELD_NAMES, +WIDTH}`), read by both producers + `CostSum` + the CLI wiring — the cycle-0082 +by-convention lockstep is **structurally gone** (four restatements collapsed to one). +The `CostNode` **factor trait** carries a cost node's only per-node difference — the +price-unit **cost numerator** (`cost_numerator(&mut self, &Ctx) -> f64`), plus +`extra_inputs` (default none) and `label`; everything else is the generic +`CostRunner` **adapter**, which holds the shared `cum`/`out` state and +implements `Node`, writing the co-temporality skeleton (geometry-only gating, +`numerator / latched` R-normalization, the closed/open charge, the running `cum`, the +3-field emit) **once**. `ConstantCost` and `VolSlippageCost` are now thin factors whose +`new()` returns `CostRunner`. Honours C9 (a `CostRunner` is a plain downstream +`Node`, no runtime sub-object) and C23 (`name()` dropped as dead surface — `label()` +remains the non-load-bearing symbol). **Behaviour-preserving**: the builders emit +unchanged schemas, so the wiring / `net_r_equity` seam / `summarize_r` are untouched and +the `numerator / latched` token form is byte-identical; the existing suite passes +verbatim and two new CLI characterization goldens pin the exact flat/composed +`net_expectancy_r` (the prior tests only asserted `net < gross`). **Still deferred** +(decision E): the multi-node cost-graph composite-builder (wiring sugar over the +already-working edge-wired seam). Decision log: #148. + **Reframe (2026-06-23, #117 — exposure → bias, R as the signal-quality unit).** [HISTORY — its R spine survives into the 2026-06-28 contract; its Stage-2 currency / realistic-broker / register / flat-1R-vs-compounding portions are SUPERSEDED by that diff --git a/docs/plans/0083-cost-node-trait.md b/docs/plans/0083-cost-node-trait.md deleted file mode 100644 index b673bcd..0000000 --- a/docs/plans/0083-cost-node-trait.md +++ /dev/null @@ -1,741 +0,0 @@ -# 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. diff --git a/docs/specs/0083-cost-node-trait.md b/docs/specs/0083-cost-node-trait.md deleted file mode 100644 index ead4482..0000000 --- a/docs/specs/0083-cost-node-trait.md +++ /dev/null @@ -1,434 +0,0 @@ -# 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`** 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` (default none), `name`, `label`. A factor is - pure data + one numerator; it holds no running state. - -3. **The `CostRunner` 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` (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 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 Node for CostRunner` 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.) - -```rust -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 { - 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`) - -```rust -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 { - 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). 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 { - 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 { - 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 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, - 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) -} -``` - -### Before → after: `ConstantCost` (the stateless factor) - -```rust -// 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 { - 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`) 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) - -```rust -// 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 { - 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 { - 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 - -```rust -// 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].` 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`, `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 param** → `assert!` 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 missing** → `None` (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.