Files
Aura/docs/plans/0085-per-cycle-accrual-carry-cost.md
T
Brummel 2a8ee86489 plan: 0085 per-cycle-held accrual + CarryCost
Parent spec docs/specs/0085 (19ba1ec). 4 tasks: (1) ChargeMode + the PerHeldCycle
accrual branch on the shared CostRunner (AtClose arm kept verbatim for byte-identity)
+ 2 RED B-proof unit tests; (2) the CarryCost node (ConstantCost twin, PerHeldCycle)
+ lib.rs registration; (3) the --carry-per-cycle CLI flag threaded through every site
(incl. the easy-to-miss main.rs:4446 bin unit-test call site); (4) two capture-then-pin
net_expectancy_r goldens + the net_r_equity-bleeds-over-the-hold integration B-proof.

summarize_r and the CLI net_eq wiring are UNCHANGED; the cycle-0083/0084 goldens are
the byte-identity regression net.

refs #148
2026-06-29 00:19:48 +02:00

30 KiB
Raw Blame History

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.rsChargeMode 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.rsCarryCost node (ConstantCost twin, charge_mode() -> PerHeldCycle) + its #[cfg(test)] tests.
  • Modify: crates/aura-std/src/lib.rs:22-24,51-53mod carry_cost;, pub use carry_cost::CarryCost;, add ChargeMode to the pub use cost::{…} re-export.
  • Modify: crates/aura-cli/src/main.rsCarryCost 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):

/// 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):

    /// 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:

    /// 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):

    #[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<F> struct (cost.rs:96-100), after cum:

pub struct CostRunner<F: CostNode> {
    factor: F,
    cum: f64,
    acc: f64,
    out: [Cell; COST_WIDTH],
}

Init it in CostRunner::new (cost.rs:104):

    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)

        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):

        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):

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):

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)
//! `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<CarryCost> {
        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<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 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

use crate::cost::{cost_node_builder, ChargeMode, CostNode, CostRunner};

and add the method inside impl CostNode for CarryCost, after label:

    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):

    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:

struct CostConfig {
    const_cost: Option<f64>,    // --cost-per-trade
    slip_vol_mult: Option<f64>, // --slip-vol-mult
    carry_per_cycle: Option<f64>, // --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):

            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):

    const_cost: Option<f64>,
    slip_vol_mult: Option<f64>,
    carry_per_cycle: Option<f64>,

:3023-3024 — extend the carrier guard and the CostConfig construction:

    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:

    cost: Option<f64>,
    slip_vol_mult: Option<f64>,
    carry_per_cycle: Option<f64>,

:3132 — add [--carry-per-cycle <f64>] to the parse_run_args usage string (append after [--slip-vol-mult <f64>]).

:3140-3141 — add the accumulator next to the existing two:

    let mut carry_per_cycle: Option<f64> = 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):

            "--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:

    Ok(RunArgs { harness, data, trace, cost, slip_vol_mult, carry_per_cycle })

:3218 — pass the new arg in the dispatch call:

        (HarnessKind::Stage1R, data) => run_stage1_r(data, trace, args.cost, args.slip_vol_mult, args.carry_per_cycle),

:3229 — add [--carry-per-cycle <f64>] to the top-level USAGE const's run form (after [--slip-vol-mult <f64>]).

:4446 — the in-crate unit test run_stage1_r_synthetic_folds_an_r_block call gains the 5th None:

    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)
/// 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)
/// 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.

/// 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<f64> {
        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::<f64>().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).