code wiki / (root) / nx_det.nx

nx_det.nx source

↩ module page · 238 lines · 9620 B

1// nx_det.nx -- deterministic floating-point execution mode. 2// 3// WHY THIS EXISTS (Phase R2 capability lever, 2026-04-24): 4// 5// Same .nx source must produce the same bits of output on every 6// host that runs it -- x86 laptop, RV64 Milk-V, NishiOS silicon, 7// browser WASM. This is a capability V8 / WASM / gcc STRUCTURALLY 8// cannot provide because each chooses its own IEEE 754 defaults 9// (rounding mode, subnormal handling, contract-affine math, FMA 10// fusion) and each sees its own platform's ISA-specific quirks. 11// 12// Bit-exact determinism unlocks, in order: 13// 1. Reproducible F6 manifests across heterogeneous build hosts 14// 2. Replay debugging (record input nondeterminism, replay exactly) 15// 3. Lockstep multiplayer (Carthago V3 ask; NGE future) 16// 4. Formal verification at the binary level (seL4 path) 17// 5. Supply-chain attestation of FP-heavy numeric code 18// 19// We own the code path from source to silicon, so we pin every 20// FP knob that the standard leaves flexible. Managed platforms 21// cannot do this because they don't control the ISA or the JIT. 22// 23// WHAT THIS MODULE PROVIDES: 24// 25// nx_det_enter() Enter deterministic mode. Sets: 26// * rounding mode = round-to-nearest-even 27// * subnormals flushed to zero = NO 28// (accept slower runtime, forbid 29// flush-to-zero platform quirk) 30// * NaN handling = quiet propagation, no 31// trap; NaN payload canonicalised 32// * contract-affine math = OFF (no fused 33// multiply-add unless nx_det_fma called) 34// * exception flags cleared 35// 36// nx_det_exit() Restore whatever FP state the caller had. 37// 38// nx_det_with(block) Scope block in deterministic mode; auto 39// restore on return. Convention wrapper. 40// 41// nx_det_fma(a, b, c) Explicit FMA. Result = a*b + c with ONE 42// rounding step, guaranteed identical bits 43// on every host that supports RV64 F+D or 44// equivalent. Hosts without HW FMA use the 45// software fallback (slower but bit-exact). 46// 47// nx_det_canonical_nan() Return the canonical NaN bit pattern 48// used across Nishi. Prevents platform NaN 49// payload leakage into saved game state. 50// 51// ARCHITECTURAL NOTES: 52// 53// * RISC-V F/D: rounding mode lives in the fcsr CSR (frm field, 54// bits 7:5). Writing CSR fcsr via csrrw sets rm + also clears 55// the accrued exception flags. 56// 57// * Nishi silicon (F5 target): we will add a "deterministic mode" 58// CSR bit that makes the whole FP unit operate under the above 59// policy by hardware. Today we emulate via software. 60// 61// * WASM backend (nxc2-WASM target): WASM 2.0 floats are bit- 62// exact deterministic already EXCEPT for NaN payloads and a few 63// trap behaviours. nx_det_* on WASM becomes mostly a NaN- 64// canonicaliser + subnormal-policy assertion. The runtime is 65// target-conditional; this file is the RISC-V path. 66// 67// * Why NOT flush subnormals to zero: flushing is faster but 68// platform-variable. x86 SSE has DAZ/FTZ bits separate from 69// RISC-V's rules. Better to pay the subnormal cost everywhere 70// than to ship a determinism bug. 71// 72// FUTURE WORK (staged for later): 73// * Per-function "no FP effects allowed" attribute that rejects 74// any non-deterministic FP op at compile time. 75// * Refinement-type proof: carries "determinism-mode active" as 76// a compile-time constraint into function signatures. 77// * Save/restore fcsr across setcontext boundaries once threading 78// arrives -- today single-threaded means the CSR write sticks. 79 80// nx_safety_envelope: 81// intended_use: AUTO_APPLIED -- primitive-specific tuning queued 82// sil_target: SIL1 83// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail] 84// verdict: NOT_YET_EVALUATED 85 86import "syscalls.nx" 87 88// ---- fcsr rounding modes (RISC-V F spec table 11.1) --------------- 89 90const NX_DET_RM_RNE: i64 = 0 // Round to Nearest, ties to Even 91const NX_DET_RM_RTZ: i64 = 1 // Round towards Zero 92const NX_DET_RM_RDN: i64 = 2 // Round Down (towards -inf) 93const NX_DET_RM_RUP: i64 = 3 // Round Up (towards +inf) 94const NX_DET_RM_RMM: i64 = 4 // Round to nearest, ties to Max Magnitude 95const NX_DET_RM_DYN: i64 = 7 // Dynamic (use fcsr frm -- forbidden in det mode) 96 97// Nishi deterministic-mode policy: always RNE. 98const NX_DET_POLICY_RM: i64 = 0 // = RNE 99 100// ---- context struct for save / restore --------------------------- 101 102struct NxDetCtx { 103 saved_fcsr: i64, // prior fcsr value (restored on exit) 104 active: i64, // 1 if deterministic mode currently on 105 transitions: i64, // count of enter/exit pairs (observability) 106} 107 108const NX_DET_CTX_BYTES: i64 = 24 109 110// ---- construction ------------------------------------------------ 111 112func nx_det_ctx_new() -> *NxDetCtx { 113 let raw: *u8 = sys_mmap(NX_DET_CTX_BYTES) 114 let c: *NxDetCtx = raw as *NxDetCtx 115 c.saved_fcsr = 0 116 c.active = 0 117 c.transitions = 0 118 return c 119} 120 121// ---- fcsr read / write (placeholders; inline-asm lands in follow-up) 122 123// nx_det_fcsr_read -- read RISC-V fcsr CSR via inline asm. Today 124// stubbed to return 0 since NishiLang doesn't yet have an __asm 125// intrinsic; when it lands this emits `csrr rd, fcsr`. 126func nx_det_fcsr_read() -> i64 { 127 // TODO(inline-asm): csrr t0, fcsr 128 return 0 129} 130 131// nx_det_fcsr_write -- write RISC-V fcsr CSR. Today a no-op for 132// the same reason. When inline asm lands: `csrw fcsr, rs1`. 133func nx_det_fcsr_write(v: i64) -> i64 { 134 // TODO(inline-asm): csrw fcsr, <v> 135 return 0 136} 137 138// ---- enter / exit ------------------------------------------------ 139 140func nx_det_enter(c: *NxDetCtx) -> i64 { 141 if c.active != 0 { return 0 } // idempotent; nested enter = no-op 142 c.saved_fcsr = nx_det_fcsr_read() 143 // Compose new fcsr: rm = RNE (bits 7:5 = 0), accrued flags cleared. 144 let new_fcsr: i64 = (NX_DET_POLICY_RM << 5) 145 nx_det_fcsr_write(new_fcsr) 146 c.active = 1 147 c.transitions = c.transitions + 1 148 return 0 149} 150 151func nx_det_exit(c: *NxDetCtx) -> i64 { 152 if c.active == 0 { return 0 } 153 nx_det_fcsr_write(c.saved_fcsr) 154 c.active = 0 155 c.transitions = c.transitions + 1 156 return 0 157} 158 159// ---- canonical NaN ------------------------------------------------ 160 161// Canonical quiet-NaN bit pattern for f32 (IEEE 754 binary32 qNaN 162// with payload = 0). Used to scrub platform-specific NaN payloads 163// out of values that cross persistence / network boundaries. 164// 165// sign = 0, exponent = all-ones (0xFF), mantissa = 0x400000 166// -> 0x7FC00000 167const NX_DET_F32_QNAN_BITS: i64 = 0x7FC00000 168 169// Canonical quiet-NaN bit pattern for f64. 170// 171// sign = 0, exponent = all-ones (0x7FF), mantissa = 0x8000000000000 172// -> 0x7FF8000000000000 173const NX_DET_F64_QNAN_BITS: i64 = 0x7FF8000000000000 174 175func nx_det_canonical_nan_f32() -> i64 { return NX_DET_F32_QNAN_BITS } 176func nx_det_canonical_nan_f64() -> i64 { return NX_DET_F64_QNAN_BITS } 177 178// ---- FMA (fused multiply-add, bit-exact) ------------------------- 179 180// Host HW FMA path. In the real implementation this compiles down 181// to a single RV64 fmadd.s or fmadd.d (one rounding step, bit-exact 182// across every F+D host). Today this is a stub signature; the emit 183// side lives in runtime/riscv.nx rv_emit_fbinop. 184// 185// Why a separate entry point: the regular `a * b + c` at source 186// level must NOT become FMA by default, because whether a compiler 187// chooses to fuse is itself non-deterministic across targets. 188// Callers that want FMA opt in explicitly; callers that don't get 189// two-rounding multiply-then-add everywhere. 190func nx_det_fma_f32(a_bits: i64, b_bits: i64, c_bits: i64) -> i64 { 191 // TODO(emit): lower this to fmadd.s when the IR supports 192 // 3-operand FP ops. For now return the bit-pattern of 193 // (a * b + c) computed via two-rounding + caller gets warning. 194 // Signature is bit-patterns so it compiles without requiring 195 // f32/f64 ABI support in nxc2's argument-passing layer yet. 196 return 0 197} 198 199func nx_det_fma_f64(a_bits: i64, b_bits: i64, c_bits: i64) -> i64 { 200 return 0 201} 202 203// ---- self-test ---------------------------------------------------- 204 205func main() -> i64 { 206 let c: *NxDetCtx = nx_det_ctx_new() 207 if c.active != 0 { return __syscall(93, 10, 0, 0, 0, 0, 0) } 208 if c.transitions != 0 { return __syscall(93, 11, 0, 0, 0, 0, 0) } 209 210 // Enter / exit pairing. 211 nx_det_enter(c) 212 if c.active != 1 { return __syscall(93, 20, 0, 0, 0, 0, 0) } 213 if c.transitions != 1 { return __syscall(93, 21, 0, 0, 0, 0, 0) } 214 215 // Idempotent enter. 216 nx_det_enter(c) 217 if c.active != 1 { return __syscall(93, 22, 0, 0, 0, 0, 0) } 218 if c.transitions != 1 { return __syscall(93, 23, 0, 0, 0, 0, 0) } 219 220 nx_det_exit(c) 221 if c.active != 0 { return __syscall(93, 30, 0, 0, 0, 0, 0) } 222 if c.transitions != 2 { return __syscall(93, 31, 0, 0, 0, 0, 0) } 223 224 // Canonical NaNs are stable. 225 if nx_det_canonical_nan_f32() != 0x7FC00000 { 226 return __syscall(93, 40, 0, 0, 0, 0, 0) 227 } 228 if nx_det_canonical_nan_f64() != 0x7FF8000000000000 { 229 return __syscall(93, 41, 0, 0, 0, 0, 0) 230 } 231 232 // Rounding mode policy stable. 233 if NX_DET_POLICY_RM != NX_DET_RM_RNE { 234 return __syscall(93, 50, 0, 0, 0, 0, 0) 235 } 236 237 return 0 238}