From fbb8694a0b71b3ac5ea33b98c968a28b3d53f2a1 Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Fri, 24 Apr 2026 12:24:48 +0200 Subject: [PATCH] feat(dqn-v2): A.2 ISV layout fingerprint at ISV[37..39) (tail placement) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements spec §4.A.2 structural layout fingerprint with tail placement rather than head placement (spec alternative: §4.A.2 Step 5.3 alt). Head placement (ISV[0..2)) was rejected because isv_signals[0] and [1] are actively written by the isv_signal_update kernel (Q-drift EMA and gradient-norm EMA). Shifting those would require updating every literal reference in experience_kernels.cu — a larger change than warranted for pure contract enforcement. Tail placement leaves all existing indices intact, touches zero kernel .cu files, and fulfils the same design contract. Key changes: - ISV_LAYOUT_FINGERPRINT_LO_INDEX = 37, HI_INDEX = 38 (u64 across 2×f32). - LAYOUT_FINGERPRINT_CURRENT: u64 = FNV-1a of slot-list seed bytes. Value: 0x85d4d76b578a7c17. Any slot change updates seed bytes, which updates the hash automatically. - Constructor writes fingerprint after zero-init; calls check_layout_fingerprint() to self-verify before returning. - check_layout_fingerprint(): reads pinned slots [37..39), recomposes u64, fails-fast on mismatch with "retrain required" message. - Error message does NOT mention migration as an option. - Pre-commit hook rejects `fn migrate_isv|upgrade_isv` names — makes the no-migration rule structurally enforced (check_no_isv_migrations). - ISV_TOTAL_DIM: 37 → 39. - Zero existing index shifts (no kernel literal sites affected). - StateResetRegistry entry renamed ISV_SCHEMA_VERSION → ISV_LAYOUT_FINGERPRINT. - ResetCategory::SchemaContract docstring updated to remove "migration" framing. - docs/isv-slots.md: updated table + design note for tail placement. Tests: state_reset_registry 3 unit tests pass with renamed entry. cargo check -p ml clean (pre-existing warnings only). Plan 1 Task 5. Spec §4.A.2 (tail-placement alternative). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ml/src/cuda_pipeline/gpu_dqn_trainer.rs | 147 ++++++++++++++++-- .../src/trainers/dqn/state_reset_registry.rs | 8 +- docs/isv-slots.md | 36 ++++- scripts/pre-commit-hook.sh | 15 ++ 4 files changed, 186 insertions(+), 20 deletions(-) diff --git a/crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs b/crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs index 3db1861df..edb1fdf02 100644 --- a/crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs +++ b/crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs @@ -164,10 +164,17 @@ const ISV_NETWORK_DIM: usize = 23; /// to ensure no (sample, branch) pair collapses to zero gradient /// contribution. /// -/// Extending past 37 requires updating the pinned allocation in the +/// Slots [37..39) carry the layout fingerprint (ISV_LAYOUT_FINGERPRINT_LO_INDEX +/// and ISV_LAYOUT_FINGERPRINT_HI_INDEX). These are placed at the tail because +/// slots [0] and [1] are actively written by `isv_signal_update` (Q-drift and +/// gradient-norm EMA respectively). Written by the constructor; checked at +/// checkpoint load. Fail-fast only — no migration path exists. See spec §4.A.2 +/// and `LAYOUT_FINGERPRINT_CURRENT` for the structural-hash design rationale. +/// +/// Extending past 39 requires updating the pinned allocation in the /// constructor and any kernel that takes an ISV pointer expecting a /// specific length. -const ISV_TOTAL_DIM: usize = 37; +const ISV_TOTAL_DIM: usize = 39; /// Legacy alias preserved for call sites that haven't been audited for the /// network-vs-total split. New code should pick `ISV_NETWORK_DIM` (for weight /// tensor sizing) or `ISV_TOTAL_DIM` (for the broadcast bus buffer). @@ -255,6 +262,79 @@ pub const GRAD_SCALE_LIMIT_INDEX: usize = 35; /// actively EMA-tracked; treated as a safety floor per /// `feedback_isv_for_adaptive_bounds.md` carve-out. pub const IQL_BRANCH_SCALE_FLOOR_INDEX: usize = 36; + +/// ISV slot [37] — low 32 bits of the u64 layout fingerprint (stored as raw f32 bits). +/// +/// Design note: this is NOT a version number. There is no ordered version space. +/// The fingerprint is a structural hash that automatically changes whenever any +/// slot is added, removed, reordered, or renamed — so there is no natural pairing +/// "v1 -> v2" and no place to write a migrator. Per spec §4.A.2 and +/// `feedback_no_legacy_aliases.md`: backward compat is structurally unwritable, +/// not merely discouraged. +/// +/// Placement at the tail (not head) is mandated by slots [0] and [1] being +/// actively written by `isv_signal_update` (Q-drift and gradient-norm EMA). +pub const ISV_LAYOUT_FINGERPRINT_LO_INDEX: usize = 37; +/// ISV slot [38] — high 32 bits of the u64 layout fingerprint (stored as raw f32 bits). +pub const ISV_LAYOUT_FINGERPRINT_HI_INDEX: usize = 38; +/// Canonical alias for the fingerprint slot (the low half). +pub const ISV_LAYOUT_FINGERPRINT_INDEX: usize = ISV_LAYOUT_FINGERPRINT_LO_INDEX; + +/// FNV-1a 64-bit hash of a byte slice, usable as a `const fn`. +const fn fnv1a_64(bytes: &[u8]) -> u64 { + const OFFSET: u64 = 0xcbf29ce484222325; + const PRIME: u64 = 0x00000100000001b3; + let mut h: u64 = OFFSET; + let mut i = 0; + while i < bytes.len() { + h ^= bytes[i] as u64; + h = h.wrapping_mul(PRIME); + i += 1; + } + h +} + +/// Source bytes for the layout fingerprint. Canonical format: +/// `b"=;=;...;ISV_TOTAL_DIM="`. +/// +/// When adding/removing a slot, add/remove its entry here in the SAME commit +/// that changes the constant list. The fingerprint recomputes automatically. +/// Order matches the constant declaration order in this module. +/// +/// Slots [0..12) have no named `pub const` (they are written directly via +/// literal indices in `isv_signal_update`). They are covered here by +/// `SLOT__` entries derived from the `ISV_TOTAL_DIM` docstring +/// and `experience_kernels.cu` write-site comments. Any renumber of these +/// slots changes the seed and invalidates the fingerprint. +const fn layout_fingerprint_seed() -> &'static [u8] { + b"SLOT_0_Q_DRIFT=0;\ + SLOT_1_GRAD_NORM_EMA=1;\ + SLOT_2_TD_ERR_EMA=2;\ + SLOT_3_ENS_VAR_EMA=3;\ + SLOT_4_ENS_VAR_VEL=4;\ + SLOT_5_REWARD_EMA=5;\ + SLOT_6_ATOM_UTIL_EMA=6;\ + SLOT_7_LOSS_EMA=7;\ + SLOT_8_ADX_EMA=8;\ + SLOT_9_REGIME_DISAGREE=9;\ + SLOT_10_REGIME_VEL_EMA=10;\ + SLOT_11_REGIME_STABILITY=11;\ + LEARNING_HEALTH=12;\ + Q_MAG_MEAN_QUARTER=13;Q_MAG_MEAN_HALF=14;Q_MAG_MEAN_FULL=15;Q_ABS_REF=16;\ + Q_DIR_MEAN_SHORT=17;Q_DIR_MEAN_HOLD=18;Q_DIR_MEAN_LONG=19;Q_DIR_MEAN_FLAT=20;Q_DIR_ABS_REF=21;\ + SHARPE_EMA=22;\ + V_CENTER_DIR=23;V_HALF_DIR=24;V_CENTER_MAG=25;V_HALF_MAG=26;\ + V_CENTER_ORD=27;V_HALF_ORD=28;V_CENTER_URG=29;V_HALF_URG=30;\ + GRAD_NORM_TARGET_DIR=31;GRAD_NORM_TARGET_MAG=32;GRAD_NORM_TARGET_ORD=33;GRAD_NORM_TARGET_URG=34;\ + GRAD_SCALE_LIMIT=35;IQL_BRANCH_SCALE_FLOOR=36;\ + ISV_LAYOUT_FINGERPRINT_LO=37;ISV_LAYOUT_FINGERPRINT_HI=38;\ + ISV_TOTAL_DIM=39" +} + +/// Compile-time layout fingerprint. Burned into the binary at build time. +/// Checked against the stored value at checkpoint load. Mismatch means retrain required. +pub const LAYOUT_FINGERPRINT_CURRENT: u64 = fnv1a_64(layout_fingerprint_seed()); + const ISV_EMB_DIM: usize = 8; // ISV embedding output dimension (FC2 output, gate/gamma input) /// First ISV tensor index in the flat param buffer. /// ISV weights (68-79) are online-only — NOT synced to the target network. @@ -1944,11 +2024,11 @@ pub struct GpuDqnTrainer { q_dir_bin_means_reduce_kernel: CudaFunction, // ── ISV core buffers (pinned device-mapped, GPU read/write) ── - // isv_signals_pinned is sized for ISV_TOTAL_DIM (31) — the extra eight + // isv_signals_pinned is sized for ISV_TOTAL_DIM (39) — the extra slots // slots [23..31] carry per-branch Q-support centres/half-widths. The // network (w_isv_fc1) and history rotation still consume only the first // ISV_NETWORK_DIM (23) slots; the tail is broadcast-bus scratchpad only. - isv_signals_pinned: *mut f32, // [ISV_TOTAL_DIM = 31] + isv_signals_pinned: *mut f32, // [ISV_TOTAL_DIM = 39] isv_signals_dev_ptr: u64, isv_history_pinned: *mut f32, // [ISV_K * 12 = 48] — history rotates slots [0..11] only isv_history_dev_ptr: u64, @@ -8087,10 +8167,11 @@ impl GpuDqnTrainer { }; // ── ISV core buffers (pinned device-mapped) ────────────────────── - // Allocation uses `ISV_TOTAL_DIM` so slots [23..31] (per-branch - // Q-support centres and half-widths) have backing storage. The - // network-facing path still consumes only the first `ISV_NETWORK_DIM` - // slots — the scratchpad tail is invisible to `w_isv_fc1`. + // Allocation uses `ISV_TOTAL_DIM` so all scratchpad slots [23..39) have + // backing storage. The network-facing path still consumes only the first + // `ISV_NETWORK_DIM` slots — the scratchpad tail is invisible to `w_isv_fc1`. + // Slots [37..39) hold the layout fingerprint (tail placement because [0..2) + // are actively written by `isv_signal_update`). let (isv_signals_pinned, isv_signals_dev_ptr) = { let num_bytes = ISV_TOTAL_DIM * std::mem::size_of::(); let mut host_ptr: *mut std::ffi::c_void = std::ptr::null_mut(); @@ -8131,6 +8212,14 @@ impl GpuDqnTrainer { // IQL readiness→1 and the losing branch has near-zero // advantage. Safety bound, not tuning. *sig_ptr.add(IQL_BRANCH_SCALE_FLOOR_INDEX) = 0.1_f32; + // Layout fingerprint (ISV[37..39)). Compile-time structural hash of + // the slot layout; checkpoint load fails-fast on mismatch. + // Stored as a u64 split across two f32 lanes using raw bit-cast so + // no precision is lost. See LAYOUT_FINGERPRINT_CURRENT docs for why + // this is NOT a version number and why no migration path exists. + let fp = LAYOUT_FINGERPRINT_CURRENT; + *sig_ptr.add(ISV_LAYOUT_FINGERPRINT_LO_INDEX) = f32::from_bits(fp as u32); + *sig_ptr.add(ISV_LAYOUT_FINGERPRINT_HI_INDEX) = f32::from_bits((fp >> 32) as u32); } (host_ptr as *mut f32, dev_ptr_out) }; @@ -8750,7 +8839,7 @@ impl GpuDqnTrainer { // D1/N1: clone Arc before it is moved into the struct literal. let stream_for_snapshots = Arc::clone(&stream); - Ok(Self { + let trainer = Self { config, stream, ptrs, @@ -9306,7 +9395,12 @@ impl GpuDqnTrainer { last_distill_active: false, last_meta_q_pred: 0.5, q_sample_history: std::collections::VecDeque::with_capacity(64), - }) + }; + // Self-check: verify the fingerprint we just wrote is readable and matches + // the compile-time constant. This is a construction-time invariant check; + // any restore path that loads persisted ISV state must also call this. + trainer.check_layout_fingerprint()?; + Ok(trainer) } /// Reference to the trainer's forked CudaStream. @@ -9793,6 +9887,39 @@ impl GpuDqnTrainer { unsafe { *self.isv_signals_pinned.add(index) = value; } } + /// Read the ISV layout fingerprint from pinned memory and compare to the + /// compile-time `LAYOUT_FINGERPRINT_CURRENT`. + /// + /// Returns `Err` on mismatch. NEVER returns `Ok` on mismatch — that would + /// permit silent backward compat, which this design forbids. Treat a null + /// ISV pointer as fingerprint `0`, which never matches any real fingerprint. + /// + /// Called at construction (after writing) to self-verify. Must also be called + /// by any restore path that loads persisted ISV state from disk. + pub fn check_layout_fingerprint(&self) -> Result<(), MLError> { + let (lo_bits, hi_bits) = if self.isv_signals_pinned.is_null() { + (0u32, 0u32) + } else if ISV_TOTAL_DIM < ISV_LAYOUT_FINGERPRINT_HI_INDEX + 1 { + (0u32, 0u32) + } else { + unsafe { + let lo = f32::to_bits(*self.isv_signals_pinned.add(ISV_LAYOUT_FINGERPRINT_LO_INDEX)); + let hi = f32::to_bits(*self.isv_signals_pinned.add(ISV_LAYOUT_FINGERPRINT_HI_INDEX)); + (lo, hi) + } + }; + let fp_stored: u64 = (lo_bits as u64) | ((hi_bits as u64) << 32); + if fp_stored != LAYOUT_FINGERPRINT_CURRENT { + return Err(MLError::ModelError(format!( + "ISV layout fingerprint mismatch: stored 0x{:016x}, current code 0x{:016x}. \ + Checkpoint layout does not match current code - retrain required. \ + (This is a structural fingerprint, not a version number. There is no migration path.)", + fp_stored, LAYOUT_FINGERPRINT_CURRENT + ))); + } + Ok(()) + } + /// Read atom utilization from q_readback_pinned[6] (one-step lag, written by /// reduce_current_q_stats). Returns 0.0 if pointer is null. pub fn read_atom_utilization(&self) -> f32 { diff --git a/crates/ml/src/trainers/dqn/state_reset_registry.rs b/crates/ml/src/trainers/dqn/state_reset_registry.rs index 9b4107411..61463673e 100644 --- a/crates/ml/src/trainers/dqn/state_reset_registry.rs +++ b/crates/ml/src/trainers/dqn/state_reset_registry.rs @@ -14,7 +14,7 @@ pub enum ResetCategory { SoftReset { decay_bars: u32 }, /// Survives across folds (learned weights). TrainingPersist, - /// Version-stable across runs; touched only by explicit migration. + /// Structurally fixed across runs; fail-fast on mismatch. No migration path exists. SchemaContract, } @@ -142,9 +142,9 @@ impl StateResetRegistry { }, // ───── Schema-contract state ──────────────────────────────── RegistryEntry { - name: "ISV_SCHEMA_VERSION", + name: "ISV_LAYOUT_FINGERPRINT", category: ResetCategory::SchemaContract, - description: "ISV[0] — slot-layout schema version (migrated only)", + description: "ISV[37..39) — compile-time structural hash of slot layout; fail-fast only, no migration path", }, RegistryEntry { name: "isv_iql_branch_scale_floor", @@ -199,7 +199,7 @@ mod tests { assert_eq!(r.category("kelly_stats"), Some(ResetCategory::FoldReset)); assert_eq!(r.category("adaptive_gamma"), Some(ResetCategory::SoftReset { decay_bars: 500 })); assert_eq!(r.category("network_params"), Some(ResetCategory::TrainingPersist)); - assert_eq!(r.category("ISV_SCHEMA_VERSION"), Some(ResetCategory::SchemaContract)); + assert_eq!(r.category("ISV_LAYOUT_FINGERPRINT"), Some(ResetCategory::SchemaContract)); } #[test] diff --git a/docs/isv-slots.md b/docs/isv-slots.md index 50b24125f..24a8b3c11 100644 --- a/docs/isv-slots.md +++ b/docs/isv-slots.md @@ -2,18 +2,42 @@ **Source of truth for ISV bus slot allocations.** Every slot has a named constant in `gpu_dqn_trainer.rs`. This doc is the cross-reference. -**Current `ISV_TOTAL_DIM` as of this plan's start:** 37. Post-full DQN v2 rollout: 72. +**Design: layout fingerprint, not schema version.** ISV[37..39) carries a +compile-time structural hash of the slot layout. Checkpoint load is +fail-fast; no migration functions exist. Backward compat is structurally +unwritable (there is no ordered version space to pair migrations against). +See spec §4.A.2. + +**Tail placement rationale:** The fingerprint occupies the tail (`[37..39)`) rather +than the head because `isv_signals[0]` and `isv_signals[1]` are actively written +by `isv_signal_update` (Q-drift EMA and gradient-norm EMA respectively). Inserting +at the head would displace those live signals and require shifting every upstream +literal in `experience_kernels.cu`. + +**Current `ISV_TOTAL_DIM`:** 39. Post-full DQN v2 rollout: 72. | Index | Name constant | Type | Producer | Consumers | Reset-category | Notes | |---|---|---|---|---|---|---| -| [0..2) | `ISV_LAYOUT_FINGERPRINT_{LO,HI}_INDEX` | u64 (as 2× f32) | construct | load | SchemaContract | Compile-time structural hash of slot layout; fail-fast only, no migration path — **not** a version number. See spec §4.A.2. | -| [1..12) | (pre-existing) | f32 | varies | varies | varies | Legacy — audit during A.2 | +| [0] | `SLOT_0_Q_DRIFT` (seed only) | f32 | isv_signal_update | ISV encoder | FoldReset | `isv_signals[0]` = (q_mean − q_ema) / max(|q_ema|, 0.1) | +| [1] | `SLOT_1_GRAD_NORM_EMA` (seed only) | f32 | isv_signal_update | ISV encoder | FoldReset | EMA of sqrt(grad_norm²) | +| [2] | `SLOT_2_TD_ERR_EMA` (seed only) | f32 | isv_signal_update | ISV encoder | FoldReset | EMA of TD-error scalar | +| [3] | `SLOT_3_ENS_VAR_EMA` (seed only) | f32 | isv_signal_update | ISV encoder | FoldReset | C51 Q-distribution variance EMA (batch-mean atom-spread) | +| [4] | `SLOT_4_ENS_VAR_VEL` (seed only) | f32 | isv_signal_update | ISV encoder | FoldReset | Delta of slot 3 (variance velocity) | +| [5] | `SLOT_5_REWARD_EMA` (seed only) | f32 | isv_signal_update | ISV encoder | FoldReset | EMA of per-batch mean reward | +| [6] | `SLOT_6_ATOM_UTIL_EMA` (seed only) | f32 | isv_signal_update | ISV encoder | FoldReset | EMA of atom utilization fraction | +| [7] | `SLOT_7_LOSS_EMA` (seed only) | f32 | isv_signal_update | ISV encoder | FoldReset | EMA of total training loss | +| [8] | `SLOT_8_ADX_EMA` (seed only) | f32 | isv_signal_update | ISV encoder | FoldReset | Batch-mean ADX EMA (regime velocity indicator) | +| [9] | `SLOT_9_REGIME_DISAGREE` (seed only) | f32 | isv_signal_update | ISV encoder | FoldReset | |norm(ADX) − norm(CUSUM)| disagreement signal | +| [10] | `SLOT_10_REGIME_VEL_EMA` (seed only) | f32 | isv_signal_update | ISV encoder | FoldReset | EMA of regime transition velocity (ADX + CUSUM delta) | +| [11] | `SLOT_11_REGIME_STABILITY` (seed only) | f32 | isv_signal_update | ISV encoder | FoldReset | 1 − sigmoid(5 × regime_vel); high = stable regime | | [12] | `LEARNING_HEALTH_INDEX` | f32 | isv_signal_update | many | FoldReset | health score ∈ [0, 1] | -| [13..17) | `Q_MAG_MEAN_*_INDEX` | f32 | q_mag_means_reduce | c51 kernels | FoldReset | Quarter/Half/Full mag Q-mean EMAs + |Q| ref | -| [17..22) | `Q_DIR_MEAN_*_INDEX` | f32 | q_dir_means_reduce | c51 kernels | FoldReset | Short/Hold/Long/Flat dir Q-mean EMAs + |Q| ref | +| [13..17) | `Q_MAG_MEAN_*_INDEX` | f32 | q_mag_means_reduce | c51 kernels | FoldReset | Quarter/Half/Full mag Q-mean EMAs + \|Q\| ref | +| [17..22) | `Q_DIR_MEAN_*_INDEX` | f32 | q_dir_means_reduce | c51 kernels | FoldReset | Short/Hold/Long/Flat dir Q-mean EMAs + \|Q\| ref | | [22] | `SHARPE_EMA_INDEX` | f32 | training_loop host | isv_signal_update | FoldReset | Training Sharpe EMA | | [23..31) | `V_{CENTER,HALF}_{DIR,MAG,ORD,URG}_INDEX` | f32 | update_eval_v_range | adaptive_atoms, warm_start | FoldReset | Per-branch Q-support | | [31..35) | `GRAD_NORM_TARGET_*_INDEX` | f32 | grad_balance_isv_update | branch_grad_rescale | SoftReset(decay_bars=500) | Per-branch grad-norm target | | [35] | `GRAD_SCALE_LIMIT_INDEX` | f32 | grad_balance_isv_update | branch_grad_rescale | SoftReset(decay_bars=500) | Scale clamp limit | | [36] | `IQL_BRANCH_SCALE_FLOOR_INDEX` | f32 | construct (static) | iql_per_branch_advantage | SchemaContract | Per-sample branch_scales floor; safety bound | -| [37..72) | (reserved for DQN v2) | | | | | Allocated incrementally by Plans 2-5 | +| [37] | `ISV_LAYOUT_FINGERPRINT_LO_INDEX` | u32 bits (in f32) | construct | check_layout_fingerprint | SchemaContract | Low 32 bits of u64 FNV-1a structural hash. Fail-fast on mismatch — NOT a version number, no migration path. | +| [38] | `ISV_LAYOUT_FINGERPRINT_HI_INDEX` | u32 bits (in f32) | construct | check_layout_fingerprint | SchemaContract | High 32 bits of u64 FNV-1a structural hash. | +| [39..72) | (reserved for DQN v2) | | | | | Allocated incrementally by Plans 2-5 | diff --git a/scripts/pre-commit-hook.sh b/scripts/pre-commit-hook.sh index 90fe72627..1d5e9469b 100755 --- a/scripts/pre-commit-hook.sh +++ b/scripts/pre-commit-hook.sh @@ -105,8 +105,23 @@ check_no_todo_fixme() { fi } +# DQN v2 Invariant (spec §4.A.2): no ISV migration functions allowed. +# The layout fingerprint is fail-fast only; no migration path exists. +check_no_isv_migrations() { + local bad=$(git diff --cached -U0 -- crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs \ + crates/ml/src/trainers/dqn/trainer/ 2>/dev/null | \ + grep -E '^\+.*fn\s+(migrate|upgrade)_isv' || true) + if [ -n "$bad" ]; then + echo "❌ Spec §4.A.2 violation: ISV migration functions are forbidden" + echo " (layout fingerprint is fail-fast only; no migration path exists)" + echo "$bad" | sed 's/^/ /' + return 1 + fi +} + check_audit_doc_updates || exit 1 check_no_todo_fixme || exit 1 +check_no_isv_migrations || exit 1 echo "✅ All pre-commit checks passed!" echo ""