arch(crt-a): delete decision_stride field — greenfields atomic refactor

Per spec 2026-05-20-continuous-reasoning-trader-design.md §3.3 and §8:
decision_stride is REMOVED, not deprecated. No backwards-compat shim,
no fallback. Every consumer migrates in this commit per
feedback_no_partial_refactor.

Removed from:
  - bin/fxt-backtest: RunArgs CLI flag, SweepBase field, SweepCell
    override field, default_decision_stride() function, all three
    BacktestHarnessConfig and RunArgs construction sites
  - crates/ml-backtesting/src/harness.rs: BacktestHarnessConfig field,
    MultiHorizonLoaderConfig decision_stride initializer, `let stride`
    local, `if event_count % stride == 0` gate around
    step_decision_with_latency; forward_step_into + step_decision now
    share a single window-full guard (merged into one `if` block)
  - crates/ml-alpha/src/data/loader.rs: MultiHorizonLoaderConfig field,
    next_sequence stride logic simplified to stride=1 (consecutive
    snapshots only)
  - crates/ml-alpha/src/trainer/perception.rs: PerceptionTrainerConfig
    field and Default impl; all four dt_s locals replaced with 1.0_f32
    (training K-loop, graph-capture K-loop, forward_step_into CfC step,
    eval K-loop)
  - crates/ml-alpha/examples/alpha_train.rs: CLI flag, trainer_cfg and
    both loader configs
  - crates/ml/examples/alpha_baseline.rs: CLI flag, train + eval stride
    gates replaced with unconditional read_all()
  - config/ml/*.yaml: decision_stride: lines removed from
    sweep_smoke, sweep_threshold_tuning, sweep_deployability,
    sweep_decision_stride_example (file repurposed as generic example)
  - tests: forward_step_golden, perception_overfit (×7 structs including
    the stride=4 smoke repurposed as a second convergence check),
    multi_horizon_loader (stride=4 spacing test repurposed as
    ts_ns monotonicity check), ring3_replay, trainer_parity

Harness loop now invokes BOTH forward_step_into AND
step_decision_with_latency on every event whenever the snapshot window
is full. forward_step_into advances SSM state and writes alpha_probs_d;
step_decision_with_latency reads alpha_probs_d immediately after —
no CPU roundtrip, no stride gate.

n_decisions ≈ events_processed - seq_len + 1 after this commit
(vs ~9999 at stride=200 in the S2 baseline).

cargo check --workspace: clean
cargo test -p ml-backtesting --lib: 33 passed
cargo test -p ml-alpha --lib: 33 passed (6 ignored)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-20 18:48:23 +02:00
parent 92f8b10ed2
commit 045850e8f3
16 changed files with 53 additions and 188 deletions

View File

@@ -67,9 +67,6 @@ struct RunArgs {
/// If omitted, every cell gets Strategy::default_for(--max-lots).
#[arg(long)]
policy_grid: Option<PathBuf>,
/// Decide every Sth event (matches training stride; default 4).
#[arg(long, default_value_t = 4)]
decision_stride: usize,
/// Total submission→fill latency in nanoseconds (IBKR + Scaleway
/// baseline = 100_000_000 = 100ms). Propagated to the resting-order
/// engine so in-flight orders wait for arrival_ts_ns before crossing.
@@ -188,7 +185,6 @@ struct SweepBase {
#[serde(default)]
predecoded_dir: Option<PathBuf>,
#[serde(default = "default_n_parallel")] n_parallel: usize,
#[serde(default = "default_decision_stride")] decision_stride: usize,
#[serde(default = "default_latency_ns")] latency_ns: u32,
#[serde(default = "default_target_vol")] target_annual_vol_units: f32,
#[serde(default = "default_ann_factor")] annualisation_factor: f32,
@@ -232,7 +228,6 @@ struct SimVariant {
}
fn default_n_parallel() -> usize { 1 }
fn default_decision_stride() -> usize { 4 }
fn default_latency_ns() -> u32 { 100_000_000 }
fn default_target_vol() -> f32 { 50.0 }
fn default_ann_factor() -> f32 { 825.0 }
@@ -255,7 +250,6 @@ struct SweepCell {
/// SweepBase.data_template. When set, the cell's data path is
/// data_template.replace("{window}", window).
#[serde(default)] window: Option<String>,
#[serde(default)] decision_stride: Option<usize>,
#[serde(default)] latency_ns: Option<u32>,
#[serde(default)] target_annual_vol_units: Option<f32>,
#[serde(default)] annualisation_factor: Option<f32>,
@@ -370,8 +364,7 @@ fn sweep(args: SweepArgs) -> Result<()> {
n_parallel: grid.base.n_parallel,
policy_grid: None, // sweep cells use the default policy; nested
// grid-of-grids is a separate v2 concern.
decision_stride: cell.decision_stride.unwrap_or(grid.base.decision_stride),
latency_ns: cell.latency_ns.unwrap_or(grid.base.latency_ns),
latency_ns: cell.latency_ns.unwrap_or(grid.base.latency_ns),
target_annual_vol_units:
cell.target_annual_vol_units.unwrap_or(grid.base.target_annual_vol_units),
annualisation_factor:
@@ -456,7 +449,6 @@ fn run_batched_cell(
data_root: cell_data.to_path_buf(),
predecoded_dir: base.predecoded_dir.clone().unwrap_or_else(|| cell_data.to_path_buf()),
n_parallel: n,
decision_stride: cell.decision_stride.unwrap_or(base.decision_stride),
target_annual_vol_units: base.target_annual_vol_units, // overridden by sim_config
annualisation_factor: base.annualisation_factor,
max_lots: base.max_lots,
@@ -543,7 +535,6 @@ fn run(args: RunArgs) -> Result<()> {
data_root: args.data.clone(),
predecoded_dir: args.predecoded_dir.clone().unwrap_or(args.data),
n_parallel: args.n_parallel,
decision_stride: args.decision_stride,
target_annual_vol_units: args.target_annual_vol_units,
annualisation_factor: args.annualisation_factor,
max_lots: args.max_lots,

View File

@@ -1,16 +1,16 @@
# Example sweep grid for `fxt-backtest sweep`.
# Mirrors the decision_stride sweep deferred from the original plan.
# A1: decision_stride removed. This file previously swept stride={1,2,4,8};
# those cell overrides are now no-ops. Repurposed as a threshold sweep example.
#
# Run:
# fxt-backtest sweep --grid config/ml/sweep_decision_stride_example.yaml \
# --out results/sweep_stride
# Produces results/sweep_stride/{stride_1,stride_2,stride_4,stride_8}/{summary.json,trades.csv,pnl_curve.bin}
# --out results/sweep_example
# Produces results/sweep_example/{cell_1,...}/{summary.json,trades.csv,pnl_curve.bin}
# plus aggregate.parquet + pareto_frontier.json at the root.
base:
data: test_data/futures-baseline/ES.FUT
n_parallel: 1
decision_stride: 4
latency_ns: 100000000 # 100 ms IBKR + Scaleway baseline
target_annual_vol_units: 50.0
annualisation_factor: 825.0
@@ -20,11 +20,7 @@ base:
# checkpoint: path/to/trained.bin # uncomment to use real weights
cells:
- name: stride_1
decision_stride: 1
- name: stride_2
decision_stride: 2
- name: stride_4
decision_stride: 4
- name: stride_8
decision_stride: 8
- name: cell_1
- name: cell_2
- name: cell_3
- name: cell_4

View File

@@ -6,7 +6,6 @@ base:
data_template: /mnt/training-data/futures-baseline-mbp10/ES.FUT/ES.FUT_{window}.dbn.zst
predecoded_dir: /feature-cache/predecoded
n_parallel: 1 # batched flow overrides with variants.len()=140
decision_stride: 4
latency_ns: 200000000 # default (overridden per variant)
target_annual_vol_units: 50.0
annualisation_factor: 825.0

View File

@@ -15,13 +15,8 @@ base:
data: /mnt/training-data/futures-baseline-mbp10/ES.FUT
predecoded_dir: /feature-cache/predecoded
n_parallel: 1 # overridden by sim_variants.len()
# decision_stride matches the latency anchor: ~1ms per event × stride=200
# = 200ms between decisions ≈ latency_ns. Prior smoke (n59t4) at stride=4
# fired decisions 50× faster than orders could land; controller made
# decisions on phantom in-flight positions, churned ~96 trades/sec, and
# burned cost-per-trade × decision-rate. With stride=200, ~10k decisions
# over 2M events — natural cadence for a 200ms-latency deployment anchor.
decision_stride: 200
# A1: decision_stride removed. Decisions now fire every event (event-rate
# CRT). With ~2M events, expect ~2M decisions (minus seq_len warmup).
latency_ns: 200000000 # realistic anchor (overridden per variant)
target_annual_vol_units: 50.0
annualisation_factor: 825.0

View File

@@ -10,7 +10,6 @@ base:
data_template: /mnt/training-data/futures-baseline-mbp10/ES.FUT/ES.FUT_{window}.dbn.zst
predecoded_dir: /feature-cache/predecoded
n_parallel: 1 # legacy default; overridden by variants.len() per cell
decision_stride: 4
latency_ns: 200000000 # anchor (Scaleway PAR → IBKR realistic RTT)
target_annual_vol_units: 50.0
annualisation_factor: 825.0

View File

@@ -153,15 +153,6 @@ struct Cli {
#[arg(long, default_value_t = 0)]
cv_train_window: usize,
/// Decision-stride sampling: yield every S-th snapshot per training
/// sequence. S=1 (default) preserves current consecutive-snapshot
/// behaviour. S>1 expands the effective time-window covered by each
/// sequence (window = (seq_len-1) * S + 1 snapshots) at the same
/// compute cost. Pairs with Mamba2 dt_s scaling so the SSM's state
/// decay reflects the actual elapsed time between K-positions.
/// Recommended S=4 with K=64 for h6000 deployment (256-tick window).
#[arg(long, default_value_t = 1)]
decision_stride: usize,
}
#[derive(Serialize, serde::Deserialize, Default)]
@@ -270,7 +261,6 @@ fn main() -> Result<()> {
seed: cli.seed,
horizon_weights,
n_batch: cli.batch_size,
decision_stride: cli.decision_stride,
};
let mut trainer = PerceptionTrainer::new(&dev, &trainer_cfg).context("trainer init")?;
@@ -400,7 +390,6 @@ fn main() -> Result<()> {
horizons,
n_max_sequences: cli.n_train_seqs,
seed: cli.seed,
decision_stride: cli.decision_stride,
inference_only: false,
})
.context("train loader")?;
@@ -411,7 +400,6 @@ fn main() -> Result<()> {
horizons,
n_max_sequences: cli.n_val_seqs,
seed: cli.seed.wrapping_add(0xC0FFEE),
decision_stride: cli.decision_stride,
inference_only: false,
})
.context("val loader")?;

View File

@@ -107,18 +107,6 @@ pub struct MultiHorizonLoaderConfig {
/// seed only randomises which snapshot inside each file becomes
/// the start of each yielded sequence.)
pub seed: u64,
/// Decision-stride sampling. With `decision_stride = S`, position k
/// of the yielded sequence reads snapshot `anchor + k * S` from the
/// source file. `S = 1` (default) preserves the original
/// consecutive-snapshot behaviour. `S > 1` expands the effective
/// time-window covered by each sequence (window = (seq_len - 1) * S
/// + 1 snapshots) at the same K-positions compute cost. The
/// per-sequence `Δt = ts_ns - prev_ts_ns` then reflects the actual
/// elapsed time between consecutive K-positions, which is what
/// downstream Mamba2 SSM dt_s + Phase 2C TGN Fourier features
/// consume. Labels stay in absolute-snapshot horizons (h=6000 is
/// always "predict 6000 snapshots forward" regardless of stride).
pub decision_stride: usize,
/// When `true`, skip per-file forward-label precomputation (saves
/// ~half the file-load cost) and enable chronological inference
/// streaming via `next_inference_input()`. Used by the ml-backtesting
@@ -290,9 +278,8 @@ impl MultiHorizonLoader {
/// Iterator-style accessor for inference mode: walks every loaded
/// snapshot across every loaded file in chronological order.
/// Returns `Ok(None)` when the stream is exhausted. Unlike
/// [`next_sequence`], does NOT slice fixed-length windows and does
/// NOT apply `decision_stride` (caller's concern — see
/// `docs/superpowers/specs/2026-05-18-real-lob-integration-design.md` §7).
/// [`next_sequence`], does NOT slice fixed-length windows.
/// A1: decision_stride removed; every snapshot is yielded consecutively.
/// Mutates the inference cursor. Errors if `cfg.inference_only` is false.
pub fn next_inference_input(&mut self) -> Result<Option<Mbp10RawInput>> {
anyhow::ensure!(
@@ -336,7 +323,8 @@ impl MultiHorizonLoader {
return Ok(None);
}
let max_horizon = *self.cfg.horizons.iter().max().expect("non-empty horizons");
let stride = self.cfg.decision_stride.max(1);
// A1: decision_stride removed; training sequences are always consecutive
// snapshots (stride = 1). dt_s = 1.0 in all CfC forward passes.
// Pick a random file from the preloaded inventory, then a random
// anchor within that file. Uniform-across-files sampling — each
@@ -344,16 +332,11 @@ impl MultiHorizonLoader {
let n_files = self.files_loaded.len();
let file_idx = self.rng.gen_range(0..n_files);
let lf = &self.files_loaded[file_idx];
// With stride S, position k reads snapshot `anchor + k * S`; the
// last sequence index is `anchor + (seq_len-1) * S`, and the
// label there looks `max_horizon` forward in raw snapshots
// (labels stay in absolute-snapshot units regardless of stride).
let last_seq_offset = (self.cfg.seq_len - 1) * stride;
let needed = last_seq_offset + 1 + max_horizon;
let needed = self.cfg.seq_len + max_horizon;
anyhow::ensure!(
lf.snapshots.len() > needed,
"file too short for seq_len={} stride={} max_horizon={}: {} <= {}",
self.cfg.seq_len, stride, max_horizon, lf.snapshots.len(), needed
"file too short for seq_len={} max_horizon={}: {} <= {}",
self.cfg.seq_len, max_horizon, lf.snapshots.len(), needed
);
let max_anchor = lf.snapshots.len() - needed;
let anchor: usize = self.rng.gen_range(0..max_anchor);
@@ -362,28 +345,15 @@ impl MultiHorizonLoader {
for h in 0..5 {
let mut row = Vec::with_capacity(self.cfg.seq_len);
for k in 0..self.cfg.seq_len {
row.push(lf.labels_full[h][anchor + k * stride]);
row.push(lf.labels_full[h][anchor + k]);
}
labels[h] = row;
}
let mut sequence = Vec::with_capacity(self.cfg.seq_len);
for k in 0..self.cfg.seq_len {
let idx = anchor + k * stride;
let idx = anchor + k;
let cur = &lf.snapshots[idx];
// `prev` for microstructure features is the snapshot one
// K-position back in the strided sequence, NOT the
// consecutive-snapshot prior. This makes `Δt = ts_ns -
// prev_ts_ns` carry the actual elapsed time between
// consecutive K-positions — Mamba2's dt_s and the Phase 2C
// TGN Fourier features both consume this.
let prev_idx = if k == 0 {
// At k=0 there's no prior K-position; fall back to the
// immediately-prior snapshot (or self if anchor=0). Δt
// here will be ~tick-scale, but only at position 0.
if idx == 0 { 0 } else { idx - 1 }
} else {
anchor + (k - 1) * stride
};
let prev_idx = if idx == 0 { 0 } else { idx - 1 };
let prev = &lf.snapshots[prev_idx];
sequence.push(convert(cur, prev, lf.regime_full[idx]));
}
@@ -567,7 +537,6 @@ mod inference_mode_tests {
horizons: [30, 100, 300, 1000, 6000],
n_max_sequences: 1,
seed: 0xCAFEF00D,
decision_stride: 1,
inference_only,
})
}
@@ -648,7 +617,6 @@ mod inference_mode_tests {
horizons: [30, 100, 300, 1000, 6000],
n_max_sequences: 1,
seed: 0,
decision_stride: 1,
inference_only: false,
};
if let Ok(mut loader) = MultiHorizonLoader::new(&cfg) {

View File

@@ -82,13 +82,6 @@ pub struct PerceptionTrainerConfig {
/// (cfc_step_batched, multi_horizon_heads_batched, etc.).
/// `n_batch=1` matches the unbatched behavior exactly.
pub n_batch: usize,
/// Decision-stride from the loader. Sets Mamba2's `dt_s` scalar to
/// reflect the time gap between consecutive K-positions: at stride=1
/// dt_s=1.0; at stride=4 dt_s=4.0. Mamba2's selective scan uses
/// `exp(-dt * sigmoid(a))` for the state-decay gate, so this matters
/// for the SSM dynamics when stride > 1. `1` matches the legacy
/// consecutive-snapshot behaviour exactly.
pub decision_stride: usize,
}
impl Default for PerceptionTrainerConfig {
@@ -101,7 +94,6 @@ impl Default for PerceptionTrainerConfig {
seed: 0x4242,
horizon_weights: [1.0; N_HORIZONS],
n_batch: 1,
decision_stride: 1,
}
}
}
@@ -1736,10 +1728,8 @@ impl PerceptionTrainer {
self.stream.memset_zeros(&mut self.zero_h_d)
.map_err(|e| anyhow::anyhow!("zero zero_h: {e}"))?;
// Launch configs reused inside the K loop. With decision_stride S,
// dt_s = S so Mamba2's selective scan `exp(-dt * sigmoid(a))`
// reflects the actual elapsed time between consecutive K-positions.
let dt_s = self.cfg.decision_stride.max(1) as f32;
// A1: decision_stride removed; CRT runs at event rate, dt = 1.0 per step.
let dt_s = 1.0_f32;
let n_in_i = HIDDEN_DIM as i32;
let n_hid_i = HIDDEN_DIM as i32;
let block_dim = 128u32;
@@ -2794,7 +2784,8 @@ impl PerceptionTrainer {
// Per-K CfC + heads. Pointer arithmetic on stable base addresses;
// no host branches inside the captured region.
let dt_s = self.cfg.decision_stride.max(1) as f32;
// A1: decision_stride removed; CRT runs at event rate, dt = 1.0 per step.
let dt_s = 1.0_f32;
let n_in_i = HIDDEN_DIM as i32;
let n_hid_i = HIDDEN_DIM as i32;
let cfc_fwd_smem = (2 * HIDDEN_DIM * std::mem::size_of::<f32>()) as u32;
@@ -3107,9 +3098,9 @@ impl PerceptionTrainer {
// ── 9. CfC single step. h_old is the persistent cfc_h_state_step;
// h_new is written to cfc_h_new_step (then copied back into
// cfc_h_state_step). decision_stride=1 in event-rate mode —
// the harness no longer applies stride scaling to dt_s.
let dt_s: f32 = self.cfg.decision_stride.max(1) as f32;
// cfc_h_state_step).
// A1: decision_stride removed; event-rate dt = 1.0 per CfC step.
let dt_s: f32 = 1.0;
let n_in_i: i32 = HIDDEN_DIM as i32;
let n_hid_i: i32 = HIDDEN_DIM as i32;
let n_batch_i: i32 = b_sz as i32;
@@ -3642,10 +3633,9 @@ impl PerceptionTrainer {
}
self.stream.memset_zeros(&mut self.zero_h_d).map_err(|e| anyhow::anyhow!("zero h: {e}"))?;
// Per-K forward (recurrent CfC + heads, batched). Eval mirrors
// training: dt_s = decision_stride so Mamba2's SSM dynamics
// match what was trained.
let dt_s = self.cfg.decision_stride.max(1) as f32;
// Per-K forward (recurrent CfC + heads, batched).
// A1: decision_stride removed; event-rate dt = 1.0 per CfC step.
let dt_s = 1.0_f32;
let n_in_i = HIDDEN_DIM as i32;
let n_hid_i = HIDDEN_DIM as i32;
// Phase B fix-up: block-per-batch (Phase B), same launch contract as

View File

@@ -96,7 +96,6 @@ fn build_trainer(dev: &MlDevice) -> Result<PerceptionTrainer> {
n_batch: 1,
mamba2_state_dim: 16,
seed: SEED,
decision_stride: 1,
..Default::default()
};
PerceptionTrainer::new(dev, &cfg).context("trainer init")

View File

@@ -22,7 +22,6 @@ fn cfg_from_env() -> Option<MultiHorizonLoaderConfig> {
horizons: [30, 100, 300, 1000, 6000],
n_max_sequences: 100,
seed: 0xA1A2_A3A4,
decision_stride: 1,
inference_only: false,
})
}
@@ -65,7 +64,6 @@ fn loader_errors_on_empty_files() {
horizons: [30, 100, 300, 1000, 6000],
n_max_sequences: 10,
seed: 0,
decision_stride: 1,
inference_only: false,
};
let res = MultiHorizonLoader::new(&cfg);
@@ -75,24 +73,18 @@ fn loader_errors_on_empty_files() {
#[test]
#[ignore]
fn loader_stride_4_yields_correct_spacing() {
// Verifies decision_stride=4 produces snapshots that are 4-apart in
// the source file. Only meaningful with real data. Uses ts_ns to
// confirm the spacing (since indices aren't exposed externally).
// A1: decision_stride removed; sequences are always consecutive snapshots.
// Test repurposed: verifies consecutive-snapshot ts_ns monotonicity.
let Some(mut cfg) = cfg_from_env() else {
eprintln!("skipping: FOXHUNT_TEST_DATA not set or mbp10 dir missing");
return;
};
cfg.decision_stride = 4;
cfg.seq_len = 32;
cfg.n_max_sequences = 5;
let mut loader = MultiHorizonLoader::new(&cfg).expect("loader init");
while let Some(seq) = loader.next_sequence().expect("next") {
assert_eq!(seq.snapshots.len(), cfg.seq_len);
// The Δt between consecutive K-positions should be visibly
// larger than a single-tick gap. At stride=4 on ES MBP-10, that
// means the median Δt across positions should exceed ~3× the
// typical tick interval (microsecond-scale jitter is fine).
let dts_ns: Vec<i64> = (1..seq.snapshots.len())
.map(|k| (seq.snapshots[k].ts_ns as i64) - (seq.snapshots[k - 1].ts_ns as i64))
.filter(|d| *d >= 0) // ts_ns is monotonic in MBP-10
@@ -101,11 +93,7 @@ fn loader_stride_4_yields_correct_spacing() {
let mut sorted = dts_ns.clone();
sorted.sort();
let median = sorted[sorted.len() / 2];
eprintln!("stride=4 median Δt_ns between K-positions = {median}");
// Sanity: median Δt should be > 0 (stride>1 means no zero-Δt
// self-pairs in the steady state). Stride > 1 with sparse
// periods could theoretically dip to zero on quiet windows,
// so don't assert a lower bound on the median magnitude.
eprintln!("consecutive median Δt_ns between K-positions = {median}");
assert!(median >= 0, "Δt_ns negative — ts_ns not monotonic?");
}
}

View File

@@ -72,7 +72,6 @@ fn stacked_trainer_loss_shrinks_on_constant_signal() {
seed: 0x4242,
horizon_weights: [1.0; 5],
n_batch: 1,
decision_stride: 1,
};
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
@@ -130,12 +129,11 @@ fn stacked_trainer_loss_shrinks_on_constant_signal() {
);
}
/// Verifies the trainer still converges on a constant-direction signal
/// with `decision_stride = 4`. Stride affects loader output AND Mamba2
/// dt_s (= 4.0 in the K-loop) — this test ensures the full chain stays
/// numerically stable. Synthetic sequence here uses consecutive
/// snapshots (the loader-level stride is what skips); the trainer-level
/// dt_s change is what this smoke proves.
/// Verifies the trainer still converges with a distinct seed and lr.
/// A1: decision_stride removed; dt_s is always 1.0 (event-rate).
/// The test previously verified stride=4 dt_s dynamics; those are
/// now unconditionally event-rate, so this becomes a second convergence
/// smoke with different hyperparameters.
#[test]
fn stacked_trainer_loss_shrinks_with_stride_4() {
let dev = test_device();
@@ -147,7 +145,6 @@ fn stacked_trainer_loss_shrinks_with_stride_4() {
seed: 0xC4C4,
horizon_weights: [1.0; 5],
n_batch: 1,
decision_stride: 4,
};
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
@@ -201,7 +198,6 @@ fn stacked_trainer_loss_shrinks_at_batch_32() {
seed: 0xB32B,
horizon_weights: [1.0; 5],
n_batch: 32,
decision_stride: 1,
};
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
@@ -299,7 +295,6 @@ fn evaluate_alone_succeeds() {
seed: 0x6262,
horizon_weights: [1.0; 5],
n_batch: 1,
decision_stride: 1,
};
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
let ts = 1_000_000u64;
@@ -327,7 +322,6 @@ fn evaluate_works_after_captured_training_step() {
seed: 0x5151,
horizon_weights: [1.0; 5],
n_batch: 1,
decision_stride: 1,
};
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
@@ -362,7 +356,6 @@ fn evaluate_works_after_capture_no_replay() {
seed: 0x8181,
horizon_weights: [1.0; 5],
n_batch: 1,
decision_stride: 1,
};
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
let ts = 1_000_000u64;
@@ -392,7 +385,6 @@ fn horizon_ema_and_lambda_track_after_training() {
seed: 0x9292,
horizon_weights: [1.0; 5],
n_batch: 1,
decision_stride: 1,
};
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
@@ -448,7 +440,6 @@ fn evaluate_works_after_warmup_only() {
seed: 0x7171,
horizon_weights: [1.0; 5],
n_batch: 1,
decision_stride: 1,
};
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
let ts = 1_000_000u64;

View File

@@ -309,11 +309,8 @@ extern "C" __global__ void resting_orders_step(
last_event_ts[b] = current_ts_ns;
// S2.2: max_hold force-close at event rate.
// Mirrors the decision-rate check that used to live in stop_check_isv (removed),
// but runs every event so the 60s cap is actually enforceable. The previous
// decision-rate check fired every decision_stride events (~13 min sim-time on ES
// data at stride=200), so it was ~13 min LATE on average. Event-rate enforces
// within one event of the cap.
// Runs every event so the 60s cap is actually enforceable.
// A1: decision_stride removed; all enforcement is event-rate.
const unsigned long long max_hold = max_hold_ns_per_b[b];
if (max_hold > 0ull && pos.position_lots != 0) {
const unsigned long long entry_ts =

View File

@@ -27,7 +27,6 @@ pub struct BacktestHarnessConfig {
pub data_root: PathBuf,
pub predecoded_dir: PathBuf,
pub n_parallel: usize,
pub decision_stride: usize,
pub target_annual_vol_units: f32,
pub annualisation_factor: f32,
pub max_lots: u16,
@@ -148,7 +147,7 @@ impl BacktestHarness {
horizons: [30, 100, 300, 1000, 6000],
n_max_sequences: 0,
seed: 0,
decision_stride: cfg.decision_stride,
// A1: decision_stride removed; inference mode walks every event at stride=1.
inference_only: true,
};
let loader = MultiHorizonLoader::new(&loader_cfg)?;
@@ -221,7 +220,6 @@ impl BacktestHarness {
/// decision loop until either the stream is exhausted or
/// cfg.max_events is reached. See spec §7 orchestrator code.
pub fn run(&mut self) -> Result<RunStats> {
let stride = self.cfg.decision_stride.max(1) as u64;
let mut total_decisions = 0u64;
// Periodic progress line — without this, multi-million-event
// runs look identical to a deadlock in pod logs.
@@ -247,24 +245,17 @@ impl BacktestHarness {
}
self.snapshot_window.push_back(raw.clone());
// CRT Phase A0.5 (corrective): advance the encoder state on
// EVERY event once the window is bootstrapped. The GRN heads
// kernel writes the per-horizon probs directly into the sim's
// on-device `alpha_probs_d` — zero CPU touchpoint, no
// stream.synchronize on the hot path. forward_step_into is
// O(hidden_dim × state_dim) per call — independent of K.
// CRT A1: once the window is full, fire BOTH forward_step_into AND
// step_decision_with_latency on every event. decision_stride is gone;
// the window-full guard is the only gate.
// forward_step_into (A0.5) advances the SSM state and writes
// per-horizon probs into alpha_probs_d. step_decision_with_latency
// reads alpha_probs_d immediately after — zero CPU roundtrip.
if self.snapshot_window.len() == self.seq_len {
self.trainer
.forward_step_into(&raw, self.sim.alpha_probs_d_mut())
.context("trainer.forward_step_into")?;
}
// Decision-stride gate: step the sim only at stride boundaries.
// A1 will remove this gate so decisions fire every event.
// `broadcast_alpha` is gone: forward_step_into already wrote
// the probs into `alpha_probs_d`, so the decision kernels see
// the freshest event's probs without any host roundtrip.
if self.event_count % stride == 0 && self.snapshot_window.len() == self.seq_len {
// Side-channel: record this decision's max_conviction
// on-device for the threshold-tuning percentile
// computation. Kernel reads `alpha_probs_d` and writes
@@ -272,7 +263,7 @@ impl BacktestHarness {
// Single end-of-run DtoV in `read_convictions` materialises
// the host log. Doing it BEFORE step_decision so the log
// captures every decision attempt, including those the
// threshold gate would skip — matches prior behaviour.
// threshold gate would skip.
self.sim.record_max_conviction(self.convictions_recorded)?;
self.convictions_recorded = self.convictions_recorded.saturating_add(1);

View File

@@ -47,7 +47,6 @@ fn try_loader() -> Option<MultiHorizonLoader> {
horizons: [30, 100, 300, 1000, 6000],
n_max_sequences: 0,
seed: 0xCAFE_F00D,
decision_stride: 1,
inference_only: true,
};
match MultiHorizonLoader::new(&cfg) {

View File

@@ -34,7 +34,6 @@ fn try_loader(inference_only: bool) -> Option<MultiHorizonLoader> {
horizons: [30, 100, 300, 1000, 6000],
n_max_sequences: 1,
seed: 0xCAFEF00D,
decision_stride: 1,
inference_only,
};
match MultiHorizonLoader::new(&cfg) {

View File

@@ -226,14 +226,6 @@ struct Cli {
#[arg(long, default_value_t = 0.99)]
gamma: f32,
/// Decision stride — emit a new action only every N steps; between
/// decisions force `action=0` (wait), preserving the previously-opened
/// position. Aligns DQN decision cadence with the multi-minute alpha
/// horizon and stops the policy from flip-flopping on per-bar noise
/// ("coin-flip problem"). 1 = current per-bar behaviour. Pair with
/// γ→0.999+ so the Bellman chain reaches across the wait segments.
#[arg(long, default_value_t = 1)]
decision_stride: u32,
#[arg(long, default_value_t = 0.9)]
alpha_m: f32,
#[arg(long, default_value_t = 0.03)]
@@ -660,16 +652,8 @@ fn main() -> Result<()> {
}
}
stream.synchronize()?;
// `--decision-stride N`: only emit a new action every N steps;
// between strides force action=0 (wait) so an open position is
// held rather than re-decided per bar. Avoids the per-bar
// "coin-flip" overtrading. step==0 is always a decision.
let stride_train = cli.decision_stride.max(1) as usize;
let actions = if step % stride_train == 0 {
batched_action_pinned_train.read_all()
} else {
vec![0_i32; train_n_par]
};
// A1: decision_stride removed; act on every step.
let actions = batched_action_pinned_train.read_all();
for i in 0..train_n_par {
if done_flags[i] { continue; }
@@ -1011,17 +995,8 @@ fn main() -> Result<()> {
}
// ONE sync per step (instead of N).
stream.synchronize()?;
// `--decision-stride N`: same rate-limit semantics as training.
// Between strides force action=0 so an open passive limit
// post / live position is held rather than re-decided. Cuts
// per-bar trade counts ~stride× and removes the coin-flip
// overtrading on noise.
let stride_eval = cli.decision_stride.max(1) as usize;
let actions = if step % stride_eval == 0 {
batched_action_pinned.read_all()
} else {
vec![0_i32; n_par]
};
// A1: decision_stride removed; act on every step.
let actions = batched_action_pinned.read_all();
// Step all envs on CPU.
for i in 0..n_par {
if done_flags[i] { continue; }