diff --git a/docs/plans/0085-per-cycle-accrual-carry-cost.md b/docs/plans/0085-per-cycle-accrual-carry-cost.md new file mode 100644 index 0000000..173d422 --- /dev/null +++ b/docs/plans/0085-per-cycle-accrual-carry-cost.md @@ -0,0 +1,744 @@ +# Per-cycle-held accrual + CarryCost — Implementation Plan + +> **Parent spec:** `docs/specs/0085-per-cycle-accrual-carry-cost.md` +> +> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run +> this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** Add a per-cycle-held accrual charge mode to the cost-node skeleton plus a +constant `CarryCost` node and a `--carry-per-cycle` CLI flag, so a carry/funding cost +bleeds the `net_r_equity` curve over the hold — while `summarize_r` and the CLI +`net_eq` wiring stay untouched and every cycle-0083/0084 golden stays byte-identical. + +**Architecture:** `ChargeMode` becomes a property of the cost factor read by the one +shared `CostRunner` (Task 1); the `AtClose` arm keeps the current eval tokens verbatim +(byte-identity), the new `PerHeldCycle` arm accrues into `acc`, dumps the total into +`cum` at close, and marks the open position (grows each held cycle) so the curve bleeds. +`CarryCost` is a `ConstantCost` twin differing only in `charge_mode()` (Task 2). The CLI +flag threads through the existing `cost_graph`/`CostSum`/`net_eq` wiring with no change to +that wiring (Task 3). Tests pin the B-proof (open_cost grows, dumps at close) at the unit +level and the bleed end-to-end (Task 4). + +**Tech Stack:** `crates/aura-std/src/cost.rs` (ChargeMode + runner branch), +`crates/aura-std/src/carry_cost.rs` (new node), `crates/aura-std/src/lib.rs` (module + +re-exports), `crates/aura-cli/src/main.rs` (flag wiring), `crates/aura-cli/tests/cli_run.rs` +(goldens + bleed test). `summarize_r` and `crates/aura-analysis` are UNCHANGED. + +--- + +**Files this plan creates or modifies:** + +- Modify: `crates/aura-std/src/cost.rs` — `ChargeMode` enum, `CostNode::charge_mode()` + default, `CostRunner::acc` + the `eval` match branch; new `#[cfg(test)]` PerHeldCycle + stub + 2 B-proof tests. +- Create: `crates/aura-std/src/carry_cost.rs` — `CarryCost` node (ConstantCost twin, + `charge_mode() -> PerHeldCycle`) + its `#[cfg(test)]` tests. +- Modify: `crates/aura-std/src/lib.rs:22-24,51-53` — `mod carry_cost;`, `pub use + carry_cost::CarryCost;`, add `ChargeMode` to the `pub use cost::{…}` re-export. +- Modify: `crates/aura-cli/src/main.rs` — `CarryCost` import (32), `CostConfig` + field (2577), the `CarryCost` push (~2743), the arg parser + `RunArgs` + + `run_stage1_r` signature + its 2 call sites (3218, 4446) + usage strings. +- Test: `crates/aura-cli/tests/cli_run.rs` — two `--carry-per-cycle` net_expectancy_r + goldens (capture-then-pin) + the `net_r_equity`-bleeds-over-the-hold test. + +--- + +## Task 1: ChargeMode + PerHeldCycle accrual branch (cost.rs) + +**Files:** +- Modify: `crates/aura-std/src/cost.rs:78-91` (trait), `:96-106` (runner struct + new), + `:124-134` (eval charge body), `:162+` (test mod) + +The `CostNode` trait gains a defaulted `charge_mode()`; `CostRunner` gains `acc` state +and one `eval` branch. The AtClose arm is the current code verbatim (byte-identity); the +PerHeldCycle arm is new. RED-first: the scaffolding (enum + defaulted method) is added so +the new tests COMPILE, then the tests fail by assertion against the still-AtClose eval, +then the branch makes them pass. + +- [ ] **Step 1: Add the `ChargeMode` enum + the defaulted `charge_mode()` (scaffolding, inert)** + +Insert the enum just above the `pub trait CostNode` line (cost.rs:78): + +```rust +/// When a cost factor charges its cost. `AtClose` — once per closed trade (commission, +/// flat cost, slippage): the per-trade default, charged on the close cycle. `PerHeldCycle` +/// — every cycle the position is held (carry, funding): the cost accrues over the hold. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ChargeMode { + AtClose, + PerHeldCycle, +} +``` + +Add the defaulted method inside the `CostNode` trait, after `extra_inputs` (cost.rs:83-85), +before `cost_numerator` (cost.rs:90): + +```rust + /// When this factor charges. Default `AtClose` (the per-trade cost shape); a + /// per-held-cycle (accrual) factor overrides this to `PerHeldCycle`. + fn charge_mode(&self) -> ChargeMode { + ChargeMode::AtClose + } +``` + +- [ ] **Step 2: Add the PerHeldCycle test stub + the two RED B-proof tests** + +In the `#[cfg(test)] mod tests` (cost.rs:162), add a stub mirroring `StubCost` (cost.rs:169-177) +but overriding `charge_mode`: + +```rust + /// A test-only factor charging per held cycle (accrual), constant numerator. + struct StubPerHeld(f64); + impl CostNode for StubPerHeld { + fn label(&self) -> String { + format!("StubPerHeld({})", self.0) + } + fn charge_mode(&self) -> ChargeMode { + ChargeMode::PerHeldCycle + } + fn cost_numerator(&mut self, _ctx: &Ctx<'_>) -> f64 { + self.0 + } + } +``` + +Add the two tests (use the existing `geom_cols()` helper at cost.rs:195): + +```rust + #[test] + fn per_held_cycle_accrues_open_cost_and_dumps_at_close() { + // per = 2/4 = 0.5 each cycle. A hold of open, open, close. + let mut r = CostRunner::new(StubPerHeld(2.0)); + // Cycle 1: held open -> accrue 0.5 into open_cost; nothing into cum yet. + let mut a = geom_cols(); + a[0].push(Scalar::bool(false)).unwrap(); // not closed + a[1].push(Scalar::bool(true)).unwrap(); // open + a[2].push(Scalar::f64(100.0)).unwrap(); + a[3].push(Scalar::f64(96.0)).unwrap(); // latched 4 -> per 0.5 + assert_eq!( + r.eval(Ctx::new(&a, Timestamp(0))), + Some([Cell::from_f64(0.0), Cell::from_f64(0.0), Cell::from_f64(0.5)].as_slice()) + ); + // Cycle 2: still held open -> open_cost GROWS to 1.0 (the accrual bleed), cum still 0. + let mut b = geom_cols(); + b[0].push(Scalar::bool(false)).unwrap(); + b[1].push(Scalar::bool(true)).unwrap(); + b[2].push(Scalar::f64(100.0)).unwrap(); + b[3].push(Scalar::f64(96.0)).unwrap(); + assert_eq!( + r.eval(Ctx::new(&b, Timestamp(1))), + Some([Cell::from_f64(0.0), Cell::from_f64(0.0), Cell::from_f64(1.0)].as_slice()) + ); + // Cycle 3: close -> accrue the closing cycle, DUMP the total 1.5 into cost_in_r, + // cum steps to 1.5, open_cost back to 0. + let mut c = geom_cols(); + c[0].push(Scalar::bool(true)).unwrap(); // closed + c[1].push(Scalar::bool(false)).unwrap(); // not open + c[2].push(Scalar::f64(100.0)).unwrap(); + c[3].push(Scalar::f64(96.0)).unwrap(); + assert_eq!( + r.eval(Ctx::new(&c, Timestamp(2))), + Some([Cell::from_f64(1.5), Cell::from_f64(1.5), Cell::from_f64(0.0)].as_slice()) + ); + } + + #[test] + fn per_held_cycle_resets_accrual_between_trades() { + // Two trades (open, close each). acc must reset at the first close so the + // second trade's dumped total is its OWN accrual, not cumulative. + let mut r = CostRunner::new(StubPerHeld(2.0)); // per 0.5 + let mut o1 = geom_cols(); + o1[0].push(Scalar::bool(false)).unwrap(); + o1[1].push(Scalar::bool(true)).unwrap(); + o1[2].push(Scalar::f64(100.0)).unwrap(); + o1[3].push(Scalar::f64(96.0)).unwrap(); + let _ = r.eval(Ctx::new(&o1, Timestamp(0))); // open_cost 0.5 + let mut c1 = geom_cols(); + c1[0].push(Scalar::bool(true)).unwrap(); + c1[1].push(Scalar::bool(false)).unwrap(); + c1[2].push(Scalar::f64(100.0)).unwrap(); + c1[3].push(Scalar::f64(96.0)).unwrap(); + assert_eq!( + r.eval(Ctx::new(&c1, Timestamp(1))), + Some([Cell::from_f64(1.0), Cell::from_f64(1.0), Cell::from_f64(0.0)].as_slice()) + ); // trade 1 total 1.0, cum 1.0, acc reset + let mut o2 = geom_cols(); + o2[0].push(Scalar::bool(false)).unwrap(); + o2[1].push(Scalar::bool(true)).unwrap(); + o2[2].push(Scalar::f64(100.0)).unwrap(); + o2[3].push(Scalar::f64(96.0)).unwrap(); + let _ = r.eval(Ctx::new(&o2, Timestamp(2))); // fresh open_cost 0.5 + let mut c2 = geom_cols(); + c2[0].push(Scalar::bool(true)).unwrap(); + c2[1].push(Scalar::bool(false)).unwrap(); + c2[2].push(Scalar::f64(100.0)).unwrap(); + c2[3].push(Scalar::f64(96.0)).unwrap(); + assert_eq!( + r.eval(Ctx::new(&c2, Timestamp(3))), + Some([Cell::from_f64(1.0), Cell::from_f64(2.0), Cell::from_f64(0.0)].as_slice()) + ); // trade 2's OWN total 1.0 (acc reset proved), cum running 2.0 + } +``` + +- [ ] **Step 3: Run the new tests to verify they FAIL (RED)** + +Run: `cargo test -p aura-std per_held_cycle` +Expected: FAIL — both `per_held_cycle_accrues_open_cost_and_dumps_at_close` and +`per_held_cycle_resets_accrual_between_trades` fail by assertion (the eval still runs the +unconditional AtClose path, so cycle 2's `open_cost_in_r` is `0.5` not `1.0`, and the close +dumps `0.5` not the accrued total). `left == right` mismatch on the cells. + +- [ ] **Step 4: Add `CostRunner::acc` + the `eval` match branch** + +Add the `acc` field to the `CostRunner` struct (cost.rs:96-100), after `cum`: + +```rust +pub struct CostRunner { + factor: F, + cum: f64, + acc: f64, + out: [Cell; COST_WIDTH], +} +``` + +Init it in `CostRunner::new` (cost.rs:104): + +```rust + pub fn new(factor: F) -> Self { + Self { factor, cum: 0.0, acc: 0.0, out: [Cell::from_f64(0.0); COST_WIDTH] } + } +``` + +In `eval`, replace the two lines (cost.rs:132-133) + +```rust + let cost_in_r = if closed { per } else { 0.0 }; + let open_cost_in_r = if open { per } else { 0.0 }; +``` + +with the match (keep cost.rs:131 `let per = …` above it and cost.rs:134 +`self.cum += cost_in_r;` below it unchanged): + +```rust + let (cost_in_r, open_cost_in_r) = match self.factor.charge_mode() { + // At-close (per-trade): charged once on the close cycle. VERBATIM the + // pre-cycle-5 tokens — IEEE-754 byte-identity with the existing goldens. + ChargeMode::AtClose => { + let cost_in_r = if closed { per } else { 0.0 }; + let open_cost_in_r = if open { per } else { 0.0 }; + (cost_in_r, open_cost_in_r) + } + // Per-held-cycle (accrual): the carry accrues every cycle the position is + // held; the accrued total realizes into `cum` at close, and marks the open + // position (grows each held cycle) so the net_r_equity curve bleeds over + // the hold rather than stepping at close. + ChargeMode::PerHeldCycle => { + if open || closed { + self.acc += per; + } + let cost_in_r = if closed { self.acc } else { 0.0 }; + let open_cost_in_r = if open { self.acc } else { 0.0 }; + if closed { + self.acc = 0.0; + } + (cost_in_r, open_cost_in_r) + } + }; +``` + +- [ ] **Step 5: Run all aura-std tests to verify GREEN (new + verbatim AtClose regression)** + +Run: `cargo test -p aura-std` +(Unfiltered on purpose: the AtClose regression tests `charges_numerator_over_latched_on_close` +and `cum_accumulates` carry no "cost" substring, so a `cost` name-filter would silently skip +them — run the whole crate so "nothing ran" cannot masquerade as green.) +Expected: PASS — the two new `per_held_cycle_*` tests pass, AND the existing AtClose +tests stay verbatim-green: `charges_numerator_over_latched_on_close`, +`open_emits_would_be_cost_not_in_cum`, `zero_latched_no_cost`, `cum_accumulates`, +`extra_input_cold_contributes_zero_but_row_emits`, `extra_input_warm_scales_numerator`, +`producer_and_aggregator_share_the_triple`, etc. + +--- + +## Task 2: CarryCost node (carry_cost.rs + lib.rs registration) + +**Files:** +- Create: `crates/aura-std/src/carry_cost.rs` +- Modify: `crates/aura-std/src/lib.rs:22-24` (mod), `:51-53` (re-exports) + +A `ConstantCost` twin differing only in `charge_mode() -> PerHeldCycle`. RED-first: the +file is created WITHOUT the `charge_mode` override (so it defaults AtClose), the tests are +written, they fail by assertion, then the override makes them pass. Depends on Task 1 +(`ChargeMode` + the PerHeldCycle branch must exist). + +- [ ] **Step 1: Register the module + re-exports in lib.rs** + +In `crates/aura-std/src/lib.rs` add `mod carry_cost;` at the alphabetical slot between +`mod bias;` (line 22) and `mod constant_cost;` (line 23): + +```rust +mod bias; +mod carry_cost; +mod constant_cost; +mod cost; +``` + +Add the re-export between `pub use bias::Bias;` (line 51) and +`pub use constant_cost::ConstantCost;` (line 52), and add `ChargeMode` to the existing +`pub use cost::{…}` line (53): + +```rust +pub use bias::Bias; +pub use carry_cost::CarryCost; +pub use constant_cost::ConstantCost; +pub use cost::{ + cost_node_builder, ChargeMode, CostNode, CostRunner, COST_FIELD_NAMES, COST_WIDTH, + GEOMETRY_WIDTH, +}; +``` + +(`ChargeMode` is re-exported for parity with the line-53 convention of surfacing every +public cost symbol — it is part of the public `CostNode::charge_mode` return type, so a +downstream crate authoring a per-held-cycle node can name it; the CLI does not need it.) + +- [ ] **Step 2: Create `carry_cost.rs` as the twin WITHOUT the charge_mode override (RED scaffolding)** + +```rust +//! `CarryCost` — a flat per-held-cycle carry/financing cost, in R. The simplest +//! ACCRUAL cost node (C10): a cost incurred for every cycle a position is held, +//! accruing over the hold rather than charged once at close. A labelled stress +//! parameter (a constant price-unit carry); the notional-based and calendar-aware +//! (overnight-swap) variants are later #148 cycles. A `ConstantCost` twin differing +//! only in `charge_mode() -> PerHeldCycle`; the shared `CostRunner` accrues, dumps at +//! close, and marks the open position so the net_r_equity curve bleeds over the hold. + +use aura_core::{Ctx, ParamSpec, PrimitiveBuilder, ScalarKind}; + +use crate::cost::{cost_node_builder, CostNode, CostRunner}; + +/// A flat per-held-cycle carry in price units (`carry_per_cycle`), emitted in R via the +/// shared [`CostRunner`] in its per-held-cycle (accrual) charge mode. +pub struct CarryCost { + carry_per_cycle: f64, +} + +impl CarryCost { + /// A flat per-held-cycle carry node: the factor wrapped in the shared [`CostRunner`]. + pub fn new(carry_per_cycle: f64) -> CostRunner { + assert!(carry_per_cycle >= 0.0, "CarryCost carry_per_cycle must be >= 0"); + CostRunner::new(CarryCost { carry_per_cycle }) + } + + /// The param-generic recipe: one `carry_per_cycle` F64 knob, no extra inputs. + pub fn builder() -> PrimitiveBuilder { + cost_node_builder( + "CarryCost", + Vec::new(), + vec![ParamSpec { name: "carry_per_cycle".into(), kind: ScalarKind::F64 }], + |p| Box::new(CarryCost::new(p[0].f64())), + ) + } +} + +impl CostNode for CarryCost { + fn label(&self) -> String { + format!("CarryCost({})", self.carry_per_cycle) + } + fn cost_numerator(&mut self, _ctx: &Ctx<'_>) -> f64 { + self.carry_per_cycle + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cost::ChargeMode; + use aura_core::{AnyColumn, Cell, Node, Scalar, Timestamp}; + + fn 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 charge_mode_is_per_held_cycle() { + assert_eq!(CarryCost { carry_per_cycle: 1.0 }.charge_mode(), ChargeMode::PerHeldCycle); + } + + #[test] + fn accrues_each_held_cycle_then_dumps_at_close() { + // per = 1/4 = 0.25. open, open, close -> open_cost 0.25, 0.5, then dump 0.75. + let mut c = CarryCost::new(1.0); + let mut a = cols(); + a[0].push(Scalar::bool(false)).unwrap(); + a[1].push(Scalar::bool(true)).unwrap(); + a[2].push(Scalar::f64(100.0)).unwrap(); + a[3].push(Scalar::f64(96.0)).unwrap(); // latched 4 + assert_eq!( + c.eval(Ctx::new(&a, Timestamp(0))), + Some([Cell::from_f64(0.0), Cell::from_f64(0.0), Cell::from_f64(0.25)].as_slice()) + ); + let mut b = cols(); + b[0].push(Scalar::bool(false)).unwrap(); + b[1].push(Scalar::bool(true)).unwrap(); + b[2].push(Scalar::f64(100.0)).unwrap(); + b[3].push(Scalar::f64(96.0)).unwrap(); + assert_eq!( + c.eval(Ctx::new(&b, Timestamp(1))), + Some([Cell::from_f64(0.0), Cell::from_f64(0.0), Cell::from_f64(0.5)].as_slice()) + ); + let mut d = cols(); + d[0].push(Scalar::bool(true)).unwrap(); + d[1].push(Scalar::bool(false)).unwrap(); + d[2].push(Scalar::f64(100.0)).unwrap(); + d[3].push(Scalar::f64(96.0)).unwrap(); + assert_eq!( + c.eval(Ctx::new(&d, Timestamp(2))), + Some([Cell::from_f64(0.75), Cell::from_f64(0.75), Cell::from_f64(0.0)].as_slice()) + ); + } + + #[test] + fn label_carries_the_carry() { + assert_eq!(CarryCost::new(0.5).label(), "CarryCost(0.5)"); + } + + #[test] + fn builder_exposes_one_param_no_extra() { + let schema = CarryCost::builder().schema(); + assert_eq!(schema.params.len(), 1); + assert_eq!(schema.params[0].name, "carry_per_cycle"); + // geometry-only inputs (4), no extras. + assert_eq!(schema.inputs.len(), crate::cost::GEOMETRY_WIDTH); + } + + #[test] + #[should_panic(expected = "carry_per_cycle must be >= 0")] + fn new_panics_on_negative_carry() { + let _ = CarryCost::new(-1.0); + } +} +``` + +- [ ] **Step 3: Run carry_cost tests to verify they FAIL (RED)** + +Run: `cargo test -p aura-std --lib carry_cost` +Expected: FAIL — `charge_mode_is_per_held_cycle` fails (`AtClose != PerHeldCycle`) and +`accrues_each_held_cycle_then_dumps_at_close` fails by assertion (defaulting AtClose, the +second open cycle's `open_cost_in_r` is `0.25` not `0.5`, and the close charges `0.25` +not the dumped `0.75`). The other three (`label_carries_the_carry`, +`builder_exposes_one_param_no_extra`, `new_panics_on_negative_carry`) already pass. + +- [ ] **Step 4: Add the `charge_mode` override** + +Add the override + the `ChargeMode` import in `carry_cost.rs`. Change the import line to + +```rust +use crate::cost::{cost_node_builder, ChargeMode, CostNode, CostRunner}; +``` + +and add the method inside `impl CostNode for CarryCost`, after `label`: + +```rust + fn charge_mode(&self) -> ChargeMode { + ChargeMode::PerHeldCycle + } +``` + +- [ ] **Step 5: Run carry_cost tests to verify GREEN** + +Run: `cargo test -p aura-std --lib carry_cost` +Expected: PASS — all five `carry_cost` tests pass. + +--- + +## Task 3: CLI `--carry-per-cycle` flag wiring (main.rs) + +**Files:** +- Modify: `crates/aura-cli/src/main.rs:32` (import), `:2576-2579` (CostConfig), + `:2732-2746` (CarryCost push), `:3005-3010` (run_stage1_r sig), `:3023-3024` (carrier + guard + construction), `:3117-3123` (RunArgs), `:3132` (usage), `:3140-3141` + (accumulators), `:3178-3208` (parser + RunArgs construction), `:3218` (dispatch call), + `:3229` (USAGE), `:4446` (in-crate unit-test call site) + +Behaviour-preserving when the flag is unset (the goldens stay green); adds the carry node +when set. ALL `run_stage1_r` call sites are threaded in this task (the signature change + +both callers), so the build gate is satisfiable. No new test here — its correctness is +build-clean + the existing goldens byte-identical + the in-crate unit test green; the new +behaviour is exercised by Task 4. + +- [ ] **Step 1: Thread the flag through every site** + +`crates/aura-cli/src/main.rs:32` — add `CarryCost` to the `aura_std` import (it lists +`ConstantCost` on line 32): + +```rust + Add, Bias, CarryCost, ConstantCost, Delay, Ema, GatedRecorder, Gt, Latch, LongOnly, Mul, +``` + +(keep the rest of the multi-line `use aura_std::{ … }` block as-is; only insert +`CarryCost` in alphabetical position before `ConstantCost`.) + +`:2576-2579` — add the field to `CostConfig`: + +```rust +struct CostConfig { + const_cost: Option, // --cost-per-trade + slip_vol_mult: Option, // --slip-vol-mult + carry_per_cycle: Option, // --carry-per-cycle +} +``` + +`:2743-2746` — in the cost block, push a `CarryCost` after the `VolSlippageCost` push and +before `let cg = g.add(cost_graph(cost_nodes));` (no `vol_slot` bookkeeping — CarryCost has +no extra inputs): + +```rust + if let Some(cpc) = cfg.carry_per_cycle { + cost_nodes.push(CarryCost::builder().bind("carry_per_cycle", Scalar::f64(cpc))); + } + // A single cost_graph composite fans the shared PM-geometry into the active +``` + +`:3005-3010` — add the param to `run_stage1_r`'s signature (after `slip_vol_mult`): + +```rust + const_cost: Option, + slip_vol_mult: Option, + carry_per_cycle: Option, +``` + +`:3023-3024` — extend the carrier guard and the `CostConfig` construction: + +```rust + let cost_bundle = if const_cost.is_some() || slip_vol_mult.is_some() || carry_per_cycle.is_some() { + Some((CostConfig { const_cost, slip_vol_mult, carry_per_cycle }, tx_net, tx_cost)) +``` + +`:3117-3123` — add the field to `RunArgs`: + +```rust + cost: Option, + slip_vol_mult: Option, + carry_per_cycle: Option, +``` + +`:3132` — add `[--carry-per-cycle ]` to the `parse_run_args` usage string (append +after `[--slip-vol-mult ]`). + +`:3140-3141` — add the accumulator next to the existing two: + +```rust + let mut carry_per_cycle: Option = None; +``` + +`:3195` — add a parser arm verbatim-mirroring the `--slip-vol-mult` arm (main.rs:3187-3194, +the `tail`/`t` split-first cursor + `usage` closure + `< 0.0` reject), inserted after that +arm (before the `_ => return Err(usage()),` catch-all at 3196): + +```rust + "--carry-per-cycle" if carry_per_cycle.is_none() => { + let (value, t) = t.split_first().ok_or_else(usage)?; + let v: f64 = value.parse().map_err(|_| usage())?; + if v < 0.0 { + return Err(usage()); + } + carry_per_cycle = Some(v); + tail = t; + } +``` + +`:3208` — add the field to the `RunArgs` construction: + +```rust + Ok(RunArgs { harness, data, trace, cost, slip_vol_mult, carry_per_cycle }) +``` + +`:3218` — pass the new arg in the dispatch call: + +```rust + (HarnessKind::Stage1R, data) => run_stage1_r(data, trace, args.cost, args.slip_vol_mult, args.carry_per_cycle), +``` + +`:3229` — add `[--carry-per-cycle ]` to the top-level `USAGE` const's `run` form +(after `[--slip-vol-mult ]`). + +`:4446` — the in-crate unit test `run_stage1_r_synthetic_folds_an_r_block` call gains the +5th `None`: + +```rust + run_stage1_r(RunData::Synthetic, None, None, None, None) +``` + +(verify the exact current arg count at main.rs:4446 — it is `run_stage1_r(RunData::Synthetic, +None, None, None)`, four args; add one more `None`.) + +- [ ] **Step 2: Build the CLI crate clean** + +Run: `cargo build -p aura-cli` +Expected: `Finished` — 0 errors. (If `run_stage1_r` has any other call site the build will +name it; thread the new `None`/arg there too — all callers live in this task.) + +- [ ] **Step 3: Run the existing CLI goldens + the in-crate unit test (byte-identity)** + +Run: `cargo test -p aura-cli --test cli_run` +Expected: PASS — every existing test green, in particular +`stage1_r_flat_cost_net_expectancy_r_golden` (-614.3134020253314), +`stage1_r_composed_cost_net_expectancy_r_golden` (-615.0304388396047), and +`stage1_r_single_run_output_golden` byte-identical. + +Run: `cargo test -p aura-cli run_stage1_r_synthetic_folds_an_r_block` +(No `--lib`: this test lives in `main.rs` (a bin-target unit test), not a lib target, so a +name-only filter across all `aura-cli` targets is what resolves it.) +Expected: PASS — the threaded unit-test call site compiles and the R-block fold is unchanged. + +--- + +## Task 4: CLI carry goldens + the net_r_equity bleed test (cli_run.rs) + +**Files:** +- Test: `crates/aura-cli/tests/cli_run.rs` — append three tests near the existing cost + goldens (~after line 1729) + +Two capture-then-pin net_expectancy_r goldens (the carry value is whatever the +deterministic binary emits — RUN it, read the float, pin it; do NOT invent it) and the +bleed test (the integration B-proof: `net_r_equity` falls DURING the terminal multi-cycle +hold, ts 14-18, where the position stays open and never closes). Depends on Task 3. + +- [ ] **Step 1: Add the carry-only net_expectancy_r golden (capture-then-pin)** + +```rust +/// Golden characterization (cycle 0085, the per-held-cycle accrual path): pins the EXACT +/// `net_expectancy_r` of `aura run --harness stage1-r --carry-per-cycle 0.5`, so the +/// CarryCost accrual (open_cost grows over the hold, dumps into cum at close) is VERIFIED +/// at the observable boundary. Deterministic over the fixed synthetic stream (C1). +#[test] +fn stage1_r_carry_cost_net_expectancy_r_golden() { + let out = Command::new(BIN) + .args(["run", "--harness", "stage1-r", "--carry-per-cycle", "0.5"]) + .output() + .unwrap(); + assert!(out.status.success(), "exit: {:?}; stderr: {}", out.status, + String::from_utf8_lossy(&out.stderr)); + let s = String::from_utf8(out.stdout).expect("utf-8 stdout"); + let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap(); + let net = v["metrics"]["r"]["net_expectancy_r"].as_f64().unwrap(); + assert_eq!( + net, /* CAPTURE: the exact f64 this run prints */ 0.0, + "carry net_expectancy_r drifted from the golden: {s}" + ); +} +``` + +- [ ] **Step 2: Capture and pin the carry-only golden value** + +Run: `cargo test -p aura-cli --test cli_run stage1_r_carry_cost_net_expectancy_r_golden` +Expected: FAIL first (placeholder `0.0` mismatches) — the panic message prints the actual +`net_expectancy_r` (and the full report `s`). Replace the `0.0` with the EXACT printed +f64, re-run. +Expected (after pin): PASS. + +- [ ] **Step 3: Add the composed cost+carry net_expectancy_r golden (capture-then-pin)** + +```rust +/// Golden characterization (cycle 0085, composed at-close + accrual): pins the EXACT +/// `net_expectancy_r` of `aura run --harness stage1-r --cost-per-trade 2 --carry-per-cycle +/// 0.5` — the per-trade ConstantCost and the per-held-cycle CarryCost sum through +/// cost_graph/CostSum into one net-R curve. Deterministic (C1). +#[test] +fn stage1_r_cost_and_carry_composed_net_expectancy_r_golden() { + let out = Command::new(BIN) + .args(["run", "--harness", "stage1-r", "--cost-per-trade", "2", "--carry-per-cycle", "0.5"]) + .output() + .unwrap(); + assert!(out.status.success(), "exit: {:?}; stderr: {}", out.status, + String::from_utf8_lossy(&out.stderr)); + let s = String::from_utf8(out.stdout).expect("utf-8 stdout"); + let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap(); + let net = v["metrics"]["r"]["net_expectancy_r"].as_f64().unwrap(); + assert_eq!( + net, /* CAPTURE: the exact f64 this run prints */ 0.0, + "composed cost+carry net_expectancy_r drifted from the golden: {s}" + ); +} +``` + +- [ ] **Step 4: Capture and pin the composed golden value** + +Run: `cargo test -p aura-cli --test cli_run stage1_r_cost_and_carry_composed_net_expectancy_r_golden` +Expected: FAIL first (placeholder), read the printed f64, pin it, re-run → PASS. + +- [ ] **Step 5: Add the net_r_equity bleed test (the integration B-proof)** + +The terminal position is held open over the last consecutive cycles (the synthetic +fixture's ts 14-18, never closing). Under accrual the gap `r_equity − net_r_equity` +(= `cum_cost_in_r + open_cost_in_r`) STRICTLY GROWS across that terminal hold (open_cost +accrues each cycle), whereas a non-accruing (A / would-be-constant) cost would leave it +FLAT. Read both taps' first column off disk via the existing `json_array_body` helper +(cli_run.rs:478) and assert strict growth over the last 3 recorded cycles. + +```rust +/// Property (cycle 0085, the approach-B discriminator): with `--carry-per-cycle`, the +/// `net_r_equity` tap BLEEDS continuously during a multi-cycle hold — the gap to the +/// gross `r_equity` strictly grows each held cycle as the carry accrues, rather than +/// stepping only at close (approach A, which net_expectancy_r alone could not tell +/// apart). Read over the synthetic fixture's terminal open hold (the position held to the +/// end, never closing), where cum_cost_in_r is constant so the growth is the open-side +/// accrual alone. +#[test] +fn stage1_r_carry_net_r_equity_bleeds_over_the_hold() { + let dir = temp_cwd("stage1-r-carry-bleed"); + let run = Command::new(BIN) + .current_dir(&dir) + .args(["run", "--harness", "stage1-r", "--carry-per-cycle", "0.5", "--trace", "bleed"]) + .output() + .unwrap(); + assert!(run.status.success(), "exit: {:?}; stderr: {}", run.status, + String::from_utf8_lossy(&run.stderr)); + + let read_col = |tap: &str| -> Vec { + let s = std::fs::read_to_string(dir.join(format!("runs/traces/bleed/{tap}.json"))) + .unwrap_or_else(|e| panic!("read {tap}.json: {e}")); + json_array_body(&s, "\"columns\":[[") + .split(',') + .map(|x| x.trim().parse::().unwrap_or_else(|_| panic!("parse {tap} value {x:?}"))) + .collect() + }; + let r_eq = read_col("r_equity"); + let net_eq = read_col("net_r_equity"); + assert_eq!(r_eq.len(), net_eq.len(), "taps are co-temporal (parallel arrays)"); + let n = r_eq.len(); + assert!(n >= 3, "need a multi-cycle terminal hold to observe the bleed; len {n}"); + // gap = r_equity − net_r_equity = cum_cost_in_r + open_cost_in_r. Over the terminal + // open hold cum is constant, so a strictly growing gap proves the open-side accrual. + let gap = |i: usize| r_eq[i] - net_eq[i]; + assert!( + gap(n - 3) < gap(n - 2) && gap(n - 2) < gap(n - 1), + "net_r_equity must bleed during the terminal hold (gap strictly grows): \ + gaps {}, {}, {}", + gap(n - 3), gap(n - 2), gap(n - 1) + ); + assert!(gap(n - 1) > 0.0, "carry is charged: terminal gap {} > 0", gap(n - 1)); + let _ = std::fs::remove_dir_all(&dir); +} +``` + +- [ ] **Step 6: Run the bleed test to verify GREEN** + +Run: `cargo test -p aura-cli --test cli_run stage1_r_carry_net_r_equity_bleeds_over_the_hold` +Expected: PASS — the terminal-hold gaps strictly increase (the accrual bleed), proving B +was built, not A. + +- [ ] **Step 7: Full workspace regression + clippy** + +Run: `cargo test --workspace` +Expected: PASS — 0 failures across all crates (the new cost.rs/carry_cost.rs/cli_run.rs +tests + every existing test, including the three byte-identity goldens). + +Run: `cargo clippy --workspace --all-targets -- -D warnings` +Expected: clean — no warnings (the new `acc` field is read on the PerHeldCycle branch; no +dead code).