From 16f4cbb5486c1734fd5470a8a16025a12c54324b Mon Sep 17 00:00:00 2001 From: Brummel Date: Sun, 28 Jun 2026 18:11:55 +0200 Subject: [PATCH] spec: 0083 cost-node trait + shared cost-record contract (boss-signed) Cycle 3 of the cost-model-graph milestone: lift the duplicated cost-node skeleton (shared verbatim by ConstantCost/VolSlippageCost, locked by convention against CostSum) into a CostNode factor trait + a generic CostRunner adapter, with one source of truth for the 3-field cost record. Behaviour-preserving: same schemas, same wiring, byte-identical output; the existing suite is the regression net. Cost-graph composite-builder deferred (decision E). Grounding-check PASS (7/7 assumption groups against named green tests). Fork decisions logged on the reference issue. refs #148 --- docs/specs/0083-cost-node-trait.md | 434 +++++++++++++++++++++++++++++ 1 file changed, 434 insertions(+) create mode 100644 docs/specs/0083-cost-node-trait.md diff --git a/docs/specs/0083-cost-node-trait.md b/docs/specs/0083-cost-node-trait.md new file mode 100644 index 0000000..ead4482 --- /dev/null +++ b/docs/specs/0083-cost-node-trait.md @@ -0,0 +1,434 @@ +# 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.