From 2be0b1882a3c29a27c7dbefa81d71abb7322c7af Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 4 Oct 2026 15:41:34 +0900 Subject: [PATCH] fix(mlx): reject non-finite and incomplete native parity output --- CHANGELOG.d/mlx-finite-parity-output.md | 6 + DOCUMENTATION.md | 1 + crates/mlx_native_receipt/src/main.rs | 284 ++++++++++++++++++++-- docs/research/mlx-finite-parity-output.md | 19 ++ 4 files changed, 293 insertions(+), 17 deletions(-) create mode 100644 CHANGELOG.d/mlx-finite-parity-output.md create mode 100644 docs/research/mlx-finite-parity-output.md diff --git a/CHANGELOG.d/mlx-finite-parity-output.md b/CHANGELOG.d/mlx-finite-parity-output.md new file mode 100644 index 000000000..6a5434043 --- /dev/null +++ b/CHANGELOG.d/mlx-finite-parity-output.md @@ -0,0 +1,6 @@ +# Native MLX CPU parity receipt finite-output guard + +- Refuse NaN, either infinity, and incomplete or oversized backend output before parity reduction and output digest construction. A single NaN must not collapse through `f64::max` into an exact-zero difference receipt. +- Retain the fixed native MLX CPU matrix objective and byte-identical finite exact-result receipt. Add permanent finite/mismatch/shape/non-finite controls and continue to execute the real native backend probe. +- Exercise the private native-call boundary with device/matmul/evaluation/null-data failure controls and actual native-handle frees; production dispatch stays bound to the pinned native functions. These are injected status controls, not observed native hardware faults. +- This is a local candidate correction of ADR0025's existing probe, not a new estimator/GPU parity claim or protected-main release. Research provenance is in `docs/research/mlx-finite-parity-output.md`; current full coverage and independent review remain separately required. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 6fa4b9683..b87a4d615 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -13,6 +13,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | naruon modular consumer contract | [`docs/connectors/naruon-artifact-consumer.md`](docs/connectors/naruon-artifact-consumer.md) | | contextual-orchestrator interpretation port | [`docs/connectors/contextual-orchestrator-interpretation-port.md`](docs/connectors/contextual-orchestrator-interpretation-port.md) | | Orchestrator live HTTP doctoring | [`docs/research/orchestrator-live-http.md`](docs/research/orchestrator-live-http.md) | +| Native MLX finite-output parity guard | [`docs/research/mlx-finite-parity-output.md`](docs/research/mlx-finite-parity-output.md) | | UML/runtime/scientific flows | [`docs/UML.md`](docs/UML.md) | | Logical/physical ERD | [`docs/ERD.md`](docs/ERD.md) | | Security policy | [`SECURITY.md`](SECURITY.md) | diff --git a/crates/mlx_native_receipt/src/main.rs b/crates/mlx_native_receipt/src/main.rs index 01d6b7e60..7f8d47bf7 100644 --- a/crates/mlx_native_receipt/src/main.rs +++ b/crates/mlx_native_receipt/src/main.rs @@ -16,8 +16,21 @@ use mlx_native_receipt::{RECEIPT_SCHEMA_VERSION, digest}; fn run() -> Result> { let lhs = [1.0_f32, 2.0]; let rhs = [3.0_f32, 4.0]; + let output = mlx_cpu_matmul(lhs, rhs)?; + receipt_from_output(lhs, rhs, &output) +} + +/// Bind an evaluated MLX output to the exact CPU reference objective. +#[cfg(target_os = "macos")] +fn receipt_from_output( + lhs: [f32; 2], + rhs: [f32; 2], + output: &[f32], +) -> Result> { let rust = [lhs[0] * rhs[0] + lhs[1] * rhs[1]]; - let output = mlx_cpu_matmul(&lhs, &rhs)?; + if output.len() != rust.len() || output.iter().any(|value| !value.is_finite()) { + return Err("MLX CPU parity failed".into()); + } let observed = output .iter() .zip(&rust) @@ -27,23 +40,72 @@ fn run() -> Result> { return Err("MLX CPU parity failed".into()); } let objective = lhs.iter().chain(&rhs).copied().collect::>(); - let result = output; Ok(ProbeReceipt { schema_version: RECEIPT_SCHEMA_VERSION, backend_code: "mlx_cpu_macos_native", execution_environment_code: "macos_native", objective_sha256: digest(&objective), - output_sha256: digest(&result), + output_sha256: digest(output), observed_maximum_difference: observed, }) } +/// Exact native calls whose failure results control probe settlement. +#[cfg(target_os = "macos")] +#[derive(Clone, Copy)] +struct MlxProbeCalls { + /// Select the native CPU device before allocating array handles. + select_device: unsafe extern "C" fn(mlx_sys::mlx_device) -> std::ffi::c_int, + /// Submit the fixed matrix product to the native stream. + matmul: unsafe extern "C" fn( + *mut mlx_sys::mlx_array, + mlx_sys::mlx_array, + mlx_sys::mlx_array, + mlx_sys::mlx_stream, + ) -> std::ffi::c_int, + /// Materialize the result only after successful submission. + evaluate: unsafe extern "C" fn(mlx_sys::mlx_array) -> std::ffi::c_int, + /// Borrow scalar storage only after successful materialization. + data: unsafe extern "C" fn(mlx_sys::mlx_array) -> *const f32, + /// Settle one owned input or result array. + free_array: unsafe extern "C" fn(mlx_sys::mlx_array) -> std::ffi::c_int, + /// Settle the owned stream after its arrays. + free_stream: unsafe extern "C" fn(mlx_sys::mlx_stream) -> std::ffi::c_int, + /// Settle the owned device on success and refusal. + free_device: unsafe extern "C" fn(mlx_sys::mlx_device) -> std::ffi::c_int, +} + +#[cfg(target_os = "macos")] +impl MlxProbeCalls { + /// Retain the pinned MLX C ABI at the production entry point. + fn native() -> Self { + Self { + select_device: mlx_sys::mlx_set_default_device, + matmul: mlx_sys::mlx_matmul, + evaluate: mlx_sys::mlx_array_eval, + data: mlx_sys::mlx_array_data_float32, + free_array: mlx_sys::mlx_array_free, + free_stream: mlx_sys::mlx_stream_free, + free_device: mlx_sys::mlx_device_free, + } + } +} + +#[cfg(target_os = "macos")] +fn mlx_cpu_matmul(lhs: [f32; 2], rhs: [f32; 2]) -> Result, String> { + mlx_cpu_matmul_with_calls(&lhs, &rhs, MlxProbeCalls::native()) +} + +/// Evaluate and settle the same owned native handles on every status path. #[cfg(target_os = "macos")] -fn mlx_cpu_matmul(lhs: &[f32; 2], rhs: &[f32; 2]) -> Result, String> { +fn mlx_cpu_matmul_with_calls( + lhs: &[f32; 2], + rhs: &[f32; 2], + calls: MlxProbeCalls, +) -> Result, String> { use mlx_sys::{ - mlx_array_data_float32, mlx_array_eval, mlx_array_free, mlx_array_new, mlx_array_new_data, - mlx_device_free, mlx_device_new_type, mlx_device_type__MLX_CPU, mlx_dtype__MLX_FLOAT32, - mlx_matmul, mlx_set_default_device, mlx_stream_free, mlx_stream_new_device, + mlx_array_new, mlx_array_new_data, mlx_device_new_type, mlx_device_type__MLX_CPU, + mlx_dtype__MLX_FLOAT32, mlx_stream_new_device, }; use std::ffi::c_void; @@ -52,8 +114,8 @@ fn mlx_cpu_matmul(lhs: &[f32; 2], rhs: &[f32; 2]) -> Result, String> { // once after the evaluated scalar is copied into Rust-owned memory. unsafe { let device = mlx_device_new_type(mlx_device_type__MLX_CPU, 0); - if mlx_set_default_device(device) != 0 { - let _ = mlx_device_free(device); + if (calls.select_device)(device) != 0 { + let _ = (calls.free_device)(device); return Err("failed to select the MLX CPU device".into()); } let stream = mlx_stream_new_device(device); @@ -70,14 +132,14 @@ fn mlx_cpu_matmul(lhs: &[f32; 2], rhs: &[f32; 2]) -> Result, String> { mlx_dtype__MLX_FLOAT32, ); let mut result = mlx_array_new(); - let operation_status = mlx_matmul(&raw mut result, left, right, stream); + let operation_status = (calls.matmul)(&raw mut result, left, right, stream); let evaluation_status = if operation_status == 0 { - mlx_array_eval(result) + (calls.evaluate)(result) } else { operation_status }; let output = if evaluation_status == 0 { - let pointer = mlx_array_data_float32(result); + let pointer = (calls.data)(result); if pointer.is_null() { None } else { @@ -86,11 +148,11 @@ fn mlx_cpu_matmul(lhs: &[f32; 2], rhs: &[f32; 2]) -> Result, String> { } else { None }; - let _ = mlx_array_free(result); - let _ = mlx_array_free(right); - let _ = mlx_array_free(left); - let _ = mlx_stream_free(stream); - let _ = mlx_device_free(device); + let _ = (calls.free_array)(result); + let _ = (calls.free_array)(right); + let _ = (calls.free_array)(left); + let _ = (calls.free_stream)(stream); + let _ = (calls.free_device)(device); output.ok_or_else(|| "MLX CPU matrix product failed".into()) } } @@ -130,8 +192,196 @@ fn main() -> Result<(), Box> { mod tests { use super::run; + /// The pinned native scheduler mutates shared stream vectors without a lock. + static NATIVE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + /// Serialize native test journeys while retaining normal parallel Rust tests. + fn native_test_guard() -> std::sync::MutexGuard<'static, ()> { + NATIVE_TEST_LOCK + .lock() + .expect("native test mutex must remain healthy") + } + + #[test] + fn refuses_non_finite_backend_output_before_parity_reduction() { + for value in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + let error = super::receipt_from_output([1.0, 2.0], [3.0, 4.0], &[value]) + .expect_err("non-finite backend output must not become a parity receipt"); + assert_eq!(error.to_string(), "MLX CPU parity failed"); + } + } + + #[test] + fn refuses_incomplete_backend_output() { + for output in [&[][..], &[11.0, 11.0][..]] { + assert!(super::receipt_from_output([1.0, 2.0], [3.0, 4.0], output).is_err()); + } + } + + #[test] + fn finite_output_preserves_exact_parity_and_digest() { + let receipt = super::receipt_from_output([1.0, 2.0], [3.0, 4.0], &[11.0]) + .expect("exact finite scalar must pass"); + assert_eq!(receipt.output_sha256, mlx_native_receipt::digest(&[11.0])); + assert_eq!( + receipt.observed_maximum_difference.to_bits(), + 0.0_f64.to_bits() + ); + assert!(super::receipt_from_output([1.0, 2.0], [3.0, 4.0], &[12.0]).is_err()); + } + + #[test] + fn rejects_non_finite_receipt_before_json_publication() { + for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let receipt = mlx_native_receipt::ProbeReceipt { + schema_version: mlx_native_receipt::RECEIPT_SCHEMA_VERSION, + backend_code: "mlx_cpu_macos_native", + execution_environment_code: "macos_native", + objective_sha256: "a".repeat(64), + output_sha256: "b".repeat(64), + observed_maximum_difference: value, + }; + assert_eq!( + super::emit_receipt(&receipt) + .expect_err("non-finite receipt must not publish JSON") + .to_string(), + "observed_maximum_difference must be finite" + ); + } + } + + #[test] + fn device_selection_failure_refuses_a_backend_result() { + unsafe extern "C" fn refused_device(_device: mlx_sys::mlx_device) -> std::ffi::c_int { + 1 + } + let _guard = native_test_guard(); + let mut calls = super::MlxProbeCalls::native(); + calls.select_device = refused_device; + assert_eq!( + super::mlx_cpu_matmul_with_calls(&[1.0, 2.0], &[3.0, 4.0], calls), + Err("failed to select the MLX CPU device".to_owned()) + ); + } + + thread_local! { + static COUNTS: std::cell::Cell<[usize; 5]> = const { + std::cell::Cell::new([0; 5]) + }; + } + + /// Record one native call within the current test thread. + fn count(index: usize) { + COUNTS.with(|counts| { + let mut value = counts.get(); + value[index] += 1; + counts.set(value); + }); + } + + #[test] + fn native_handles_are_settled_on_every_backend_status_path() { + unsafe extern "C" fn free_array(array: mlx_sys::mlx_array) -> std::ffi::c_int { + count(0); + // SAFETY: the runner owns this native handle and settles it once. + unsafe { mlx_sys::mlx_array_free(array) } + } + unsafe extern "C" fn free_stream(stream: mlx_sys::mlx_stream) -> std::ffi::c_int { + count(1); + // SAFETY: the runner owns this native handle and settles it once. + unsafe { mlx_sys::mlx_stream_free(stream) } + } + unsafe extern "C" fn free_device(device: mlx_sys::mlx_device) -> std::ffi::c_int { + count(2); + // SAFETY: the runner owns this native handle and settles it once. + unsafe { mlx_sys::mlx_device_free(device) } + } + unsafe extern "C" fn evaluate(array: mlx_sys::mlx_array) -> std::ffi::c_int { + count(3); + // SAFETY: this is the live native result supplied by the runner. + unsafe { mlx_sys::mlx_array_eval(array) } + } + unsafe extern "C" fn data(array: mlx_sys::mlx_array) -> *const f32 { + count(4); + // SAFETY: the runner evaluated this live array before reading it. + unsafe { mlx_sys::mlx_array_data_float32(array) } + } + unsafe extern "C" fn refused_device(_device: mlx_sys::mlx_device) -> std::ffi::c_int { + 1 + } + unsafe extern "C" fn refused_matmul( + _result: *mut mlx_sys::mlx_array, + _left: mlx_sys::mlx_array, + _right: mlx_sys::mlx_array, + _stream: mlx_sys::mlx_stream, + ) -> std::ffi::c_int { + 1 + } + unsafe extern "C" fn refused_evaluation(_array: mlx_sys::mlx_array) -> std::ffi::c_int { + count(3); + 1 + } + unsafe extern "C" fn null_data(_array: mlx_sys::mlx_array) -> *const f32 { + count(4); + std::ptr::null() + } + let _guard = native_test_guard(); + let native = super::MlxProbeCalls { + evaluate, + data, + free_array, + free_stream, + free_device, + ..super::MlxProbeCalls::native() + }; + let cases = [ + (native, Ok(vec![11.0]), [3, 1, 1, 1, 1]), + ( + super::MlxProbeCalls { + select_device: refused_device, + ..native + }, + Err("failed to select the MLX CPU device".to_owned()), + [0, 0, 1, 0, 0], + ), + ( + super::MlxProbeCalls { + matmul: refused_matmul, + ..native + }, + Err("MLX CPU matrix product failed".to_owned()), + [3, 1, 1, 0, 0], + ), + ( + super::MlxProbeCalls { + evaluate: refused_evaluation, + ..native + }, + Err("MLX CPU matrix product failed".to_owned()), + [3, 1, 1, 1, 0], + ), + ( + super::MlxProbeCalls { + data: null_data, + ..native + }, + Err("MLX CPU matrix product failed".to_owned()), + [3, 1, 1, 1, 1], + ), + ]; + for (calls, expected, counts) in cases { + COUNTS.with(|counter| counter.set([0; 5])); + assert_eq!( + super::mlx_cpu_matmul_with_calls(&[1.0, 2.0], &[3.0, 4.0], calls), + expected + ); + assert_eq!(COUNTS.with(std::cell::Cell::get), counts); + } + } + #[test] fn emits_only_an_exact_macos_native_mlx_cpu_receipt() { + let _guard = native_test_guard(); let receipt = run().expect("installed MLX CPU must execute the probe"); assert_eq!(receipt.backend_code, "mlx_cpu_macos_native"); assert_eq!(receipt.execution_environment_code, "macos_native"); diff --git a/docs/research/mlx-finite-parity-output.md b/docs/research/mlx-finite-parity-output.md new file mode 100644 index 000000000..7b6966295 --- /dev/null +++ b/docs/research/mlx-finite-parity-output.md @@ -0,0 +1,19 @@ +# macOS MLX probe finite parity output + +## Contract and finding + +PRD v0.4 §13 requires measured compute parity and §17 treats numerical divergence as scientific-integrity failure. This correction enforces the existing native MLX CPU matrix-probe contract; it does not change an estimator target, introduce a new GPU backend, or establish scientific GPU parity. ADR 0025 remains authoritative. + +The existing probe reduced absolute differences with `fold(0.0_f64, f64::max)`. Rust documents `f64::max` as ignoring a single NaN operand (Rust Project Developers, n.d.). Consequently, a synthetic backend output `[NaN]` produced `observed_maximum_difference = 0.0` and an accepted receipt. The exact expression was executed with pinned Rust1.98.0, and the extracted production receipt path then failed the permanent rejection assertion. `zip` also truncates unequal lengths: empty/extra output could pass the parity reduction. A separate permanent shape rejection test failed before the guard was added. + +## Repair and verification boundaries + +The real MLX runner now delegates receipt construction to one private `receipt_from_output` boundary. It requires the complete expected scalar output and finite backend values before computing parity or digests. The unchanged finite exact result11 retains its objective/output digests and zero difference; finite mismatch12 fails. Tests cover NaN, positive/negative infinity, empty/extra output, exact finite success, mismatch, and actual native MLX execution. These injected outputs are deterministic boundary fixtures, not observations of the real MLX backend emitting NaN. + +The FFI status/control flow and matching native constructors/frees are preserved through a private call table; production fills it only with the pinned native MLX functions. Test-only call substitutions exercise device-selection, multiplication, evaluation and null-data failure paths against real allocated native handles. Recording wrappers delegate actual frees and assert the expected array/stream/device settlement counts and that evaluation/data calls do not occur after an earlier failure. These synthetic return-code controls cover runner behavior, not an observed native OOM or hardware failure. The ordinary native probe and packaged binary still execute unmodified native function targets. A full workspace run of the initial call-table candidate terminated with SIGTRAP while two native test threads were concurrently creating streams. The retained macOS crash frames identify simultaneous `Scheduler::new_stream` calls; the pinned scheduler implementation mutates its stream/thread vectors without a lock. Four unchanged-source serial/parallel replay processes later passed, so those retries are not credited as a causal repair. A test-only mutex now serializes every native test journey while leaving ordinary Rust tests parallel and production dispatch unchanged. The call-table refactor, cleanup controls and test serialization require a new whole-candidate review rather than reuse of the earlier finite-output-only verdict. The probe is not an Event Lineage estimator, full CPUf64 estimator parity study, Metal/GPU execution receipt, or release evidence. Earlier full-workspace passes precede this source change and must be reacquired. Protected-main adoption and independent exact-head review remain pending. + +## References (APA 7) + +Rust Project Developers. (n.d.). *f64: max*. Rust standard library documentation. Retrieved October 3, 2026, from https://doc.rust-lang.org/std/primitive.f64.html#method.max + +Actual primary-source retrieval: parent `web.run` opened the official documentation and located the `max` section. Its documented NaN behavior is also independently reproduced with the repository's pinned Rust1.98.0 toolchain. Published documentation currently renders standard library1.99.0; it is supporting semantics, not proof of installed compiler identity.