audit(0083): cycle close — drift-clean (code); C10 cycle-0083 note + lib.rs doc
Architect drift review over 6c7f5dd..HEAD. No regression scripts in this project → the architect is the sole gate; verdict: shipped code byte-clean. What holds (verified against the diff): - C9 — a CostRunner<F> is a plain downstream Node; the two cost nodes are thin factors wired through the same cost_node_builder. No runtime sub-object. - C18 / byte-identity — the numerator/latched token form is preserved verbatim; the two new CLI goldens pin exact net_expectancy_r and pass; the no-cost golden is untouched. - C10 co-temporality + C23 + invariant-4 — the gate-on-PM-geometry-only rule (cold factor input → 0 cost, row still emits) is now centralized once in the runner; name() dropped cleanly (label() remains the non-load-bearing symbol); the 3-field triple is a single source (COST_FIELD_NAMES) read by both producers + CostSum + the CLI — the cycle-0082 by-convention lockstep is structurally gone. Resolution: - [fix] docs/design/INDEX.md C10 — added the cycle-0083 realization note (each prior cost cycle got one; the deferred general CostNode trait is now shipped and the lockstep eliminated). Ledger deferred-work tracking re-synced. - [fix] crates/aura-std/src/lib.rs:5 — module doc now names the CostNode/CostRunner authoring surface. - [carry-on, low debt] vol_slippage_cost.rs still declares its extra-input port twice (the cost_node_builder arg and the trait extra_inputs()); a clean unifier needs design thought (builder() is static, no factor instance) and is not worth forcing at cycle close. Documented here; agreement rests on the helper + a test + the harness debug-assert. Cycle 0083 ephemera removed (docs/specs/0083 + docs/plans/0083). Verified: cargo build/test --workspace clean (0 failures), clippy -D warnings clean, cargo doc -p aura-std clean (CostNode/CostRunner intra-doc links resolve). refs #148
This commit is contained in:
@@ -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):
|
||||
//!
|
||||
|
||||
@@ -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<F: CostNode>` **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<Self>`. Honours C9 (a `CostRunner<F>` 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
|
||||
|
||||
@@ -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<F>` adapter with one source of truth for the 3-field cost
|
||||
record, migrating both shipped cost nodes — behaviour-preserving (byte-identical
|
||||
output, unchanged schemas/wiring).
|
||||
|
||||
**Architecture:** A new `aura-std/src/cost.rs` owns the contract (`COST_WIDTH`,
|
||||
`COST_FIELD_NAMES`, `GEOMETRY_WIDTH`), the `CostNode` trait (one hook:
|
||||
`cost_numerator`), the `CostRunner<F>` node (holds `cum`/`out`, the co-temporality
|
||||
skeleton), and a `cost_node_builder` schema assembler. `ConstantCost` and
|
||||
`VolSlippageCost` become thin factors whose `new()` returns `CostRunner<Self>`.
|
||||
`CostSum` and `main.rs` drop their local triple consts for the shared source.
|
||||
|
||||
**Tech Stack:** aura-std (node lib), aura-core (Node/Ctx/Cell/PrimitiveBuilder),
|
||||
aura-cli (the cost-block wiring). No new dependencies.
|
||||
|
||||
**Files this plan creates or modifies:**
|
||||
|
||||
- Create: `crates/aura-std/src/cost.rs` — contract + `CostNode` + `CostRunner<F>` + `cost_node_builder` + tests + author doctest
|
||||
- Modify: `crates/aura-std/src/lib.rs:18-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<F>` adapter that wraps a factor into an engine `Node`.
|
||||
//!
|
||||
//! A cost node's only per-node difference is the **price-unit cost numerator** the
|
||||
//! runner divides by the latched 1R distance. Everything else — gating on the PM
|
||||
//! geometry (the co-temporality contract: the cost stream stays 1:1 with the
|
||||
//! executor's record), the closed/open charge, the running `cum`, the 3-field
|
||||
//! emit — is the runner's, written once. The 3-field cost record
|
||||
//! (`COST_FIELD_NAMES`) is one source of truth, read by both the producer side
|
||||
//! (`cost_node_builder`) and the `CostSum` aggregator; this mirrors the
|
||||
//! `position_management::{FIELD_NAMES, WIDTH}` precedent and replaces what was a
|
||||
//! by-convention triple lockstep.
|
||||
|
||||
use aura_core::{
|
||||
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
|
||||
ScalarKind,
|
||||
};
|
||||
|
||||
/// The 3-field cost-in-R record every cost node emits, in slot order — one source
|
||||
/// of truth for the producer schema and the `CostSum` aggregator.
|
||||
pub const COST_WIDTH: usize = 3;
|
||||
pub const COST_FIELD_NAMES: [&str; COST_WIDTH] =
|
||||
["cost_in_r", "cum_cost_in_r", "open_cost_in_r"];
|
||||
|
||||
/// The PM-geometry input prefix every cost node gates on (`closed`, `open`,
|
||||
/// `entry_price`, `stop_price`). A factor's own extra inputs are appended after
|
||||
/// these, beginning at slot `GEOMETRY_WIDTH`.
|
||||
pub const GEOMETRY_WIDTH: usize = 4;
|
||||
|
||||
fn geometry_input_ports() -> Vec<PortSpec> {
|
||||
vec![
|
||||
PortSpec { kind: ScalarKind::Bool, firing: Firing::Any, name: "closed".into() },
|
||||
PortSpec { kind: ScalarKind::Bool, firing: Firing::Any, name: "open".into() },
|
||||
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "entry_price".into() },
|
||||
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "stop_price".into() },
|
||||
]
|
||||
}
|
||||
|
||||
fn cost_output_fields() -> Vec<FieldSpec> {
|
||||
COST_FIELD_NAMES
|
||||
.iter()
|
||||
.map(|n| FieldSpec { name: (*n).into(), kind: ScalarKind::F64 })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A cost factor: the per-round-trip cost in *price units* (the numerator the
|
||||
/// runner divides by the latched 1R distance). The only thing a cost node differs
|
||||
/// in; the co-temporality skeleton is [`CostRunner`]'s, shared.
|
||||
///
|
||||
/// # Authoring a cost node
|
||||
///
|
||||
/// ```
|
||||
/// use aura_core::Ctx;
|
||||
/// use aura_std::{CostNode, CostRunner};
|
||||
///
|
||||
/// pub struct HalfSpreadCost {
|
||||
/// half_spread: f64,
|
||||
/// }
|
||||
///
|
||||
/// impl HalfSpreadCost {
|
||||
/// pub fn new(half_spread: f64) -> CostRunner<HalfSpreadCost> {
|
||||
/// assert!(half_spread >= 0.0, "HalfSpreadCost half_spread must be >= 0");
|
||||
/// CostRunner::new(HalfSpreadCost { half_spread })
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// impl CostNode for HalfSpreadCost {
|
||||
/// fn name(&self) -> &'static str {
|
||||
/// "HalfSpreadCost"
|
||||
/// }
|
||||
/// fn label(&self) -> String {
|
||||
/// format!("HalfSpreadCost({})", self.half_spread)
|
||||
/// }
|
||||
/// fn cost_numerator(&mut self, _ctx: &Ctx<'_>) -> f64 {
|
||||
/// self.half_spread // price units; the runner divides by the latched 1R distance
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// let _node = HalfSpreadCost::new(0.5); // a ready-to-wire cost node
|
||||
/// ```
|
||||
pub trait CostNode: 'static {
|
||||
/// Static node-type name (a non-load-bearing debug symbol, C23).
|
||||
fn name(&self) -> &'static str;
|
||||
/// One-line render label carrying the identifying param (C23).
|
||||
fn label(&self) -> String;
|
||||
/// Extra input ports beyond the 4 geometry inputs, appended at slot
|
||||
/// `GEOMETRY_WIDTH`. Default: none.
|
||||
fn extra_inputs(&self) -> Vec<PortSpec> {
|
||||
Vec::new()
|
||||
}
|
||||
/// The round-trip cost in price units this cycle, BEFORE R-normalization and
|
||||
/// BEFORE the closed/open gate. Reads its extra inputs from `ctx` at
|
||||
/// `GEOMETRY_WIDTH + i`; an empty window during warm-up means 0 (the runner
|
||||
/// still emits the row — co-temporality).
|
||||
fn cost_numerator(&mut self, ctx: &Ctx<'_>) -> f64;
|
||||
}
|
||||
|
||||
/// The shared co-temporality skeleton wrapping any [`CostNode`] factor into a
|
||||
/// `Node`. Holds the only running state a cost node needs — `cum` and the output
|
||||
/// buffer — so a factor impl stays pure.
|
||||
pub struct CostRunner<F: CostNode> {
|
||||
factor: F,
|
||||
cum: f64,
|
||||
out: [Cell; COST_WIDTH],
|
||||
}
|
||||
|
||||
impl<F: CostNode> CostRunner<F> {
|
||||
pub fn new(factor: F) -> Self {
|
||||
Self { factor, cum: 0.0, out: [Cell::from_f64(0.0); COST_WIDTH] }
|
||||
}
|
||||
}
|
||||
|
||||
impl<F: CostNode> Node for CostRunner<F> {
|
||||
fn lookbacks(&self) -> Vec<usize> {
|
||||
vec![1; GEOMETRY_WIDTH + self.factor.extra_inputs().len()]
|
||||
}
|
||||
|
||||
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
|
||||
// Gate ONLY on the PM geometry (co-temporality): the cost stream stays 1:1
|
||||
// with the executor's record. A factor's not-yet-warm input contributes 0
|
||||
// (handled in `cost_numerator`), it does not withhold the row.
|
||||
let closed_w = ctx.bool_in(0);
|
||||
let open_w = ctx.bool_in(1);
|
||||
let entry_w = ctx.f64_in(2);
|
||||
let stop_w = ctx.f64_in(3);
|
||||
if closed_w.is_empty() || open_w.is_empty() || entry_w.is_empty() || stop_w.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let closed = closed_w[0];
|
||||
let open = open_w[0];
|
||||
let latched = (entry_w[0] - stop_w[0]).abs();
|
||||
let numerator = self.factor.cost_numerator(&ctx);
|
||||
// Zero latched distance = no valid 1R denominator -> no cost. The
|
||||
// `numerator / latched` token form is preserved verbatim from the
|
||||
// pre-migration nodes for byte-identity (IEEE-754).
|
||||
let per = if latched > 0.0 { numerator / latched } else { 0.0 };
|
||||
let cost_in_r = if closed { per } else { 0.0 };
|
||||
let open_cost_in_r = if open { per } else { 0.0 };
|
||||
self.cum += cost_in_r;
|
||||
self.out = [
|
||||
Cell::from_f64(cost_in_r),
|
||||
Cell::from_f64(self.cum),
|
||||
Cell::from_f64(open_cost_in_r),
|
||||
];
|
||||
Some(&self.out)
|
||||
}
|
||||
|
||||
fn label(&self) -> String {
|
||||
self.factor.label()
|
||||
}
|
||||
}
|
||||
|
||||
/// Assemble a cost-node `PrimitiveBuilder`: the 4 geometry inputs ++ the factor's
|
||||
/// extra inputs, the standard 3-field cost output, the given params, and a build
|
||||
/// closure. The single home for the cost-node schema shape.
|
||||
pub fn cost_node_builder(
|
||||
name: &'static str,
|
||||
extra_inputs: Vec<PortSpec>,
|
||||
params: Vec<ParamSpec>,
|
||||
build: impl Fn(&[Cell]) -> Box<dyn Node> + 'static,
|
||||
) -> PrimitiveBuilder {
|
||||
let mut inputs = geometry_input_ports();
|
||||
inputs.extend(extra_inputs);
|
||||
PrimitiveBuilder::new(name, NodeSchema { inputs, output: cost_output_fields(), params }, build)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{ConstantCost, CostSum};
|
||||
use aura_core::{AnyColumn, Scalar, Timestamp};
|
||||
|
||||
/// A test-only factor: a constant numerator, no extra inputs.
|
||||
struct StubCost(f64);
|
||||
impl CostNode for StubCost {
|
||||
fn name(&self) -> &'static str {
|
||||
"StubCost"
|
||||
}
|
||||
fn label(&self) -> String {
|
||||
format!("StubCost({})", self.0)
|
||||
}
|
||||
fn cost_numerator(&mut self, _ctx: &Ctx<'_>) -> f64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// A test-only factor with one extra f64 input, read at `GEOMETRY_WIDTH`.
|
||||
struct StubExtra(f64);
|
||||
impl CostNode for StubExtra {
|
||||
fn name(&self) -> &'static str {
|
||||
"StubExtra"
|
||||
}
|
||||
fn label(&self) -> String {
|
||||
"StubExtra".into()
|
||||
}
|
||||
fn extra_inputs(&self) -> Vec<PortSpec> {
|
||||
vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "x".into() }]
|
||||
}
|
||||
fn cost_numerator(&mut self, ctx: &Ctx<'_>) -> f64 {
|
||||
let w = ctx.f64_in(GEOMETRY_WIDTH);
|
||||
let x = if w.is_empty() { 0.0 } else { w[0] };
|
||||
self.0 * x
|
||||
}
|
||||
}
|
||||
|
||||
fn geom_cols() -> Vec<AnyColumn> {
|
||||
vec![
|
||||
AnyColumn::with_capacity(ScalarKind::Bool, 1), // closed
|
||||
AnyColumn::with_capacity(ScalarKind::Bool, 1), // open
|
||||
AnyColumn::with_capacity(ScalarKind::F64, 1), // entry
|
||||
AnyColumn::with_capacity(ScalarKind::F64, 1), // stop
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn withholds_until_geometry_present() {
|
||||
let mut r = CostRunner::new(StubCost(2.0));
|
||||
let inputs = geom_cols(); // empty
|
||||
assert_eq!(r.eval(Ctx::new(&inputs, Timestamp(0))), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn charges_numerator_over_latched_on_close() {
|
||||
let mut r = CostRunner::new(StubCost(2.0));
|
||||
let mut inputs = geom_cols();
|
||||
inputs[0].push(Scalar::bool(true)).unwrap();
|
||||
inputs[1].push(Scalar::bool(false)).unwrap();
|
||||
inputs[2].push(Scalar::f64(100.0)).unwrap();
|
||||
inputs[3].push(Scalar::f64(96.0)).unwrap(); // latched 4 -> 2/4 = 0.5
|
||||
assert_eq!(
|
||||
r.eval(Ctx::new(&inputs, Timestamp(0))),
|
||||
Some([Cell::from_f64(0.5), Cell::from_f64(0.5), Cell::from_f64(0.0)].as_slice())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_emits_would_be_cost_not_in_cum() {
|
||||
let mut r = CostRunner::new(StubCost(2.0));
|
||||
let mut inputs = geom_cols();
|
||||
inputs[0].push(Scalar::bool(false)).unwrap();
|
||||
inputs[1].push(Scalar::bool(true)).unwrap(); // open
|
||||
inputs[2].push(Scalar::f64(100.0)).unwrap();
|
||||
inputs[3].push(Scalar::f64(96.0)).unwrap();
|
||||
assert_eq!(
|
||||
r.eval(Ctx::new(&inputs, Timestamp(0))),
|
||||
Some([Cell::from_f64(0.0), Cell::from_f64(0.0), Cell::from_f64(0.5)].as_slice())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_latched_no_cost() {
|
||||
let mut r = CostRunner::new(StubCost(2.0));
|
||||
let mut inputs = geom_cols();
|
||||
inputs[0].push(Scalar::bool(true)).unwrap();
|
||||
inputs[1].push(Scalar::bool(false)).unwrap();
|
||||
inputs[2].push(Scalar::f64(100.0)).unwrap();
|
||||
inputs[3].push(Scalar::f64(100.0)).unwrap(); // latched 0
|
||||
assert_eq!(
|
||||
r.eval(Ctx::new(&inputs, Timestamp(0))),
|
||||
Some([Cell::from_f64(0.0), Cell::from_f64(0.0), Cell::from_f64(0.0)].as_slice())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cum_accumulates() {
|
||||
let mut r = CostRunner::new(StubCost(2.0));
|
||||
let mut a = geom_cols();
|
||||
a[0].push(Scalar::bool(true)).unwrap();
|
||||
a[1].push(Scalar::bool(false)).unwrap();
|
||||
a[2].push(Scalar::f64(100.0)).unwrap();
|
||||
a[3].push(Scalar::f64(96.0)).unwrap(); // 0.5
|
||||
let _ = r.eval(Ctx::new(&a, Timestamp(0)));
|
||||
let mut b = geom_cols();
|
||||
b[0].push(Scalar::bool(true)).unwrap();
|
||||
b[1].push(Scalar::bool(false)).unwrap();
|
||||
b[2].push(Scalar::f64(100.0)).unwrap();
|
||||
b[3].push(Scalar::f64(98.0)).unwrap(); // latched 2 -> 1.0; cum 1.5
|
||||
assert_eq!(
|
||||
r.eval(Ctx::new(&b, Timestamp(1))),
|
||||
Some([Cell::from_f64(1.0), Cell::from_f64(1.5), Cell::from_f64(0.0)].as_slice())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_input_cold_contributes_zero_but_row_emits() {
|
||||
// Co-temporality: a not-yet-warm factor input -> 0 cost, but a row IS emitted.
|
||||
let mut r = CostRunner::new(StubExtra(0.5));
|
||||
let mut inputs = geom_cols();
|
||||
inputs.push(AnyColumn::with_capacity(ScalarKind::F64, 1)); // x, empty
|
||||
inputs[0].push(Scalar::bool(true)).unwrap();
|
||||
inputs[1].push(Scalar::bool(false)).unwrap();
|
||||
inputs[2].push(Scalar::f64(100.0)).unwrap();
|
||||
inputs[3].push(Scalar::f64(96.0)).unwrap();
|
||||
assert_eq!(
|
||||
r.eval(Ctx::new(&inputs, Timestamp(0))),
|
||||
Some([Cell::from_f64(0.0), Cell::from_f64(0.0), Cell::from_f64(0.0)].as_slice())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_input_warm_scales_numerator() {
|
||||
let mut r = CostRunner::new(StubExtra(0.5));
|
||||
let mut inputs = geom_cols();
|
||||
inputs.push(AnyColumn::with_capacity(ScalarKind::F64, 1));
|
||||
inputs[0].push(Scalar::bool(true)).unwrap();
|
||||
inputs[1].push(Scalar::bool(false)).unwrap();
|
||||
inputs[2].push(Scalar::f64(100.0)).unwrap();
|
||||
inputs[3].push(Scalar::f64(96.0)).unwrap(); // latched 4
|
||||
inputs[4].push(Scalar::f64(3.0)).unwrap(); // x=3 -> 0.5*3=1.5 -> 1.5/4 = 0.375
|
||||
assert_eq!(
|
||||
r.eval(Ctx::new(&inputs, Timestamp(0))),
|
||||
Some([Cell::from_f64(0.375), Cell::from_f64(0.375), Cell::from_f64(0.0)].as_slice())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookbacks_count_geometry_plus_extra() {
|
||||
assert_eq!(CostRunner::new(StubCost(1.0)).lookbacks(), vec![1; GEOMETRY_WIDTH]);
|
||||
assert_eq!(CostRunner::new(StubExtra(1.0)).lookbacks(), vec![1; GEOMETRY_WIDTH + 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runner_label_delegates_to_factor() {
|
||||
assert_eq!(CostRunner::new(StubCost(2.0)).label(), "StubCost(2)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geometry_width_matches_port_count() {
|
||||
assert_eq!(GEOMETRY_WIDTH, geometry_input_ports().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_output_fields_are_the_triple() {
|
||||
let names: Vec<String> = cost_output_fields().into_iter().map(|f| f.name).collect();
|
||||
assert_eq!(names, COST_FIELD_NAMES.to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn producer_and_aggregator_share_the_triple() {
|
||||
// The structural lockstep: producer output and aggregator output/inputs all
|
||||
// read COST_FIELD_NAMES (one source), replacing the by-convention triple.
|
||||
let prod: Vec<String> =
|
||||
ConstantCost::builder().schema().output.iter().map(|f| f.name.clone()).collect();
|
||||
assert_eq!(prod, COST_FIELD_NAMES.to_vec());
|
||||
let agg_out: Vec<String> =
|
||||
CostSum::builder(1).schema().output.iter().map(|f| f.name.clone()).collect();
|
||||
assert_eq!(agg_out, COST_FIELD_NAMES.to_vec());
|
||||
let agg_in: Vec<String> =
|
||||
CostSum::builder(1).schema().inputs.iter().map(|p| p.name.clone()).collect();
|
||||
let expected: Vec<String> =
|
||||
COST_FIELD_NAMES.iter().map(|f| format!("cost[0].{f}")).collect();
|
||||
assert_eq!(agg_in, expected);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Wire the module into `crates/aura-std/src/lib.rs`**
|
||||
|
||||
Insert `mod cost;` between `mod constant_cost;` (line 21) and `mod cost_sum;` (line 22):
|
||||
|
||||
```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<ConstantCost> {
|
||||
assert!(cost_per_trade >= 0.0, "ConstantCost cost_per_trade must be >= 0");
|
||||
CostRunner::new(ConstantCost { cost_per_trade })
|
||||
}
|
||||
|
||||
/// The param-generic recipe: one `cost_per_trade` F64 knob, no extra inputs.
|
||||
pub fn builder() -> PrimitiveBuilder {
|
||||
cost_node_builder(
|
||||
"ConstantCost",
|
||||
Vec::new(),
|
||||
vec![ParamSpec { name: "cost_per_trade".into(), kind: ScalarKind::F64 }],
|
||||
|p| Box::new(ConstantCost::new(p[0].f64())),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl CostNode for ConstantCost {
|
||||
fn name(&self) -> &'static str {
|
||||
"ConstantCost"
|
||||
}
|
||||
fn label(&self) -> String {
|
||||
format!("ConstantCost({})", self.cost_per_trade)
|
||||
}
|
||||
fn cost_numerator(&mut self, _ctx: &Ctx<'_>) -> f64 {
|
||||
self.cost_per_trade
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Amend the test module's `use` line (now line ~63 after the rewrite)**
|
||||
|
||||
The kept test bodies use `Cell::from_f64`, which previously reached them via
|
||||
`use super::*` from the parent's `Cell` import (now removed — the non-test code no
|
||||
longer names `Cell`). Add `Cell` to the test module's import:
|
||||
|
||||
Change `use aura_core::{AnyColumn, Scalar, Timestamp};` to
|
||||
`use aura_core::{AnyColumn, Cell, Scalar, Timestamp};`
|
||||
|
||||
Everything else in the `#[cfg(test)] mod tests` block stays verbatim.
|
||||
|
||||
- [ ] **Step 3: Build and run the (unchanged) ConstantCost tests through the runner**
|
||||
|
||||
Run: `cargo test -p aura-std`
|
||||
Expected: PASS — the 7 existing `constant_cost` tests pass verbatim (now exercising
|
||||
`CostRunner<ConstantCost>`), byte-identical output (`0.5`, `1.5`), the negative-param
|
||||
panic still fires in `new`.
|
||||
|
||||
- [ ] **Step 4: Clippy-clean**
|
||||
|
||||
Run: `cargo clippy -p aura-std --all-targets -- -D warnings`
|
||||
Expected: clean.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Migrate `VolSlippageCost` to a `CostNode` factor
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/aura-std/src/vol_slippage_cost.rs:1-100` (replace), `:105` (test `use` line)
|
||||
|
||||
- [ ] **Step 1: Replace lines 1-100 (module doc through `impl Node`) with the factor**
|
||||
|
||||
```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<VolSlippageCost> {
|
||||
assert!(slip_vol_mult >= 0.0, "VolSlippageCost slip_vol_mult must be >= 0");
|
||||
CostRunner::new(VolSlippageCost { slip_vol_mult })
|
||||
}
|
||||
|
||||
/// The param-generic recipe: one `slip_vol_mult` F64 knob; one extra
|
||||
/// `volatility` input appended after the geometry.
|
||||
pub fn builder() -> PrimitiveBuilder {
|
||||
cost_node_builder(
|
||||
"VolSlippageCost",
|
||||
vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "volatility".into() }],
|
||||
vec![ParamSpec { name: "slip_vol_mult".into(), kind: ScalarKind::F64 }],
|
||||
|p| Box::new(VolSlippageCost::new(p[0].f64())),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl CostNode for VolSlippageCost {
|
||||
fn name(&self) -> &'static str {
|
||||
"VolSlippageCost"
|
||||
}
|
||||
fn label(&self) -> String {
|
||||
format!("VolSlippageCost({})", self.slip_vol_mult)
|
||||
}
|
||||
fn extra_inputs(&self) -> Vec<PortSpec> {
|
||||
vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "volatility".into() }]
|
||||
}
|
||||
fn cost_numerator(&mut self, ctx: &Ctx<'_>) -> f64 {
|
||||
// Extra input slot 0 (after the 4 geometry inputs).
|
||||
let vol_w = ctx.f64_in(GEOMETRY_WIDTH);
|
||||
let vol = if vol_w.is_empty() { 0.0 } else { vol_w[0] }; // 0 during proxy warm-up
|
||||
self.slip_vol_mult * vol
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Amend the test module's `use` line**
|
||||
|
||||
Change `use aura_core::{AnyColumn, Scalar, Timestamp};` to
|
||||
`use aura_core::{AnyColumn, Cell, Scalar, Timestamp};`
|
||||
|
||||
(The kept test bodies — including `vol_not_yet_warm_emits_zero_cost_co_temporally` —
|
||||
use `Cell::from_f64`, previously reached via `use super::*`.) Everything else in the
|
||||
test block stays verbatim.
|
||||
|
||||
- [ ] **Step 3: Build and run the (unchanged) VolSlippageCost tests through the runner**
|
||||
|
||||
Run: `cargo test -p aura-std`
|
||||
Expected: PASS — the 8 existing `vol_slippage_cost` tests pass verbatim, byte-identical
|
||||
output (`0.375`, `1.375`), the co-temporality 0-cost-row test green, the negative-param
|
||||
panic fires.
|
||||
|
||||
- [ ] **Step 4: Clippy-clean**
|
||||
|
||||
Run: `cargo clippy -p aura-std --all-targets -- -D warnings`
|
||||
Expected: clean.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: `CostSum` reads the shared cost-record contract
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/aura-std/src/cost_sum.rs:9-75`
|
||||
|
||||
- [ ] **Step 1: Add the shared-contract import after the `aura_core` use block (line 11)**
|
||||
|
||||
```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.
|
||||
@@ -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<F>`** that
|
||||
owns the shared co-temporality skeleton (geometry gating, R-normalization, the
|
||||
closed/open charge, the running `cum`, the 3-field emit). One source of truth for
|
||||
the 3-field cost record (`COST_FIELD_NAMES`/`COST_WIDTH`) replaces the cycle-2
|
||||
audit-flagged by-convention lockstep with a structural single-source contract.
|
||||
|
||||
Hard constraint: **behaviour-preserving.** Every shipped cost node emits
|
||||
byte-identical output; the node builders produce unchanged schemas (same port
|
||||
names), so the `main.rs` wiring, the `net_r_equity` seam, and `summarize_r` are
|
||||
untouched and every existing test stays green verbatim. The deliverable is an
|
||||
authoring-surface and a maintainability win, not a behaviour change.
|
||||
|
||||
This is cycle 3 of the "Cost-model graph (in R)" milestone (#148). The
|
||||
cost-graph composite-builder is deferred (decision E on #148).
|
||||
|
||||
## Architecture
|
||||
|
||||
Three layers replace the two near-identical monolith nodes:
|
||||
|
||||
1. **The cost-record contract** (`aura-std/src/cost.rs`): `COST_WIDTH = 3` and
|
||||
`COST_FIELD_NAMES = ["cost_in_r", "cum_cost_in_r", "open_cost_in_r"]`, plus
|
||||
`GEOMETRY_WIDTH = 4` (the PM-geometry input prefix). Mirrors the
|
||||
`position_management::{WIDTH, FIELD_NAMES}` precedent (aura-std). Both the
|
||||
producer side (the runner's schema helper) and the consumer side (`CostSum`)
|
||||
read these — the lockstep is now structural.
|
||||
|
||||
2. **The `CostNode` factor trait** (`aura-std/src/cost.rs`): the per-node hook —
|
||||
`cost_numerator(&mut self, &Ctx) -> f64` (the round-trip cost in *price
|
||||
units*, before R-normalization and before the closed/open gate),
|
||||
`extra_inputs() -> Vec<PortSpec>` (default none), `name`, `label`. A factor is
|
||||
pure data + one numerator; it holds no running state.
|
||||
|
||||
3. **The `CostRunner<F: CostNode>` adapter** (`aura-std/src/cost.rs`): a concrete
|
||||
generic node holding the shared mutable state (`cum`, `out: [Cell; COST_WIDTH]`)
|
||||
and implementing `Node`. Its `eval` is the co-temporality skeleton, written
|
||||
once. It gates **only** on the 4 PM-geometry inputs (so a not-yet-warm factor
|
||||
input contributes 0 cost but the row still emits, 1:1 with the PM record),
|
||||
divides the factor's numerator by the latched 1R distance, charges on
|
||||
close / would-be-charges on open, accumulates `cum`, and emits the triple.
|
||||
|
||||
`ConstantCost` and `VolSlippageCost` become thin `CostNode` factors; their
|
||||
`new()` returns `CostRunner<Self>` (a ready-to-wire `Node`), preserving every
|
||||
existing call site and unit test. A cost-node `PrimitiveBuilder` is assembled by
|
||||
a shared `cost_node_builder` helper that prepends the geometry inputs and appends
|
||||
the standard 3-field output.
|
||||
|
||||
**Not a blanket impl.** `impl<T: CostNode> Node for T` is rejected (decision A on
|
||||
#148): the runner must hold `cum`/`out`, which a trait cannot add; and the recon
|
||||
found zero blanket impls in `crates/` — the norm is concrete types implementing
|
||||
`Node`. `impl<F: CostNode> Node for CostRunner<F>` is the idiomatic generic
|
||||
wrapper and keeps the numerator call inlinable (no vtable on the hot path).
|
||||
|
||||
## Concrete code shapes
|
||||
|
||||
### Author-facing: writing a new cost node is now one method
|
||||
|
||||
A cost-node author writes **only** the numerator — the entire co-temporality
|
||||
skeleton is the runner's. (Illustrative third node, NOT shipped this cycle; it is
|
||||
the worked example the acceptance criterion is judged against.)
|
||||
|
||||
```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<HalfSpreadCost> {
|
||||
assert!(half_spread >= 0.0, "HalfSpreadCost half_spread must be >= 0");
|
||||
CostRunner::new(HalfSpreadCost { half_spread })
|
||||
}
|
||||
}
|
||||
|
||||
impl CostNode for HalfSpreadCost {
|
||||
fn name(&self) -> &'static str {
|
||||
"HalfSpreadCost"
|
||||
}
|
||||
fn label(&self) -> String {
|
||||
format!("HalfSpreadCost({})", self.half_spread)
|
||||
}
|
||||
fn cost_numerator(&mut self, _ctx: &Ctx<'_>) -> f64 {
|
||||
self.half_spread // price units; the runner divides by latched 1R distance
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
That is the whole node: ~16 lines, one numerator method, no `eval`, no `cum`, no
|
||||
geometry gating, no 3-field schema. Compare the ~90-line per-node skeleton this
|
||||
cycle removes from each of the two shipped nodes.
|
||||
|
||||
### The contract + trait + runner (new `aura-std/src/cost.rs`)
|
||||
|
||||
```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<PortSpec> {
|
||||
vec![
|
||||
PortSpec { kind: ScalarKind::Bool, firing: Firing::Any, name: "closed".into() },
|
||||
PortSpec { kind: ScalarKind::Bool, firing: Firing::Any, name: "open".into() },
|
||||
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "entry_price".into() },
|
||||
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "stop_price".into() },
|
||||
]
|
||||
}
|
||||
|
||||
fn cost_output_fields() -> Vec<FieldSpec> {
|
||||
COST_FIELD_NAMES
|
||||
.iter()
|
||||
.map(|n| FieldSpec { name: (*n).into(), kind: ScalarKind::F64 })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A cost factor: the per-round-trip cost in *price units* (the numerator the
|
||||
/// runner divides by the latched 1R distance). This is the only thing a cost node
|
||||
/// differs in; the co-temporality skeleton is `CostRunner`'s, shared.
|
||||
pub trait CostNode: 'static {
|
||||
/// Static node-type name (a non-load-bearing debug symbol, C23).
|
||||
fn name(&self) -> &'static str;
|
||||
/// One-line render label, carrying the identifying param (C23).
|
||||
fn label(&self) -> String;
|
||||
/// Extra input ports beyond the 4 geometry inputs, appended at slot
|
||||
/// `GEOMETRY_WIDTH`. Default: none (a stateless cost like a flat fee).
|
||||
fn extra_inputs(&self) -> Vec<PortSpec> {
|
||||
Vec::new()
|
||||
}
|
||||
/// The round-trip cost in price units this cycle, BEFORE R-normalization and
|
||||
/// BEFORE the closed/open gate. Reads its extra inputs from `ctx` at
|
||||
/// `GEOMETRY_WIDTH + i`; an empty window during warm-up means the factor
|
||||
/// contributes 0 (the runner still emits the row — co-temporality).
|
||||
fn cost_numerator(&mut self, ctx: &Ctx<'_>) -> f64;
|
||||
}
|
||||
|
||||
/// The shared co-temporality skeleton, wrapping any [`CostNode`] factor into a
|
||||
/// `Node`. Holds the only running state a cost node needs — the `cum` and the
|
||||
/// output buffer — so a factor impl stays pure. One home for the contract that
|
||||
/// was duplicated across `ConstantCost`/`VolSlippageCost` and locked by convention
|
||||
/// against `CostSum`.
|
||||
pub struct CostRunner<F: CostNode> {
|
||||
factor: F,
|
||||
cum: f64,
|
||||
out: [Cell; COST_WIDTH],
|
||||
}
|
||||
|
||||
impl<F: CostNode> CostRunner<F> {
|
||||
pub fn new(factor: F) -> Self {
|
||||
Self { factor, cum: 0.0, out: [Cell::from_f64(0.0); COST_WIDTH] }
|
||||
}
|
||||
}
|
||||
|
||||
impl<F: CostNode> Node for CostRunner<F> {
|
||||
fn lookbacks(&self) -> Vec<usize> {
|
||||
vec![1; GEOMETRY_WIDTH + self.factor.extra_inputs().len()]
|
||||
}
|
||||
|
||||
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
|
||||
// Gate ONLY on the PM geometry (co-temporality contract): the cost stream
|
||||
// stays 1:1 with the executor's PM record. A factor's own not-yet-warm
|
||||
// input contributes 0 (handled in `cost_numerator`), it does not withhold.
|
||||
let closed_w = ctx.bool_in(0);
|
||||
let open_w = ctx.bool_in(1);
|
||||
let entry_w = ctx.f64_in(2);
|
||||
let stop_w = ctx.f64_in(3);
|
||||
if closed_w.is_empty() || open_w.is_empty() || entry_w.is_empty() || stop_w.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let closed = closed_w[0];
|
||||
let open = open_w[0];
|
||||
let latched = (entry_w[0] - stop_w[0]).abs();
|
||||
let numerator = self.factor.cost_numerator(&ctx);
|
||||
// Zero latched distance = no valid 1R denominator -> no cost (matches
|
||||
// summarize_r's guard). The `numerator / latched` token form is preserved
|
||||
// verbatim from the pre-migration nodes for byte-identity (IEEE-754).
|
||||
let per = if latched > 0.0 { numerator / latched } else { 0.0 };
|
||||
let cost_in_r = if closed { per } else { 0.0 };
|
||||
let open_cost_in_r = if open { per } else { 0.0 };
|
||||
self.cum += cost_in_r;
|
||||
self.out = [
|
||||
Cell::from_f64(cost_in_r),
|
||||
Cell::from_f64(self.cum),
|
||||
Cell::from_f64(open_cost_in_r),
|
||||
];
|
||||
Some(&self.out)
|
||||
}
|
||||
|
||||
fn label(&self) -> String {
|
||||
self.factor.label()
|
||||
}
|
||||
}
|
||||
|
||||
/// Assemble a cost-node `PrimitiveBuilder`: the 4 geometry inputs ++ the factor's
|
||||
/// extra inputs, the standard 3-field cost output, the given params, and a build
|
||||
/// closure. The single home for the cost-node schema shape.
|
||||
pub fn cost_node_builder(
|
||||
name: &'static str,
|
||||
extra_inputs: Vec<PortSpec>,
|
||||
params: Vec<ParamSpec>,
|
||||
build: impl Fn(&[Cell]) -> Box<dyn Node> + 'static,
|
||||
) -> PrimitiveBuilder {
|
||||
let mut inputs = geometry_input_ports();
|
||||
inputs.extend(extra_inputs);
|
||||
PrimitiveBuilder::new(name, NodeSchema { inputs, output: cost_output_fields(), params }, build)
|
||||
}
|
||||
```
|
||||
|
||||
### Before → after: `ConstantCost` (the stateless factor)
|
||||
|
||||
```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<ConstantCost> {
|
||||
assert!(cost_per_trade >= 0.0, "ConstantCost cost_per_trade must be >= 0");
|
||||
CostRunner::new(ConstantCost { cost_per_trade })
|
||||
}
|
||||
|
||||
pub fn builder() -> PrimitiveBuilder {
|
||||
cost_node_builder(
|
||||
"ConstantCost",
|
||||
Vec::new(), // no extra inputs beyond geometry
|
||||
vec![ParamSpec { name: "cost_per_trade".into(), kind: ScalarKind::F64 }],
|
||||
|p| Box::new(ConstantCost::new(p[0].f64())),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl CostNode for ConstantCost {
|
||||
fn name(&self) -> &'static str {
|
||||
"ConstantCost"
|
||||
}
|
||||
fn label(&self) -> String {
|
||||
format!("ConstantCost({})", self.cost_per_trade)
|
||||
}
|
||||
fn cost_numerator(&mut self, _ctx: &Ctx<'_>) -> f64 {
|
||||
self.cost_per_trade
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The existing `ConstantCost` unit-test module is kept **unchanged**: every test
|
||||
calls `ConstantCost::new(x)` (now a `CostRunner<ConstantCost>`) and `.eval(...)`,
|
||||
which produces byte-identical output; `new_panics_on_negative_cost` still panics
|
||||
in `new`. The tests become the migration's verbatim regression guard.
|
||||
|
||||
### Before → after: `VolSlippageCost` (the state-dependent factor)
|
||||
|
||||
```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<VolSlippageCost> {
|
||||
assert!(slip_vol_mult >= 0.0, "VolSlippageCost slip_vol_mult must be >= 0");
|
||||
CostRunner::new(VolSlippageCost { slip_vol_mult })
|
||||
}
|
||||
|
||||
pub fn builder() -> PrimitiveBuilder {
|
||||
cost_node_builder(
|
||||
"VolSlippageCost",
|
||||
vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "volatility".into() }],
|
||||
vec![ParamSpec { name: "slip_vol_mult".into(), kind: ScalarKind::F64 }],
|
||||
|p| Box::new(VolSlippageCost::new(p[0].f64())),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl CostNode for VolSlippageCost {
|
||||
fn name(&self) -> &'static str {
|
||||
"VolSlippageCost"
|
||||
}
|
||||
fn label(&self) -> String {
|
||||
format!("VolSlippageCost({})", self.slip_vol_mult)
|
||||
}
|
||||
fn extra_inputs(&self) -> Vec<PortSpec> {
|
||||
vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "volatility".into() }]
|
||||
}
|
||||
fn cost_numerator(&mut self, ctx: &Ctx<'_>) -> f64 {
|
||||
// Extra input slot 0 (after the 4 geometry inputs).
|
||||
let vol_w = ctx.f64_in(GEOMETRY_WIDTH);
|
||||
let vol = if vol_w.is_empty() { 0.0 } else { vol_w[0] }; // 0 during proxy warm-up
|
||||
self.slip_vol_mult * vol
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Byte-identity: the runner computes `numerator / latched`. For `ConstantCost`,
|
||||
`numerator = cost_per_trade`, giving `cost_per_trade / latched` (identical to the
|
||||
original). For `VolSlippageCost`, `numerator = slip_vol_mult * vol`, giving
|
||||
`(slip_vol_mult * vol) / latched` — left-associative-identical to the original
|
||||
`slip_vol_mult * vol / latched`. The intermediate multiply rounds to the same f64
|
||||
whether stored or inlined, so the divide sees the same bits.
|
||||
|
||||
`extra_inputs()` is declared in both the trait impl and consulted by `builder()`;
|
||||
the duplication between `builder()`'s `volatility` PortSpec and `extra_inputs()`
|
||||
is acceptable (the builder is the param-generic recipe, the trait method is the
|
||||
runtime lookback driver) — alternatively `builder()` may call `Self::new(0.0)
|
||||
.extra_inputs()`-style, left to the planner if it reads cleaner.
|
||||
|
||||
### Before → after: `CostSum` reads the shared contract
|
||||
|
||||
```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].<field>` port names are unchanged (identical string values).
|
||||
```
|
||||
|
||||
### `main.rs`: the third triple duplicate is unified (behaviour-neutral)
|
||||
|
||||
`aura-cli/src/main.rs` defines its own `const COST_FIELDS: [&str; 3]` (driving
|
||||
`COST_SUM_PORTS` interning and the cost-block wiring loops). It is replaced by the
|
||||
re-exported `aura_std::COST_FIELD_NAMES` (identical values), removing the third
|
||||
copy of the triple. The wiring, port names, and the C18 golden are unchanged.
|
||||
|
||||
## Components
|
||||
|
||||
- **`aura-std/src/cost.rs` (new):** `COST_WIDTH`, `COST_FIELD_NAMES`,
|
||||
`GEOMETRY_WIDTH`, `geometry_input_ports`, `cost_output_fields`, the `CostNode`
|
||||
trait, `CostRunner<F>`, `cost_node_builder`. Owns the co-temporality contract
|
||||
and its unit tests (via a test-only stub factor).
|
||||
- **`aura-std/src/lib.rs`:** `mod cost;` + `pub use cost::{CostNode, CostRunner,
|
||||
COST_FIELD_NAMES, COST_WIDTH};` (and `GEOMETRY_WIDTH` if a downstream test
|
||||
needs it). `cost_node_builder` stays `pub` for cross-module node authors.
|
||||
- **`aura-std/src/constant_cost.rs`:** stripped to a `CostNode` factor + thin
|
||||
`new`/`builder`; unit tests unchanged.
|
||||
- **`aura-std/src/vol_slippage_cost.rs`:** same; unit tests unchanged.
|
||||
- **`aura-std/src/cost_sum.rs`:** local triple const removed, reads
|
||||
`crate::cost::{COST_FIELD_NAMES, COST_WIDTH}`; tests unchanged.
|
||||
- **`aura-cli/src/main.rs`:** local `COST_FIELDS` const replaced by
|
||||
`aura_std::COST_FIELD_NAMES`; wiring unchanged.
|
||||
|
||||
No changes to `aura-engine`, `aura-analysis`, or the composites crate (they
|
||||
consume the unchanged schemas). The `aura-engine` E2E helpers call
|
||||
`ConstantCost::new(c)` / `VolSlippageCost::new(k)` without a type annotation, so
|
||||
they bind the `CostRunner<_>` return transparently and keep passing.
|
||||
|
||||
## Data flow
|
||||
|
||||
Unchanged from cycle 2. Per cycle: the executor (`exec`) emits PM geometry
|
||||
(`closed_this_cycle`, `open`, `entry_price`, `stop_price`); each cost node's
|
||||
`CostRunner` reads those 4 (slots 0-3) plus its factor's extra inputs (slot 4+,
|
||||
e.g. `volatility` from the vol proxy); it emits the 3-field cost record; `CostSum`
|
||||
sums N records per-field; `summarize_r` and the `net_r_equity` LinComb read the
|
||||
aggregate. The graph topology, the edges, and the recorded traces are identical.
|
||||
|
||||
## Error handling
|
||||
|
||||
- **Negative 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.
|
||||
Reference in New Issue
Block a user