diff --git a/crates/ml-alpha/src/cfc/snap_features.rs b/crates/ml-alpha/src/cfc/snap_features.rs index 87a78aea3..bc28d48db 100644 --- a/crates/ml-alpha/src/cfc/snap_features.rs +++ b/crates/ml-alpha/src/cfc/snap_features.rs @@ -20,6 +20,17 @@ const KERNEL_CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/snap_featu pub const ES_TICK_SIZE: f32 = 0.25; pub const FEATURE_DIM: usize = 40; +/// Number of book-side levels in one MBP-10 snapshot (bid + ask). +/// Canonical structural dim — every Rust array sized for book depth +/// (`[f32; BOOK_LEVELS]`) and every loop iterating over book depth +/// (`0..BOOK_LEVELS`) MUST reference this constant, not the literal `10`. +/// Per feedback_use_consts_not_literals_for_structural_dims. +/// +/// Re-exported by `ml_backtesting::lob::BOOK_LEVELS` for downstream use +/// in the LOB simulator (ml-backtesting depends on ml-alpha already via +/// `Mbp10RawInput` import). +pub const BOOK_LEVELS: usize = 10; + /// Number of regime-context features pre-computed by the loader and /// piped into snap_features slots `out[20..20+REGIME_DIM]`. These give /// the model microstructure-burst (~2.6ms) and 1-2-second-clustering @@ -41,10 +52,10 @@ pub const REGIME_DIM: usize = 6; /// converts predecoded sidecar rows to this shape. #[derive(Clone, Copy, Debug, Default)] pub struct Mbp10RawInput { - pub bid_px: [f32; 10], - pub bid_sz: [f32; 10], - pub ask_px: [f32; 10], - pub ask_sz: [f32; 10], + pub bid_px: [f32; BOOK_LEVELS], + pub bid_sz: [f32; BOOK_LEVELS], + pub ask_px: [f32; BOOK_LEVELS], + pub ask_sz: [f32; BOOK_LEVELS], pub prev_mid: f32, pub trade_signed_vol: f32, pub trade_count: u32, diff --git a/crates/ml-alpha/src/data/aggregation.rs b/crates/ml-alpha/src/data/aggregation.rs index a3a724c3b..fc5873dae 100644 --- a/crates/ml-alpha/src/data/aggregation.rs +++ b/crates/ml-alpha/src/data/aggregation.rs @@ -133,7 +133,7 @@ impl std::fmt::Display for MultiResolutionConfig { } } -use crate::cfc::snap_features::Mbp10RawInput; +use crate::cfc::snap_features::{Mbp10RawInput, BOOK_LEVELS}; /// Aggregate a slice of consecutive `Mbp10RawInput`s into one pseudo-snapshot. /// Field semantics: @@ -158,7 +158,7 @@ pub fn aggregate_window>(window: &[T]) -> Mbp10RawInput let mut out = Mbp10RawInput::default(); - for lvl in 0..10 { + for lvl in 0..BOOK_LEVELS { let mut sum_bid_px = 0.0_f32; let mut sum_bid_sz = 0.0_f32; let mut sum_ask_px = 0.0_f32; diff --git a/crates/ml-alpha/src/data/loader.rs b/crates/ml-alpha/src/data/loader.rs index a53f7ed5c..71a8bca7d 100644 --- a/crates/ml-alpha/src/data/loader.rs +++ b/crates/ml-alpha/src/data/loader.rs @@ -24,7 +24,7 @@ use rand::{Rng, SeedableRng}; use rand_chacha::ChaCha8Rng; use rayon::prelude::*; -use crate::cfc::snap_features::{Mbp10RawInput, ES_TICK_SIZE, REGIME_DIM}; +use crate::cfc::snap_features::{Mbp10RawInput, BOOK_LEVELS, ES_TICK_SIZE, REGIME_DIM}; use crate::heads::N_HORIZONS; use crate::multi_horizon_labels::{generate_labels, generate_outcome_labels_ab}; @@ -869,11 +869,11 @@ fn convert( prev: &Mbp10Snapshot, regime: [f32; REGIME_DIM], ) -> Mbp10RawInput { - let mut bid_px = [0.0f32; 10]; - let mut bid_sz = [0.0f32; 10]; - let mut ask_px = [0.0f32; 10]; - let mut ask_sz = [0.0f32; 10]; - for (i, lvl) in cur.levels.iter().take(10).enumerate() { + let mut bid_px = [0.0f32; BOOK_LEVELS]; + let mut bid_sz = [0.0f32; BOOK_LEVELS]; + let mut ask_px = [0.0f32; BOOK_LEVELS]; + let mut ask_sz = [0.0f32; BOOK_LEVELS]; + for (i, lvl) in cur.levels.iter().take(BOOK_LEVELS).enumerate() { bid_px[i] = BidAskPair::price_to_f64(lvl.bid_px) as f32; bid_sz[i] = lvl.bid_sz as f32; ask_px[i] = BidAskPair::price_to_f64(lvl.ask_px) as f32; @@ -957,7 +957,7 @@ mod trade_flow_tests { use super::*; fn snap_with_l1(ts: u64, bid_px: f64, bid_sz: u32, ask_px: f64, ask_sz: u32, trade_count: u32) -> Mbp10Snapshot { - let mut levels = vec![BidAskPair::empty(); 10]; + let mut levels = vec![BidAskPair::empty(); BOOK_LEVELS]; levels[0] = BidAskPair { bid_px: BidAskPair::price_from_f64(bid_px), bid_sz, diff --git a/crates/ml-alpha/src/multi_horizon_labels.rs b/crates/ml-alpha/src/multi_horizon_labels.rs index c1801eecf..ffcc4b897 100644 --- a/crates/ml-alpha/src/multi_horizon_labels.rs +++ b/crates/ml-alpha/src/multi_horizon_labels.rs @@ -486,7 +486,7 @@ mod tests { // pnl_long = +1.0 > 0 → y_prof_long = 1 at every valid t (9 of them). // pnl_short = -1.0 ≤ 0 → y_prof_short = 0 at every valid t. // pos_fraction[long, h=0] should be 1.0; [short, h=0] should be 0.0. - let prices: Vec = (0..10).map(|i| 100.0 + i as f32).collect(); + let prices: Vec = (0..10).map(|i| 100.0 + i as f32).collect(); // audit-ignore: BOOK_LEVELS — 10-step test price ramp, unrelated to book depth let labels = generate_outcome_labels_ab(&prices, &[1; N_HORIZONS], 0.0).unwrap(); // Manual count. let mut pos_l = 0_usize; @@ -554,14 +554,14 @@ mod tests { #[test] fn ab_labels_reject_zero_horizon() { - let prices = vec![100.0_f32; 10]; + let prices = vec![100.0_f32; 10]; // audit-ignore: BOOK_LEVELS — 10-element test price vec, unrelated to book depth let err = generate_outcome_labels_ab(&prices, &[0; N_HORIZONS], 0.5); assert!(err.is_err()); } #[test] fn ab_labels_reject_negative_cost() { - let prices = vec![100.0_f32; 10]; + let prices = vec![100.0_f32; 10]; // audit-ignore: BOOK_LEVELS — 10-element test price vec, unrelated to book depth let err = generate_outcome_labels_ab(&prices, &[3; N_HORIZONS], -0.1); assert!(err.is_err()); } diff --git a/crates/ml-alpha/src/trainer/perception.rs b/crates/ml-alpha/src/trainer/perception.rs index 3582c7a17..ff9c43c76 100644 --- a/crates/ml-alpha/src/trainer/perception.rs +++ b/crates/ml-alpha/src/trainer/perception.rs @@ -50,7 +50,7 @@ use crate::aux_heads::{ AuxMaskedHuberLoss, DEFAULT_HUBER_DELTA, N_AUX_HORIZONS, }; use crate::cfc::aux_trunk::AUX_HIDDEN; -use crate::cfc::snap_features::{Mbp10RawInput, ES_TICK_SIZE, FEATURE_DIM, REGIME_DIM}; +use crate::cfc::snap_features::{Mbp10RawInput, BOOK_LEVELS, ES_TICK_SIZE, FEATURE_DIM, REGIME_DIM}; use crate::cfc::AuxTrunk; use crate::cfc::AuxTrunkConfig; use crate::heads::{HEAD_MID_DIM, HIDDEN_DIM, N_HORIZONS}; @@ -2747,13 +2747,13 @@ impl PerceptionTrainer { for (b_idx, snapshots) in snapshots_batch.iter().enumerate() { for (k, snap) in snapshots.iter().enumerate() { let n = b_idx * k_seq + k; - let base10 = n * 10; + let base_book = n * BOOK_LEVELS; let base6 = n * REGIME_DIM; - for i in 0..10 { - bid_px_h[base10 + i] = snap.bid_px[i]; - bid_sz_h[base10 + i] = snap.bid_sz[i]; - ask_px_h[base10 + i] = snap.ask_px[i]; - ask_sz_h[base10 + i] = snap.ask_sz[i]; + for i in 0..BOOK_LEVELS { + bid_px_h[base_book + i] = snap.bid_px[i]; + bid_sz_h[base_book + i] = snap.bid_sz[i]; + ask_px_h[base_book + i] = snap.ask_px[i]; + ask_sz_h[base_book + i] = snap.ask_sz[i]; } for i in 0..REGIME_DIM { regime_h[base6 + i] = snap.regime[i]; @@ -6269,13 +6269,13 @@ impl PerceptionTrainer { for k in 0..k_seq { let n = b_idx * k_seq + k; let snap = &snapshots[n]; - let base10 = n * 10; + let base_book = n * BOOK_LEVELS; let base6 = n * REGIME_DIM; - for i in 0..10 { - bid_px_h[base10 + i] = snap.bid_px[i]; - bid_sz_h[base10 + i] = snap.bid_sz[i]; - ask_px_h[base10 + i] = snap.ask_px[i]; - ask_sz_h[base10 + i] = snap.ask_sz[i]; + for i in 0..BOOK_LEVELS { + bid_px_h[base_book + i] = snap.bid_px[i]; + bid_sz_h[base_book + i] = snap.bid_sz[i]; + ask_px_h[base_book + i] = snap.ask_px[i]; + ask_sz_h[base_book + i] = snap.ask_sz[i]; } for i in 0..REGIME_DIM { regime_h[base6 + i] = snap.regime[i]; @@ -6683,7 +6683,7 @@ impl PerceptionTrainer { let ask_px_h = self.stg_step_ask_px.host_slice_mut(); let ask_sz_h = self.stg_step_ask_sz.host_slice_mut(); let regime_h = self.stg_step_regime.host_slice_mut(); - for i in 0..10 { + for i in 0..BOOK_LEVELS { bid_px_h[i] = snapshot.bid_px[i]; bid_sz_h[i] = snapshot.bid_sz[i]; ask_px_h[i] = snapshot.ask_px[i]; @@ -7244,13 +7244,13 @@ impl PerceptionTrainer { for (b_idx, snapshots) in snapshots_batch.iter().enumerate() { for (k, snap) in snapshots.iter().enumerate() { let n = b_idx * k_seq + k; - let base10 = n * 10; + let base_book = n * BOOK_LEVELS; let base6 = n * REGIME_DIM; - for i in 0..10 { - bid_px_h[base10 + i] = snap.bid_px[i]; - bid_sz_h[base10 + i] = snap.bid_sz[i]; - ask_px_h[base10 + i] = snap.ask_px[i]; - ask_sz_h[base10 + i] = snap.ask_sz[i]; + for i in 0..BOOK_LEVELS { + bid_px_h[base_book + i] = snap.bid_px[i]; + bid_sz_h[base_book + i] = snap.bid_sz[i]; + ask_px_h[base_book + i] = snap.ask_px[i]; + ask_sz_h[base_book + i] = snap.ask_sz[i]; } for i in 0..REGIME_DIM { regime_h[base6 + i] = snap.regime[i]; diff --git a/crates/ml-backtesting/src/harness.rs b/crates/ml-backtesting/src/harness.rs index 92fee62be..10f75032c 100644 --- a/crates/ml-backtesting/src/harness.rs +++ b/crates/ml-backtesting/src/harness.rs @@ -567,11 +567,11 @@ impl BacktestHarness { ); } - // Group B — smoothed-conviction histogram. + // Group B — smoothed-conviction histogram (10 buckets at 0.1 intervals). let mut conv_str = String::new(); - for bk in 0..10 { + for bk in 0..10 { // audit-ignore: BOOK_LEVELS — conviction-bucket count, not book depth let count: u64 = (0..n_b) - .map(|b| diag.conv_hist[b * 10 + bk] as u64) + .map(|b| diag.conv_hist[b * 10 + bk] as u64) // audit-ignore: BOOK_LEVELS .sum(); let lo = bk as f32 / 10.0; let hi = (bk + 1) as f32 / 10.0; @@ -590,8 +590,8 @@ impl BacktestHarness { } eprintln!("crt_diag hold_time_hist:{}", hold_str); - // Group D — outcome by entry-conviction. - for bk in 0..10 { + // Group D — outcome by entry-conviction (10 buckets). + for bk in 0..10 { // audit-ignore: BOOK_LEVELS — conviction-bucket count, not book depth let n_total: u64 = (0..n_b) .map(|b| diag.outcome_n[b * 10 + bk] as u64) .sum(); diff --git a/crates/ml-backtesting/src/lob/mod.rs b/crates/ml-backtesting/src/lob/mod.rs index e0b3ca15b..813407280 100644 --- a/crates/ml-backtesting/src/lob/mod.rs +++ b/crates/ml-backtesting/src/lob/mod.rs @@ -1,7 +1,10 @@ //! Host-side mirrors of cuda/lob_state.cuh layouts. //! See spec §2 (CUDA data layout) and §5b (state ownership). -pub const BOOK_LEVELS: usize = 10; +/// Re-export of `ml_alpha::cfc::snap_features::BOOK_LEVELS` — single source +/// of truth. Definition lives in ml-alpha because ml-backtesting depends +/// on ml-alpha (`Mbp10RawInput` import in `harness.rs`). +pub const BOOK_LEVELS: usize = ml_alpha::cfc::snap_features::BOOK_LEVELS; /// Re-export of `ml_alpha::heads::N_HORIZONS` — single source of truth. pub const N_HORIZONS: usize = ml_alpha::heads::N_HORIZONS; pub const MAX_LIMITS: usize = 32; diff --git a/crates/ml-backtesting/tests/lob_sim_fuzz.rs b/crates/ml-backtesting/tests/lob_sim_fuzz.rs index db8c2c1d6..e0f830ef1 100644 --- a/crates/ml-backtesting/tests/lob_sim_fuzz.rs +++ b/crates/ml-backtesting/tests/lob_sim_fuzz.rs @@ -13,12 +13,12 @@ //! See docs/superpowers/specs/2026-05-18-real-lob-integration-design.md §8 Ring 2. use anyhow::Result; +use ml_backtesting::lob::BOOK_LEVELS; use ml_backtesting::sim::LobSimCuda; use ml_core::device::MlDevice; use rand::{Rng, SeedableRng}; use rand_chacha::ChaCha8Rng; -const BOOK_LEVELS: usize = 10; const TICK: f32 = 0.25; fn make_initial_book() -> ([f32; BOOK_LEVELS], [f32; BOOK_LEVELS], [f32; BOOK_LEVELS], [f32; BOOK_LEVELS]) { diff --git a/crates/ml-backtesting/tests/lob_sim_integrated_fuzz.rs b/crates/ml-backtesting/tests/lob_sim_integrated_fuzz.rs index d44ddc97a..0a3e523e4 100644 --- a/crates/ml-backtesting/tests/lob_sim_integrated_fuzz.rs +++ b/crates/ml-backtesting/tests/lob_sim_integrated_fuzz.rs @@ -13,13 +13,13 @@ //! resting-order pressure + trade-flow simulation. use anyhow::Result; +use ml_backtesting::lob::BOOK_LEVELS; use ml_backtesting::policy::{IsvKellyStateHost, N_HORIZONS}; use ml_backtesting::sim::LobSimCuda; use ml_core::device::MlDevice; use rand::{Rng, SeedableRng}; use rand_chacha::ChaCha8Rng; -const BOOK_LEVELS: usize = 10; const TICK: f32 = 0.25; fn make_initial_book() -> ([f32; BOOK_LEVELS], [f32; BOOK_LEVELS], [f32; BOOK_LEVELS], [f32; BOOK_LEVELS]) { diff --git a/scripts/audit-manifest/rust-consts.txt b/scripts/audit-manifest/rust-consts.txt new file mode 100644 index 000000000..aa5f9da81 --- /dev/null +++ b/scripts/audit-manifest/rust-consts.txt @@ -0,0 +1,15 @@ +# Rust-side structural constants that MUST be referenced (not duplicated +# as literals) per feedback_use_consts_not_literals_for_structural_dims. +# +# Format: `SYMBOL=VALUE` +# The audit greps `crates/*/src/` and `crates/*/examples/` for the VALUE +# used in array-sizing or range contexts, and flags occurrences in files +# that do NOT also reference SYMBOL by name (suggesting literal drift). +# +# Append here whenever a kernel-side `#define` is mirrored by a Rust +# `pub const` — the audit then catches all future drift. +N_ACTIONS=11 +Q_N_ATOMS=21 +HIDDEN_DIM=128 +MAX_UNITS=4 +BOOK_LEVELS=10 diff --git a/scripts/audit-rust-consts.sh b/scripts/audit-rust-consts.sh new file mode 100755 index 000000000..04babd3c7 --- /dev/null +++ b/scripts/audit-rust-consts.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# audit-rust-consts.sh — catch Rust literals that mirror kernel-side +# structural dimensions but should reference the const, per +# `feedback_use_consts_not_literals_for_structural_dims`. +# +# Reads scripts/audit-manifest/rust-consts.txt which maps SYMBOL=VALUE +# pairs. For each pair, greps `crates/*/src/` and `crates/*/examples/` +# for the VALUE appearing in suspect contexts: +# * `[T; ]` array sizing +# * `[; ]` array literal +# * `0..` and `0..=` range bounds +# * `< ` and `<= ` comparison bounds +# * `== ` equality on usize-likely lhs +# +# Flags occurrences in files that do NOT also reference the SYMBOL +# by name — suggests the literal could/should be the const reference. +# +# Heuristic: false positives possible (e.g., `[u8; 4]` for 4-byte +# arrays unrelated to MAX_UNITS). Manifest can be tuned per-project; +# the audit's job is to RAISE flags for human review, not auto-fix. +# +# Exit codes: +# 0 — zero suspect literals found +# 1 — one or more suspect literals found +# 2 — usage / setup error + +set -uo pipefail + +ROOT=$(git rev-parse --show-toplevel) +MANIFEST="$ROOT/scripts/audit-manifest/rust-consts.txt" +SCAN_DIRS=( + "$ROOT/crates/ml-alpha/src" + "$ROOT/crates/ml-alpha/examples" + "$ROOT/crates/ml-backtesting/src" +) + +if [[ ! -f "$MANIFEST" ]]; then + echo "ERROR: manifest not found: $MANIFEST" >&2 + exit 2 +fi + +mapfile -t entries < <(grep -vE '^\s*(#|$)' "$MANIFEST") + +if [[ ${#entries[@]} -eq 0 ]]; then + echo "audit-rust-consts: manifest empty — nothing to audit. PASS." + exit 0 +fi + +violations=0 +flagged=0 + +echo "audit-rust-consts: scanning ${#entries[@]} const(s)..." + +for entry in "${entries[@]}"; do + symbol="${entry%%=*}" + value="${entry##*=}" + if [[ -z "$symbol" || -z "$value" || "$symbol" == "$value" ]]; then + echo "ERROR: malformed manifest entry: '$entry' (expected SYMBOL=VALUE)" >&2 + exit 2 + fi + # Patterns that suggest mirroring the const value: + # [T; ] array sizing + # 0.. exclusive range + # 0..= inclusive range + # < upper bound + # <= upper bound inclusive + # == equality vs usize-likely + # + # Combined into one extended regex. Word boundary `\b` on the value + # to avoid `11` matching inside `1100`. The patterns INCLUDE typical + # spacing variants. + pat="(\\[[^]]+;[[:space:]]*${value}[[:space:]]*\\]|0\\.\\.=?${value}\\b|<[[:space:]]*${value}\\b|<=[[:space:]]*${value}\\b|==[[:space:]]*${value}\\b)" + + for dir in "${SCAN_DIRS[@]}"; do + [[ ! -d "$dir" ]] && continue + # Find .rs files containing the suspect pattern. + while IFS= read -r match_file; do + [[ -z "$match_file" ]] && continue + # If file already references the symbol by name, no flag. + if grep -qE "\\b${symbol}\\b" "$match_file"; then + continue + fi + # Otherwise flag with line excerpts. + while IFS=: read -r lineno text; do + # Strip leading whitespace from the excerpt for compact output. + excerpt="$(echo "$text" | sed -E 's/^[[:space:]]+//;s/[[:space:]]+$//')" + # Skip obvious false positives in this file context that aren't + # caught by the file-level filter: `// ... 11 ...` in pure + # comments that happen to match. Cheap check: skip lines that + # are entirely comment. + if [[ "$excerpt" =~ ^// ]]; then + continue + fi + # Explicit per-line ignore marker. Format: + # ` // audit-ignore: [ — ]` + # Use sparingly — prefer fixing the literal. Reason is required + # so future readers know WHY the literal stays. + if echo "$excerpt" | grep -qE "//[[:space:]]*audit-ignore:[[:space:]]*${symbol}\\b"; then + continue + fi + # Skip byte-buffer patterns `[u8; ]` / `[u8; ]` + # — these are almost always I/O scratch buffers (e.g., u32 decode), + # not structural-dim arrays. False-positive class is too noisy to + # require per-site ignore markers. + if [[ "$excerpt" =~ \[(0?u8|u8)\;[[:space:]]*${value}[[:space:]]*\] ]]; then + continue + fi + echo " FLAG ${match_file}:${lineno} ($symbol=$value) — $excerpt" + flagged=$((flagged + 1)) + done < <(grep -nE "$pat" "$match_file" 2>/dev/null || true) + done < <(grep -rlE "$pat" "$dir" --include='*.rs' 2>/dev/null || true) + done +done + +echo +echo "audit-rust-consts: $flagged suspect literal(s) flagged" + +if [[ $flagged -gt 0 ]]; then + echo + echo "Per feedback_use_consts_not_literals_for_structural_dims:" + echo " if these literals mirror a structural const, replace with the const reference." + echo " if they're unrelated (false positive), confirm by inspection." + echo " False positives can be excluded by ensuring the file imports + uses" + echo " the const by name elsewhere (audit then skips that file)." + exit 1 +fi + +echo "audit-rust-consts: PASS" +exit 0